Test / test (push) Successful in 49s
Forty releases carried only a version bump in their commit, so what changed lived in the commits between them and in the content repos' pin bumps. The changelog is reconstructed from that history, newest first, with an Unreleased section on top and a table of which version each of the four sites runs. The publish workflow now posts the tag's section as the Gitea release notes, and the README says to move the Unreleased lines under a version heading before cutting. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
177 lines
8.5 KiB
Markdown
177 lines
8.5 KiB
Markdown
# 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](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.
|
|
|
|
## 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. 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 when it is on that path (the
|
|
page the chain verifiably answered) or further down from the current
|
|
page over `action` edges - one followup path never unlocks another.
|
|
- **Business needs as a checkable spec** (`src/needs.rs`,
|
|
`question_lint`): 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. The lint proves the structure
|
|
(a path of submissions within an effort budget, a desk held by that
|
|
group, every finished state reachable, no record stranding; a
|
|
`planned` need warns instead of failing). Its personas - visitors in
|
|
their own words - become typed `choice` tasks for any external
|
|
answering engine (`--needs-tasks`, `--skim` for headings only) whose
|
|
picks are graded with `--needs-score`: a misroute is a wording
|
|
defect. The runtime never reads the file.
|
|
- **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
|
|
|
|
```sh
|
|
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):
|
|
|
|
```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
|
|
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
|
|
`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` —
|
|
|
|
```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.
|