Bendik Aagaard LynghaugandClaude Fable 5.1 0c68396d5b
Test / test (push) Failing after 1m3s
Publish release / publish (push) Successful in 3m6s
Release 0.3.44
The mail stream is `mail`, not shouted; gdo 0.1.2 matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:15:40 +02:00
2026-09-23 21:15:40 +02:00
2026-09-23 21:15:40 +02:00
2026-09-23 21:15:40 +02:00
2026-09-23 21:15:40 +02:00

portal

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: 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 pushes hot-reload every running instance; instances differ only by env vars. uhhm.no (uhhm/questions) and redoal.com (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.

What the engine provides

  • Content-driven pages (src/content/, src/app.rs): 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 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).
  • 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.
  • 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.
  • 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 (unsubscribe) authorize by holding one. The question nav reads it 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.
  • 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.
  • Business needs as a checkable spec (in 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.
  • Live resources (src/resource.rs): content can pull a KV 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.
  • 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).
  • iris — the lint, from uhhm/iris, 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 lint with the exact portal version its instance runs: iris check --path <dir>.

Development

cargo leptos build            # full app (server + wasm)
cargo test --features ssr     # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL

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.

Release and deploy

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):

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 attaches portal-v<version>.tar.gz (portal + iris + 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 tests (test.yml) — nothing reaches production from this repo.

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.

Roll it out (in a content repo): edit one line in that repo's .gitea/workflows/deploy.yml —

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.

S
Description
A Leptos/Axum onboarding portal — question/alternative content engine with a crypto-chain audit trail, Kanidm-gated content, and NATS-backed resources.
Readme
2.6 MiB
v0.6.23
Latest
2026-10-07 09:25:08 +00:00
Languages
Rust 92.6%
JavaScript 4.2%
CSS 3%
Shell 0.2%