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
}