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

nordisk/gunnar-ui

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")
}