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.