Gunnar

nordisk/gunnar-ui

crates/gunnar-ui-server/Cargo.toml

[package]
name = "gunnar-ui-server"
description = "Backend-for-frontend for the gunnar console: passkey auth, REST/SSE, sshsig-signed gRPC to gunnar"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
repository.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true

[features]
default = []
# **The paid half, compiled out.** The name is gunnar's own
# (`gunnar-server/Cargo.toml`), reused rather than invented: the appliance turns
# ONE word on across every binary it ships, and a second spelling here would
# mean an operator could build a console that talks about money against a
# gunnar that cannot. gunnar's rule is quoted verbatim in its
# `entitlement.proto`: "the community appliance does not DECLINE to enforce
# plans, it does not CONTAIN the code". Same here — without this feature the
# monetize protos are not compiled, the client type does not exist, and
# `serve::router` has no route to register. TLS is the monetize channel's: a
# monetize off this box is dialled over https:// only (stinky #25).
metered = ["tonic/tls-ring", "tonic/tls-webpki-roots", "dep:monetize-site-buy", "dep:monetize-proto"]
# **gunnar.rs's public face** (owner 2026-10-25, the SLIM RULE): llms.txt,
# robots.txt, the sitemap, the head tags, the public home and the public
# repository pages, all in the separate `gunnar-public-site` crate and mounted
# only here. OFF by default: the default build has no such crate in its tree
# and serves none of those routes (`tests/public_site_feature.rs`). gunnar.rs's
# release build turns it on through gunnar-front's `public-site`.
public-site = ["dep:gunnar-public-site", "dep:ureq", "dep:nordisk-console-switch"]
# **Gunnar's MCP connector** (TASKS-13, owner 2026-10-25, the same SLIM RULE as
# the public site): `/mcp` and its OAuth 2.1 server, all in the separate
# `gunnar-mcp` crate and mounted only here (`src/mcp.rs`). OFF by default: the
# default build has no such crate in its tree and serves no `/mcp`
# (`tests/mcp_feature.rs`, `anonymous::mcp`). gunnar-front's `mcp` turns it on.
mcp = ["dep:gunnar-mcp", "dep:nordisk-console-switch"]
# Forge import (connect GitHub or a GitLab; list; import): outbound, so OFF by
# default (AIRGAP). The console also needs GUNNAR_UI_FORGES=on. Every gunnar-front row
# that ships the console names it, so a live or sold box keeps it.
forges = ["dep:gunnar-forge", "dep:ureq"]
# **Company sign-in** (A34-15): the SSO doors (`/auth/sso/*`, `/login/sso/<account>`,
# `/scim/v2/*`) and the account admin's Identity provider API (`/api/account/sso*`).
# OFF by default: the default build serves none of them and its status offers no
# company field (`anonymous::sso_switch`). gunnar-front's `sso` turns it on, and every
# gunnar-front row that ships the console names it.
sso = []
# The invite mail (an SMTP relay, AIRGAP): OFF by default; a mail build also needs its
# relay configured (GUNNAR_UI_SMTP_FROM plus a token, `auth::mail`) before it sends.
mail = ["dep:lettre"]

[dependencies]
# ── the ONE gunnar crate this console links, and why it is the only one ──────
#
# `tests/contract.rs` says out loud that gunnar's protos and its credential
# constants are COPIED here rather than depended on, because `gunnar-grpc` pulls
# in `gunnar-proto`, whose build script needs `protoc` and pins tonic 0.13
# against this workspace's 0.14. That argument is about the proto graph and
# reaches exactly as far as the proto graph.
#
# `gunnar-forge` is outside it. Its whole dependency list is `ureq`,
# `serde_json` and `thiserror`; it links nothing of gunnar's, has no build
# script, and gunnar's own `xtask/src/guard.rs` keeps it out of the appliance
# graph deliberately — the appliance has no outbound HTTPS and must never hold
# a user's forge token. Its two intended callers are `gunnar-cli` on a laptop
# and this BFF.
#
# Copying it instead would mean a second implementation of "attach a token to a
# request" — the one thing that crate is careful about, with its own
# `token_never_leaks.rs` proving the negative — and two copies of that is how
# one of them ends up putting a credential in a URL.
#
# FROM THE REGISTRY, not a sibling checkout (2026-09-10). This line used to read
# `{ path = "../../../gunnar/crates/gunnar-forge" }` and the comment here used to
# call the resulting cost — "a machine with no gunnar beside this repo cannot
# build the console" — the price of not duplicating the token handling. It was
# the wrong price to pay, because it is not paid by whoever accepts it: a path
# dependency escaping the repo makes a FRESH CLONE unbuildable, and no clone can
# carry the instruction "first park gunnar at exactly ../../../gunnar". The operator's
# ruling was that `git clone <gunnar-ui> && cargo build --release -p
# gunnar-ui-server` must succeed with NO sibling checkouts, and this was the ONE
# blocker (`cargo metadata` on a clean clone names no other path-only package).
#
# So gunnar now publishes this crate and ONLY this crate — see its manifest and
# the `[repos.gunnar]` block in the constellation descriptor, both of which carry
# the reason. The argument above is unchanged and is why the fix was "publish it",
# never "copy it".
#
# `"1.4"` is a caret requirement on gunnar's workspace version, which is what
# gunnar-forge is versioned by; `Cargo.lock` pins the exact 1.4.0. It also means
# THE SIBLING CHECKOUT NO LONGER DECIDES WHAT IS BUILT: a path dep always
# compiled the newest source next door, so nothing ever verified what this
# manifest declared, and a floor below the API actually called was invisible
# until the first consumer resolved from the registry.
# AIRGAP (owner rule 2026-10-25): optional, behind `forges` (off by default): forge import
# dials the public forges (github.com, gitlab.com, …). A `forges` build also needs
# GUNNAR_UI_FORGES=on before any door answers.
gunnar-forge = { version = "1.4", optional = true }
# The OAuth code-for-token exchange, and nothing else. Pinned to the same major
# `gunnar-forge` uses so the tree holds ONE HTTP client: pure Rust, rustls, no
# OpenSSL, blocking — which is why every call to it goes through
# `spawn_blocking` (see `api::forges`). `default-features = false` + `tls` drops
# gzip, exactly as gunnar-forge does, for a body that is one JSON object.
ureq = { version = "3", default-features = false, features = ["rustls"], optional = true }

tonic.workspace = true
tonic-prost.workspace = true
prost.workspace = true
anyhow.workspace = true

# Every service in the suite goes through skidbladnir — unit generation,
# install, uninstall. See `lifecycle`, and the workspace manifest for why it is
# a published version rather than the checkout next door.
skidbladnir.workspace = true
serde.workspace = true
serde_json.workspace = true

tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync"] }
tokio-stream = { version = "0.1", features = ["sync"] }
futures-util = "0.3"
async-stream = "0.3"

axum = { version = "0.8", features = ["json", "query"] }
tower = "0.5"
# `fs` serves the built console; the two compression features are what keep the
# 1.38 MB wasm bundle from being 1.38 MB on the wire (see `serve::console_files`).
# Both are pure Rust — `flate2`'s rust backend and the `brotli` crate — so the
# binary stays airgap-clean and needs no second toolchain.
tower-http = { version = "0.7", features = [
  "fs",
  "compression-gzip",
  "compression-br",
] }
http = "1"

# The passkey ceremony. NOT hand-rolled here any more. It was — 142 lines of
# clientDataJSON/authenticatorData checking, attestation parsing and ES256
# verification — and the same 142 lines stood, character for character, in
# holger-ui. holger-ui stopped carrying them on 2026-09-20 (`1e4b98c`) and
# links `nornir-auth-webauthn` instead; this console is the second half of that
# pair and now links the same crate. Two copies of one signature check were two
# places for one bug to be fixed in one of them.
#
# default-features = false + webauthn-pure is the airgap-clean backend and
# nothing else: pure RustCrypto (p256 0.14 since 2026-10-22, the generation russh
# brings — sha2, ciborium, base64, blake3), ES256 over P-256 only, NO
# OPENSSL, transitively or otherwise. The crate's other backend (`webauthn`,
# which reuses webauthn-rs) links the native openssl stack, which is exactly
# what may never enter this binary.
# The `__Host-` HttpOnly session cookie, edda's one copy (A34-38, M-5). A PATH
# with the version kept, as the siblings above: a crates.io build waits on the
# nornir-identity-session 0.1.1 publish.
nornir-identity-session = { version = "0.1.1", path = "../../../edda/crates/nornir-identity-session", default-features = false, features = ["session"] }
# The login page helpers, edda's one copy for both consoles (A34-51, M-7): spliced
# into /login. A PATH with the version kept: a crates.io build waits on the
# nornir-identity-web 0.1.0 publish.
nornir-identity-web = { version = "0.1.0", path = "../../../edda/crates/nornir-identity-web", default-features = false, features = ["login-js"] }
# The directory's one CSV limit and its named refusal (A34-44, M-6). `csv` only:
# no SCIM, nothing decided here; gunnar judges every row.
nornir-identity-directory = { version = "0.1.1", path = "../../../edda/crates/nornir-identity-directory", default-features = false, features = ["csv"] }
# A PATH with the version kept, the one copy the identity core links too (one
# `nornir-auth-webauthn` in the graph): the console still reads a registration's
# credential and key out of the browser's attestation object (`pure`) before it
# hands them to the core, which verifies everything again.
nornir-auth-webauthn = { version = "0.2.1", path = "../../../edda/crates/nornir-auth-webauthn", default-features = false, features = [
    "webauthn-pure",
] }
# **The ONE identity core's client** (IDENTITY-HOST, A34-18/19/21): login, step-up,
# logout, invites, the first admin and every session are gunnar's
# `nornir.identity.v1.IdentityService`; the console relays and keeps nothing. `grpc`
# only: the generated client (and the server type the tests mount). A PATH with the
# version kept, as the siblings above.
nornir-identity = { version = "0.2.0", path = "../../../edda/crates/nornir-identity", features = ["grpc"] }
# Still a direct dependency, for the tests that drive a real P-256 key through
# the public door.
p256 = { version = "0.14", features = ["ecdsa"] }
sha2 = "0.11"
ciborium = "0.2"
base64 = "0.23"
blake3 = "1"
uuid = { version = "1", features = ["v4"] }
redb = "2"

# gunnar builds ssh-key without ecdsa/rsa, so it can only decode ed25519 public
# keys. Matching that here keeps the BFF from minting an identity gunnar would
# reject with an opaque "did not verify against any registered key".
# pre-release: no stable yet (2026-10-02); exact pin because rc APIs change between
# candidates; move to the stable when it ships.
ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["ed25519", "std", "rand_core"] }
rand = "0.10"

# **The estate, drawn — the suite's ONE renderer** (owner, 2026-10-04): the
# Overview's Machine card draws `Admin.Topology` with the same board monetize's
# console draws with `EstateAdmin.Draw`. Zero dependencies, a pure function from
# rows to an SVG string.
#
# A PATH with the version KEPT, not a `[patch.crates-io]`: gunnar's front links
# this crate by path (`gunnar-front` → `gunnar-ui-server`), and gunnar's workspace
# forbids `[patch.crates-io]` (the structural publishing blocker) — a patch here
# would not reach it, and a bare `"0.1"` does not resolve until the crate is on
# crates.io. This is the spelling gunnar used for nornir-build-thing before it was
# published: the day nordisk-estate-svg 0.1 is on crates.io, the `path =` is
# deleted and nothing else changes. Until then a clone needs `../monetize` beside
# it (as it already needs `../edda` for the bridge in the workspace root), and the
# build container mounts it (`gunnar-ui-recipe`: `siblings`).
nordisk-estate-svg = { version = "0.1", path = "../../../monetize/crates/estate-svg" }
# The administrator's switches' rules and their follower, once for both consoles (RA2-15, edda):
# only in a `public-site` or `mcp` build, the builds that carry a switch.
nordisk-console-switch = { version = "0.1.0", path = "../../../edda/crates/nordisk-console-switch", optional = true }

# **The buy flow, shared with every product site** (owner 2026-10-23, A12): this console's
# sign-up, private-instance and grow doors, MOVED into monetize's open crate so Holger runs the
# same code; this console calls it with its own session, tickets and pages (`auth::buy_site`).
# A path with the version kept, for estate-svg's reason above. `metered` only: an open console
# sells nothing.
monetize-site-buy = { version = "0.3.0", path = "../../../monetize/crates/monetize-site-buy", optional = true }
# monetize's wire (RA3-4): the crate, not hand copies of its protos.
monetize-proto = { version = "0.3.0", path = "../../../monetize/crates/monetize-proto", optional = true }

# Encrypted repositories (2026-10-05): the console marks a file whose bytes are
# a gunnar-crypt envelope as `sealed`. FORMAT ONLY: `default-features = false`
# is the crate with no cipher and no dependencies at all — the console holds no
# key and opens nothing (the browser does, with the reader's own key ring).
# A sibling path for the same reason as estate-svg above; the console's wasm
# links the same crate with `cipher` + `diff`.
gunnar-crypt = { version = "1.4", path = "../../../gunnar/crates/gunnar-crypt", default-features = false }

# **The Twin panel's verbs** (5.4 resilience, 2026-10-10): review_twin,
# recharge_from_twin and fence_primary are gunnar-repair's — ONE implementation
# behind this console's buttons, gunnar's boot check and a pulled monetize order.
# `grpc` = the pump and the poison-able Bootstrap.Restore stream over tonic; the
# adapters over this console's own `pb` are in `api::twin`.
# `>=1.5, <3` (lane G-BOTH, 2026-10-26): gunnar-repair goes to 2.0.0 for the R5C publish (A28
# M11/M12 removed the order check and the vendored fleet module; this console uses neither),
# so this requirement takes gunnar master before and after that merge.
gunnar-repair = { version = ">=1.5, <3", path = "../../../gunnar/crates/gunnar-repair", features = ["grpc"] }

# Phase two decodes git objects itself. gunnar hands back the undecorated
# payload — no "tree <n>\0" header and no zlib frame — so these parse the bytes
# directly, and every call needs the repository's hash kind: a sha256 repository
# has 32-byte oids in its trees and sha1 parsing silently mis-reads them.
gix-object = "0.65"
# Both algorithms, because gunnar serves both: sha1 for older repositories and
# sha256 for anything it creates now.
gix-hash = { version = "0.27", features = ["sha1", "sha256"] }
# The same diff engine gitoxide uses, without gitoxide's object database: this
# console fetches objects one at a time over gRPC, which no synchronous
# object-lookup trait can express.
imara-diff = "0.2"
# Pure Rust (fancy-regex, not onig): the console must build and run in an
# airgap, and a C dependency is a second toolchain to keep working there.
syntect = { version = "5", default-features = false, features = [
  "default-fancy",
] }

# ── The README view: markdown -> typst -> HTML / SVG, all in this process ───
#
# **The engine is edda's, not ours.** `nornir-docbook` is the constellation's
# documentation render primitive — assembled markdown, through pulldown-cmark,
# into typst markup, out as PDF / HTML / per-page SVG, no shellout and no
# JavaScript. It already had every part of this pipeline; a second copy living
# here would have been a second markdown->typst translator to keep correct, and
# the one in edda has scar tissue this one would have had to grow again (its
# escaping is the way it is because `**SIGKILL**s` once took down a 297 KB
# render and named no line).
#
# 0.1.1 is the version that made it usable from a SERVER rather than a build
# step, and the three things it added are the three things a forge needs:
# `ImageSet` (images offered from memory, so a README's picture can come out of
# a git tree over gRPC with no directory mounted and no outbound fetch),
# `export_svg_column` (one tall page at a column width, rather than a book cut
# into A4 sheets), and `export_html` (the same, with the text still text).
#
# **0.1.2 is what makes the typeset page the DEFAULT here.** It adds
# `TextLayer`: an invisible `<text>` element over each shaped run, at the
# baseline the typesetter measured, stretched to the run's advance width — the
# text layer a PDF viewer has always had. Until it existed the typeset grade was
# unreadable by anything but an eye, and a console cannot lead with a page a
# screen reader sees nothing in. Now it can, so it does.
#
# **0.1.3 gives the links back.** 0.1.2's text layer sat on top of every link
# and swallowed its click — on gunnar.rs, the README's link to its own PDF. The
# links are lifted into a layer above the text, so the default grade's links
# work, which `readme::links` then points at the console's own routes.
#
# **0.1.4 gives the PDF its pictures.** `export_pdf` takes the same `ImageSet`
# the column export always took, so "View PDF" on a README with a badge draws
# the badge instead of its alt text — measured missing on gunnar.rs 2026-09-12.
nornir-docbook = "0.1.4"
# The sanitiser. Pure Rust (html5ever), and what makes both of docbook's
# outputs safe to put through the console's `inner_html`: no script, no style,
# no iframe, no event handlers, no `javascript:` — see `readme::sanitise`.
ammonia = "4"
# **Pinned to the major docbook uses**, so the tree holds ONE markdown parser.
# This crate parses a README exactly once and for exactly one reason: to learn
# which images it references, so the blobs can be fetched out of the tree before
# the render (`api::readme::destinations`). The translation itself is docbook's.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# A README picture's width and height, read off its header, so a picture too
# large to carry is laid out at its own size and named by URL
# (`readme::pictures`). Already in the tree under nornir-docbook with exactly
# these formats; named here so the use says so.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
# A README's link targets, decoded and re-encoded into console URLs
# (`readme::links`), and a raw file's name in its `Content-Disposition`.
# Already in the tree under `url`; named here so the two uses say so.
percent-encoding = "2"

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Outbound SMTP for the invite mail (`auth::mail`), behind `mail` (AIRGAP). **rustls, never
# native-tls**: Njord's email crate reaches for `tokio1-native-tls`, and taking
# that here would put OpenSSL into a binary whose own manifest says "no
# openssl, so the binary stays airgap-clean" — and the tree currently has one
# rustls and zero native-tls. `builder` is the Message type; no other feature
# is on, so no IMAP and no pool.
lettre = { version = "0.11", default-features = false, features = [
    "builder",
    "smtp-transport",
    "tokio1",
    "tokio1-rustls-tls",
    "ring",
    "webpki-roots",
], optional = true }

# The public site (`public-site` only); see the feature above.
gunnar-public-site = { version = "1.5", path = "../gunnar-public-site", optional = true }
# The MCP connector (`mcp` only); see the feature above.
gunnar-mcp = { version = "1.5", path = "../gunnar-mcp", optional = true }

[dev-dependencies]
tempfile = "3"
# The identity core itself as the fake gunnar's identity half (IDENTITY-HOST): edda's
# `IdentityGrpc` over its in-memory `TestHost`, and a soft passkey.
nornir-identity = { version = "0.2.0", path = "../../../edda/crates/nornir-identity", features = ["grpc", "test-support"] }
# The console switch's follow loop is tested on a paused clock (`console_setting`, RA2-1).
tokio = { version = "1", features = ["test-util"] }
# `auth::buy_site`'s tests: this console's provider over the shared buy flow's fake ONE box
# and fake Stripe (monetize-site-buy `testkit`), the same fake its own tests run against.
monetize-site-buy = { version = "0.3.0", path = "../../../monetize/crates/monetize-site-buy", features = ["testkit"] }
# The test fakes of monetize's services (private_order, increase, subscription) write only the
# calls their tests make: `default-stubs` answers UNIMPLEMENTED for every other method, so a
# new monetize RPC breaks no fake (gunnar-ui main was red at E0046, 2026-10-27).
monetize-proto = { version = "0.3.0", path = "../../../monetize/crates/monetize-proto", features = ["default-stubs"] }
# A page's own JavaScript rule (`/^…$/`), run against the console's: the
# welcome page's name check is a behaviour test, not a grep (stinky #22).
regex = "1"

[build-dependencies]
protox.workspace = true
tonic-prost-build.workspace = true