# portal
The question runtime behind [uhhm.no](https://uhhm.no). Every page,
form, review desk, and state machine it serves is declared in the
[`questions`](https://project.uhhm.no/uhhm/questions) content repo —
this codebase is the engine that renders, enforces, and records, and
it special-cases none of it.
## 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, or any public JSON URL (SSRF
fail-closed), reshaped by a content-declared `jq` filter.
- **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
`.
## 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`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v.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` —
```yaml
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
```
Its CI downloads the pinned artifact, ships
`/srv/app//releases/`, flips `current`, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
`app@`, 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.