Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

One Rust binary (Leptos SSR + hydrate, Axum underneath) serving any number of sites. The engine special-cases nothing: every page, form, desk and state graph a site serves is declared in its content repo, and instances differ only by environment variables.

It composes with the surrounding stack rather than bundling it. Gitea hosts the content, NATS JetStream stores the events and projections, Kanidm provides identity, and anything downstream subscribes to the answer stream.

The pieces

Content (src/content/) — YAML loaded from Gitea at boot and hot-swapped on a NATS reload signal. A bad push keeps the last-good content serving. The questions/ tree is the router: file paths become URLs, _section.yaml applies to a whole directory, and [name].yaml serves any /dir/<value> with the segment fed into the page’s resource keys. See filesystem routes.

The lint (src/content/validate.rs) — every rule a content repo is held to. It runs at boot, on every hot reload, and in a content repo’s CI through iris, which depends on this crate and so applies exactly the version the instance runs.

State machines (src/aggregates/) — aggregates.yaml declares each bucket’s states and legal moves. A record’s state is replayed from its own event history, undeclared moves are refused, and racing decisions are settled by CAS on the log’s sequence. An empty history reseeds from the KV projection, so wiping the event stream strands nothing. See state machines.

Events and projections (src/events/, src/answers.rs) — every submission and decision appends to a JetStream log and projects into a NATS KV bucket that pages read back, and publishes on portal.answers.submitted for automations to react to.

Answer chains (src/chain.rs) — each submission hashes its parents, so a visitor’s path through the questions is a verifiable lineage. ?chain= links carry it; a followup page is offered only once the chain has earned it, and a page behind an answer somebody has not given is never offered.

Access (src/access.rs) — who may do what to what, compiled from content on every load into one Cedar policy set that every server entry point asks. See access.

People (src/people.rs) — onboarding as a form. An alternative with invite: {group} creates the person in Kanidm, records their email, adds them to the group and mints a one-time credential link. A state may also grant a group on its own (grants:), so an approval that means membership needs no second step.

Mail (src/mail.rs) — a state may declare the mail its arrival sends. Portal renders it and publishes on portal.mail.send; gdo delivers it and carries replies back on portal.mail.received, where they become notes beside the record. A note never moves a case.

Sweepers (src/announce.rs, src/deadlines.rs) — the two things that move without a person. An announced page’s window closes and its record becomes a “post what happened” task; a state with a deadline waits out its days and then makes the move itself. Both go through the same transition path as a desk button and publish the same event.

Resources (src/resource.rs) — content can read a KV bucket, Gitea starred repos, org repos or releases, or any public JSON URL (SSRF fail-closed), reshaped by a content-declared jq filter. What a resource reads is never a client-supplied parameter: the client names a page and a feature, never a bucket or URL.

Maps (src/maps.rs) — MapLibre is vendored and every tile, style and still image is proxied through portal, so a page with a map makes no third-party request and needs no consent banner.

Desks — any Kv resource with transitions renders rows with per-state buttons and one shared confirm. Owners walk records through their graphs with no bespoke UI per bucket.

Binaries

  • portal — the server.
  • iris — the lint, from its own repo, shipped in every release tarball so a content repo’s CI can lint with the exact version its instance runs.

Sessions (src/sessions.rs) — in the same JetStream KV as everything else, so a restart does not sign anybody out. The bucket carries a max_age so NATS collects what nobody comes back for, and each record’s own expiry_date is what decides on the way out.