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/app.rs

//! What every handler shares.

use std::path::PathBuf;
use std::sync::Arc;

use crate::api::Caches;
use crate::auth::Webauthn;
use crate::git::ObjectCache;
use crate::grpc::Gunnar;
use crate::sse::EventHub;

/// The console's server-side state.
pub struct App {
    /// `None` when passkey login is not configured, which disables the whole
    /// `/auth/passkey/*` surface and leaves the console open on loopback.
    pub webauthn: Option<Arc<Webauthn>>,
    /// The break-glass bearer token, used once to enroll the first passkey.
    ///
    /// It exists because the registration ceremony has to be reachable before
    /// any credential does: without it the console can never gain its first
    /// admin.
    pub admin_token: Option<String>,
    pub gunnar: Gunnar,
    /// **The identity anonymous traffic is served as, and NEVER the one above.**
    ///
    /// [`App::gunnar`] signs with the console's own key, and that key holds a
    /// **server-wide `Admin` grant** — `gunnar-ui-server enroll` asks for one,
    /// because the console administers the server. Serving a route that needs
    /// no session with that credential is the textbook confused deputy: the
    /// visitor is anonymous, the credential is not, and gunnar answers the
    /// credential. `Repos.List` on the admin key returns **every private
    /// repository on the box**, which is precisely what a login page must not
    /// be able to print.
    ///
    /// So anonymous traffic gets a second channel signed by a second key
    /// belonging to a second gunnar principal that holds **no grants at all**.
    /// Since gunnar's `Repos.List` narrows a grantless caller to the public
    /// subset rather than refusing it, that principal can see exactly what a
    /// stranger is allowed to see and nothing else — and the enforcement is on
    /// gunnar's side of the wire, where a mistake in this crate cannot reach it.
    ///
    /// `None` when no guest key is configured, and then **the anonymous surface
    /// does not exist**: the routes answer empty or 404 rather than falling back
    /// to [`App::gunnar`]. A fallback is the one thing this field cannot be
    /// allowed to have, because the fallback is the hole.
    pub guest: Option<Gunnar>,
    /// **The bearer this console presents to monetize, and the third
    /// credential in this struct.**
    ///
    /// `None` — and on a build without `metered` the field is not here at all —
    /// means the console sells nothing: `serve::router` registers no route for
    /// it, so a tenant sees no panel, not a disabled one. That is the same
    /// shape as [`App::guest`] and for the same reason: a surface that cannot
    /// be served correctly must be absent rather than degraded.
    ///
    /// It must NEVER be filled in from either of the other two. They are
    /// different protocols to different servers — [`App::gunnar`] signs
    /// `sshsig`, this one sends a bearer — and the type system is what says so:
    /// `Monetize` has no constructor that takes a `Signer`.
    #[cfg(feature = "metered")]
    pub monetize: Option<crate::monetize::Monetize>,
    /// **The twin's control plane** (`GUNNAR_UI_TWIN`), signed with this console's own
    /// key. `None` means no twin is configured: the restore action is never
    /// drawn. The Twin panel does not need it — gunnar reports its twins itself.
    pub twin: Option<crate::api::twin::Twin>,
    /// Per-reviewer signing identities: the key a web approval is signed with,
    /// so the approval names the REVIEWER against gunnar's key table and not
    /// the console. See [`crate::reviewer`].
    pub reviewers: Arc<crate::reviewer::ReviewerKeys>,
    /// **Per-tenant READ credentials, and the fifth credential in this
    /// struct.**
    ///
    /// [`App::gunnar`] is the console's own key and [`App::guest`] is nobody's;
    /// a logged-in person is neither. This holds one key per console user,
    /// enrolled on that person's own gunnar principal, so their reads carry
    /// their grants — which is what makes a paying tenant able to see their own
    /// private repositories without the console lending them its server-wide
    /// Admin. See [`crate::tenant`], and [`crate::api::Reader`] for which
    /// session gets which.
    pub tenants: Arc<crate::tenant::TenantKeys>,
    pub events: EventHub,
    /// **The forge tokens this console is holding, and the ONLY place one
    /// exists in this process.**
    ///
    /// A fourth credential, and the only one that belongs to a *person* rather
    /// than to the console: the three above are the console's own identity, the
    /// grantless guest, and the monetize bearer, all read from the environment
    /// at start. This one arrives from a browser — pasted, or fetched by the
    /// OAuth callback — is keyed to the session that produced it, is never
    /// written to disk, and is handed to `Mirror.SetCredential` exactly once
    /// before being dropped. See [`crate::forges`].
    ///
    /// In-memory on purpose. A restart forgets every connection, which is the
    /// right trade: a persisted forge token is a long-lived bearer credential
    /// for somebody's whole account sitting in a file, and reconnecting costs
    /// one paste.
    pub forges: Arc<crate::forges::ForgeSessions>,
    /// The OAuth applications this console was configured with, if any.
    ///
    /// Empty is the ordinary state and not a degraded one: pasting a token
    /// works on every forge, self-hosted included, and needs no app
    /// registration. A forge with no app here has **no button** — absent, not
    /// disabled, the same shape as [`App::guest`] and the monetize routes.
    pub forge_apps: Arc<crate::forges::ForgeApps>,
    /// **What each person has in flight, and the limits on it** — the per-user
    /// half of importing into one's own namespace: at most N imports or syncs
    /// at once and at most M syncs an hour. In memory, like [`App::forges`]. See
    /// [`crate::api::mirror_gate`].
    pub imports: Arc<crate::api::mirror_gate::Imports>,
    /// Where the built console lives. `None` serves the API only.
    pub ui_dir: Option<PathBuf>,
    pub caches: Caches,
    /// Git objects held by id, shared by every request.
    ///
    /// An object is immutable under its id, so this never needs invalidating —
    /// and it is what stops a second person opening the same file from paying
    /// for it again in gunnar's credential budget.
    pub objects: std::sync::Arc<ObjectCache>,
    /// **Rendered READMEs, held by the blob they were rendered from.**
    ///
    /// A second cache beside [`App::objects`] and for a different reason: that
    /// one holds what gunnar sent, this one holds what typst made of it. Both
    /// are keyed by an object id and neither ever needs invalidating, because a
    /// git object is immutable under its id — a push that changes the README
    /// asks a different question rather than making this answer wrong.
    ///
    /// Shared across sessions on purpose, and safely: what is in it is the
    /// typeset form of a blob somebody was already allowed to read, and reading
    /// it needs the key that names the blob's oid. gunnar decides whether the
    /// caller may get that far; see [`crate::api::readme`].
    pub readmes: std::sync::Arc<crate::readme::Cache>,
}

/// A cheap handle to [`App`].
pub type AppState = Arc<App>;

impl App {
    /// True when passkey login is configured, so the console must be logged in
    /// to.
    pub fn passkey_enabled(&self) -> bool {
        self.webauthn.is_some()
    }

    /// **True when this console can carry a paid signup all the way through.**
    ///
    /// Both halves are needed and neither is optional: monetize is the only
    /// party that knows whether the money landed, and the passkey subsystem is
    /// the only door an invite can be minted through. With either missing there
    /// is nothing to serve at `/welcome` but a page that would poll for three
    /// minutes and then apologise, so the routes are not registered and the
    /// paths are not public — the same shape as [`App::guest`] and the money
    /// panel, and for the same reason.
    ///
    /// Always false without the `metered` feature, where the code is not in the
    /// binary at all.
    pub fn signup_offered(&self) -> bool {
        #[cfg(feature = "metered")]
        {
            self.monetize.is_some() && self.webauthn.is_some()
        }
        #[cfg(not(feature = "metered"))]
        {
            false
        }
    }

    /// **The site whose shop this console's purchases are**, on a SOLD
    /// (provided) Gunnar (one shop, owner 2026-10-07): its own monetize sells
    /// nothing, and every purchase is the provider's. `None` where this
    /// estate sells (or sells nothing at all). Metered only.
    #[cfg(feature = "metered")]
    pub fn provider(&self) -> Option<String> {
        self.monetize.as_ref().and_then(|m| m.provider().map(str::to_owned))
    }

    /// **True when THIS console sells**: it can carry a paid signup
    /// ([`App::signup_offered`]) and its shop is not a provider's
    /// ([`App::provider`]). The sign-up pages and every purchase route hang on
    /// it; a SOLD Gunnar offers no purchase of its own.
    pub fn sells_here(&self) -> bool {
        #[cfg(feature = "metered")]
        {
            self.signup_offered() && self.provider().is_none()
        }
        #[cfg(not(feature = "metered"))]
        {
            self.signup_offered()
        }
    }

    /// **True when a purchase may START on this console**: monetize is
    /// behind it and its shop is not a provider's. The routes that begin an
    /// order (more room, a renewal, a bundle, users bought with room) hang on
    /// it; the reads beside them stay. Metered only.
    #[cfg(feature = "metered")]
    pub fn buys_here(&self) -> bool {
        self.monetize.is_some() && self.provider().is_none()
    }

    /// True when this console can serve a visitor with no session at all.
    ///
    /// Reads as a question about configuration and not about a request, because
    /// that is what it is: with no guest key there is no identity to serve
    /// anonymous traffic AS, and the honest answer to every anonymous request is
    /// the one the console gave before this existed.
    pub fn anonymous_enabled(&self) -> bool {
        self.guest.is_some()
    }
}