A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.
The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.
Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.
Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.
95 tests, up from 82.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26c1762dfa
commit
f18d1a5181
@@ -1,190 +1,51 @@
|
||||
# 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
|
||||
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.
|
||||
underneath.
|
||||
|
||||
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.
|
||||
The engine special-cases nothing. Everything a site serves is declared
|
||||
in its content repo, content pushes hot-reload every running instance,
|
||||
and the same build serves any number of sites: instances differ only
|
||||
by environment variables. [uhhm.no](https://uhhm.no) and
|
||||
[redoal.com](https://redoal.com) are two faces of the same binary 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.
|
||||
It composes with the surrounding stack rather than bundling it. Gitea
|
||||
hosts the content, NATS JetStream stores the events and projections,
|
||||
Kanidm provides identity, and anything downstream — automations,
|
||||
newsletters, onboarding — subscribes to the answer stream.
|
||||
|
||||
## What the engine provides
|
||||
## Documentation
|
||||
|
||||
- **Content-driven pages** (`src/content/`, `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 once the visitor has earned
|
||||
it - the chain verifiably answered the alternative that leads there,
|
||||
or it hangs off an earned page by a link that records nothing. A
|
||||
page behind an answer they have not given is never offered.
|
||||
- **One access policy** (`src/access.rs`): who may do what to what is
|
||||
compiled from content on every load into a Cedar policy set - `qualifies`
|
||||
on a page, `requires_group`/`public` on a resource, a desk's transitions,
|
||||
a `self_transition` by link and email, an `invite` - and every server
|
||||
entry point asks that one policy. Decisions carry the rule ids that
|
||||
granted them; refusals and every mutation are published on
|
||||
`portal.access.decided`, and other apps ask the same question over
|
||||
request-reply on `portal.access.<site>.may`. Identity stays Kanidm's.
|
||||
- **Onboarding as a form** (`src/people.rs`): an alternative with
|
||||
`invite: {group}` on a gated page creates the person in Kanidm, records
|
||||
their email, adds them to the group and mints a one-time link to set
|
||||
their own credentials, through a service account in
|
||||
`idm_people_on_boarding` (`KANIDM_API_TOKEN`). The result lands in the
|
||||
`portal_invites` bucket for the inviter's desk and on
|
||||
`portal.people.invited` for whatever sends the mail.
|
||||
- **Business needs as a checkable spec** (in
|
||||
[uhhm/iris](https://project.uhhm.no/uhhm/iris)): 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.
|
||||
`iris check` proves the site meets it. The runtime never reads the
|
||||
file; portal only provides the content types iris reads.
|
||||
- **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.
|
||||
Everything is under [`docs/`](docs/README.md):
|
||||
|
||||
## Binaries
|
||||
|
||||
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
|
||||
- `iris` — the lint, from [uhhm/iris](https://project.uhhm.no/uhhm/iris),
|
||||
which depends on this crate as a library pinned to a release tag and
|
||||
carries the same version: the publish job here re-pins, tests and tags
|
||||
iris for every portal release.
|
||||
Every release tarball ships that `iris` binary
|
||||
too, so each content repo's CI can
|
||||
lint with the exact portal version its instance runs:
|
||||
`iris check --path <dir>`.
|
||||
- [Architecture](docs/architecture.md) — what the engine is made of.
|
||||
- [Content reference](docs/content-reference.md) — every key a content
|
||||
repo may write.
|
||||
- [State machines](docs/state-machines.md) — `aggregates.yaml`.
|
||||
- [Access](docs/access.md) — the policy compiled from content.
|
||||
- [Operations](docs/operations.md) — releasing, deploying, adding a site.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cargo leptos build # full app (server + wasm)
|
||||
cargo test --features ssr # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL
|
||||
cargo leptos build # server + wasm
|
||||
cargo test --features ssr # tests/jetstream.rs needs PORTAL_TEST_NATS_URL
|
||||
```
|
||||
|
||||
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):
|
||||
Content is linted by [iris](https://project.uhhm.no/uhhm/iris), which
|
||||
depends on this crate and carries the same version, so a content repo
|
||||
can check itself against the exact portal its instance runs:
|
||||
|
||||
```sh
|
||||
cargo release patch # or minor / major
|
||||
iris check --path questions
|
||||
```
|
||||
|
||||
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` + `iris` +
|
||||
`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.
|
||||
## Releasing
|
||||
|
||||
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.
|
||||
`cargo release patch` tags and pushes; CI builds the tarball and
|
||||
attaches it to the Gitea release. A site takes it by bumping
|
||||
`PORTAL_RELEASE` in its own content repo. See
|
||||
[operations](docs/operations.md).
|
||||
|
||||
Reference in New Issue
Block a user