2026-08-12 23:30:29 +02:00
|
|
|
# portal
|
|
|
|
|
|
2026-08-24 21:53:17 +02:00
|
|
|
A content-driven question engine: one Rust binary that turns a Git
|
|
|
|
|
repo of YAML into a live site — pages, forms, review desks, and state
|
|
|
|
|
machines, with a durable event-sourced record of every answer
|
|
|
|
|
underneath. The engine special-cases nothing: everything a site
|
|
|
|
|
serves is declared in its content repo, and the same build serves any
|
|
|
|
|
number of sites.
|
|
|
|
|
|
|
|
|
|
Point an instance at a content repo and that repo *is* the site:
|
2026-08-25 17:26:29 +02:00
|
|
|
pages, copy, state graphs, branding, and even the landing hero
|
|
|
|
|
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
|
|
|
|
|
hero.js}` — a JavaScript module the content repo ships, served
|
|
|
|
|
same-origin at `/site/<path>`, that portal mounts at parse time and
|
|
|
|
|
stops on navigation; the YES canvas and redoal's sine swings are both
|
|
|
|
|
content, not engine). Content
|
2026-08-24 21:53:17 +02:00
|
|
|
pushes hot-reload every running instance; instances differ only by
|
|
|
|
|
env vars. [uhhm.no](https://uhhm.no)
|
|
|
|
|
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
|
|
|
|
|
[redoal.com](https://redoal.com)
|
|
|
|
|
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
|
|
|
|
|
two faces of the same binary, deployed from the same release
|
|
|
|
|
artifact.
|
|
|
|
|
|
|
|
|
|
It composes with the surrounding stack rather than bundling it: Gitea
|
|
|
|
|
hosts and serves the content, NATS JetStream stores events and
|
|
|
|
|
projections, Kanidm provides identity, and anything downstream
|
|
|
|
|
(automations, newsletters, onboarding) subscribes to the answer
|
|
|
|
|
stream.
|
2026-08-12 23:30:29 +02:00
|
|
|
|
|
|
|
|
## What the engine provides
|
|
|
|
|
|
2026-09-22 17:29:26 +02:00
|
|
|
- **Content-driven pages** (`src/content/`, `src/app.rs`): YAML
|
2026-08-12 23:30:29 +02:00
|
|
|
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
|
2026-08-24 22:33:37 +02:00
|
|
|
a bad push keeps the last-good content serving. The `questions/`
|
|
|
|
|
tree IS the router — file paths become URLs, `_section.yaml`
|
|
|
|
|
applies criteria to a whole directory, `[name].yaml` pages serve
|
|
|
|
|
any `/dir/<value>` with the segment fed into resource keys, and
|
|
|
|
|
`requires_chain` gates a page on verifiable answer provenance next
|
|
|
|
|
to `qualifies`' Kanidm-group identity gate (see
|
|
|
|
|
`docs/design/filesystem-routes.md`).
|
2026-08-30 16:00:25 +02:00
|
|
|
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
|
|
|
|
|
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
|
|
|
|
|
redoal repo) - the input shows the key's own decode under the
|
|
|
|
|
stroke, who is at a similar shape now, and *places* with recordings
|
|
|
|
|
the visitor can pick to make that key theirs; a voice field records
|
|
|
|
|
in the browser and leaves the audio at a key. Actions may be
|
|
|
|
|
templated from the answer (`/shape/{curve.key}`) onto dynamic
|
|
|
|
|
pages, whose URL segment also fills `value: "{key}"` presets and
|
|
|
|
|
`url` resource sources; resource items with an https `audio` field
|
|
|
|
|
render a player.
|
2026-08-30 12:34:34 +02:00
|
|
|
- **Announced pages** (`src/announce.rs`): a question with an
|
|
|
|
|
`event: {starts, duration, place}` window is announced in the
|
|
|
|
|
header (not the footer nav) while the window is open, becomes a
|
|
|
|
|
followup when it closes, and its record in the runtime-owned
|
|
|
|
|
`portal_events` bucket moves to `awaiting_summary` - a "post what
|
|
|
|
|
happened" task any review desk can read, published on NATS like a
|
|
|
|
|
decision. A one-minute sweeper keeps it all idempotent.
|
2026-08-12 23:30:29 +02:00
|
|
|
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
|
|
|
|
|
declares each bucket's states and legal transitions; the engine
|
|
|
|
|
replays a record's event history and refuses undeclared moves, with
|
|
|
|
|
optimistic concurrency (CAS on the event log's sequence) against
|
|
|
|
|
racing decisions. An empty history reseeds from the KV projection,
|
|
|
|
|
so wiping the event stream strands nothing.
|
|
|
|
|
- **Durable events + projections** (`src/events/`, `src/answers.rs`):
|
|
|
|
|
every submission and decision appends to a JetStream event log and
|
|
|
|
|
projects into a NATS KV bucket pages read back; every one also
|
|
|
|
|
publishes on `portal.answers.submitted` for automations (n8n) to
|
|
|
|
|
react to.
|
|
|
|
|
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
|
|
|
|
parent(s), so a visitor's path through the questions is a verifiable
|
|
|
|
|
lineage; `?chain=` links carry it, and self-service transitions
|
2026-09-11 19:40:46 +02:00
|
|
|
(unsubscribe) authorize by holding one. The question nav reads it
|
2026-09-22 16:58:40 +02:00
|
|
|
too: a `followup` page is offered only once the visitor has earned
|
|
|
|
|
it - the chain verifiably answered the alternative that leads there,
|
|
|
|
|
or it hangs off an earned page by a link that records nothing. A
|
|
|
|
|
page behind an answer they have not given is never offered.
|
2026-09-23 11:44:39 +02:00
|
|
|
- **One access policy** (`src/access.rs`): who may do what to what is
|
|
|
|
|
compiled from content on every load into a Cedar policy set - `qualifies`
|
|
|
|
|
on a page, `requires_group`/`public` on a resource, a desk's transitions,
|
|
|
|
|
a `self_transition` by link and email, an `invite` - and every server
|
|
|
|
|
entry point asks that one policy. Decisions carry the rule ids that
|
|
|
|
|
granted them; refusals and every mutation are published on
|
|
|
|
|
`portal.access.decided`, and other apps ask the same question over
|
|
|
|
|
request-reply on `portal.access.<site>.may`. Identity stays Kanidm's.
|
|
|
|
|
- **Onboarding as a form** (`src/people.rs`): an alternative with
|
|
|
|
|
`invite: {group}` on a gated page creates the person in Kanidm, records
|
|
|
|
|
their email, adds them to the group and mints a one-time link to set
|
|
|
|
|
their own credentials, through a service account in
|
|
|
|
|
`idm_people_on_boarding` (`KANIDM_API_TOKEN`). The result lands in the
|
|
|
|
|
`portal_invites` bucket for the inviter's desk and on
|
|
|
|
|
`portal.people.invited` for whatever sends the mail.
|
2026-09-22 17:29:26 +02:00
|
|
|
- **Business needs as a checkable spec** (in
|
|
|
|
|
[uhhm/iris](https://project.uhhm.no/uhhm/iris)): a content repo may
|
|
|
|
|
ship a `needs.yaml` saying who wants what, which bucket the answer
|
|
|
|
|
must land in, and which group has to carry it to a finished state.
|
|
|
|
|
`iris check` proves the site meets it. The runtime never reads the
|
|
|
|
|
file; portal only provides the content types iris reads.
|
2026-08-12 23:30:29 +02:00
|
|
|
- **Live resources** (`src/resource.rs`): content can pull a KV
|
2026-08-24 21:53:17 +02:00
|
|
|
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
|
|
|
|
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
|
|
|
|
- **Input kinds as content**: a requirement's `type:` picks the
|
|
|
|
|
widget — plain fields, a rich-text editor, or a `gesture` drawing
|
|
|
|
|
canvas that can connect to a redoal relay so similar strokes echo
|
|
|
|
|
between visitors live. Submitted values are arbitrary JSON; the
|
|
|
|
|
engine records what the widget produced.
|
2026-08-12 23:30:29 +02:00
|
|
|
- **Review desks**: any Kv resource with `transitions` renders rows
|
|
|
|
|
with per-state action buttons and one shared confirm per
|
|
|
|
|
alternative — owners walk records through their graphs without
|
|
|
|
|
bespoke UI per bucket.
|
|
|
|
|
|
|
|
|
|
## Binaries
|
|
|
|
|
|
|
|
|
|
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
|
2026-09-22 17:29:26 +02:00
|
|
|
- `iris` — the lint, from [uhhm/iris](https://project.uhhm.no/uhhm/iris),
|
2026-09-22 22:05:31 +02:00
|
|
|
which depends on this crate as a library pinned to a release tag and
|
|
|
|
|
carries the same version: the publish job here re-pins, tests and tags
|
|
|
|
|
iris for every portal release.
|
|
|
|
|
Every release tarball ships that `iris` binary
|
|
|
|
|
too, so each content repo's CI can
|
2026-09-22 17:29:26 +02:00
|
|
|
lint with the exact portal version its instance runs:
|
|
|
|
|
`iris check --path <dir>`.
|
2026-08-12 23:30:29 +02:00
|
|
|
|
|
|
|
|
## Development
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
cargo leptos build # full app (server + wasm)
|
2026-09-22 17:29:26 +02:00
|
|
|
cargo test --features ssr # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL
|
2026-08-12 23:30:29 +02:00
|
|
|
```
|
|
|
|
|
|
2026-08-24 21:49:31 +02:00
|
|
|
Runtime configuration is env vars (see `src/main.rs`, and each
|
|
|
|
|
content repo's `.gitea/workflows/deploy.yml` for the values in
|
|
|
|
|
production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
|
|
|
|
|
(`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
|
|
|
|
|
`GITEA_API_TOKEN` for authenticated resource pulls,
|
|
|
|
|
`AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
|
2026-08-12 23:30:29 +02:00
|
|
|
|
2026-08-24 21:49:31 +02:00
|
|
|
## Release and deploy
|
2026-08-12 23:30:29 +02:00
|
|
|
|
2026-08-24 21:49:31 +02:00
|
|
|
Portal doesn't deploy itself — it publishes versions, and each site
|
|
|
|
|
decides when to take one. The whole day-to-day surface is two
|
|
|
|
|
commands:
|
|
|
|
|
|
|
|
|
|
**Cut a release** (here):
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
cargo release patch # or minor / major
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
|
|
|
|
|
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
|
2026-09-22 17:29:26 +02:00
|
|
|
attaches `portal-v<version>.tar.gz` (`portal` + `iris` +
|
2026-08-25 19:52:31 +02:00
|
|
|
`hash.txt` + `site/`) to the Gitea release. Pkg files are
|
|
|
|
|
content-hashed (`hash-files = true`; instances run with
|
|
|
|
|
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
|
|
|
|
|
stale cached bundle with new wasm. Plain pushes to `main` only run the
|
2026-08-24 21:49:31 +02:00
|
|
|
tests (`test.yml`) — nothing reaches production from this repo.
|
|
|
|
|
|
2026-09-22 16:51:15 +02:00
|
|
|
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
|
|
|
|
|
heading for the new version. That section becomes the Gitea release
|
|
|
|
|
notes, and the table at the end of the file records which version each
|
|
|
|
|
site runs; update it when a pin moves.
|
|
|
|
|
|
2026-08-24 21:49:31 +02:00
|
|
|
**Roll it out** (in a content repo): edit one line in that repo's
|
|
|
|
|
`.gitea/workflows/deploy.yml` —
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
env:
|
|
|
|
|
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Its CI downloads the pinned artifact, ships
|
|
|
|
|
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
|
|
|
|
|
instance env from that repo's own Actions variables/secrets, restarts
|
|
|
|
|
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
|
|
|
|
|
commit, so reverting a bad version is `git revert` + push. Sites
|
|
|
|
|
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
|
|
|
|
|
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
|
|
|
|
|
:3020) can pin different versions.
|
|
|
|
|
|
|
|
|
|
Content changes never come through any of this — they hot-reload
|
|
|
|
|
live from the content repos over NATS.
|
|
|
|
|
|
|
|
|
|
**Add a site**: new content repo with a copy of an existing
|
|
|
|
|
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
|
|
|
|
|
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
|
|
|
|
|
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
|
|
|
|
|
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
|
|
|
|
|
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
|
|
|
|
|
content repo handles all branding; no portal changes needed.
|