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-server/src/api/mod.rs

//! The console's own HTTP API: gunnar's answers, shaped for a browser.
//!
//! Everything here is same-origin JSON. Two things are done for the browser
//! that gunnar does not do for anyone:
//!
//! **Lists are sliced here.** gunnar's `page_size` is read by exactly one RPC
//! (`Refs.Log`); every other list ignores it and answers with the whole
//! collection in one message. So the honest thing the console can say is not
//! "the first N" but "N of M" — it received all M and chose to render N.
//!
//! **Refusals keep gunnar's words.** A [`Fail`] carries gunnar's status code and
//! its message verbatim. Rewording them would make the console a second source
//! of truth about what the server decided, and an operator comparing the two
//! would have no way to know which one to believe.

/// **A namespace, or a repository under it, the caller cannot see does not
/// exist**: one 404, one body, whatever is behind the name. A layer over every
/// namespace-scoped read; see the module.
pub mod absent;
pub mod access;
/// **A person's OWN SSH keys**, and the one mutating surface a viewer may
/// reach. Singular, and not to be confused with [`accounts`] — see the module.
pub mod account;
pub mod encryption_key;
pub mod accounts;
/// **Åtgärder** — the queue of things the caller's own account has to do
/// something about, and the one line the console carries at the top of every
/// page. See the module for why the telling happens here and not inside a
/// purchase.
pub mod actions;
pub mod admin;
pub mod blame;
pub mod browse;
pub mod chain;
/// **You → Clans** (2026-10-05): the person's clan verbs, signed as the
/// person; gunnar decides. See the module.
pub mod clans;
/// **Every disk, for the administrator**: the data set and its members, the
/// system disk, every mount and block device, the exploded read cache and the
/// twins' volumes. `Admin.Capacity { disks: true }` + `StorageInfo` + `Health`.
/// An ADMIN read. See the module.
pub mod disks;
/// **Connecting a forge**: the token door, the OAuth door, and the listing a
/// picker is built from. Reaches no gunnar RPC — see the module.
#[cfg(feature = "forges")]
pub mod forges;
/// Not built (AIRGAP): every forge door is the one refusal that names the `forges` feature.
#[cfg(not(feature = "forges"))]
pub mod forges {
    pub use crate::forges::door as authorize;
    pub use crate::forges::door as callback;
    pub use crate::forges::door as connect;
    pub use crate::forges::door as forget;
    pub use crate::forges::door as list;
    pub use crate::forges::door as repos;
}
/// **The tenant's "more room" panel.** Only under `metered` — the community
/// appliance does not contain it. See the module for the whole argument.
#[cfg(feature = "metered")]
pub mod increase;
pub mod issues;
/// **The machine's storage reality**: what the installer decided, what its
/// filesystems hold now, and where gunnar is configured to put things, read
/// out of `Admin.StorageInfo`'s `reality` field. The appliance has no shell,
/// so this is the only road those facts have. See the module for why the three
/// layers are carried separately, why the install record is carried unparsed,
/// and why nothing in it is ever inferred.
pub mod machine;
/// **Pull mirrors**: import, sync, watch, forget. The `Mirror` service.
pub mod mirrors;
/// **Importing into one's own namespace**: who may, whether it fits, how
/// much at once. See the module.
pub mod mirror_gate;
pub mod policy;
pub mod prs;
/// **The repository's front page, typeset.** The tree walk is `browse`'s and
/// the credential is [`Reader`]'s; what is new is that the answer is a picture.
/// See [`crate::readme`].
pub mod readme;
/// **Any markdown file in a tree, typeset** — the README's pipeline pointed at
/// a path the caller names, plus the PDF of it. The logged-in / anonymous fork
/// is [`crate::reading`]'s, not this module's.
pub mod render;
pub mod refs;
pub mod repos;
/// What THIS person may do on one repository: gunnar's `Access.Check`, asked as them.
pub mod rights;
pub mod search;
/// **"My room"**: the session's own namespace — used, cap, free — and its
/// repositories (`GET /api/account/room`, You → Account → Storage).
pub mod room;
/// **The forge's SSH host key** (`GET /api/server-key`), for the Overview's
/// "Server key SHA256:…" line. See the module.
pub mod server_key;
/// **A customer renewing their own subscription.** Only under `metered`, like
/// [`increase`] and for the same reason — the community appliance sells
/// nothing, so it does not contain this either. The two are the same
/// transaction from opposite sides: `increase` is an operator acting on a
/// tenant's behalf, this is the tenant acting for themselves, and the
/// difference is enforced by gunnar rather than asserted here. See the module.
#[cfg(feature = "metered")]
pub mod subscription;
// The twin: its heartbeat as the primary sees it, and the restore from it.
pub mod twin;
/// [Rebuild main]: the ONE box's guardian, asked for THIS estate's main (main rebuild 2026-10-12).
#[cfg(feature = "metered")]
pub mod rebuild;
/// `Admin.Topology`, carried and drawn by the suite's one estate renderer (2026-10-04).
pub mod topology;
pub mod usage;
// Who may change a repository's visibility: the account admin.
pub mod visibility;
// The account admin gives a member Read/Write on a repository (2026-10-07).
pub mod members;
// The account admin's own surface: the namespace's settings and its employees.
pub mod account_admin;
/// Administration → Identity provider (company SSO, 2026-10-05); feature `sso` (A34-15).
#[cfg(feature = "sso")]
pub mod sso;
/// The owner's enterprise list (2026-10-05): the audit log, the operator's look, the names held.
pub mod enterprise;

use std::time::Duration;

use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::grpc::{Cached, Fail};
use crate::pb;

/// How long a read is held before it costs another credential.
///
/// Short enough that no operator notices, long enough that a page of panels
/// asking the same question at once costs one call rather than six. gunnar's
/// replay guard remembers 8192 nonces across five minutes for the whole server,
/// and this is what keeps the console well clear of it.
const READ_TTL: Duration = Duration::from_secs(3);

/// How many rows the console renders unless asked otherwise.
const DEFAULT_LIMIT: usize = 250;

/// The most rows one response will carry.
const MAX_LIMIT: usize = 2000;

/// Reads worth holding briefly.
pub struct Caches {
    pub health: Cached<admin::Health>,
    pub stats: Cached<admin::Stats>,
    pub storage: Cached<admin::Storage>,
    /// The box — `Admin.Capacity` asked for nothing in particular. One value
    /// for the whole server and one caller (this console's key), so the
    /// per-namespace, per-caller hazard that keeps `Usage.Read` out of here
    /// does not apply.
    pub capacity: Cached<usage::Machine>,
    pub repos: Cached<Vec<repos::Repo>>,
    pub namespaces: Cached<Vec<repos::Namespace>>,
    pub principals: Cached<Vec<access::Principal>>,
    pub keys: Cached<Vec<access::Key>>,
    pub grants: Cached<Vec<access::Grant>>,
    /// **Whether any account here has an identity provider** (`Sso.Discover`
    /// `any_provider`, round 5.3): the login page's company field. Kept
    /// `crate::auth::sso::SSO_OFFERED_FRESH` (feature `sso`).
    #[cfg(feature = "sso")]
    pub sso_offered: Cached<bool>,
}

impl Default for Caches {
    fn default() -> Caches {
        Caches {
            health: Cached::new(READ_TTL),
            stats: Cached::new(READ_TTL),
            storage: Cached::new(READ_TTL),
            capacity: Cached::new(READ_TTL),
            repos: Cached::new(READ_TTL),
            namespaces: Cached::new(READ_TTL),
            principals: Cached::new(READ_TTL),
            keys: Cached::new(READ_TTL),
            grants: Cached::new(READ_TTL),
            #[cfg(feature = "sso")]
            sso_offered: Cached::new(crate::auth::sso::SSO_OFFERED_FRESH),
        }
    }
}

impl Caches {
    /// Drop everything a repository mutation could have changed.
    pub async fn invalidate_repos(&self) {
        self.repos.invalidate().await;
        self.namespaces.invalidate().await;
        self.stats.invalidate().await;
        self.storage.invalidate().await;
    }

    /// Drop everything an identity mutation could have changed.
    pub async fn invalidate_access(&self) {
        self.principals.invalidate().await;
        self.keys.invalidate().await;
        self.grants.invalidate().await;
        self.stats.invalidate().await;
    }
}

/// A slice of a list, and how much was left out.
#[derive(Debug, Clone, Serialize)]
pub struct Page<T> {
    pub items: Vec<T>,
    /// Everything gunnar returned. The console shows "N of M" from this.
    pub total: usize,
    /// True when `items` is shorter than `total`.
    pub truncated: bool,
}

impl<T> Page<T> {
    /// Take the first `limit` of `all`, remembering how many there were.
    pub fn of(all: Vec<T>, limit: usize) -> Page<T> {
        let total = all.len();
        let limit = limit.clamp(1, MAX_LIMIT);
        let mut items = all;
        items.truncate(limit);
        Page {
            truncated: items.len() < total,
            items,
            total,
        }
    }
}

/// `?limit=` on a list request.
#[derive(Debug, Clone, Deserialize)]
pub struct Limit {
    #[serde(default)]
    pub limit: Option<usize>,
}

impl Limit {
    /// The requested limit, or the default.
    pub fn get(&self) -> usize {
        self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
    }
}

/// A refusal on its way to the browser.
#[derive(Debug)]
pub struct ApiError(pub Fail);

impl From<tonic::Status> for ApiError {
    fn from(status: tonic::Status) -> ApiError {
        let fail = Fail::from(status);
        if fail.is_restoring() {
            tracing::info!(message = %fail.message, "gunnar's main is being restored; the person is told to try again in a few minutes");
        } else if fail.is_clock_skew() {
            // Not an identity fault and not the operator's: the front and the
            // appliance disagree about the time. Logged apart from the key
            // fault below, so the journal does not say "your key is not
            // enrolled" about a key that is.
            tracing::warn!(
                message = %fail.message,
                "gunnar refused the console's credential on clock skew; the key is fine, the clocks are not"
            );
        } else if fail.is_console_identity_problem() {
            // Not the operator's problem and not fixable by retrying: the
            // console's own key is not enrolled, or lost its grant.
            tracing::error!(
                code = %fail.code,
                message = %fail.message,
                "gunnar refused the console's own credential"
            );
        }
        ApiError(fail)
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let status = self.0.http_status();
        if self.0.is_restoring() {
            // The console's word, and nothing of gunnar's: the page says "Gunnar is being
            // restored. Try again in a few minutes." (`api::Fail::summary`).
            return (status, Json(serde_json::json!({ "code": "restoring", "message": "" }))).into_response();
        }
        if self.0.is_infrastructure_fault() {
            // The visitor gets one word; the journal already has the reason
            // (`From<tonic::Status>` above logged gunnar's message verbatim).
            // Not even the raw message travels in the body: devtools is a
            // screen too.
            return (status, Json(serde_json::json!({ "code": "maintenance", "message": "" }))).into_response();
        }
        (status, Json(&self.0)).into_response()
    }
}

#[cfg(test)]
mod maintenance_tests {
    use super::*;

    async fn body_of(r: Response) -> String {
        let bytes = axum::body::to_bytes(r.into_body(), 64 * 1024).await.unwrap();
        String::from_utf8(bytes.to_vec()).unwrap()
    }

    #[tokio::test]
    async fn a_clock_skew_refusal_reaches_the_browser_as_maintenance_with_no_internals() {
        let r = ApiError::from(tonic::Status::unauthenticated(
            "the credential is 7200691 ms away from this server's clock; the window is ±300000 ms",
        ))
        .into_response();
        assert_eq!(r.status(), http::StatusCode::SERVICE_UNAVAILABLE);
        let body = body_of(r).await.to_ascii_lowercase();
        assert!(body.contains("\"maintenance\""), "{body}");
        for leak in ["clock", "credential", "ms away", "unauthenticated", "window"] {
            assert!(!body.contains(leak), "internal word {leak:?} reached the browser: {body}");
        }
    }

    /// ★ **"maintenance" is never produced for a condition that is not
    /// maintenance.** The word is reserved for the two estate faults
    /// `Fail::is_infrastructure_fault` names. monetize refusing an order because
    /// the box has no room is neither: it is `FailedPrecondition`, it will not
    /// clear by waiting, and the body must carry what the server said.
    /// MEASURED 2026-09-20: the owner read "while we carry out maintenance" over
    /// a box that was 62 GiB short and nobody was maintaining anything.
    #[tokio::test]
    async fn a_capacity_refusal_from_monetize_is_not_maintenance() {
        let reason = "the product cannot deliver this to rickard: gunnar cannot serve 100.0 GiB of \
                      stored bytes: /data has 39.8 GiB free, so 37.8 GiB may be served — short 62.1 GiB";
        let r = ApiError::from(tonic::Status::failed_precondition(reason)).into_response();
        assert_eq!(r.status(), http::StatusCode::BAD_REQUEST);
        let body = body_of(r).await;
        assert!(
            !body.to_ascii_lowercase().contains("maintenance"),
            "a full store was dressed as maintenance: {body}"
        );
        assert!(body.contains("short 62.1 GiB"), "the server's words were dropped: {body}");
    }

    /// ★ **A write while Gunnar is being restored says so in customer words** (wiring tracer B
    /// 2026-10-08). gunnar bddb36d answers a write on a box whose log follows the twin's with
    /// UNAVAILABLE "this box is being restored (or is a twin): its transaction log follows the
    /// twin's …"; the console passed it to the browser verbatim — "transaction log" and "twin"
    /// on a customer's screen during a recharge. The body carries the console's word and none
    /// of gunnar's internals; the journal keeps the message.
    #[tokio::test]
    async fn a_write_refused_while_gunnar_is_restored_reaches_the_browser_as_restoring_with_no_internals() {
        let r = ApiError::from(tonic::Status::unavailable(
            "this box is being restored (or is a twin): its transaction log follows the twin's and it takes no write until the restore is whole — try again",
        ))
        .into_response();
        assert_eq!(r.status(), http::StatusCode::SERVICE_UNAVAILABLE);
        let body = body_of(r).await.to_ascii_lowercase();
        assert!(body.contains("\"restoring\""), "{body}");
        for leak in ["transaction log", "twin", "follows", "unavailable"] {
            assert!(!body.contains(leak), "internal word {leak:?} reached the browser: {body}");
        }
    }

    #[tokio::test]
    async fn an_ordinary_refusal_still_carries_gunnars_own_words() {
        let r = ApiError::from(tonic::Status::not_found("no such repository")).into_response();
        assert_eq!(r.status(), http::StatusCode::NOT_FOUND);
        assert!(body_of(r).await.contains("no such repository"));
    }
}

/// What a handler returns.
pub type ApiResult<T> = Result<Json<T>, ApiError>;

/// **Which gunnar credential this request is answered with, decided once.**
///
/// Every read handler that a visitor with no session can reach takes one of
/// these instead of reaching for `state.gunnar`, and that is the entire
/// mechanism by which anonymous traffic is kept off the console's admin key.
///
/// # Why an extractor and not a helper function
///
/// Because a helper is something a handler can forget to call, and the failure
/// mode of forgetting is not a compile error or a 500 — it is the private
/// repositories of every tenant, served to a stranger, with a 200. An extractor
/// is in the signature: a handler either has the credential in its hands or it
/// does not have one at all. `state.gunnar` remains available and remains
/// correct for the handlers that are behind a session, so this is not a rule to
/// remember, it is a fork in the road with both branches named.
///
/// # Why the session's ROLE is the input, and not merely `Caller`'s presence
///
/// [`crate::auth::middleware::require_auth`] inserts a `Caller` extension when
/// and only when it verified a session or the admin token, and it lets an
/// anonymous request through only for the two allow-listed read surfaces. So
/// "no `Caller` in the extensions" is not a guess about who is calling — it is
/// the middleware's own decision, read back.
///
/// Until 2026-09-11 that presence was the WHOLE input: any `Caller` at all got
/// `state.gunnar`. Measured against the fake gunnar in [`crate::anonymous`]: a
/// session minted with `Role::Viewer` — which is exactly what a paid signup
/// mints (`auth::signup`) and what an invite may mint — read a **private**
/// repository in a namespace it has nothing to do with, and the fingerprint on
/// the wire was the console's server-wide admin key. `role_floor` could not
/// help: every GET is a viewer's by the table, whatever namespace the path
/// names.
///
/// So the credential is chosen by what the session may do, and the two
/// credentials this console holds map onto the ladder:
///
/// * **admin / operator** — `state.gunnar`. The console's key does not exceed
///   what these sessions may already do through it: an operator's POST deletes
///   a repository in any namespace and an admin's hands out grants, both signed
///   with this same key. Narrowing only their READS would leave the console
///   showing an operator less than it lets them destroy.
/// * **viewer** — **the viewer's OWN key**, minted by this console and enrolled
///   on that person's own gunnar principal ([`crate::tenant`]). A viewer is the
///   tenant role: the lowest rung, minted by payment, and not somebody this
///   console may lend its admin grant to — but also somebody who is PAYING to
///   see their own private repositories. Signing as them is the only credential
///   that is both: gunnar applies their grants, on gunnar's side of the wire.
/// * **a viewer this console cannot name at gunnar** — `state.guest`, the
///   grantless identity, and the same narrowing gunnar performs for a stranger.
///   That is a console user with no gunnar principal: there is nothing that
///   would be their credential, and the console will not invent one on a GET.
///   It is also where every failure lands, because the guest is the narrowest
///   thing this console holds.
#[derive(Clone)]
pub struct Reader {
    /// The channel to sign this request's control-plane calls with.
    pub gunnar: crate::grpc::Gunnar,
    /// True when this request is answered by the GUEST identity — whether
    /// because nobody is logged in, or because who is logged in is a viewer.
    ///
    /// Read by the handlers that hold a cache. It is NOT read to decide what
    /// may be served — gunnar decides that — only to decide what may be
    /// *reused*, which is a different question with a different wrong answer.
    /// It follows the CHANNEL and not the session, because the cache holds the
    /// admin's answer and the hazard is being handed one you did not ask for.
    pub guest: bool,
    /// True when this request is answered by the SESSION'S OWN key — a
    /// credential enrolled on the caller's gunnar principal.
    ///
    /// Never true at the same time as `guest`: they are two of the three
    /// credentials this console can reach for, and [`Reader::narrowed`] is the
    /// question both of them answer.
    pub tenant: bool,
    /// True when NOBODY is logged in — no `Caller` at all.
    ///
    /// Not the same question as `guest`, and the merge that brought the two
    /// together is where the difference started to matter. `guest` follows the
    /// CHANNEL and is true for a logged-in viewer as well; this one follows the
    /// SESSION. The concurrency ceiling in [`search`] is keyed on it: the
    /// ceiling exists because an unauthenticated GET is free to repeat, and a
    /// viewer's is not — theirs is behind a passkey and an invite, and can be
    /// revoked by name.
    pub anonymous: bool,
}

impl Reader {
    /// **May this request's answer come out of, or go into, a shared cache?**
    ///
    /// No, for an anonymous request. The console's caches
    /// ([`Caches::repos`] and friends) hold one value for the whole server with
    /// no key for who asked, because until now there was only ever one asker:
    /// the console's own admin principal. A guest reading `Caches::repos` would
    /// be handed the admin's answer — every private repository on the box —
    /// without a single gRPC call being made, and gunnar would never learn it
    /// had been asked. That is the same disclosure the guest key exists to
    /// prevent, arriving through the side door.
    ///
    /// Bypassing rather than partitioning, because a second cache is a second
    /// thing to invalidate and the cost here is one gRPC call on a page a
    /// stranger loads. [`crate::git::ObjectCache`] is the exception and is left
    /// shared on purpose — see [`crate::git::Repository::read_object`].
    pub fn may_cache(&self) -> bool {
        !self.narrowed()
    }

    /// **Is this answer narrower than the console's own view of the server?**
    ///
    /// True for the guest and true for a tenant, and it is the question every
    /// caller actually has. A tenant credential is not the guest — it holds
    /// that person's grants and sees their private repositories — but it is
    /// just as much NOT the console's key, so anything keyed on "may this be
    /// shared" or "must this be filtered per subscriber" wants this and not
    /// `guest`. [`crate::sse::events`] is the second caller.
    pub fn narrowed(&self) -> bool {
        self.guest || self.tenant
    }
}

/// The guest channel, or the refusal that says why there is none.
///
/// One function for the two callers that reach for it — the visitor with no
/// session and the viewer whose session does not buy the console's grant —
/// because the answer is the same channel and the only thing that differs is
/// what to say when it is absent. **There is no fallback to `state.gunnar`
/// here, and there must never be one**: falling back on a misconfiguration is
/// how a console serves every private repository on the box and looks like it
/// is working.
///
/// `permissiondenied` and not an invented code: [`Fail`] carries gunnar's
/// vocabulary and `http_status` maps this one to 403, which is the truth — the
/// console will not act for a caller it cannot name. A 401 would send the
/// browser to a login it does not need, or already has.
fn guest_reader(state: &crate::app::AppState, anonymous: bool) -> Result<Reader, ApiError> {
    match state.guest.clone() {
        Some(gunnar) => Ok(Reader {
            gunnar,
            guest: true,
            tenant: false,
            anonymous,
        }),
        None if anonymous => Err(ApiError(Fail {
            code: "permissiondenied".to_owned(),
            message: "this console has no guest identity, so it serves nothing anonymously"
                .to_owned(),
        })),
        None => Err(ApiError(Fail {
            code: "permissiondenied".to_owned(),
            message: "this console has no guest identity, so a viewer's reads cannot be answered \
                      at all; set GUNNAR_UI_GUEST_IDENTITY, or give this session the operator role"
                .to_owned(),
        })),
    }
}

tokio::task_local! {
    /// **The signed-in person of the request the gate let through**
    /// (`auth::middleware::require_auth`): what [`person_identity`] acts as
    /// for a handler that names the person and holds no [`Caller`] of its own.
    ///
    /// [`Caller`]: crate::auth::middleware::Caller
    pub(crate) static PERSON: crate::auth::Session;
}

/// The request's signed-in person ([`PERSON`]), when the gate found one.
pub(crate) fn current_person() -> Option<crate::auth::Session> {
    PERSON.try_with(Clone::clone).ok()
}

/// **The credential a session's person acts with**: their identity-core
/// session (IDENTITY-HOST, A34-18) — every call carries the core's token and
/// gunnar's gate asks the core who it is, so gunnar applies THEIR grants. The
/// console holds no key of theirs.
///
/// **Test fixtures only**: a session a test planted
/// (`Webauthn::make_session`) has no core behind it; pinned to a principal
/// (`plant_bound_credential_for_tests`) it stands on a key enrolled through
/// the fake's `AddKey` ([`crate::tenant::TenantKeys::ensure`]), the stand-in
/// for gunnar knowing the person. Never compiled outside `cfg(test)`.
pub(crate) async fn identity_of(
    state: &crate::app::AppState,
    session: &crate::auth::Session,
) -> Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError> {
    #[cfg(test)]
    if crate::auth::Webauthn::is_planted(&session.token) {
        return planted_identity(state, &session.user).await;
    }
    Ok(Some(crate::tenant::TenantIdentity {
        principal_id: session.user.clone(),
        // A session holds no key at gunnar: nothing of the person's key list
        // is this console's.
        fingerprint: String::new(),
        gunnar: state.gunnar.as_session(&session.token),
        signer: state.gunnar.signer(),
    }))
}

#[cfg(test)]
async fn planted_identity(
    state: &crate::app::AppState,
    user: &str,
) -> Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError> {
    match state.webauthn.as_ref().and_then(|webauthn| webauthn.principal_of(user)) {
        Some(principal) => state.tenants.ensure(user, &principal, &state.gunnar).await.map(Some),
        None => Ok(None),
    }
}

/// [`identity_of`] the request's signed-in person ([`PERSON`]), when that is
/// `user`; `None` when the request carries no session of theirs.
pub(crate) async fn person_identity(
    state: &crate::app::AppState,
    user: &str,
) -> Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError> {
    if let Some(session) = PERSON.try_with(Clone::clone).ok().filter(|s| s.user == user) {
        return identity_of(state, &session).await;
    }
    #[cfg(test)]
    if state.webauthn.as_ref().is_some_and(|w| w.principal_of(user).is_some()) {
        return planted_identity(state, user).await;
    }
    Ok(None)
}

/// **The session's own credential, or the guest — never the console's.**
///
/// The person's session key ([`person_identity`]): gunnar named the principal
/// when it verified their passkey, and their reads are signed with the key it
/// enrolled there, so gunnar applies THEIR grants. This asks gunnar nothing
/// about who the caller is.
///
/// Two ways to end up on the guest instead, and they are the same shape —
/// *this console cannot answer as you, so it answers as nobody*:
///
/// * the session holds no live session key here;
/// * the state store refused, or the key was unusable. Logged at `warn` with
///   the reason, because a console quietly serving the public subset to a
///   paying tenant is a support ticket nobody can diagnose from the outside.
///
/// **There is no arm here that falls back to `state.gunnar`**, and there must
/// never be one, for the reason stated on [`crate::app::App::guest`]: the
/// fallback IS the hole.
pub(crate) async fn tenant_reader(state: &crate::app::AppState, user: &str) -> Result<Reader, ApiError> {
    reader_from(state, user, person_identity(state, user).await)
}

/// [`tenant_reader`] for the session the gate found.
pub(crate) async fn session_reader(state: &crate::app::AppState, session: &crate::auth::Session) -> Result<Reader, ApiError> {
    reader_from(state, &session.user, identity_of(state, session).await)
}

fn reader_from(
    state: &crate::app::AppState,
    user: &str,
    identity: Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError>,
) -> Result<Reader, ApiError> {
    match identity {
        Ok(Some(identity)) => Ok(Reader {
            gunnar: identity.gunnar,
            guest: false,
            tenant: true,
            anonymous: false,
        }),
        Ok(None) => guest_reader(state, false),
        Err(why) => {
            tracing::warn!(
                target: crate::auth::AUDIT_TARGET,
                event = "tenant.credential_unavailable",
                user = %user,
                reason = %why,
                "this console could not sign as the person, so their reads are answered \
                 by the grantless guest and show only the public subset"
            );
            guest_reader(state, false)
        }
    }
}

impl axum::extract::FromRequestParts<crate::app::AppState> for Reader {
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut http::request::Parts,
        state: &crate::app::AppState,
    ) -> Result<Reader, ApiError> {
        match parts.extensions.get::<crate::auth::middleware::Caller>() {
            // The break-glass token: bootstrap, before any passkey exists — the
            // console's own key, which is what that token stands for.
            Some(crate::auth::middleware::Caller::AdminToken) => Ok(Reader {
                gunnar: state.gunnar.clone(),
                guest: false,
                tenant: false,
                anonymous: false,
            }),
            // **Every passkey session reads as its person — an operator and an
            // administrator included** (thin layer, owner 2026-10-04: "roles →
            // gunnar grants"). Their console role IS their server-wide grant at
            // gunnar, so their own key reads what the role reads, and gunnar's
            // audit names them rather than the console. Answered as the guest
            // where this console cannot name them at gunnar.
            #[cfg(test)]
            Some(caller) if console_stand_in(state, caller) => Ok(Reader {
                gunnar: state.gunnar.clone(),
                guest: false,
                tenant: false,
                anonymous: false,
            }),
            Some(crate::auth::middleware::Caller::Operator(session)) => session_reader(state, session).await,
            // The open-console posture (`trunk serve` on loopback, no passkey
            // and no admin token) has no `Caller` either, and there it means the
            // opposite thing: everybody is admin. Told apart by asking the state
            // rather than by inspecting the request, because it is a fact about
            // how the process was started.
            None if !state.passkey_enabled() && state.admin_token.is_none() => Ok(Reader {
                gunnar: state.gunnar.clone(),
                guest: false,
                tenant: false,
                anonymous: false,
            }),
            None => guest_reader(state, true),
        }
    }
}

/// **The credential a session ACTS with** (thin layer, owner 2026-10-04).
///
/// [`Reader`] is the read half; this is the half that changes something. The
/// console does not decide whether a person may set a repository's
/// visibility, their account's settings, enroll an employee or point a
/// repository at a remote: it signs the call AS THE PERSON and gunnar's grant
/// table, groups and account gates decide — the same answer the person would
/// get from `gunnar control …` with their own key.
///
/// * the box's administrator (an admin session, the break-glass token, the
///   open console) signs with the console's own key, which holds the
///   server-wide `Admin` that role stands for — until console roles are
///   grants on the person's own principal;
/// * every other session signs with the person's own key, enrolled on the
///   principal their passkey is BOUND to ([`crate::tenant::TenantKeys`]) —
///   an operator included: an operator's console role is not an account's
///   admin;
/// * a session bound to no principal, or one this console cannot sign for, is
///   refused here — **never the guest**: a mutation signed by nobody is not
///   something gunnar should be asked to refuse.
#[derive(Clone)]
pub struct Actor {
    /// The client that signs AS this session's person.
    pub gunnar: crate::grpc::Gunnar,
    /// Who acted, for the audit line.
    pub who: String,
    /// True when the console's own key signs (the box's administrator).
    pub console: bool,
    /// The principal the person's key is enrolled on; `None` for the console.
    pub principal: Option<String>,
}

impl std::fmt::Debug for Actor {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Actor")
            .field("who", &self.who)
            .field("console", &self.console)
            .field("principal", &self.principal)
            .finish()
    }
}

/// `principal_id` for gunnar's lists that means **every principal**: only an
/// administrator asks for it, by this name. An empty id is gunnar's "all", so
/// an unknown principal must never fall back to it.
pub const EVERY_PRINCIPAL: &str = "";

impl Actor {
    /// **The principal this person's own key is enrolled on**, for a lookup of
    /// what is theirs. `None` (the console's own key: the break-glass token,
    /// the open console) is refused — never `""`, which gunnar reads as every
    /// principal.
    pub fn own_principal(&self) -> Result<String, ApiError> {
        self.principal.clone().filter(|p| !p.is_empty()).ok_or_else(|| {
            ApiError(Fail {
                code: "permissiondenied".to_owned(),
                message: "This sign-in is bound to no gunnar principal, so it has no passkeys of its own to list."
                    .to_owned(),
            })
        })
    }

    /// The console's own key, for the box's administrator.
    fn console(state: &crate::app::AppState, who: &str) -> Actor {
        Actor {
            gunnar: state.gunnar.clone(),
            who: who.to_owned(),
            console: true,
            principal: None,
        }
    }

    /// The person's own credential — their identity-core session — or the
    /// refusal that says why there is none.
    pub(crate) async fn person(state: &crate::app::AppState, session: &crate::auth::Session) -> Result<Actor, ApiError> {
        Actor::person_from(&session.user, identity_of(state, session).await)
    }

    /// [`Actor::person`] for the request's signed-in person named `user`
    /// ([`PERSON`]): a handler that holds a name and no session.
    pub(crate) async fn person_named(state: &crate::app::AppState, user: &str) -> Result<Actor, ApiError> {
        Actor::person_from(user, person_identity(state, user).await)
    }

    fn person_from(
        user: &str,
        identity: Result<Option<crate::tenant::TenantIdentity>, crate::tenant::TenantError>,
    ) -> Result<Actor, ApiError> {
        match identity {
            Ok(Some(identity)) => Ok(Actor {
                gunnar: identity.gunnar,
                who: user.to_owned(),
                console: false,
                principal: Some(identity.principal_id),
            }),
            Ok(None) => Err(ApiError(Fail {
                code: "permissiondenied".to_owned(),
                message: "Your sign-in holds no key at gunnar any more, so this console cannot \
                          act as you; log in again."
                    .to_owned(),
            })),
            Err(why) => {
                tracing::warn!(
                    target: crate::auth::AUDIT_TARGET,
                    event = "tenant.credential_unavailable",
                    user = %user,
                    reason = %why,
                    "this console could not sign as the person, so their act was not sent"
                );
                Err(ApiError(Fail {
                    code: "unavailable".to_owned(),
                    message: format!(
                        "This console could not sign as you just now, so nothing was changed: \
                         {why}"
                    ),
                }))
            }
        }
    }
}

impl axum::extract::FromRequestParts<crate::app::AppState> for Actor {
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut http::request::Parts,
        state: &crate::app::AppState,
    ) -> Result<Actor, ApiError> {
        Actor::of(state, parts.extensions.get::<crate::auth::middleware::Caller>()).await
    }
}

impl Actor {
    /// The [`Actor`] for `caller`, for a handler that needs one only on some
    /// requests (a PATCH that touches visibility and not just the branch).
    pub(crate) async fn of(
        state: &crate::app::AppState,
        caller: Option<&crate::auth::middleware::Caller>,
    ) -> Result<Actor, ApiError> {
        match caller {
            // The break-glass token: bootstrap — the console's own key.
            Some(crate::auth::middleware::Caller::AdminToken) => {
                Ok(Actor::console(state, crate::auth::middleware::Caller::AdminToken.name()))
            }
            #[cfg(test)]
            Some(caller) if console_stand_in(state, caller) => Ok(Actor::console(state, caller.name())),
            // Every passkey session acts as its person — the administrator
            // too: their role is their server-wide grant at gunnar.
            Some(crate::auth::middleware::Caller::Operator(session)) => Actor::person(state, session).await,
            None if !state.passkey_enabled() && state.admin_token.is_none() => {
                Ok(Actor::console(state, crate::forges::OPEN_CONSOLE))
            }
            None => Err(ApiError(Fail {
                code: "permissiondenied".to_owned(),
                message: "this console acts only for a session it can name".to_owned(),
            })),
        }
    }
}

/// **May the caller of `gunnar` do `level` on `namespace` / `repo`?** —
/// gunnar's own `Access.Check`, asked AS the caller (thin layer, owner
/// 2026-10-04). The same decision every verb's own check makes (grants,
/// groups, account ownership, the gate), asked without acting; the page draws
/// a control from it and the verb, signed as the same person, is decided by
/// gunnar again. `Err` is gunnar not answering — never "no".
///
/// `Ok((allowed, reason))`: `reason` is gunnar's refusal sentence when not
/// allowed, empty when allowed.
pub async fn may(
    gunnar: &crate::grpc::Gunnar,
    namespace: &str,
    repo: &str,
    level: pb::GrantLevel,
) -> Result<(bool, String), ApiError> {
    let answer = gunnar
        .access()
        .check(pb::CheckRequest {
            namespace: namespace.to_owned(),
            repo: repo.to_owned(),
            level: level as i32,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok((answer.allowed, answer.reason))
}

/// **Test fixtures only**: a session a test minted directly
/// (`Webauthn::make_session`) at operator or admin, bound to no principal,
/// reads and acts with the console's own key — the stand-in for "this person's
/// key holds a server-wide grant" in the many fixtures that model gunnar as
/// "the console's key may do everything". Never compiled outside `cfg(test)`;
/// production has no such arm.
#[cfg(test)]
fn console_stand_in(state: &crate::app::AppState, caller: &crate::auth::middleware::Caller) -> bool {
    let crate::auth::middleware::Caller::Operator(session) = caller else {
        return false;
    };
    let Some(webauthn) = state.webauthn.as_ref() else {
        return false;
    };
    webauthn
        .planted_role(&session.user)
        .is_some_and(|role| role.allows(crate::role::Role::Operator))
        && webauthn.principal_of(&session.user).is_none()
}

/// **A caller who may hold a forge token and import repositories** — any
/// session that can be NAMED, since 2026-10-02.
///
/// An extractor for the reason [`Reader`] and `buy_site::GunnarSite::operator` (was `increase::Ordering`) are: a
/// helper is something a handler can forget to call, and forgetting here means
/// a visitor with no session at all reaches a surface that holds somebody's
/// GitHub credential and points repositories at remotes. A handler either has
/// one of these in its signature or it has no name to key a connection by.
///
/// # Who may do what, and where that is decided
///
/// Until 2026-10-02 this was an ADMIN floor, because the console calls gunnar
/// with its own server-wide admin key and gunnar cannot tell one browser
/// session from another. The reason stands; the floor moved. An ordinary user
/// may now import into a namespace where THEIR principal holds write or admin,
/// and that is checked against gunnar's own grant table by
/// [`mirror_gate`] before `Mirror.Add` is signed — so this extractor carries
/// what the check needs ([`Importer::admin`], [`Importer::principal`]) and
/// decides nothing itself. An administrator's session keeps today's full,
/// box-wide power: [`Importer::admin`] is true and the grant check is skipped.
///
/// Connecting a forge (`/api/forges`) needs only a name: the connection is the
/// person's own, keyed to their session in [`crate::forges::ForgeSessions`],
/// and reaches no gunnar RPC.
///
/// # Anonymous gets nothing, and the refusal is here
///
/// None of these paths is on `is_anonymous_read` or `is_anonymous_page`, so a
/// request with no session is refused by the middleware before a handler
/// exists. This is the second wall, and it is the one that would still hold if
/// somebody added a mirror route to an allow-list by mistake: with no `Caller`
/// and no open-console posture there is no owner to key a connection by, so the
/// refusal is a fact about the request rather than a rule to remember. The
/// guest identity is **never** reached from any of these handlers.
#[derive(Debug, Clone)]
pub struct Importer {
    /// The session this connection belongs to, and the name in the audit line.
    /// Also the key into [`crate::forges::ForgeSessions`] and into
    /// [`mirror_gate::Imports`].
    pub who: String,
    /// True for an administrator's session, the break-glass token and the
    /// open console: the box-wide view and no per-namespace check.
    pub admin: bool,
    /// The gunnar principal this session's passkey is BOUND to, read out of
    /// the console's own table and never resolved from a name (GUI-001).
    /// `None` for the break-glass token, the open console, and a passkey bound
    /// to nobody — which may then import nowhere unless it is an admin.
    pub principal: Option<String>,
}

impl axum::extract::FromRequestParts<crate::app::AppState> for Importer {
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut http::request::Parts,
        state: &crate::app::AppState,
    ) -> Result<Importer, ApiError> {
        match parts.extensions.get::<crate::auth::middleware::Caller>() {
            Some(caller) => {
                let principal = match caller {
                    crate::auth::middleware::Caller::Operator(session) => state
                        .webauthn
                        .as_ref()
                        .and_then(|webauthn| webauthn.principal_of(&session.user)),
                    crate::auth::middleware::Caller::AdminToken => None,
                };
                Ok(Importer {
                    who: caller.name().to_owned(),
                    admin: caller.role().allows(crate::role::Role::Admin),
                    principal,
                })
            }
            // The open-console posture: no passkey and no admin token, which
            // `open_console_is_confined` has already restricted to loopback.
            // Told apart by asking the state rather than the request, because
            // it is a fact about how the process was started.
            None if !state.passkey_enabled() && state.admin_token.is_none() => Ok(Importer {
                who: crate::forges::OPEN_CONSOLE.to_owned(),
                admin: true,
                principal: None,
            }),
            None => Err(ApiError(Fail {
                code: "permissiondenied".to_owned(),
                message: "importing repositories needs a session that can be named".to_owned(),
            })),
        }
    }
}

/// **A caller with a session of any role**, for the reads that are not an act
/// of administration.
///
/// Seeing that a mirror exists and where its sync got to is a reader's
/// business, exactly as `GcStatus` is: `role_floor` already puts every GET at
/// [`crate::role::Role::Viewer`], and this adds only the half that table cannot
/// express — that the guest identity must never answer one of these. An
/// anonymous request has no `Caller`, gets no `SignedIn`, and is refused
/// without gunnar being dialled at all.
#[derive(Debug, Clone)]
pub struct SignedIn {
    /// Who is reading, for the audit line and for keying a connection.
    pub who: String,
}

impl axum::extract::FromRequestParts<crate::app::AppState> for SignedIn {
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut http::request::Parts,
        state: &crate::app::AppState,
    ) -> Result<SignedIn, ApiError> {
        if let Some(caller) = parts.extensions.get::<crate::auth::middleware::Caller>() {
            return Ok(SignedIn {
                who: caller.name().to_owned(),
            });
        }
        if !state.passkey_enabled() && state.admin_token.is_none() {
            return Ok(SignedIn {
                who: crate::forges::OPEN_CONSOLE.to_owned(),
            });
        }
        Err(ApiError(Fail {
            code: "permissiondenied".to_owned(),
            message: "this console does not answer mirror questions anonymously".to_owned(),
        }))
    }
}

/// A Unix-millisecond timestamp, or `None` when gunnar did not record one.
///
/// Several of gunnar's timestamps are never filled in — principals, grants and
/// namespaces all arrive with a zero — and proto3 cannot tell "unset" from
/// "zero". Passing that through renders as January 1970, which in a relative
/// clock reads as "689 months ago" next to a row created two minutes back.
/// gunnar was not running in 1970, so zero means unrecorded.
pub fn recorded(unix_ms: i64) -> Option<i64> {
    (unix_ms > 0).then_some(unix_ms)
}

/// `<namespace>/<repo>` as a path gunnar understands.
pub fn repo_path(namespace: &str, repo: &str) -> pb::RepoPath {
    pb::RepoPath {
        namespace: namespace.to_owned(),
        repo: repo.to_owned(),
    }
}

/// The lowercase name of a hash kind, or `"unspecified"`.
pub fn hash_kind_name(value: i32) -> &'static str {
    match pb::HashKind::try_from(value) {
        Ok(pb::HashKind::Sha1) => "sha1",
        Ok(pb::HashKind::Sha256) => "sha256",
        _ => "unspecified",
    }
}

/// The lowercase name of a visibility.
pub fn visibility_name(value: i32) -> &'static str {
    match pb::Visibility::try_from(value) {
        Ok(pb::Visibility::Private) => "private",
        Ok(pb::Visibility::Internal) => "internal",
        Ok(pb::Visibility::Public) => "public",
        _ => "unspecified",
    }
}

/// Parse a visibility name back into gunnar's enum.
pub fn visibility_from_name(name: &str) -> Option<pb::Visibility> {
    match name {
        "private" => Some(pb::Visibility::Private),
        "internal" => Some(pb::Visibility::Internal),
        "public" => Some(pb::Visibility::Public),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_page_reports_what_it_left_out() {
        // gunnar hands over everything; the count is the only honest thing the
        // console can say about what it is not showing.
        let page = Page::of((0..500).collect::<Vec<_>>(), 100);

        assert_eq!(page.items.len(), 100);
        assert_eq!(page.total, 500);
        assert!(page.truncated);
    }

    #[test]
    fn a_short_list_is_not_marked_truncated() {
        let page = Page::of(vec![1, 2, 3], 100);

        assert_eq!(page.total, 3);
        assert!(!page.truncated);
    }

    #[test]
    fn a_limit_cannot_be_zero_or_unbounded() {
        assert_eq!(Limit { limit: Some(0) }.get(), 1);
        assert_eq!(
            Limit {
                limit: Some(usize::MAX)
            }
            .get(),
            MAX_LIMIT
        );
        assert_eq!(Limit { limit: None }.get(), DEFAULT_LIMIT);
        assert_eq!(Page::of(vec![0u8; 5000], 0).items.len(), 1);
    }

    #[test]
    fn enum_names_round_trip() {
        for name in ["private", "internal", "public"] {
            let parsed = visibility_from_name(name).expect("a known name");
            assert_eq!(visibility_name(parsed as i32), name);
        }
        assert_eq!(visibility_from_name("secret"), None);
    }

    #[test]
    fn an_unknown_enum_value_is_named_rather_than_guessed() {
        assert_eq!(hash_kind_name(99), "unspecified");
        assert_eq!(visibility_name(0), "unspecified");
    }

    #[test]
    fn an_unrecorded_timestamp_is_absent_rather_than_1970() {
        // Observed against a real gunnar: principals and grants created moments
        // ago carry a zero, which a relative clock renders as "689 months ago".
        assert_eq!(recorded(0), None);
        assert_eq!(recorded(-1), None);
        assert_eq!(recorded(1_700_000_000_000), Some(1_700_000_000_000));
    }
}