Bendik Aagaard LynghaugandClaude Fable 5 452ea88fbf
Test / test (push) Successful in 23s
Filesystem routes, sections, dynamic segments; instant YES hero
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.

Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +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, and branding (site.yaml: title, wordmark, and which hero the landing page opens on — the YES canvas, a gesture-drawing canvas wired to a relay, or plain copy). 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.rs, 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. A page's id is its URL; qualifies gates it to a Kanidm group.
  • 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.
  • 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).
  • question_lint — headless content validation, shipped inside every release artifact so each content repo's CI lints with the exact portal version its instance runs; also works offline: question_lint --path <dir>.

Development

cargo leptos build            # full app (server + wasm)
cargo test --features ssr     # engine tests
cargo build --features ssr --bin question_lint

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 + question_lint + site/) to the Gitea release. Plain pushes to main only run the tests (test.yml) — nothing reaches production from this repo.

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
1.4 MiB
Languages
Rust 83.5%
JavaScript 10.3%
CSS 6.2%