Test / test (push) Successful in 24s
site.yaml's hero is now plain | module, where module names a JavaScript file the content repo ships (mount(container) -> handle with stop()). Portal serves content assets same-origin at /site/<path> (Gitea raw sends no CORS headers), starts the module at HTML parse time, adopts it on hydration, mounts fresh via the inline script's __mountHero on client-side navigation, and stops it on leave. The YES canvas (yes.js) and the gesture hero mode leave the engine - uhhm/questions ships YES as its hero.js, redoal/questions ships a sine-swings band. Gesture form canvas no longer balloons after a stroke: the wrap's aspect-ratio reservation is scoped to :empty (pre-mount) so it can't turn echo-strip height into width inside the flex field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
6.1 KiB
Markdown
138 lines
6.1 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`).
|
|
- **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
|
|
|
|
```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.
|