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