From f18d1a518199f8144c9d8015ca571167a613c66d Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Fri, 25 Sep 2026 21:35:25 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 9 ++ README.md | 201 +++++---------------------------- docs/README.md | 22 ++++ docs/access.md | 55 +++++++++ docs/architecture.md | 87 ++++++++++++++ docs/content-reference.md | 232 ++++++++++++++++++++++++++++++++++++++ docs/operations.md | 80 +++++++++++++ docs/state-machines.md | 151 +++++++++++++++++++++++++ src/access.rs | 61 ++++++++-- src/aggregates/mod.rs | 59 ++++++++++ src/answers.rs | 10 +- src/app.rs | 32 +++++- src/content/mod.rs | 138 +++++++++++++++++++++++ src/content/validate.rs | 93 +++++++++++++++ src/deadlines.rs | 196 ++++++++++++++++++++++++++++++++ src/lib.rs | 3 + src/main.rs | 1 + src/people.rs | 62 ++++++++++ src/resource.rs | 109 +++++++++++++++++- 19 files changed, 1417 insertions(+), 184 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/access.md create mode 100644 docs/architecture.md create mode 100644 docs/content-reference.md create mode 100644 docs/operations.md create mode 100644 docs/state-machines.md create mode 100644 src/deadlines.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a1eadfc..f6bb05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## Unreleased + +- A submitter stays in dialogue with their case for as long as it runs. `self_transition` takes `from: [state, ...]`, and the access policy compiles one rule per state named; it defaulted to the initial state alone and was written that way in the compiler, so an applicant written to for a missing drawing could not send it, and nobody could withdraw once anything had happened. Omitted, it still means the initial state alone. +- A record can belong to another record. `type: record` with `of: ` stores the parent's id, which arrives on the link rather than being typed; a submission naming a record that is not there is refused. The other half is `where: { field, equals }` on a Kv resource, so an application's page can list the objections that name it. +- A public view need not publish the whole bucket. `states: [...]` on a Kv resource shows only those, for one record as well as a listing - a register that is public by law is public about what was decided, not about what was withdrawn. The desk over the same bucket is a different spec and still sees all of it. +- A state can grant a group: `grants: ` beside its `event`, so an approval that means membership does not also need somebody to remember to invite them afterwards. The lint requires the forms that fill such a bucket to collect a required name and email, because by the time a desk presses approve there is nobody left to ask. +- A state can be moved by a clock: `after: { days, to }`, swept hourly, measured from the record's last move (`src/deadlines.rs`). A deadline may only do what a person could have done - `after.to` is held to the same declared edge as a desk button - and the move fires the same mail and publishes the same event, stamped `deadline`. +- Documentation moved out of the README into `docs/`: architecture, content reference, state machines, access, operations. + ## 0.4.0 (2026-09-24) - A field can be a place: `type: location` puts a pin on a map and stores `{lat, lon, zoom}`; a record holding one renders as a still map on its desk and in any resource card. diff --git a/README.md b/README.md index cadf5fe..5c007e1 100644 --- a/README.md +++ b/README.md @@ -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/`, 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/` 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..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 `. +- [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`, and pushes; CI -(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and -attaches `portal-v.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//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. +`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). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1715c4b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# portal documentation + +Portal turns a Git repo of YAML into a running site. Almost everything +here is about that YAML: what a content repo may say, and what the +engine does with it. + +| | | +|---|---| +| [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. | +| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. | +| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. | +| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. | +| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. | +| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. | + +Two repos in the same family carry their own docs: + +- [uhhm/iris](https://project.uhhm.no/uhhm/iris) — the lint. Versioned + as portal and released by portal's publish job; it is the thing a + content repo's CI runs before anything reaches a site. +- [uhhm/gdo](https://project.uhhm.no/uhhm/gdo) — the mailer. Delivers + what content asked to be sent, and carries replies back. diff --git a/docs/access.md b/docs/access.md new file mode 100644 index 0000000..c71ca41 --- /dev/null +++ b/docs/access.md @@ -0,0 +1,55 @@ +# Access + +Who may do what to what is compiled from content on every load into +one Cedar policy set, and every server entry point asks that one +policy. Identity stays Kanidm's; portal only decides. + +## What content compiles to + +| content | rule | +|---|---| +| a page with no `qualifies` | anyone may open and submit | +| `qualifies: case_officers` | that group may open and submit | +| `requires_chain: /start` | the visitor's verified answer lineage must reach it | +| a resource with `public: true` | anyone may read it | +| a resource with `requires_group: g` | that group may read it | +| a desk resource's `transitions` | that group may make those moves | +| an alternative's `self_transition` | the submitter may make it, from each state in `from`, with their link and their matching email | +| an alternative's `invite: {group}` | the page's own group may invite into that group | + +Mutations always need a group, whatever `public` says. A resource with +neither `public` nor `requires_group` is unreachable rather than open: +the policy fails closed. + +## Reading the policy + +```sh +iris check --path questions --access +``` + +prints every rule the content compiled to, as a table of who may do +what from where to where. It is the fastest way to find a rule a +content repo did not mean to write — a refusal available on the day a +case arrives, a board that can decide a case it never received. + +## Asking it + +Every decision carries the rule ids that granted it. Refusals and +every mutation are published on `portal.access.decided`. + +Other applications ask the same question over NATS request-reply on +`portal.access..may`, with the same principal, action, resource +and context the server uses internally — so an automation acting as a +group gets exactly the answer a person in that group would. + +## The submitter + +A person who sent a form has no account. Their credential is the link: +an unguessable chain hash, plus the email they gave, which the policy +compares against the one stored on the record. + +What they may do, and from where, is `self_transition.from`. Its +default is the initial state alone. A refusal reads "not found" while +the record is somewhere the move could have been made from — so the +endpoint cannot be used to discover which records or addresses exist — +and "already processed" once the case has moved past it. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5eb16b8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,87 @@ +# Architecture + +One Rust binary (Leptos SSR + hydrate, Axum underneath) serving any +number of sites. The engine special-cases nothing: every page, form, +desk and state graph a site serves is declared in its content repo, +and instances differ only by environment variables. + +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 subscribes to the +answer stream. + +## The pieces + +**Content** (`src/content/`) — 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 to a whole directory, and +`[name].yaml` serves any `/dir/` with the segment fed into the +page's resource keys. See [filesystem routes](design/filesystem-routes.md). + +**The lint** (`src/content/validate.rs`) — every rule a content repo +is held to. It runs at boot, on every hot reload, and in a content +repo's CI through [iris](https://project.uhhm.no/uhhm/iris), which +depends on this crate and so applies exactly the version the instance +runs. + +**State machines** (`src/aggregates/`) — `aggregates.yaml` declares +each bucket's states and legal moves. A record's state is replayed +from its own event history, undeclared moves are refused, and racing +decisions are settled by CAS on the log's sequence. An empty history +reseeds from the KV projection, so wiping the event stream strands +nothing. See [state machines](state-machines.md). + +**Events and projections** (`src/events/`, `src/answers.rs`) — every +submission and decision appends to a JetStream log and projects into +a NATS KV bucket that pages read back, and publishes on +`portal.answers.submitted` for automations to react to. + +**Answer chains** (`src/chain.rs`) — each submission hashes its +parents, so a visitor's path through the questions is a verifiable +lineage. `?chain=` links carry it; a `followup` page is offered only +once the chain has earned it, and a page behind an answer somebody has +not given is never offered. + +**Access** (`src/access.rs`) — who may do what to what, compiled from +content on every load into one Cedar policy set that every server +entry point asks. See [access](access.md). + +**People** (`src/people.rs`) — onboarding as a form. An alternative +with `invite: {group}` creates the person in Kanidm, records their +email, adds them to the group and mints a one-time credential link. A +state may also grant a group on its own (`grants:`), so an approval +that means membership needs no second step. + +**Mail** (`src/mail.rs`) — a state may declare the mail its arrival +sends. Portal renders it and publishes on `portal.mail.send`; +[gdo](https://project.uhhm.no/uhhm/gdo) delivers it and carries +replies back on `portal.mail.received`, where they become notes beside +the record. A note never moves a case. + +**Sweepers** (`src/announce.rs`, `src/deadlines.rs`) — the two things +that move without a person. An announced page's window closes and its +record becomes a "post what happened" task; a state with a deadline +waits out its days and then makes the move itself. Both go through the +same transition path as a desk button and publish the same event. + +**Resources** (`src/resource.rs`) — content can read a KV bucket, +Gitea starred repos, org repos or releases, or any public JSON URL +(SSRF fail-closed), reshaped by a content-declared `jq` filter. What a +resource reads is never a client-supplied parameter: the client names +a page and a feature, never a bucket or URL. + +**Maps** (`src/maps.rs`) — MapLibre is vendored and every tile, style +and still image is proxied through portal, so a page with a map makes +no third-party request and needs no consent banner. + +**Desks** — any Kv resource with `transitions` renders rows with +per-state buttons and one shared confirm. Owners walk records through +their graphs with no bespoke UI per bucket. + +## Binaries + +- `portal` — the server. +- `iris` — the lint, from its own repo, shipped in every release + tarball so a content repo's CI can lint with the exact version its + instance runs. diff --git a/docs/content-reference.md b/docs/content-reference.md new file mode 100644 index 0000000..eb0c35c --- /dev/null +++ b/docs/content-reference.md @@ -0,0 +1,232 @@ +# Content reference + +A content repo is the site. This is everything it may say. + +``` +site.yaml branding, language, mail +aggregates.yaml the state machine of every bucket +needs.yaml what the site is for (read by iris, never by portal) +questions/ + index.yaml the front page, served at / + about.yaml served at /about + review/ + _section.yaml applies to everything under /review + index.yaml served at /review + [record].yaml serves /review/ +``` + +Nothing here is required except a `questions/` tree with something in +it. Portal's lint refuses content it cannot serve honestly, at boot, +on every hot reload, and in CI through `iris check`. + +## site.yaml + +```yaml +title: Vestmark kommune # the browser title and the header +wordmark: /site/logo.svg # an image instead of the title +wordmark_invert: true # flip it in dark mode +wordmark_height: 2.4 # rem +lang: no # the interface language: en or no +stylesheet: /site/theme.css # served same-origin from the repo +favicon: /site/icon.svg +hero: { kind: module, module: hero.js } # or { kind: plain } +mail: + from: Vestmark kommune + reply_to: post@vestmark.example + invite: { subject: "...", body: "..." } # rewords the built-in invite mail +``` + +`mail.from` is required as soon as any state declares a mail. + +## A question + +One YAML file is one page. + +```yaml +id: /apply # defaults to the file's path +name: Apply to build +description: What we need from you. +qualifies: case_officers # a Kanidm group may open this page +requires_chain: /start # or: an answer they must already have given +followup: true # keep it out of the nav until earned +responsible: { name: Solveig, email: post@vestmark.example } +event: # this page announces an occasion + starts: 2026-10-14T18:00 + duration: 2h + place: The town hall +place: # this page is about somewhere + lat: 59.8938 + lon: 11.0332 + zoom: 15 + name: Tomter +alternatives: [ ... ] +``` + +`qualifies`, `requires_chain` and `responsible` are inherited from the +nearest `_section.yaml`. `event` makes a page an occasion with a time; +`place` makes it a place, drawing a still map under the question and +opening any `type: location` field on the page there rather than on +the whole world. + +## An alternative + +One choice. Never two actions behind one heading: if two things can be +done, they are two alternatives. + +```yaml +alternatives: + - name: Send the drawings + description: We answer within twelve weeks. + action: /sent # the page to go to; /shape/{field} templates + record_as: applications # the bucket the answer lands in + disabled: false + consequence: [What happens next] + encouragements: [It takes two minutes] + images: [/site/drawing.png] + features: [ ... ] +``` + +Three things an alternative may do instead of, or beside, recording an +answer: + +```yaml + self_transition: # the person moves their own record + bucket: applications + to: complete + from: [open, incomplete] # defaults to the initial state alone + label: Send them + invite: { group: case_officers } # onboard someone into Kanidm +``` + +`self_transition.from` is what keeps a person in dialogue with their +case: an applicant written to for a missing drawing answers from +`incomplete`, and may withdraw from wherever the case has got to. +Omitted, it is the initial state alone, which is what a one-shot +unsubscribe link means. Each state listed becomes its own access rule, +and the lint refuses a `from -> to` the state machine does not declare. + +An `invite` needs a required `name` and `email` on the same +alternative, and the inviting group must be allowed to invite that +group by the policy. + +## A feature + +What an alternative offers, explained. A feature that is only layout +is a feature wasted: each one says something true about the choice. + +```yaml + features: + - name: What you need + description: The case number from the letter. + icon: document + color: "#8ec2c0" + requirements: [ ... ] # form fields + resource: { ... } # live data +``` + +## A requirement (a form field) + +```yaml + requirements: + - name: email # snake_case; this is the stored key + label: Your email + placeholder: you@example.no + type: email + optional: false + value: "{key}" # a preset; a dynamic page's segment fills it +``` + +`type` is one of: + +| type | what it is | +|---|---| +| `text` | the default | +| `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type | +| `textarea` | several lines | +| `prosekit` | a rich-text editor | +| `select` | one choice, or `multiple: true` for any number | +| `file` | an upload, with `accept:` and `multiple:` | +| `location` | a pin on a map, stored as `{lat, lon, zoom}` | +| `record` | the record this one belongs to | +| `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay | +| `hidden` | a carrier value, no label row | +| `module` | a widget the content repo ships | + +A `select` takes either inline `options: [...]` or a `resource:` to +draw them from, with `id_field:` naming each item's stable id. + +A field may load its value from a resource when a sibling changes: + +```yaml + - name: contents + type: textarea + bind: + field: path # the sibling to watch + resource: { source: { kind: url, url: "https://git.example/raw/{path}" } } +``` + +### type: record + +How one case belongs to another — an objection to the application it +objects to, an appeal to the decision it appeals. + +```yaml + - name: case + type: record + of: applications # the bucket the other record is in + value: "{key}" # arrives on the link, on a dynamic page +``` + +Nobody types one of these: the stored value is the parent's record id, +which is a chain hash. It arrives the way it was given out, so the +field carries rather than asks, and renders hidden. A submission +naming a record that is not there is refused, so a reference in a +stored record is one that resolved at least once. + +The other half is a listing that asks for the children: + +```yaml + resource: + source: { kind: kv, bucket: objections } + requires_group: case_officers + where: { field: case, equals: "{key}" } +``` + +## A resource + +Live data, declared by content and read by the server. What a resource +reads is never a client-supplied parameter. + +```yaml + resource: + source: { kind: kv, bucket: applications } + key: abc123 # one record instead of the list + public: true # or requires_group: case_officers + states: [approved, refused] # only these; empty means all + where: { field: case, equals: "{key}" } + jq: ".[] | {name, url: .html_url}" + empty: Nothing decided yet. + transitions: # the desk's buttons + - { from: complete, to: on_hearing, label: Send it out } +``` + +`source.kind` is one of `kv`, `gitea_starred`, `gitea_org_repos`, +`gitea_releases`, `url`. + +A spec with neither `public: true` nor `requires_group` is +unreachable: reads fail closed, and mutations always need a group +whatever `public` says. + +`states` and `where` narrow a listing. They matter most for a register +that is public by law — public about what was decided, not about what +was withdrawn — while the desk working the same bucket is a different +spec and still sees all of it. A record that has no such field never +matches: "everything that did not say" is not something a page can ask +for by accident. + +## needs.yaml + +What the site is for, as a checkable spec: who wants what, which +bucket the answer must land in, which group carries it to a finished +state. The runtime never reads it. `iris check` proves the site meets +it, and `iris replay` runs cases through the real engine. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..5e75d56 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,80 @@ +# Operations + +Portal does not deploy itself. It publishes versions; each site +decides when to take one. + +## Cut a release + +```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`, `iris`, `hash.txt`, +`site/`) to the Gitea release. Plain pushes to `main` only run the +tests: nothing reaches production from this repo. + +The same job re-pins, tests and tags [iris](https://project.uhhm.no/uhhm/iris) +to match, so a portal change to anything the lint reads fails the +release rather than the next content push. + +Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a +heading for the new version. That section becomes the release notes, +and the table at the end of the file records which version each site +runs. + +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. + +## Roll it out + +In the content repo, one line of `.gitea/workflows/deploy.yml`: + +```yaml +env: + PORTAL_RELEASE: v0.4.0 +``` + +Its CI downloads the pinned artifact, ships +`/srv/app//releases/`, flips `current`, rewrites the +instance env from that repo's own Actions variables and secrets, +restarts `app@` and refreshes the Caddy route. Every rollout +is a commit, so reverting a bad version is `git revert` and a push. + +Sites upgrade independently and may pin different versions. + +Content changes never come through any of this: they hot-reload live +from the content repos over NATS. + +## Add a site + +A new content repo with a copy of an existing `deploy.yml` (change +`INSTANCE`, the port and the 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` handles all branding; no portal +change is needed. + +## Environment + +Runtime configuration is environment variables; `src/main.rs` is the +authority and each content repo's `deploy.yml` holds the production +values. + +| | | +|---|---| +| `NATS_URL` | events, projections, content reload, access questions | +| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site | +| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in | +| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens | +| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail | +| `GITEA_API_TOKEN` | authenticated resource pulls | +| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint | +| `GARAGE_*` | uploads | +| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off | + +Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a +separate unit on the same host reading the same NATS. diff --git a/docs/state-machines.md b/docs/state-machines.md new file mode 100644 index 0000000..31375ab --- /dev/null +++ b/docs/state-machines.md @@ -0,0 +1,151 @@ +# State machines + +`aggregates.yaml` declares what a bucket's records may be and how they +move. It is the one file that decides what is possible; the pages only +decide who presses what. + +```yaml +aggregates: + - bucket: applications + initial: open + states: + open: { event: received } + incomplete: { event: incomplete } + complete: { event: complete } + on_hearing: { event: on_hearing } + recommended: { event: recommended } + approved: { event: approved } + refused: { event: refused } + withdrawn: { event: withdrawn } + transitions: + open: [incomplete, complete, withdrawn] + incomplete: [complete, withdrawn] + complete: [incomplete, on_hearing, refused, withdrawn] + on_hearing: [incomplete, recommended, refused, withdrawn] + recommended: [approved, refused, withdrawn] +``` + +`transitions` is an arbitrary directed graph. Cycles are allowed and +are often the honest shape: a case sent back for the drawing it was +missing returns to a stage it was already past. A good ending may sit +partway along — an authority that changes its own decision finishes +the case without walking to the end of the line. + +Each state's `event` string is the name written into the log, and must +be unique within the bucket. A record's state is replayed from its own +events, so renaming an event orphans the records that carry the old +name: when rebuilding a live site, keep the names it already has. + +## What a state may carry + +Beside its `event`, a state may declare three things that happen when +a record arrives in it. + +### mail + +```yaml + approved: + event: approved + mail: + to: submitter # or one fixed address + subject: "Your application" + body: | + Hello, + + {property_address} is approved. The decision is at + {site}/status/{chain}. +``` + +`{field}` is any field of the record, plus `{site}`, `{chain}`, +`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent +as text with an HTML alternative. `to: submitter` means the record's +`email` field, and the lint then requires every form filling that +bucket to collect a required `email`. + +Portal renders it and publishes on `portal.mail.send`. gdo delivers +it, and a reply comes back on `portal.mail.received` as a note beside +the record. A note never moves a case. + +### grants + +```yaml + approved: + event: approved + grants: approved_firms +``` + +The person the record is about becomes a member of that group when the +record arrives here. An approval that *means* membership should not +also need somebody to remember to invite them afterwards. + +The lint requires every form filling that bucket to collect a required +`name` and a required `email`, because by the time a desk presses +approve there is nobody left to ask. An instance with no Kanidm +configured grants nothing and says so in the log; the transition +itself always stands. + +### after + +```yaml + on_hearing: + event: on_hearing + after: { days: 42, to: recommended } +``` + +The move a clock makes rather than a person. A hearing that gets no +answer in six weeks has to go on without one, and nobody in the +building is in a position to press that button. + +A deadline may only do what a person could have done: `after.to` is +held to the same declared edge as a desk button, and content that says +otherwise is refused when it loads, not when the hour comes round. The +wait is measured from the record's last move. The move goes through +the same transition path, fires the same mail, and lands on +`portal.answers.submitted` like any decision, stamped `deadline`. + +The unit is days and the sweep is hourly. This is a deadline, not a +job queue. + +## attended_by + +```yaml + - bucket: newsletter + initial: open + states: { open: { event: received } } + attended_by: n8n newsletter compose +``` + +Names what consumes a bucket no page reads back. The lint refuses a +bucket that a form fills and nothing attends — a question was once +dropped and its answers silently fell out of every reader's view, and +this is the contract that replaced the convention. + +## Who moves what + +Nothing in this file says who. A move becomes possible for somebody +when a page offers it: + +- a desk resource's `transitions` gives it to that resource's + `requires_group`; +- an alternative's `self_transition` gives it to the person who sent + the record, by their link and their email, from the states its + `from` names; +- a state's `after` gives it to the clock. + +All three compile into the same [access policy](access.md), and the +engine refuses anything the graph above does not declare, whoever asks. + +## Testing one + +`iris replay` runs cases through this exact machinery — the same +`store_answer` and `transition` the running site uses, against a +throwaway JetStream, reporting what the engine accepted and refused. + +```sh +docker run -d --rm -p 127.0.0.1:14222:4222 nats:2.14.6-alpine -js +iris replay --path questions --nats nats://127.0.0.1:14222 --cases cases.jsonl +``` + +Each line of `cases.jsonl` is one case: a need, a record, and the +states it walks. Point it at a throwaway NATS, never a site's own: it +writes. diff --git a/src/access.rs b/src/access.rs index ea8c2e3..aa576d2 100644 --- a/src/access.rs +++ b/src/access.rs @@ -221,16 +221,20 @@ impl Policy { } } if let Some(st) = &alt.self_transition { - let from = crate::answers::OPEN_STATE; - emit( - format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition", - format!("bucket {}", st.bucket), format!("{from} -> {}", st.to), - format!( - "permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};", - quote(&st.bucket), quote(from), quote(&st.to) - ), - &mut text, &mut rules, - ); + // One rule per state the move may be made from: a + // person stays in dialogue with their case for as + // long as it runs, and `from` says how long that is. + for from in &st.from { + emit( + format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition", + format!("bucket {}", st.bucket), format!("{from} -> {}", st.to), + format!( + "permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};", + quote(&st.bucket), quote(from), quote(&st.to) + ), + &mut text, &mut rules, + ); + } } if let (Some(invite), Some(g)) = (&alt.invite, &q.qualifies) { emit( @@ -536,6 +540,43 @@ mod tests { assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow); } + // The applicant written to for a missing drawing sends it from + // `incomplete`, and may withdraw at any point up to the signature. + // Before `from`, every one of those moves was compiled as if it + // could only be made on the day the case arrived. + #[test] + fn a_submitter_stays_in_dialogue_for_as_long_as_the_case_runs() { + let files = vec![( + "index.yaml".to_string(), + "name: Home\nalternatives:\n - name: Apply\n record_as: cases\n - name: Send the papers\n self_transition: { bucket: cases, to: complete, from: [open, incomplete], label: Send }\n - name: Withdraw\n self_transition: { bucket: cases, to: withdrawn, from: [open, incomplete, complete], label: Withdraw }\n".to_string(), + )]; + let questions = build_questions(&files).unwrap(); + let aggregates = parse_aggregates_yaml( + "aggregates:\n - bucket: cases\n initial: open\n states:\n open: { event: a }\n incomplete: { event: b }\n complete: { event: c }\n withdrawn: { event: d }\n transitions:\n open: [incomplete, complete, withdrawn]\n incomplete: [complete, withdrawn]\n complete: [withdrawn]\n", + ) + .unwrap(); + let p = Policy::from_content(&questions, &aggregates); + assert_eq!(p.validate(), Ok(()), "{}", p.text); + let bucket = Resource::Bucket { name: "cases".into() }; + let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() }; + let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" }); + assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow); + assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow); + // And only where it was said: the paperwork cannot be sent + // after the case is already withdrawn. + assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow); + assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow); + } + + // Omitted, `from` is the initial state alone - what every + // unsubscribe link written before this existed already meant. + #[test] + fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() { + let p = site(); + assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed")); + assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1); + } + #[test] fn invites_and_automation() { let p = site(); diff --git a/src/aggregates/mod.rs b/src/aggregates/mod.rs index 6e9582c..ed0fd6d 100644 --- a/src/aggregates/mod.rs +++ b/src/aggregates/mod.rs @@ -41,6 +41,16 @@ pub struct AggregateSchema { /// that enters it, so content decides when a person hears /// something and the mailer never has to know a site. pub mail_for_state: HashMap, + /// What a state grants the person whose record entered it: a group + /// they become a member of. A firm approved to file is a member of + /// the approved firms from that moment, and the decision that made + /// it so is the same one the desk already pressed - nobody should + /// have to remember to invite them afterwards. + pub grants_for_state: HashMap, + /// The move a clock makes rather than a person. A hearing that gets + /// no answer in six weeks has to go on without one, and nothing in + /// a building is going to press that button. + pub after_for_state: HashMap, } /// A mail to send when a record enters a state. `to` is `submitter` @@ -64,6 +74,19 @@ impl MailSpec { } } +/// A move made by a clock: `after: { days: 42, to: no_answer }` on the +/// state a record is waiting in. The wait is measured from when the +/// record entered that state, and `to` must be an edge the same schema +/// already declares - a deadline may only do what a person could have +/// done. Days, because every deadline a public body keeps is in days +/// and a shorter unit would invite this to be used as a job queue. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AfterSpec { + pub days: u32, + pub to: String, +} + impl AggregateSchema { pub fn has_state(&self, state: &str) -> bool { self.event_for_state.contains_key(state) @@ -73,6 +96,14 @@ impl AggregateSchema { self.mail_for_state.get(state) } + pub fn grants_for(&self, state: &str) -> Option<&str> { + self.grants_for_state.get(state).map(String::as_str) + } + + pub fn after_for(&self, state: &str) -> Option<&AfterSpec> { + self.after_for_state.get(state) + } + pub fn allowed(&self, state: &str) -> &[String] { self.transitions .get(state) @@ -86,6 +117,10 @@ struct RawState { event: String, #[serde(default)] mail: Option, + #[serde(default)] + grants: Option, + #[serde(default)] + after: Option, } #[derive(Debug, serde::Deserialize)] @@ -156,7 +191,29 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result anyhow::Result store.get(given).await.ok().flatten().is_some(), + Err(_) => false, + }; + if !found { + return Err(ServerFnError::new(format!("the {} this is about is not on file", requirement.name))); + } + } + // An invite: the policy says whether this group may extend one, // Kanidm does the onboarding, and what came back (the account, the // one-time link) is recorded with the answer for the inviter's desk @@ -2494,6 +2523,7 @@ pub async fn submit_answer( // state sends, it sends now. if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) { crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses).await; + crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await; } } diff --git a/src/content/mod.rs b/src/content/mod.rs index baeeef4..fc3e489 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -344,6 +344,19 @@ pub struct SelfTransition { pub bucket: String, pub to: String, pub label: String, + /// The states this move may be made from. A person is in dialogue + /// with a case for as long as it runs, not only on the day they + /// sent it: an applicant written to for a missing drawing answers + /// from `incomplete`, and may withdraw from anywhere along the + /// way. Defaults to the initial state alone, which is what every + /// one-shot "unsubscribe" link wants and what this was before it + /// could be said. + #[serde(default = "initial_state_only")] + pub from: Vec, +} + +fn initial_state_only() -> Vec { + vec![crate::answers::OPEN_STATE.to_string()] } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -391,6 +404,21 @@ pub struct ResourceSpec { /// `requires_group` regardless of this flag. #[serde(default)] pub public: bool, + /// The record states this resource shows, for a `Kv` source; empty + /// shows every one. A register that is public by law is public + /// about what was decided, not about what was withdrawn or turned + /// down, and the desk that works the same bucket is a different + /// spec and still sees all of it. + #[serde(default)] + pub states: Vec, + /// Show only the records whose `where.field` holds `where.equals`, + /// for a `Kv` source. With a `type: record` field on the other + /// side, this is the half that reads: an application's page lists + /// the objections that name it, because each objection stored the + /// application's id in a field and this asks for exactly those. + /// `equals` takes the page's own `{key}` like a resource key does. + #[serde(default, rename = "where")] + pub filter: Option, /// The moves a listed answer may make, one button each - empty /// means read-only. Server calls are checked against this /// allow-list, so a client can never fire a transition content @@ -505,6 +533,18 @@ pub struct Requirement { /// page's key into the answer. #[serde(default)] pub value: Option, + /// `type: record` only - the bucket the referenced record lives in. + /// This is how one case belongs to another: an objection names the + /// application it objects to, an appeal the decision it appeals. + /// The stored value is the parent's record id, and a submission + /// naming a record that is not there is refused, so a reference in + /// a record is one that resolved at least once. + /// + /// A person never types one of these: the id is a chain hash. It + /// arrives the way it was given out - `value: "{key}"` on a dynamic + /// page, so the link in the letter carries the case it is about. + #[serde(default)] + pub of: Option, /// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance /// the drawing widget connects to for live echoes of similar /// strokes. Absent means the widget works offline: the drawn path @@ -518,6 +558,16 @@ pub struct Requirement { /// value. When the sibling changes, `resource` is fetched with the /// value as a parameter and the result replaces this field's value /// (an empty sibling never fetches, and never clears an edit). +/// "Show the ones whose `field` is `equals`." The whole of what a +/// listing may ask about a record's own contents: one field, one +/// value, compared as strings. Anything more is a query language, and +/// a jq filter is already there for shaping what comes back. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FieldIs { + pub field: String, + pub equals: String, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Bind { /// The sibling requirement (same alternative) to watch. @@ -1075,6 +1125,94 @@ alternatives: std::collections::HashMap::from([(question.id.clone(), question)]) } + + fn question_with_record(field_yaml: &str) -> std::collections::HashMap { + let question: Question = serde_yaml::from_str(&format!( + r#" +id: /object +name: Object to it +alternatives: + - name: The register + features: + - name: All + resource: + public: true + source: {{ kind: kv, bucket: things }} + - name: Object + record_as: things + features: + - name: Yours + requirements: + - name: what +{field_yaml} +"# + )) + .unwrap(); + std::collections::HashMap::from([(question.id.clone(), question)]) + } + + // An objection belongs to the application it objects to. The + // bucket it points at has to be one this repo declares, or the + // reference could never resolve. + #[test] + fn a_record_can_belong_to_another_record() { + let good = question_with_record(" - { name: case, type: record, of: things }"); + validate_questions(&good, &schema_fixture()).unwrap(); + + let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }"); + let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err(); + assert!(err.to_string().contains("has no aggregate declared"), "{err}"); + + let unsaid = question_with_record(" - { name: case, type: record }"); + let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err(); + assert!(err.to_string().contains("must say `of:"), "{err}"); + + // `of` means one thing, so it belongs to one type. + let stray = question_with_record(" - { name: case, of: things }"); + let err = validate_questions(&stray, &schema_fixture()).unwrap_err(); + assert!(err.to_string().contains("only a type: record field has"), "{err}"); + + // The id arrives on the link; it is not looked up. + let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }"); + let err = validate_questions(&bound, &schema_fixture()).unwrap_err(); + assert!(err.to_string().contains("the link it came in on carries the id"), "{err}"); + } + + // A state that grants a group makes an account for someone, so the + // form that fills the bucket has to have asked who they are. + #[test] + fn a_state_that_grants_a_group_needs_a_name_and_an_address_on_file() { + let granting = crate::aggregates::parse_aggregates_yaml( + "aggregates:\n - bucket: things\n initial: open\n states:\n open: { event: opened }\n middle: { event: advanced }\n done: { event: finished, grants: approved_firms }\n transitions:\n open: [middle]\n middle: [done]\n", + ) + .unwrap(); + + let asks = question_with_record(" - { name: name }\n - { name: email, type: email }"); + validate_questions(&asks, &granting).unwrap(); + + let no_name = question_with_record(" - { name: email, type: email }"); + let err = validate_questions(&no_name, &granting).unwrap_err(); + assert!(err.to_string().contains("grant a group"), "{err}"); + + // The same form is fine against a schema that grants nothing. + validate_questions(&no_name, &schema_fixture()).unwrap(); + } + + // A deadline may only make a move a person could have made. + #[test] + fn a_states_deadline_is_held_to_the_same_graph_as_a_button() { + let ok = crate::aggregates::parse_aggregates_yaml( + "aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions:\n open: [no_answer]\n", + ) + .unwrap(); + assert_eq!(ok["hearings"].after_for("open").unwrap().days, 42); + + let undeclared = crate::aggregates::parse_aggregates_yaml( + "aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n", + ); + assert!(undeclared.unwrap_err().to_string().contains("not a declared transition")); + } + #[test] fn declared_edge_passes() { let questions = question_with_transitions( diff --git a/src/content/validate.rs b/src/content/validate.rs index 75a9e3e..74f2482 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -115,6 +115,38 @@ pub fn validate_questions( } } } + + // A state that grants a group makes an account for the person the + // record is about, so the record has to know who they are. Same + // shape as an invite's own rule, one step further back: the form + // that fills the bucket is where the name and the address have to + // be asked for, because by the time a desk presses approve there is + // nobody left to ask. + for (bucket, schema) in aggregates { + let mut granting: Vec<&String> = schema.grants_for_state.keys().collect(); + if granting.is_empty() { + continue; + } + granting.sort(); + for question in questions.values() { + for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { + let required = |name: &str, kind: &str| { + alternative + .features + .iter() + .flat_map(|f| &f.requirements) + .any(|r| r.name == name && r.kind == kind && !r.optional) + }; + if !required("email", "email") || !required("name", "text") { + anyhow::bail!( + "question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} grant a group, so it must collect a required `name` and a required `email` field of type email", + question.id, alternative.name, granting + ); + } + } + } + } + for question in questions.values() { if let Some(event) = &question.event { if question.is_dynamic() { @@ -210,6 +242,30 @@ pub fn validate_questions( question.id, alternative.name, st.to, st.bucket ); } + if st.from.is_empty() { + anyhow::bail!( + "question {:?} alternative {:?}: self_transition.from is empty, so the move can never be made", + question.id, alternative.name + ); + } + for from in &st.from { + if !schema.has_state(from) { + anyhow::bail!( + "question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}", + question.id, alternative.name, from, st.bucket + ); + } + // The policy would otherwise permit a move the + // engine refuses: the reader of the content + // should hear about that here, not from a + // person who clicked the button. + if !schema.allowed(from).iter().any(|s| s == &st.to) { + anyhow::bail!( + "question {:?} alternative {:?}: self_transition {:?} -> {:?} is not a declared edge for bucket {:?}", + question.id, alternative.name, from, st.to, st.bucket + ); + } + } } } // Bind targets resolve across the whole alternative, same @@ -275,6 +331,35 @@ pub fn validate_questions( } } } + // One case belonging to another. The bucket has to + // be one this repo declares, or the reference could + // never resolve; and it is the only thing `of` + // means, so it belongs to no other type. + if requirement.kind == "record" { + let Some(of) = &requirement.of else { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - it must say `of: `, the bucket the record it points at lives in", + question.id, alternative.name, feature.name, requirement.name + ); + }; + if !aggregates.contains_key(of) { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} points at bucket {of:?}, which has no aggregate declared", + question.id, alternative.name, feature.name, requirement.name + ); + } + if requirement.resource.is_some() || requirement.bind.is_some() { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - the link it came in on carries the id, it can't also load one from a resource or bind", + question.id, alternative.name, feature.name, requirement.name + ); + } + } else if requirement.of.is_some() { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} has `of`, which only a type: record field has", + question.id, alternative.name, feature.name, requirement.name + ); + } if let Some(bind) = &requirement.bind { if bind.field == requirement.name || !sibling_names.contains(bind.field.as_str()) @@ -295,6 +380,14 @@ pub fn validate_questions( let Some(schema) = aggregates.get(bucket) else { continue; }; + for state in &resource.states { + if !schema.has_state(state) { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: states {:?} is not a declared state for bucket {:?}", + question.id, alternative.name, feature.name, state, bucket + ); + } + } for transition in &resource.transitions { if !schema.has_state(&transition.from) { anyhow::bail!( diff --git a/src/deadlines.rs b/src/deadlines.rs new file mode 100644 index 0000000..078f7ca --- /dev/null +++ b/src/deadlines.rs @@ -0,0 +1,196 @@ +//! The moves a clock makes rather than a person. +//! +//! A state in `aggregates.yaml` may carry `after: { days: 42, to: +//! no_answer }`: when a record has waited there that long, portal makes +//! the move itself. Every deadline a public body keeps is of this +//! shape - a hearing goes out and the authority has six weeks to +//! answer, and when it does not the case has to go on without one, with +//! nobody in the building in a position to press that button. +//! +//! A deadline may only do what a person could have done: `after.to` is +//! held to the same declared edge as a desk button (see +//! `parse_aggregates_yaml`), the move goes through the same +//! `aggregates::transition`, the same mail fires, and it lands on +//! `portal.answers.submitted` like any other decision. What it is not +//! is a job queue: the unit is days, the tick is an hour, and a record +//! that has waited long enough is moved once, because after the move it +//! is no longer in a state that has a deadline. + +use crate::answers::Answer; +use crate::events::{emit_answer_submitted, AnswerSubmitted}; +use crate::server::AppState; + +/// Stamped on the record and on the transition event in place of a +/// username, so a desk and an n8n workflow can both tell a decision +/// nobody made from one somebody did. +pub const BY: &str = "deadline"; + +const DAY_MS: i64 = 24 * 60 * 60 * 1000; + +pub async fn run(state: AppState) { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); + loop { + tick.tick().await; + if let Err(e) = sweep(&state).await { + tracing::warn!(error = %e, "deadline sweep failed"); + } + } +} + +/// When a record entered the state it is in now. A record nobody has +/// moved has been waiting since it was sent. +fn waiting_since(answer: &Answer) -> i64 { + answer.decided_ms.unwrap_or(answer.submitted_ms) +} + +/// Whether a record in `state` has waited out its deadline by `now_ms`. +pub fn due(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> Option { + let after = schema.after_for(&answer.state)?; + let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS; + (now_ms >= deadline).then(|| after.to.clone()) +} + +/// One pass over every bucket that has a deadline on any of its states. +pub async fn sweep(state: &AppState) -> anyhow::Result<()> { + let now_ms = chrono::Utc::now().timestamp_millis(); + let aggregates = state.aggregates.load(); + for schema in aggregates.values() { + if schema.after_for_state.is_empty() { + continue; + } + // A bucket is created on first write, so one that has never + // been written to is an ordinary empty bucket, not an error. + let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { + continue; + }; + let keys: Vec = { + use futures::TryStreamExt; + match store.keys().await { + Ok(keys) => keys.try_collect().await.unwrap_or_default(), + Err(_) => continue, + } + }; + for id in keys { + let Ok(Some(bytes)) = store.get(&id).await else { continue }; + let Ok(mut answer) = serde_json::from_slice::(&bytes) else { continue }; + let Some(target) = due(schema, &answer, now_ms) else { continue }; + + let payload = serde_json::json!({ "to": target, "item": id, "by": BY }); + if let Err(e) = crate::aggregates::transition( + &state.jetstream, schema, &id, &target, payload.clone(), now_ms, + ) + .await + { + // A record whose events are gone, or one another writer + // moved between the read and the write: neither is this + // sweep's to fix, and the next tick sees it again. + tracing::warn!(bucket = %schema.bucket, id, target, "a deadline could not be made: {e}"); + continue; + } + + answer.state = target.clone(); + answer.decided_ms = Some(now_ms); + answer.decided_by = Some(BY.to_string()); + if let Ok(data) = serde_json::to_vec(&answer) { + let _ = store.put(&id, data.into()).await; + } + crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses).await; + crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await; + + let parent_hashes = vec![id.clone()]; + let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms); + if let Err(e) = emit_answer_submitted( + &state.nats, + &AnswerSubmitted { + chain_hash, + parent_hashes, + question_id: answer.question_id.clone(), + alternative: target.clone(), + responses: payload, + timestamp_ms: now_ms, + }, + ) + .await + { + tracing::warn!(bucket = %schema.bucket, id, "the deadline was made but not published: {e}"); + } + tracing::info!(bucket = %schema.bucket, id, target, "a deadline passed and the case went on"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema(yaml: &str) -> crate::aggregates::AggregateSchema { + crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap() + } + + const HEARINGS: &str = r#" +aggregates: + - bucket: hearings + initial: open + states: + open: { event: received, after: { days: 42, to: no_answer } } + answered: { event: answered } + no_answer: { event: no_answer } + transitions: + open: [answered, no_answer] +"#; + + fn waiting(state: &str, since_ms: i64) -> Answer { + Answer { + id: "r".into(), question_id: "/q".into(), alternative: "a".into(), + responses: serde_json::json!({}), state: state.into(), + submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], + } + } + + #[test] + fn a_deadline_comes_due_the_day_it_says_and_not_before() { + let s = schema(HEARINGS); + let sent = 1_000_000_000_000; + let answer = waiting("open", sent); + assert_eq!(due(&s, &answer, sent), None); + assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None); + assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), Some("no_answer".into())); + } + + // The six weeks run from the last move, not from the day the case + // was opened: a record someone touched has been waiting since then. + #[test] + fn the_wait_is_measured_from_the_last_move() { + let s = schema(HEARINGS); + let sent = 1_000_000_000_000; + let mut answer = waiting("open", sent); + answer.decided_ms = Some(sent + 40 * DAY_MS); + assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None); + assert_eq!(due(&s, &answer, sent + 82 * DAY_MS), Some("no_answer".into())); + } + + #[test] + fn a_state_with_no_deadline_waits_forever() { + let s = schema(HEARINGS); + let answer = waiting("answered", 0); + assert_eq!(due(&s, &answer, i64::MAX / 2), None); + } + + // A clock may only make a move a person could have made, so a + // deadline onto an undeclared edge is refused when content loads, + // not when the hour comes round. + #[test] + fn a_deadline_onto_an_undeclared_edge_is_refused_on_load() { + let bad = HEARINGS.replace("to: no_answer }", "to: answered }").replace("open: [answered, no_answer]", "open: [no_answer]"); + let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string(); + assert!(err.contains("not a declared transition"), "{err}"); + } + + #[test] + fn a_deadline_of_no_days_is_refused() { + let bad = HEARINGS.replace("days: 42", "days: 0"); + let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string(); + assert!(err.contains("at least one"), "{err}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7f545df..5806c98 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,9 @@ pub mod aggregates; #[cfg(feature = "ssr")] pub mod announce; +#[cfg(feature = "ssr")] +pub mod deadlines; + #[cfg(feature = "ssr")] pub mod http; diff --git a/src/main.rs b/src/main.rs index 3421b0f..09d2741 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,7 @@ async fn main() -> anyhow::Result<()> { // Announced pages: keep their desk records current, close ended // windows (see announce.rs). tokio::spawn(portal::announce::run(state.clone())); + tokio::spawn(portal::deadlines::run(state.clone())); // Replies to case mail, matched to their record by whatever reads // the mailbox (uhhm/gdo over JMAP) and turned into notes here. A diff --git a/src/people.rs b/src/people.rs index 66895bd..e8d6af5 100644 --- a/src/people.rs +++ b/src/people.rs @@ -189,6 +189,68 @@ pub fn username_for(email: &str) -> String { } } +/// The person a record is about, for a state that grants a group. +/// Both are required by the lint of any form that fills such a bucket +/// (see `validate_questions`), so a record missing either is one that +/// predates the rule, and is reported rather than guessed at. +pub fn who_for(responses: &serde_json::Value) -> Option<(String, String)> { + let field = |name: &str| { + responses + .get(name) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + }; + let email = field("email").filter(|e| e.contains('@'))?; + Some((field("name")?, email)) +} + +/// A record entered `state`: if that state grants a group, put the +/// person the record is about in it. The decision the desk already +/// pressed is the whole ceremony - an approval that means membership +/// should not also need somebody to remember to invite them. +/// +/// Best-effort, exactly like the mail beside it: the transition is +/// committed either way, and an instance with no Kanidm configured +/// simply grants nothing and says so once per occurrence. +pub async fn on_state( + state: &crate::server::AppState, + bucket: &str, + id: &str, + to_state: &str, + responses: &serde_json::Value, +) { + let Some(group) = state + .aggregates + .load() + .get(bucket) + .and_then(|s| s.grants_for(to_state).map(str::to_string)) + else { + return; + }; + let Some(kanidm) = Kanidm::from_env() else { + tracing::warn!(bucket, id, to_state, group, "this state grants a group, but invites are not configured on this instance (KANIDM_API_TOKEN) - nobody was added"); + return; + }; + let Some((name, email)) = who_for(responses) else { + tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and email - nobody was added"); + return; + }; + let invited = match kanidm.invite(&name, &email, &group).await { + Ok(invited) => invited, + Err(e) => { + tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}"); + return; + } + }; + if let Ok(bytes) = serde_json::to_vec(&invited) { + let _ = state.nats.publish(INVITED_SUBJECT, bytes.into()).await; + } + crate::mail::on_invite(state, &invited).await; + tracing::info!(bucket, id, to_state, group, user = %invited.username, created = invited.created, "the decision granted a group"); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/resource.rs b/src/resource.rs index 988b9e3..6be697a 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -188,6 +188,50 @@ async fn authorize_resource( } } +/// Whether a stored record passes the resource's `states` filter. No +/// filter shows everything, which is what every resource written +/// before this existed means. A record with no `state` at all is not +/// an aggregate and is left alone. +#[cfg(feature = "ssr")] +fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Value) -> bool { + if resource.states.is_empty() { + return true; + } + match value.get("state").and_then(|s| s.as_str()) { + Some(state) => resource.states.iter().any(|s| s == state), + None => true, + } +} + +/// Whether a stored record passes the resource's `where` filter. The +/// field is looked for in the record's `responses`, which is where a +/// form's answers live, and then on the record itself, so a listing +/// can be narrowed by what was submitted or by what the runtime +/// stamped on it. A missing field never matches: "everything that did +/// not say" is not something a page should be able to ask for by +/// accident. +#[cfg(feature = "ssr")] +fn matches_filter( + resource: &crate::content::ResourceSpec, + value: &serde_json::Value, + params: &std::collections::HashMap, +) -> bool { + let Some(filter) = &resource.filter else { + return true; + }; + let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params)) + .unwrap_or_else(|| filter.equals.clone()); + let found = value + .get("responses") + .and_then(|r| r.get(&filter.field)) + .or_else(|| value.get(&filter.field)); + match found { + Some(serde_json::Value::String(s)) => s == &wanted, + Some(other) => other.to_string() == wanted, + None => false, + } +} + #[cfg(feature = "ssr")] async fn fetch_resource_value( state: &crate::server::AppState, @@ -216,7 +260,14 @@ async fn fetch_resource_value( .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new("resource key not found"))?; - serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))? + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?; + // A state filter holds for one record as well as a + // listing, or naming the key would walk around it. + if !shows_state(resource, &value) || !matches_filter(resource, &value, params) { + return Err(ServerFnError::new("resource key not found")); + } + value } (None, Some(store)) => { use futures::TryStreamExt; @@ -231,7 +282,9 @@ async fn fetch_resource_value( for key in keys { if let Ok(Some(bytes)) = store.get(&key).await { if let Ok(value) = serde_json::from_slice::(&bytes) { - items.push(value); + if shows_state(resource, &value) && matches_filter(resource, &value, params) { + items.push(value); + } } } } @@ -470,6 +523,58 @@ pub fn apply_jq( mod tests { use super::*; + fn spec(yaml: &str) -> crate::content::ResourceSpec { + serde_yaml::from_str(yaml).unwrap() + } + + fn record(state: &str, responses: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "id": "r", "state": state, "responses": responses }) + } + + // A register that is public by law is public about what was + // decided, not about what was withdrawn. + #[test] + fn a_listing_can_be_narrowed_to_the_states_worth_showing() { + let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n"); + let decided = spec("source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n"); + for state in ["approved", "refused", "withdrawn", "open"] { + assert!(shows_state(&all, &record(state, serde_json::json!({})))); + } + assert!(shows_state(&decided, &record("approved", serde_json::json!({})))); + assert!(shows_state(&decided, &record("refused", serde_json::json!({})))); + assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({})))); + // Something that is not an aggregate has no state to filter on + // and is left alone. + assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" }))); + } + + // The other half of a record belonging to a record: the + // application's page asking for the objections that name it. + #[test] + fn a_listing_can_be_narrowed_to_the_children_of_one_record() { + let children = spec("source: { kind: kv, bucket: objections }\nrequires_group: case_officers\nwhere: { field: case, equals: \"{key}\" }\n"); + let params = std::collections::HashMap::from([("key".to_string(), "abc123".to_string())]); + let mine = record("open", serde_json::json!({ "case": "abc123", "your_objection": "the sun" })); + let theirs = record("open", serde_json::json!({ "case": "def456" })); + let neither = record("open", serde_json::json!({ "your_objection": "no case named" })); + assert!(matches_filter(&children, &mine, ¶ms)); + assert!(!matches_filter(&children, &theirs, ¶ms)); + // A record that did not say is not everyone's child. + assert!(!matches_filter(&children, &neither, ¶ms)); + // No filter, no narrowing: every resource written before this + // existed still lists its whole bucket. + let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n"); + assert!(matches_filter(&plain, &theirs, ¶ms)); + } + + #[test] + fn a_filter_can_name_a_value_outright() { + let fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n"); + let params = std::collections::HashMap::new(); + assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), ¶ms)); + assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms)); + } + /// A canned, Gitea-API-shaped fixture - the same fields the real /// `/users/{username}/starred` endpoint returns (Gitea 1.27: /// `stars_count`, not GitHub's `stargazers_count`; `website` is ""