crates/gunnar-ui-web/src/api.rs
//! Talking to the console's own server.
//!
//! Same-origin by construction. `base()` is empty in a release build, so a
//! crafted URL cannot repoint the console at somewhere else; the `?api=`
//! override exists only under `debug_assertions`, for pointing a `trunk serve`
//! session at a server on another port.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
/// A refusal, as the console shows it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fail {
/// The HTTP status, or 0 when the request never reached the server.
pub status: u16,
/// gunnar's own status code when it refused, otherwise empty.
pub code: String,
/// The server's words, never reworded.
pub message: String,
}
impl Fail {
/// The one-line summary above the server's own message.
///
/// This explains what the status means for the operator; the detail line
/// below it stays verbatim, so the two can never disagree.
pub fn summary(&self) -> &'static str {
// The console's OWN refusals on an import — not yours, needs more
// room, too many at once — worded for the customer on the host side
// of the wasm boundary, where they are tested.
if let Some(words) = crate::mirror::refusal_summary(self.status, &self.code) {
return words;
}
// GUN-SESSION-RESTART: gunnar is restarting; the session is kept.
if let Some(words) = crate::refusal::restarting_summary(self.status, &self.code) {
return words;
}
match (self.status, self.code.as_str()) {
(0, _) => "The console could not reach the server.",
(401, _) => "Your session is no longer valid.",
// gunnar answers PermissionDenied both for a repository that is not
// there and for one the console may not read, and does not say
// which. Neither does this.
(403, "permissiondenied") => {
"Gunnar denied access. Either it does not exist, or the console lacks \
permission to it."
}
// Only freshness: the touch asked in place was cancelled or refused.
(403, crate::refusal::STEP_UP_CODE) => crate::refusal::STEP_UP_SUMMARY,
(403, _) => "Your role does not allow this.",
(404, _) => "It does not exist.",
(409, _) => "That name is already taken.",
(501, _) => "Gunnar does not have that feature yet.",
(502, _) => "Gunnar answered with an error.",
// An estate fault the visitor cannot act on (clock skew, the
// console's key refused) or a proxy's error page standing in for
// the console's answer. The reason is in the server's journal.
(_, "maintenance") => "Gunnar is temporarily down for maintenance. Please try again shortly.",
// gunnar's main is being restored from its twin: the BFF's word, no internals.
(_, "restoring") => "Gunnar is being restored. Try again in a few minutes.",
(503, _) => "Gunnar is not responding right now.",
(504, _) => "Gunnar did not answer in time.",
(413, _) => "The object is too large to fetch.",
_ => "The request failed.",
}
}
/// True when retrying could plausibly work.
pub fn worth_retrying(&self) -> bool {
matches!(self.code.as_str(), "maintenance" | "restoring") || matches!(self.status, 0 | 502 | 503 | 504)
}
}
impl crate::gate::Refusal for Fail {
fn status(&self) -> u16 {
self.status
}
fn code(&self) -> &str {
&self.code
}
}
/// The shape the server sends a refusal in.
#[derive(Debug, Deserialize)]
struct WireFail {
code: String,
message: String,
}
/// Where the API lives.
pub fn base() -> String {
#[cfg(debug_assertions)]
{
if let Some(window) = web_sys::window() {
if let Ok(search) = window.location().search() {
if let Some(value) = search
.trim_start_matches('?')
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(name, _)| *name == "api")
.map(|(_, value)| value.to_owned())
{
return value;
}
}
}
}
String::new()
}
/// The request never got an answer: said in plain words, never the
/// browser's (`crate::refusal::UNREACHABLE`).
fn unsendable(_e: impl std::fmt::Display) -> Fail {
Fail {
status: 0,
code: String::new(),
message: crate::refusal::UNREACHABLE.to_owned(),
}
}
/// Finish a builder that carries no body.
fn built(builder: gloo_net::http::RequestBuilder) -> Result<gloo_net::http::Request, Fail> {
builder.build().map_err(unsendable)
}
async fn send<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();
// The server sends JSON when gunnar refused and plain text when the
// console itself did. Both end up in the same place on screen — but
// only after `readable_body` has made sure it is something to READ.
let (code, message) = match serde_json::from_str::<WireFail>(&body) {
Ok(wire) => (wire.code, wire.message),
Err(_) if crate::refusal::is_proxy_page(&body) => ("maintenance".to_owned(), String::new()),
Err(_) => (String::new(), crate::refusal::readable_body(&body)),
};
return Err(Fail {
status,
code,
message,
});
}
response.json::<T>().await.map_err(|_| Fail {
status,
code: String::new(),
message: crate::refusal::UNREADABLE.to_owned(),
})
}
/// **Send, and answer a stale touch in place** (owner 2026-10-19): a refusal
/// the server marks `"code":"stepup"` (the person may do this, their
/// user-verified touch is only too old) asks the person's key for a fresh
/// touch right here ([`step_up`]) and sends the same call ONCE more. Every
/// other refusal comes back as it was; the server still decides.
async fn call<T: DeserializeOwned>(
path: &str,
make: impl Fn() -> Result<gloo_net::http::Request, Fail>,
) -> Result<T, Fail> {
match send::<T>(make()?).await {
Err(fail) if crate::refusal::wants_step_up(fail.status, &fail.code) && crate::refusal::may_step_up_for(path) => {
step_up().await?;
send(make()?).await
}
answered => answered,
}
}
/// **The fresh touch**: the options for the person's own keys (user
/// verification required), `navigator.credentials.get`, and the assertion
/// back to the server, which re-mints the session's user-verified time. A
/// cancelled touch, or one the server turns away, is a step-up refusal in
/// plain words — never the browser's.
async fn step_up() -> Result<(), Fail> {
let as_step_up = |mut fail: Fail| {
if fail.status == 403 {
fail.code = crate::refusal::STEP_UP_CODE.to_owned();
}
fail
};
let path = format!("{}{}", base(), crate::refusal::STEP_UP_PATH);
let options: serde_json::Value = send(built(gloo_net::http::Request::get(&path))?).await.map_err(as_step_up)?;
let touch = crate::machine_enrol::touch(&options).await.map_err(|message| Fail {
status: 403,
code: crate::refusal::STEP_UP_CODE.to_owned(),
message,
})?;
let ceremony = options.get("ceremony_id").and_then(|c| c.as_str()).unwrap_or_default();
let request = gloo_net::http::Request::post(&path)
.json(&crate::refusal::step_up_body(ceremony, &touch))
.map_err(unsendable)?;
send::<serde_json::Value>(request).await.map_err(as_step_up)?;
Ok(())
}
/// Read something.
pub async fn get<T: DeserializeOwned>(path: &str) -> Result<T, Fail> {
call(path, || built(gloo_net::http::Request::get(&format!("{}{path}", base())))).await
}
/// Read raw bytes (a file as stored: for an encrypted repository, ciphertext).
pub async fn get_bytes(path: &str) -> Result<Vec<u8>, Fail> {
let request = built(gloo_net::http::Request::get(&format!("{}{path}", base())))?;
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();
return Err(Fail {
status,
code: String::new(),
message: crate::refusal::readable_body(&body),
});
}
response.binary().await.map_err(|_| Fail {
status,
code: String::new(),
message: crate::refusal::UNREADABLE.to_owned(),
})
}
/// Send something.
pub async fn post<B: Serialize, T: DeserializeOwned>(path: &str, body: &B) -> Result<T, Fail> {
call(path, || gloo_net::http::Request::post(&format!("{}{path}", base())).json(body).map_err(unsendable)).await
}
/// Send nothing, with a body the server ignores.
pub async fn post_empty<T: DeserializeOwned>(path: &str) -> Result<T, Fail> {
post(path, &serde_json::json!({})).await
}
/// Remove something.
pub async fn delete<T: DeserializeOwned>(path: &str) -> Result<T, Fail> {
call(path, || built(gloo_net::http::Request::delete(&format!("{}{path}", base())))).await
}
/// Replace something whole.
///
/// Distinct from [`patch`] because the two are not the same promise: the
/// ref-name policy is one document gunnar swaps wholesale, and sending it as a
/// PATCH would read as "add these patterns" when it means "these are now the
/// only patterns" — including when both lists are empty, which is how a policy
/// is cleared.
pub async fn put<B: Serialize, T: DeserializeOwned>(path: &str, body: &B) -> Result<T, Fail> {
call(path, || gloo_net::http::Request::put(&format!("{}{path}", base())).json(body).map_err(unsendable)).await
}
/// Change part of something.
pub async fn patch<B: Serialize, T: DeserializeOwned>(path: &str, body: &B) -> Result<T, Fail> {
call(path, || gloo_net::http::Request::patch(&format!("{}{path}", base())).json(body).map_err(unsendable)).await
}
/// A slice of a list, and how much the server left out.
#[derive(Debug, Clone, Deserialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub total: usize,
pub truncated: bool,
}
/// Percent-encode one path segment.
///
/// Repository and namespace names travel in the path, and a name with a space
/// or a plus would otherwise change what was asked for.
pub fn segment(value: &str) -> String {
crate::auth::percent_encode(value).replace('/', "%2F")
}