Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-ui-web/src/api.rs

//! Talking to the console's own server. Same-origin by construction.

use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;

pub use crate::fmt::Fail;

#[derive(Debug, Deserialize)]
struct WireFail {
    code: String,
    message: String,
}

fn unsendable(e: impl std::fmt::Display) -> Fail {
    Fail {
        status: 0,
        code: String::new(),
        message: e.to_string(),
    }
}

async fn send_once<T: DeserializeOwned>(request: gloo_net::http::Request) -> Result<T, Fail> {
    let response = request.send().await.map_err(unsendable)?;
    let status = response.status();
    if !(200..300).contains(&status) {
        let body = response.text().await.unwrap_or_default();
        // JSON when monetize refused, plain text when the console's server did.
        let (code, message) = match serde_json::from_str::<WireFail>(&body) {
            Ok(w) => (w.code, w.message),
            Err(_) => (String::new(), body),
        };
        return Err(Fail {
            status,
            code,
            message,
        });
    }
    response.json::<T>().await.map_err(|e| Fail {
        status,
        code: String::new(),
        message: format!("could not read the response: {e}"),
    })
}

/// **STEP-UP** (5.4 §3b): send, and when the server answers `403 stepup`
/// (money or keys, and the passkey touch is older than its window) ask for
/// the touch — the same ceremony as the login page — and send the SAME
/// request once more. `make` builds it again, since a sent request is spent.
async fn send<T: DeserializeOwned>(
    make: impl Fn() -> Result<gloo_net::http::Request, Fail>,
) -> Result<T, Fail> {
    match send_once(make()?).await {
        Err(f) if f.wants_step_up() => {
            step_up().await?;
            send_once(make()?).await
        }
        answered => answered,
    }
}

/// A fresh passkey touch: `login/start` (no name: the key answers with the
/// passkey it keeps), the ceremony, `login/finish` — whose new session cookie
/// carries a new `auth_time`.
async fn step_up() -> Result<(), Fail> {
    let options: Value = send_once(
        gloo_net::http::Request::get(crate::fmt::PASSKEY_LOGIN_START)
            .build()
            .map_err(unsendable)?,
    )
    .await?;
    let cred = crate::passkey::get(&options).await.map_err(|e| Fail {
        status: 403,
        code: "stepup".into(),
        message: format!("no passkey touch confirmed it ({e}) — press again and touch your key"),
    })?;
    let body = crate::fmt::passkey_login_body(&options, &cred);
    send_once::<Value>(
        gloo_net::http::Request::post("/auth/passkey/login/finish")
            .json(&body)
            .map_err(unsendable)?,
    )
    .await?;
    Ok(())
}

thread_local! {
    /// **The estate the shell is showing**, set from the address
    /// (`/e/<estate>/…`). Pages read it for their titles and paths; it is not
    /// sent anywhere: the console reads the ONE monetize, which has no estate
    /// to dispatch to (the old `x-monetize-estate` header is LEGACY).
    static ESTATE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}

/// Make `estate` the one every following call names (`None` on Overview,
/// which names its estates in its paths).
pub fn set_estate(estate: Option<String>) {
    ESTATE.with(|e| *e.borrow_mut() = estate);
}

/// The estate the shell is showing, if any.
pub fn estate() -> Option<String> {
    ESTATE.with(|e| e.borrow().clone())
}

/// **A money panel's path for the site being shown**: `?estate=<label>`, so a
/// site lists only its own vendor's rows (owner 2026-10-12: test2's Money page
/// showed gunnar.rs's Stripe sessions). No site picked: every row.
pub fn site_path(path: &str) -> String {
    crate::fmt::site_path(path, estate().as_deref())
}

fn with_bearer(
    b: gloo_net::http::RequestBuilder,
    bearer: Option<&str>,
) -> gloo_net::http::RequestBuilder {
    match bearer {
        Some(t) if !t.is_empty() => b.header("authorization", &format!("Bearer {t}")),
        _ => b,
    }
}

pub async fn get<T: DeserializeOwned>(path: &str) -> Result<T, Fail> {
    get_as(path, None).await
}

/// A read carrying the break-glass token, for the first passkey's enrolment.
pub async fn get_as<T: DeserializeOwned>(path: &str, bearer: Option<&str>) -> Result<T, Fail> {
    send(|| {
        with_bearer(gloo_net::http::Request::get(path), bearer)
            .build()
            .map_err(unsendable)
    })
    .await
}

pub async fn post<B: Serialize, T: DeserializeOwned>(path: &str, body: &B) -> Result<T, Fail> {
    post_as(path, body, None).await
}

pub async fn post_as<B: Serialize, T: DeserializeOwned>(
    path: &str,
    body: &B,
    bearer: Option<&str>,
) -> Result<T, Fail> {
    send(|| {
        with_bearer(gloo_net::http::Request::post(path), bearer)
            .json(body)
            .map_err(unsendable)
    })
    .await
}

/// Replace something whole — a plan is one document monetize swaps wholesale.
pub async fn put<B: Serialize, T: DeserializeOwned>(path: &str, body: &B) -> Result<T, Fail> {
    send(|| {
        with_bearer(gloo_net::http::Request::put(path), None)
            .json(body)
            .map_err(unsendable)
    })
    .await
}