2026-09-25 21:35:25 +02:00
|
|
|
# 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](design/filesystem-routes.md).
|
|
|
|
|
|
|
|
|
|
**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](https://project.uhhm.no/uhhm/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](state-machines.md).
|
|
|
|
|
|
|
|
|
|
**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](access.md).
|
|
|
|
|
|
|
|
|
|
**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](https://project.uhhm.no/uhhm/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.
|
2026-09-28 16:38:05 +02:00
|
|
|
|
|
|
|
|
**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.
|