Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
4.4 KiB
Markdown
106 lines
4.4 KiB
Markdown
# 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 <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` +
|
|
`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/<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.
|