Compare commits
@@ -0,0 +1,45 @@
|
||||
name: docs
|
||||
|
||||
# docs/ as a site at portal.uhhm.no: built with mdBook and put to
|
||||
# git-pages on every push to main that touches it (the uhhm pages
|
||||
# pattern, uhhm/infrastructure PAGES.md). The changelog rides along as
|
||||
# the book's last page. Authorized by the _git-pages-forge-allowlist
|
||||
# TXT record for portal.uhhm.no plus a token the forge confirms may
|
||||
# push to this repo (the GITEA_TOKEN secret).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["docs/**", "book.toml", "CHANGELOG.md", ".gitea/workflows/docs.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MDBOOK_VERSION: v0.5.4
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: bare
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: build the book
|
||||
run: |
|
||||
set -eu
|
||||
tool=/var/local/cargo-target/mdbook-$MDBOOK_VERSION
|
||||
if [ ! -x "$tool/mdbook" ]; then
|
||||
mkdir -p "$tool"
|
||||
curl -sSfL "https://github.com/rust-lang/mdBook/releases/download/$MDBOOK_VERSION/mdbook-$MDBOOK_VERSION-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$tool"
|
||||
fi
|
||||
cp CHANGELOG.md docs/changelog.md
|
||||
"$tool/mdbook" build
|
||||
test -f target/book/index.html
|
||||
test -f target/book/memberships.html
|
||||
|
||||
- name: deploy to portal.uhhm.no
|
||||
run: |
|
||||
tar -czf site.tar.gz -C target/book .
|
||||
curl --fail -sS -X PUT "https://portal.uhhm.no/" \
|
||||
-H "Content-Type: application/x-tar+gzip" \
|
||||
-H "Forge-Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
--data-binary @site.tar.gz \
|
||||
-w "deployed: %{http_code}\n"
|
||||
@@ -56,18 +56,12 @@ jobs:
|
||||
- name: Build
|
||||
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
|
||||
|
||||
# cargo-leptos only builds the leptos bin-target ("portal") - the
|
||||
# question-lint utility binary needs its own plain cargo build.
|
||||
- name: Build question-lint
|
||||
run: cargo build --release --bin question_lint --features ssr
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ github.ref_name }}"
|
||||
stage=$(mktemp -d)
|
||||
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
|
||||
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
|
||||
# hash-files = true: leptos resolves hash.txt next to the running
|
||||
# binary, so it ships beside portal in the release dir.
|
||||
cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt"
|
||||
@@ -75,7 +69,12 @@ jobs:
|
||||
# not affected by CARGO_TARGET_DIR - only the plain `cargo build`
|
||||
# outputs (release/, front/) move with that override.
|
||||
cp -r target/site "$stage/site"
|
||||
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint hash.txt site
|
||||
tar -C "$stage" -czf "portal-$tag.tar.gz" portal hash.txt site
|
||||
# The bare binary as well: a content repo's CI runs `portal lint`
|
||||
# and needs nothing else from the release. Named to sort after
|
||||
# the tarball: Gitea lists assets by name, and deploy scripts
|
||||
# written before 0.6 take the first one.
|
||||
cp "$stage/portal" portal-bin
|
||||
rm -rf "$stage"
|
||||
|
||||
# The run's own ephemeral token has write access to this repo -
|
||||
@@ -88,9 +87,10 @@ jobs:
|
||||
tag="${{ github.ref_name }}"
|
||||
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
subject=$(git log -1 --format=%s)
|
||||
body=$(printf '{"tag_name":"%s","name":"%s"}' \
|
||||
"$tag" "$tag: $(echo "$subject" | sed 's/"/\\"/g')")
|
||||
# The release notes are this version's section of CHANGELOG.md.
|
||||
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
|
||||
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $tag - write it and re-tag"; exit 1; }
|
||||
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
|
||||
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "$body" "$api/releases" | jq .id) \
|
||||
|| id=$(curl -sf -H "$auth" "$api/releases/tags/$tag" | jq .id)
|
||||
@@ -101,4 +101,7 @@ jobs:
|
||||
curl -sf -X POST -H "$auth" \
|
||||
-F "attachment=@portal-$tag.tar.gz" \
|
||||
"$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null
|
||||
curl -sf -X POST -H "$auth" \
|
||||
-F "attachment=@portal-bin" \
|
||||
"$api/releases/$id/assets?name=portal-x86_64-linux" > /dev/null
|
||||
echo "published $tag"
|
||||
|
||||
@@ -22,5 +22,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# A throwaway JetStream for tests/jetstream.rs. Best effort: if the
|
||||
# runner cannot use docker the variable stays unset and that one
|
||||
# test reports itself skipped rather than failing the build.
|
||||
- name: Start a throwaway JetStream
|
||||
run: |
|
||||
docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
||||
if docker run -d --rm --name portal-test-nats -p 127.0.0.1:14223:4222 nats:2.14.6-alpine -js >/dev/null 2>&1; then
|
||||
sleep 2
|
||||
echo "PORTAL_TEST_NATS_URL=nats://127.0.0.1:14223" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::no docker on this runner - the JetStream integration test will be skipped"
|
||||
fi
|
||||
|
||||
- name: Test
|
||||
run: cargo test --features ssr
|
||||
|
||||
- name: Stop the throwaway JetStream
|
||||
if: always()
|
||||
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
.claude/
|
||||
docs/changelog.md
|
||||
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
# Changelog
|
||||
|
||||
One line per change, grouped by the release that shipped it. Newest
|
||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## 0.6.1 (2026-09-30)
|
||||
|
||||
- Trainer: `documents` in a plan. A text the site carries word for word (bylaws, terms) becomes a page of its own where every section is one alternative: its heading the card's, its paragraphs the card's words, from a markdown file beside the plan. No model touches it.
|
||||
|
||||
- `slideshow` on a page: a strip of pictures the full width of the screen under the question, `max_height` in svh, each picture with its caption laid over it. Swiper when the bundle has loaded, a sideways-scrolling row before. `/site/` serves `jpeg`, and lets a browser keep photographs for an hour.
|
||||
|
||||
- The release's bare binary is named `portal-x86_64-linux` (it was `portal` in 0.6.0 only). Gitea lists assets by name, and deploy scripts that take the first asset got the binary instead of the tarball.
|
||||
|
||||
## 0.6.0 (2026-09-30)
|
||||
|
||||
- One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of `portal`: `portal serve` (the default), `portal lint`, and `portal plan`, `build`, `answer`, `rate`, `prefer`, `score`, `simulate`, `compare`, `report`, `generate`. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bare `portal` binary beside the tarball, so a content repo's CI downloads it and runs `portal lint`. No more iris releases; the pins that exist keep working. The trainer's `plans/`, `intakes/`, `engines/`, `sectors/` and its reference prompt live here now; its `runs/` stay in uhhm/portal-trainer. Docs gained "Training a site" and "The lint".
|
||||
|
||||
## 0.5.11 (2026-09-30, tagged, never published)
|
||||
|
||||
The publish job failed at its lint step: iris, built against this tag, did not compile against the storylines change (the same two fixes that went into `src/lint/` when the lint moved in). Nothing was attached to the tag; 0.6.0 is the first release that ships storylines.
|
||||
|
||||
- Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else.
|
||||
|
||||
## 0.5.10 (2026-09-30)
|
||||
|
||||
- The lint lets an invite form collect a required `phone` in place of a required `email`: memberships know a person by number first.
|
||||
|
||||
## 0.5.9 (2026-09-30)
|
||||
|
||||
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
|
||||
|
||||
## 0.5.8 (2026-09-30)
|
||||
|
||||
- Fixed: 0.5.7 asked every provider for the `phoneNumber` scope, and Kanidm denies a sign-in that asks for a scope the person does not hold, so nobody could sign in. The scope is asked for only with `OIDC_EXTRA_SCOPES=phoneNumber` (Vipps). 0.5.7 must not be deployed on a Kanidm site.
|
||||
|
||||
## 0.5.7 (2026-09-30)
|
||||
|
||||
- Memberships: groups kept by portal itself (`src/memberships.rs`, `docs/memberships.md`). A membership is a case in the built-in `portal_memberships` bucket - `invited` by a desk, a `grants`, the seed or the responsible pass; `active` from the person's first sign-in, when the provider's subject, phone number or address matches; `removed` by a desk. A person is their phone number first (`+4791234567`, `PHONE_COUNTRY` for bare numbers), their address second, the provider's subject third, and one record per group and person however it was keyed. At sign-in the active memberships join the `groups` claim, so Cedar sees the same parents as before. `PEOPLE_BACKEND=memberships` switches a site; unset, a `KANIDM_API_TOKEN` means Kanidm and no token means memberships. `SEED_ADMIN_PHONE` seeds by number. The sign-in asks for the `phoneNumber` scope and reads `phone_number` from userinfo.
|
||||
- The NATS access service answers for a person named by `sub`, `phone` or `email` with no groups listed: their memberships are looked up first.
|
||||
- `who_for` (the person a `grants` is about) takes `navn` as well as `name`, and a phone number in place of an address.
|
||||
- The invite mail for someone who needs no link says where to sign in.
|
||||
|
||||
## 0.5.6 (2026-09-30)
|
||||
|
||||
- A group may hold a page's responsibility: `responsible: { name, contact, group: styret }`. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board's shared address was onboarded as a person.
|
||||
|
||||
## 0.5.5 (2026-09-30)
|
||||
|
||||
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
|
||||
- The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
|
||||
|
||||
## 0.5.4 (2026-09-29)
|
||||
|
||||
- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (`portal.content.reloaded`, published once new content is in place), so a new page's person is set up without a restart - and someone the content has stopped naming leaves `RESPONSIBLE_GROUP` in the same pass, if portal is who put them there.
|
||||
- An invite is a case that keeps itself current (`portal_invites`: `open`, `expired`, `done`). Signing in closes a person's invites; a sweep every ten minutes moves one whose link ran out unused to `expired`, and takes the link off the record. From `expired` a desk may send it again: the move back to `open` makes a fresh link and mails it. Content offers that with `{ from: expired, to: open }` on the list of invites. The invites portal makes itself at start are on file with the rest.
|
||||
- Fixed: an invite mail said the link worked "until" and then nothing. Kanidm 1.11 gives a link's end as seconds since the epoch, and portal read only the older RFC 3339 string.
|
||||
- Fixed: a search by mail that Kanidm failed to answer (a 5xx) was read as "not permitted", and the invite went on to match by username - which could give someone with an account under another name a second one. Only a refusal is read that way now.
|
||||
- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's `responsible.contact`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. It is a case in the built-in `portal_edit_suggestions` bucket - `open`, `discussed`, `done` or `declined` - so a desk that lists the bucket takes it up and settles it, the person who made it is mailed at each move, and the card shows them what became of what they sent before. The session now carries the signed-in person's email.
|
||||
- The mail to someone named responsible names the first three questions and counts the rest; on a site where one address answers for every page it used to list them all.
|
||||
- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and `idm_people_on_boarding` may create but not modify: 403. The address now goes in with the person.
|
||||
|
||||
## 0.5.3 (2026-09-29)
|
||||
|
||||
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
|
||||
|
||||
## 0.5.2 (2026-09-28)
|
||||
|
||||
- The rate limiter answered 500 to any request that arrived without `X-Forwarded-For`. It reads the caller from that header or `X-Real-IP` and falls back to the socket, and 0.5.0 served the app without connect info, so there was no socket to fall back to and no key to rate-limit by. Through Caddy every request carries the header and the site was fine; a health check on localhost got a 500, which is how the first deploy of 0.5.1 failed.
|
||||
|
||||
## 0.5.1 (2026-09-28)
|
||||
|
||||
- A `self_transition` that names no `from` means the bucket's own initial state, not the word `open`. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive at `pending` and confirm themselves into `open`, so the rule compiled as `open -> open` and the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists.
|
||||
|
||||
## 0.5.0 (2026-09-28)
|
||||
|
||||
- Sessions live in NATS KV (`src/sessions.rs`), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket's `max_age` collects what nobody returns for, and a record's own expiry is what answers a load.
|
||||
- Fixed: the JetStream integration test asserted a record's projected state was one a desk move had never written to it, so it failed whenever it actually ran. `test.yml` has been red since 0.3.47 while `publish.yml` stayed green, which is how 0.4.0 shipped past it.
|
||||
- A file can come back out. `GET /attachment/{bucket}/{record}/{field}` streams what a `type: file` field stored, reading the object key out of the record rather than taking one from the caller - so a file is only reachable through a record somebody may see, and a key that leaks off a desk is a link to nothing. Before this, uploads were one-way: a desk showed the key as a string and there was no way to open it.
|
||||
- Rate limiting, per caller IP, outside everything else: a burst of 30 refilled one every two seconds (`PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS`; either 0 turns it off). A form-filling visitor never reaches it; a script does. There was none at all before.
|
||||
- `/upload` asks the policy, like every other way in. It asked nothing: any caller who could reach the host could put 20 MB in the bucket, as often as they liked, and a site whose forms are public is a site whose upload route is public. It now takes the `question` and `alternative` the file is being attached to, refuses a page that declares no `type: file` field or an alternative that is closed, and asks `Submit` on that page - the same question the submission itself will ask - before a byte is written. Latent until an instance had storage configured; the first one now does.
|
||||
- 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: <bucket>` 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: <group>` 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.
|
||||
- A page says nothing until it has something to say. The Suspense fallback on a question was the whole served page - no header, no wordmark - so the first and only thing a visitor or a crawler read was the word "loading…", which is true and is not about them. It now holds the space and stays quiet, with `aria-busy` for a reader that would otherwise find an empty document.
|
||||
- Gone: the `gitea_org_repos` resource kind, which no site ever named. `gitea_starred` and `gitea_releases` stay.
|
||||
|
||||
## 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.
|
||||
- A question can be about a place: `place: { lat, lon, zoom, name }` is the counterpart to `event:` - event makes a page an occasion with a time, place makes it somewhere, draws the map under the question, and opens any location field on that page there instead of on the globe.
|
||||
- Maps are served from this origin. Portal fetches every tile and every still itself (`src/maps.rs`, MapLibre vendored), so a page with a map on it makes no third-party request, needs no consent banner, and the `MAPBOX_TOKEN` never leaves the server. Unset, the feature is simply off.
|
||||
- A reply to a case mail becomes a note on its record: whatever reads the mailbox publishes on `portal.mail.received` and portal appends the note, after checking the record exists and the sender is the address the record itself holds. A note never moves a case.
|
||||
|
||||
## 0.3.47 (2026-09-24)
|
||||
|
||||
- iris: needs.yaml takes `stage_words`, each state in the site's own words, carried into the simulation model for the trainer's desk score. No change to portal itself.
|
||||
|
||||
## 0.3.46 (2026-09-23)
|
||||
|
||||
- An invite first looks the email up in Kanidm (`/v1/raw/search` on `mail`, when the service account may read people: `idm_people_pii_read`) and only adds an existing account, under whatever username, to the group; no second account, no reset link.
|
||||
|
||||
## 0.3.45 (2026-09-23)
|
||||
|
||||
- A mail stream that cannot be created at boot (subjects owned by an older stream) is a warning, not a failed start; 0.3.44 took the kasse sites down for two minutes on the rename.
|
||||
|
||||
## 0.3.44 (2026-09-23)
|
||||
|
||||
- The mail stream is named `mail` (was `WORMHOLE`); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
|
||||
|
||||
## 0.3.43 (2026-09-23)
|
||||
|
||||
- Mail is content: `mail: { to, subject, body }` on a state in aggregates.yaml sends when a record enters that state (`to: submitter` or one address; `{field}`, `{site}`, `{chain}`, `{email}` in the templates), and an invite mails its one-time link built in; `site.yaml` gains `mail: { from, reply_to, invite }`. Portal renders and publishes on `portal.mail.send` in the `WORMHOLE` stream; uhhm/gdo delivers. The lint requires a required `email` field on every form recording into a bucket that mails the submitter.
|
||||
|
||||
## 0.3.42 (2026-09-23)
|
||||
|
||||
- people: the Kanidm person lookup reads the body, since Kanidm 1.11 answers a missing person with 200 and `null` rather than 404; before this every invite skipped account creation and failed on the group step
|
||||
|
||||
## 0.3.41 (2026-09-23)
|
||||
|
||||
- publish: the iris release step retries on a fresh clone when its push loses a race against a push to iris main (the v0.3.40 tag never published)
|
||||
- iris: `check --access` prints the access matrix, and every check validates the compiled policy against the schema
|
||||
|
||||
## 0.3.40 (2026-09-23)
|
||||
|
||||
- One access policy, compiled from content into Cedar on every load (`src/access.rs`): pages, resource reads, desk transitions, self transitions and the automation endpoint all decide through it; decisions carry rule ids, refusals and mutations publish on `portal.access.decided`, and `portal.access.<site>.may` answers the same question for other apps over NATS
|
||||
- `invite: {group}` on an alternative onboards a person into Kanidm from a desk (`src/people.rs`, `KANIDM_API_TOKEN`), recording the one-time credential link in `portal_invites` and on `portal.people.invited`
|
||||
|
||||
## 0.3.39 (2026-09-23)
|
||||
|
||||
- publish: releases the matching uhhm/iris by proxy - re-pins, builds, tests and tags it as this version (needs the `IRIS_PUSH_USER` variable and `IRIS_PUSH_TOKEN` secret); iris carries portal's version number from here on
|
||||
|
||||
## 0.3.38 (2026-09-22)
|
||||
|
||||
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
|
||||
|
||||
## 0.3.37 (2026-09-22)
|
||||
|
||||
- The lint moves to its own repo, uhhm/iris: `question_lint` becomes `iris check`, `needs_replay` becomes `iris replay`; the tarball ships `iris` (pinned in publish.yml) instead of `question_lint`; the needs module and local loaders leave portal
|
||||
|
||||
## 0.3.36 (2026-09-22)
|
||||
|
||||
- Question nav: a followup is offered only once earned - the verified chain answered the alternative leading there, or a link that records nothing leads on from an earned page. Standing on a page no longer unlocks the pages behind its forms; the chain index now records which alternative was answered
|
||||
- One bounded HTTP client for every server-side fetch: 20 s budget, size caps per kind of fetch (`src/http.rs`)
|
||||
- Uploads are refused past 20 MB while streaming, and the route body limit that had capped them at axum's 2 MB default is raised to match
|
||||
- `tests/jetstream.rs`: the store, the event log and the projection tested against a real JetStream (CI starts a throwaway one)
|
||||
- `content` split into `gitea`, `validate`, `markdown`, `handlers`; every path still `content::x`
|
||||
- This changelog, and the table of what each site runs
|
||||
- Business needs as a checkable spec: needs.yaml + question_lint pass
|
||||
- needs: `success` states and a resolved simulation model
|
||||
- needs: `stages` - every stage the business named must be a state
|
||||
- needs_replay: make simulated cases real aggregates, report buckets by state
|
||||
- needs: also_moved_by - a need carried by more than one group's desk
|
||||
|
||||
## 0.3.35 (2026-09-12)
|
||||
|
||||
- Item cards: no paragraph margin under the description
|
||||
|
||||
## 0.3.34 (2026-09-11)
|
||||
|
||||
- Question nav: offer followups by reach, not by carrying any chain
|
||||
|
||||
## 0.3.33 (2026-09-02)
|
||||
|
||||
- Head preloads: latin font, wordmark, content-host preconnect
|
||||
|
||||
## 0.3.32 (2026-09-01)
|
||||
|
||||
- Per-site favicon via site.yaml
|
||||
|
||||
## 0.3.31 (2026-09-01)
|
||||
|
||||
- Markdown links allow tel:
|
||||
|
||||
## 0.3.30 (2026-09-01)
|
||||
|
||||
- Single alt-image shows natural height
|
||||
|
||||
## 0.3.29 (2026-09-01)
|
||||
|
||||
- Markdown image layout hints + AVIF assets
|
||||
|
||||
## 0.3.28 (2026-09-01)
|
||||
|
||||
- Hero description renders inline markdown
|
||||
|
||||
## 0.3.27 (2026-09-01)
|
||||
|
||||
- wordmark_invert: explicit, not inferred from .svg
|
||||
|
||||
## 0.3.26 (2026-09-01)
|
||||
|
||||
- Select fields: inline static options
|
||||
|
||||
## 0.3.25 (2026-09-01)
|
||||
|
||||
- Content-shipped stylesheet override (site.yaml stylesheet)
|
||||
|
||||
## 0.3.24 (2026-09-01)
|
||||
|
||||
- Gateway submit-as-link loses the underline
|
||||
|
||||
## 0.3.23 (2026-09-01)
|
||||
|
||||
- Chrome speaks the site's language: site.yaml lang + i18n table
|
||||
|
||||
## 0.3.22 (2026-09-01)
|
||||
|
||||
- site.yaml wordmark_height; raster logos keep their colors
|
||||
|
||||
## 0.3.21 (2026-09-01)
|
||||
|
||||
- Hero paragraph centers its measure box
|
||||
|
||||
## 0.3.20 (2026-09-01)
|
||||
|
||||
- Sign-in becomes optional: KANIDM_URL unset disables auth cleanly
|
||||
|
||||
## 0.3.19 (2026-09-01)
|
||||
|
||||
- Markdown images in descriptions, gated and framed
|
||||
|
||||
## 0.3.18 (2026-09-01)
|
||||
|
||||
- Item cards render inline markdown descriptions
|
||||
- Convergence: ink converges onto the decoded key (WebGL glow)
|
||||
- Gesture results overlay the canvas - the page never jumps
|
||||
|
||||
## 0.3.16 (2026-08-30)
|
||||
|
||||
- Announce sweeper: refresh open records from content
|
||||
|
||||
## 0.3.15 (2026-08-30)
|
||||
|
||||
- Hidden fields: carrier value without a label row
|
||||
|
||||
## 0.3.14 (2026-08-30)
|
||||
|
||||
- Feature cards: icon column, one text edge
|
||||
|
||||
## 0.3.13 (2026-08-30)
|
||||
|
||||
- Voice field: nothing leaves the browser until Keep it here
|
||||
|
||||
## 0.3.12 (2026-08-30)
|
||||
|
||||
- Voice preview stays hidden until there is something to play
|
||||
|
||||
## 0.3.11 (2026-08-30)
|
||||
|
||||
- README: gesture and voice fields
|
||||
- Validate templated actions against the page pattern
|
||||
|
||||
## 0.3.10 (2026-08-30)
|
||||
|
||||
- type: voice, Requirement.value, playable resource cards
|
||||
|
||||
## 0.3.9 (2026-08-30)
|
||||
|
||||
- Templated actions; places are pickable in the gesture input
|
||||
|
||||
## 0.3.8 (2026-08-30)
|
||||
|
||||
- Descriptions are inline markdown; Feature.link withdrawn
|
||||
|
||||
## 0.3.7 (2026-08-30)
|
||||
|
||||
- Feature.link: a card's name may point elsewhere
|
||||
|
||||
## 0.3.6 (2026-08-30)
|
||||
|
||||
- Announced pages: event windows, header announcements, summary tasks
|
||||
|
||||
## 0.3.5 (2026-08-30)
|
||||
|
||||
- Gesture widget: stop drawing the decoded ghost path
|
||||
- Gesture widget: ghost path back, for v2 keys only
|
||||
|
||||
## 0.3.4 (2026-08-25)
|
||||
|
||||
- Alternative.disabled: announced but not yet takeable
|
||||
|
||||
## 0.3.3 (2026-08-25)
|
||||
|
||||
- Empty list says so before it can parse as zero answers
|
||||
|
||||
## 0.3.2 (2026-08-25)
|
||||
|
||||
- README: hashed pkg assets in the release flow
|
||||
- Resource empty text: content-declared, and null counts as empty
|
||||
|
||||
## 0.3.1 (2026-08-25)
|
||||
|
||||
- Content-hashed pkg assets (hash-files) so stale bundles can't pair with new wasm
|
||||
|
||||
## 0.3.0 (2026-08-25)
|
||||
|
||||
- Hero becomes a content-owned module; site asset proxy; gesture growth fix
|
||||
|
||||
## 0.2.4 (2026-08-25)
|
||||
|
||||
- Gates keep the question nav
|
||||
|
||||
## 0.2.3 (2026-08-25)
|
||||
|
||||
- Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
|
||||
|
||||
## 0.2.2 (2026-08-24)
|
||||
|
||||
- One app-level Title fed by a shared site resource
|
||||
|
||||
## 0.2.1 (2026-08-24)
|
||||
|
||||
- Docs: routing bullet + design doc marked implemented
|
||||
- Revalidating cache on app assets; site title survives SPA navigation
|
||||
|
||||
## 0.2.0 (2026-08-24)
|
||||
|
||||
- Initial commit: content-driven onboarding portal
|
||||
- Load content from Gitea directly, drop the local clone; add deploy workflow
|
||||
- Share sccache between manual dev builds and CI
|
||||
- Give CI its own SCCACHE_SERVER_PORT
|
||||
- Erase view types at every list/component boundary, not just leaves
|
||||
- Redirect cargo-leptos's tool cache out of act-runner-bare's StateDirectory
|
||||
- Fix Ship release: site-root is project-relative, not CARGO_TARGET_DIR
|
||||
- Drop sudo from the deploy workflow - NoNewPrivileges blocks it outright
|
||||
- Caddy snippet: use multi-line log block
|
||||
- Fix static asset 404s and build-time SITE_NAME
|
||||
- Hot-reload content on a NATS trigger instead of requiring a restart
|
||||
- Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
|
||||
- Fix prosekit-editor.js: remove bare CSS imports that aborted the whole module
|
||||
- Redirect back to the originating page after sign-in, not always /
|
||||
- Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
|
||||
- yes.js: guard against the canvas not being in the DOM yet
|
||||
- Fix get_resource/transition_answer: scope feature lookup to its own alternative
|
||||
- Add self-service transitions, authorized by item possession not group membership
|
||||
- Event-sourced applicant/subscriber/project aggregates, generalized resources
|
||||
- deploy.yml: write GITEA_API_TOKEN for the new GiteaStarred/OrgRepos resource sources
|
||||
- Cargo.toml: disambiguate bin-target for cargo-leptos
|
||||
- Add manually-triggered backfill workflow, no terminal/sudo needed
|
||||
- Trigger redeploy to pick up PORTAL_GITEA_API_TOKEN secret
|
||||
- Render generic resource lists as cards, not raw JSON dump
|
||||
- Fix recursion-limit build failure in the new item-card renderer
|
||||
- Resource-backed multi/single-select requirement
|
||||
- Add Organization aggregate (client-as-status), wired but inert
|
||||
- Fix favicon (real icon, not an unrelated orange circle) + stack encouragements with the submit button
|
||||
- Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
|
||||
- Inline encouragements with the submit button, vertically centered
|
||||
- Use the real institutional logo for the header wordmark, not plain text
|
||||
- Use the actual UHHM-letters wordmark, not the institutional mark
|
||||
- Responsive base font size via svmin, not fixed 17px
|
||||
- Add plain-unit fallbacks for svmin/svh in case of unsupported engines
|
||||
- Size the YES canvas from its container, not window.innerHeight
|
||||
- Debounce the YES canvas resize handler and skip no-op resizes
|
||||
- Revert canvas sizing to window.innerWidth/innerHeight, gate resize on width change
|
||||
- Stop blocking native scroll on the YES canvas's touch handlers
|
||||
- Quicksand: font-display optional, not swap, to stop the text-jump on scroll
|
||||
- Use lvh/lvmin, not svh/svmin - svh was the wrong end of the viewport
|
||||
- Update style/main.css
|
||||
- Update style/main.css
|
||||
- YES canvas: size off its own container rect, not window.innerHeight
|
||||
- yes.js: freeze .hero-yes's height via inline style, don't trust svh alone
|
||||
- add responsive container width
|
||||
- deploy.yml: build and publish question_lint alongside the app binary
|
||||
- Make aggregate state graphs content-driven, not compiled Rust
|
||||
- One shared, content-labeled Confirm button per alternative instead of one per row
|
||||
- get_resource/get_requirement_options: #[server(default)] on params
|
||||
- Add optional Alternative.image and Feature.color/icon for richer layouts
|
||||
- Drop the feature list's left-padding
|
||||
- Drop question-report entirely, obfuscate the mailto link instead
|
||||
- Transition.from: state graphs deeper than one decision
|
||||
- Alternative.images: array of urls, rendered as a Swiper card deck
|
||||
- Reseed lost event history from the KV projection on transition
|
||||
- Lighten the comment load
|
||||
- Validate action targets; format timestamps client-side; clippy cleanup
|
||||
- Question nav + gateway alternatives
|
||||
- Responsible note under the title; followup pages out of the nav until a chain exists
|
||||
- README: what the engine provides
|
||||
- Feature icons: masked span painted by currentColor, not <img>
|
||||
- Responsible note: one line, same hue and size as the hero description
|
||||
- Responsible note as `<small>`: meta information, dimmer than the description
|
||||
- Responsible note back to the footer, above the question nav
|
||||
- Bound inputs: a field that loads its value from a sibling-parameterized resource
|
||||
- add some more width to our alternatives
|
||||
- Accent to a dusty press-cyan, a muted echo of the canvas's cyan ink
|
||||
- Editor type in rem: prosekit's px typography ignored the responsive root
|
||||
- Ban px from stylesheets: every length rem/em off the responsive root
|
||||
- No translucent input chrome: solid ink-dim placeholders and embed meta
|
||||
- Editor type scales for real: drop prosekit typography.css, fix selectors
|
||||
- Stable server fn endpoints: open tabs survive deploys
|
||||
- Drop stray local tool temp file, ignore .claude/
|
||||
- Noise-driven YES, sticky hero with frosted cards, full light theme
|
||||
- increase translucency of laternative cards
|
||||
- Showcase jq test tracks Gitea repo shape and website-first links
|
||||
- Gesture input type, redoal-relay client, gitea_releases, content-driven branding
|
||||
- Echo thumbnails fade in via timeout, not rAF
|
||||
- Trigger deploy: redoal OAuth2 credentials now configured
|
||||
- Deploy becomes publish: portal ships as a versioned release artifact
|
||||
- Semver releases cut with cargo-release; publish on v* tags only
|
||||
- Docs: release/rollout flow after the instance-ownership refactor
|
||||
- README: frame portal as a generic multi-site question engine
|
||||
- Design doc: filesystem routes, sections, dynamic segments
|
||||
- Filesystem routes, sections, dynamic segments; instant YES hero
|
||||
|
||||
|
||||
## What each site runs
|
||||
|
||||
Read from the hosts on 2026-09-28; a rollout is a pin bump in the
|
||||
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`) - on both
|
||||
hosts now, since kasse's runner deploys its own instances.
|
||||
|
||||
| Site | Host | Instance | Version |
|
||||
|---|---|---|---|
|
||||
| uhhm.no | ergo | `app@uhhm-portal` | v0.5.2 |
|
||||
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
|
||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
|
||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
|
||||
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.6.0, memberships |
|
||||
Generated
+661
-3
@@ -44,6 +44,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -53,6 +62,21 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
||||
|
||||
[[package]]
|
||||
name = "ascii-canvas"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
|
||||
dependencies = [
|
||||
"term",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
@@ -108,9 +132,9 @@ checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -661,6 +685,16 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -724,6 +758,70 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cedar-policy"
|
||||
version = "4.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e4f5e46c425491b7133eecb3030d82d27301b84bfa08533c59a6902cca7eb80"
|
||||
dependencies = [
|
||||
"cedar-policy-core",
|
||||
"cedar-policy-formatter",
|
||||
"itertools 0.15.0",
|
||||
"linked-hash-map",
|
||||
"linked_hash_set",
|
||||
"miette",
|
||||
"ref-cast",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"smol_str",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cedar-policy-core"
|
||||
version = "4.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a30d11033d59b262bbb380d8bb5dc5c3dd2dd2281675c558ef4fd8a161ce669"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"educe",
|
||||
"either",
|
||||
"itertools 0.15.0",
|
||||
"lalrpop",
|
||||
"lalrpop-util",
|
||||
"linked-hash-map",
|
||||
"linked_hash_set",
|
||||
"miette",
|
||||
"nonempty 0.12.0",
|
||||
"ref-cast",
|
||||
"regex",
|
||||
"rustc-literal-escaper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"smol_str",
|
||||
"stacker",
|
||||
"thiserror 2.0.19",
|
||||
"unicode-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cedar-policy-formatter"
|
||||
version = "4.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a2c8dc671e62597c85caa11694365b76050abcd9f2177f0520ccac0580f9a41"
|
||||
dependencies = [
|
||||
"cedar-policy-core",
|
||||
"itertools 0.15.0",
|
||||
"logos",
|
||||
"miette",
|
||||
"pretty",
|
||||
"regex",
|
||||
"smol_str",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -962,6 +1060,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -1063,6 +1167,20 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1229,6 +1347,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "educe"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd"
|
||||
dependencies = [
|
||||
"enum-ordinalize",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.17.0"
|
||||
@@ -1266,6 +1396,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ena"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1275,6 +1414,26 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-ordinalize"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677"
|
||||
dependencies = [
|
||||
"enum-ordinalize-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-ordinalize-derive"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1345,6 +1504,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1372,6 +1537,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty 0.7.0",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1449,6 +1624,12 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
@@ -1497,9 +1678,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,6 +1733,29 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.5",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -1611,6 +1817,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1848,6 +2060,19 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.11.0",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2066,6 +2291,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2193,6 +2427,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "konst"
|
||||
version = "0.2.20"
|
||||
@@ -2208,6 +2451,38 @@ version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
|
||||
dependencies = [
|
||||
"ascii-canvas",
|
||||
"bit-set",
|
||||
"ena",
|
||||
"itertools 0.14.0",
|
||||
"lalrpop-util",
|
||||
"petgraph",
|
||||
"pico-args",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"sha3",
|
||||
"string_cache",
|
||||
"term",
|
||||
"unicode-xid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop-util"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2451,6 +2726,24 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked_hash_set"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8"
|
||||
dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2479,6 +2772,38 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
|
||||
dependencies = [
|
||||
"logos-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-codegen"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-derive"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
|
||||
dependencies = [
|
||||
"logos-codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
@@ -2548,6 +2873,29 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"miette-derive",
|
||||
"serde",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette-derive"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2592,6 +2940,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "next_tuple"
|
||||
version = "0.1.0"
|
||||
@@ -2613,6 +2967,27 @@ dependencies = [
|
||||
"signatory",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2712,6 +3087,15 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oco_ref"
|
||||
version = "0.2.1"
|
||||
@@ -2872,6 +3256,31 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2948,14 +3357,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.3.33"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"cedar-policy",
|
||||
"chrono",
|
||||
"console_error_panic_hook",
|
||||
"dotenvy",
|
||||
@@ -2979,6 +3390,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -3012,6 +3424,23 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "pretty"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"typed-arena",
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -3105,6 +3534,16 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
@@ -3123,6 +3562,21 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -3322,6 +3776,15 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reactive_graph"
|
||||
version = "0.2.14"
|
||||
@@ -3549,6 +4012,12 @@ version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-literal-escaper"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3874,6 +4343,7 @@ version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -4107,6 +4577,16 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -4154,6 +4634,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -4175,6 +4661,16 @@ version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -4207,6 +4703,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -4223,6 +4728,31 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -4334,6 +4864,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -4494,6 +5033,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -4572,6 +5122,35 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"h2 0.4.15",
|
||||
"http 1.4.2",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.11.0",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2 0.6.5",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -4580,9 +5159,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 2.14.0",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -4694,6 +5276,23 @@ dependencies = [
|
||||
"tower-sessions-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower_governor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"governor",
|
||||
"http 1.4.2",
|
||||
"pin-project",
|
||||
"thiserror 2.0.19",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -4838,12 +5437,49 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-script"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-security"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50"
|
||||
dependencies = [
|
||||
"unicode-normalization",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -5079,6 +5715,22 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5088,6 +5740,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+12
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.3.33"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -11,7 +11,9 @@ leptos = { version = "0.8" }
|
||||
leptos_meta = { version = "0.8" }
|
||||
leptos_router = { version = "0.8" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# preserve_order: a rendered page is read by the people who own it, so
|
||||
# the keys come out as written (name, then description), not sorted.
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
# Inline markdown for content descriptions (links, emphasis, code) -
|
||||
# runs on both server and client renders, so not feature-gated.
|
||||
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||
@@ -38,6 +40,7 @@ uuid = { version = "1", features = ["v4", "serde"], optional = true }
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
anyhow = { version = "1", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
cedar-policy = { version = "4", optional = true }
|
||||
tracing = { version = "0.1", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
# jq-filter evaluation for ResourceSource's optional `jq` field
|
||||
@@ -62,6 +65,8 @@ web-sys = { version = "0.3", features = [
|
||||
"Response",
|
||||
"Window",
|
||||
], optional = true }
|
||||
tower_governor = { version = "0.8.0", optional = true }
|
||||
async-trait = { version = "0.1.92", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -80,6 +85,8 @@ ssr = [
|
||||
"dep:leptos_axum",
|
||||
"dep:axum",
|
||||
"dep:aws-sdk-s3",
|
||||
"dep:tower_governor",
|
||||
"dep:async-trait",
|
||||
"dep:tokio",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
@@ -96,6 +103,7 @@ ssr = [
|
||||
"dep:dotenvy",
|
||||
"dep:anyhow",
|
||||
"dep:futures",
|
||||
"dep:cedar-policy",
|
||||
"dep:tracing",
|
||||
"dep:tracing-subscriber",
|
||||
"dep:jaq-core",
|
||||
@@ -106,9 +114,8 @@ ssr = [
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
[[bin]]
|
||||
name = "question_lint"
|
||||
path = "src/bin/question_lint.rs"
|
||||
[[test]]
|
||||
name = "jetstream"
|
||||
required-features = ["ssr"]
|
||||
|
||||
[profile.wasm-release]
|
||||
|
||||
@@ -1,157 +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.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`).
|
||||
- **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.
|
||||
- **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).
|
||||
- `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>`.
|
||||
- [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
|
||||
cargo build --features ssr --bin question_lint
|
||||
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` + `question_lint` +
|
||||
`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
|
||||
|
||||
**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).
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# The docs as a site: portal.uhhm.no, built by .gitea/workflows/docs.yml
|
||||
# from docs/ with mdBook and put to git-pages (see uhhm/infrastructure
|
||||
# PAGES.md). `mdbook serve` here previews it.
|
||||
[book]
|
||||
title = "portal"
|
||||
description = "A Git repo of YAML becomes a running site: pages, desks, cases, people."
|
||||
authors = ["uhhm"]
|
||||
language = "en"
|
||||
src = "docs"
|
||||
|
||||
[build]
|
||||
build-dir = "target/book"
|
||||
create-missing = false
|
||||
|
||||
[output.html]
|
||||
default-theme = "light"
|
||||
preferred-dark-theme = "navy"
|
||||
git-repository-url = "https://project.uhhm.no/uhhm/portal"
|
||||
site-url = "/"
|
||||
no-section-label = true
|
||||
|
||||
[output.html.search]
|
||||
limit-results = 20
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Getting started](getting-started.md) | Bringing your people in: from an empty Kanidm to everyone at their own desk, from what the content says. |
|
||||
| [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. |
|
||||
| [Storylines](storylines.md) | The same site told several ways: lists of texts, one storyline per visitor, counted per storyline. |
|
||||
| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
|
||||
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
|
||||
| [Training a site](training.md) | `portal plan` and `portal build`: a business in plain words becomes a content repo, held to the lint. |
|
||||
| [The lint](lint.md) | `portal lint`: what a content repo's CI runs before anything reaches a site. |
|
||||
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
||||
|
||||
One repo in the same family carries its own docs:
|
||||
|
||||
- [uhhm/gdo](https://project.uhhm.no/uhhm/gdo) — the mailer. Delivers
|
||||
what content asked to be sent, and carries replies back.
|
||||
|
||||
The lint (once uhhm/iris) and the trainer (once uhhm/portal-trainer)
|
||||
are commands of the `portal` binary since 0.6; their chapters are
|
||||
above, and the trainer's runs stay in the old repo.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Summary
|
||||
|
||||
[portal](README.md)
|
||||
|
||||
# Using it
|
||||
|
||||
- [Getting started](getting-started.md)
|
||||
- [Content reference](content-reference.md)
|
||||
- [State machines](state-machines.md)
|
||||
- [Access](access.md)
|
||||
- [Storylines](storylines.md)
|
||||
- [Memberships](memberships.md)
|
||||
- [Operations](operations.md)
|
||||
|
||||
# The trainer and the lint
|
||||
|
||||
- [Training a site](training.md)
|
||||
- [The lint](lint.md)
|
||||
|
||||
# How it is made
|
||||
|
||||
- [Architecture](architecture.md)
|
||||
- [Filesystem routes](design/filesystem-routes.md)
|
||||
|
||||
---
|
||||
|
||||
[Changelog](changelog.md)
|
||||
[The lint's changelog, to 0.5.11](lint-changelog.md)
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
|
||||
## Where the groups come from
|
||||
|
||||
The policy names groups; it does not hold them. A `User` entity's
|
||||
parents are the person's groups, and those come from the sign-in: the
|
||||
`groups` claim the provider sent, joined with the person's active
|
||||
[memberships](memberships.md) when portal keeps them itself. Cedar
|
||||
sees one list either way.
|
||||
|
||||
## 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.<site>.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.
|
||||
|
||||
A principal may also name a person by `sub`, `phone` or `email` and
|
||||
list no groups; portal then looks their [memberships](memberships.md)
|
||||
up before deciding. That is what makes the policy usable for someone
|
||||
who has never opened the site.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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/<value>` 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.
|
||||
|
||||
**Sessions** (`src/sessions.rs`) — in the same JetStream KV as
|
||||
everything else, so a restart does not sign anybody out. The bucket
|
||||
carries a `max_age` so NATS collects what nobody comes back for, and
|
||||
each record's own `expiry_date` is what decides on the way out.
|
||||
@@ -0,0 +1,278 @@
|
||||
# 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/<anything>
|
||||
```
|
||||
|
||||
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 <post@vestmark.example>
|
||||
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.
|
||||
|
||||
Any text a person reads (`name`, `description`, `label`,
|
||||
`placeholder`, a mail's `subject` and `body`, `consequence`,
|
||||
`encouragements`) may be a list instead of one string: one entry per
|
||||
[storyline](storylines.md), all lists the same length.
|
||||
|
||||
## 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, contact: post@vestmark.example }
|
||||
# or a group's: no account is made for the inbox, and any signed-in
|
||||
# member of the group may suggest a change to the page
|
||||
responsible: { name: The board, contact: board@vestmark.example, group: board }
|
||||
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: The town hall
|
||||
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.
|
||||
|
||||
### Pictures across the page
|
||||
|
||||
`slideshow` lays a strip of pictures under the question, the full
|
||||
width of the screen: every picture as tall as the strip and as wide as
|
||||
its own shape, swiped or arrowed through, its caption over its foot.
|
||||
|
||||
```yaml
|
||||
slideshow:
|
||||
max_height: 60 # svh; 60 unless set, between 20 and 100
|
||||
slides:
|
||||
- image: images/built/01.webp # a path in the content repo, or https://
|
||||
caption: The canteen at the theatre, renewed
|
||||
width: 3207 # the picture's own size, optional:
|
||||
height: 1400 # holds its place before it has loaded
|
||||
```
|
||||
|
||||
Pictures in the content repo are served from this origin under
|
||||
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
|
||||
hour. Keep them web-sized: around 1400 pixels tall is plenty.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
`value: "{user.email}"` or `"{user.name}"` is the signed-in person's
|
||||
own, and a field it fills is carried rather than asked: someone the
|
||||
site knows is not asked who they are. A visitor gets the field, empty.
|
||||
|
||||
`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}" }
|
||||
```
|
||||
|
||||
And the way there from a list: `link` puts a link on every listed
|
||||
record to a page about it, with `{id}` for the record's id and
|
||||
`{field}` for any of its fields. A notice on a public board links to
|
||||
`/notices/{id}`, the dynamic page `/notices/[key]` whose form carries
|
||||
the notice's id back in with `value: "{key}"`:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: notices }
|
||||
public: true
|
||||
link: { to: "/notices/{id}", label: I can lend or borrow this }
|
||||
```
|
||||
|
||||
## 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.
|
||||
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
||||
transitions: # the desk's buttons
|
||||
- { from: complete, to: on_hearing, label: Send it out }
|
||||
```
|
||||
|
||||
`source.kind` is one of `kv`, `gitea_starred`,
|
||||
`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.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Getting started: bringing your people in
|
||||
|
||||
A site is run by a handful of people: whoever answers for each question
|
||||
it asks, and whoever works each desk. This is how they get in - from
|
||||
an empty Kanidm to everyone signed in at their own desk - without
|
||||
anyone handing out passwords or running an admin tool per person.
|
||||
|
||||
The idea in one line: **the content says who is involved, and portal
|
||||
makes their accounts.** A page names who is responsible for it; a
|
||||
directory is gated on a group; a desk may invite into groups. Portal
|
||||
reads that and does the rest at start.
|
||||
|
||||
## What you need
|
||||
|
||||
- A content repo, and a portal instance running it
|
||||
([Operations](operations.md) has adding a site).
|
||||
- A Kanidm of the site's own. Group names below are exactly the ones
|
||||
the content uses, with no prefix; that is only safe when the Kanidm
|
||||
serves this site alone. (Or no Kanidm at all: with
|
||||
`PEOPLE_BACKEND=memberships` portal keeps the groups itself and any
|
||||
OIDC provider that can say who someone is will do; see
|
||||
[Memberships](memberships.md). The rest of this page is the Kanidm
|
||||
way.)
|
||||
- Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without
|
||||
it accounts are still made, but nobody hears about them.
|
||||
|
||||
## 1. Say who is involved, in the content
|
||||
|
||||
Three things in the content decide who needs an account:
|
||||
|
||||
| content | who | what they get |
|
||||
|---|---|---|
|
||||
| `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
|
||||
| `responsible: { name, contact, group }` | a group answering for it | nothing: the inbox is not a person; the group's members have the desk, and each of them may suggest changes to the page |
|
||||
| `qualifies: board` on a page or `_section.yaml` | members of that group | the pages and desks under it |
|
||||
| `invite: { group: treasurer }` on a desk page | the desk's own group | the right to bring people into `treasurer` |
|
||||
|
||||
The desk whose group may invite into the most groups is the site's most
|
||||
privileged one; for an association that is the board. Portal works that out from
|
||||
the content (see step 4).
|
||||
|
||||
## 2. Set up Kanidm for portal
|
||||
|
||||
Portal needs four things from Kanidm. The
|
||||
[tomtervel/infrastructure](https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure)
|
||||
repo's `kanidm-setup.sh` does all of them and is a good starting point;
|
||||
by hand, as `idm_admin`:
|
||||
|
||||
```sh
|
||||
K="kanidm -D idm_admin -H https://id.example.no"
|
||||
|
||||
# An onboarding account: may create people and issue a first password
|
||||
# link, nothing more. pii_read lets an invite find someone who already
|
||||
# has an account by their email instead of making a second one.
|
||||
$K service-account create portal-onboarding "Portal onboarding" idm_admin
|
||||
$K group add-members idm_people_on_boarding portal-onboarding
|
||||
$K group add-members idm_people_pii_read portal-onboarding
|
||||
|
||||
# One group per `qualifies:` in the content, managed by that account:
|
||||
# adding a member to a group takes the right to manage it.
|
||||
for g in board treasurer traffic; do
|
||||
$K group create "$g" portal-onboarding
|
||||
done
|
||||
|
||||
# The sign-in client. Everyone in this Kanidm may sign in; what they
|
||||
# see comes from their groups, in the `groups` claim.
|
||||
$K system oauth2 create portal "My site" https://site.example.no
|
||||
$K system oauth2 add-redirect-url portal https://site.example.no/auth/callback
|
||||
$K system oauth2 update-scope-map portal idm_all_persons openid profile email
|
||||
for g in board treasurer traffic; do
|
||||
$K system oauth2 update-claim-map portal groups "$g" "$g"
|
||||
done
|
||||
$K system oauth2 update-claim-map-join portal groups array
|
||||
|
||||
# The onboarding token, read-write, shown once: into portal's env.
|
||||
$K service-account api-token generate portal-onboarding portal --readwrite
|
||||
```
|
||||
|
||||
Then portal's environment:
|
||||
|
||||
| variable | value |
|
||||
|---|---|
|
||||
| `KANIDM_URL` | `https://id.example.no` |
|
||||
| `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | the client above (`show-basic-secret`) |
|
||||
| `KANIDM_API_TOKEN` | the onboarding token |
|
||||
|
||||
## 3. Name the first person
|
||||
|
||||
Someone has to be first: every later person is invited from a desk,
|
||||
and on an empty Kanidm there is no desk anyone can open yet.
|
||||
|
||||
| variable | value |
|
||||
|---|---|
|
||||
| `SEED_ADMIN_EMAIL` | the first person's address |
|
||||
| `SEED_ADMIN_NAME` | their name, as it should appear (optional) |
|
||||
| `SEED_GROUP` | a group to put them in, if not the most privileged one (optional) |
|
||||
| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `editors` (optional) |
|
||||
|
||||
## 4. Start portal
|
||||
|
||||
At start, portal:
|
||||
|
||||
1. **Invites the first person** into `SEED_GROUP`, or the most
|
||||
privileged group, and mails them the one-time link. Once: it is
|
||||
remembered in the `portal_seed` bucket, so restarts and upgrades
|
||||
never send a second link.
|
||||
2. **Makes an account for everyone named responsible** who has none,
|
||||
and mails them that they are listed as responsible for asking those
|
||||
questions, with their link. With `RESPONSIBLE_GROUP` they all join
|
||||
it; someone who already had an account is added without a mail.
|
||||
|
||||
The two run side by side. Kanidm may still be starting beside portal,
|
||||
so each is tried again a minute apart, up to twenty times. The log says
|
||||
what happened, per person.
|
||||
|
||||
The second happens again **whenever the content changes**, so a new
|
||||
page naming a new person sets them up without a restart. And it works
|
||||
both ways: someone the content has stopped naming leaves
|
||||
`RESPONSIBLE_GROUP` in the same pass, and with it whatever else that
|
||||
group opens. Only if portal is who put them there - a person who was in
|
||||
the group before is left in it - and their account stays, since it is
|
||||
theirs.
|
||||
|
||||
## 5. The rest come in from the desks
|
||||
|
||||
From here nobody touches Kanidm:
|
||||
|
||||
- **At a desk**, whoever may invite fills in a name and an address.
|
||||
Portal makes the account, adds them to the group, and mails the link.
|
||||
- **A decision can be the invitation.** A state with `grants: group`
|
||||
in `aggregates.yaml` puts the person a case is about into that group
|
||||
when the case arrives there - an approved committee application that
|
||||
*is* the membership.
|
||||
- **A new page with a new responsible person** gets them an account
|
||||
when the page goes live.
|
||||
|
||||
### An invite that nobody used
|
||||
|
||||
A link lives a day. Every invite - a desk's, and the ones portal made
|
||||
at start - is a case in `portal_invites`, and keeps itself current:
|
||||
|
||||
| state | means | how it gets there |
|
||||
|---|---|---|
|
||||
| `open` | the link is waiting | the invite was sent, or sent again |
|
||||
| `done` | the person is in | they signed in to the site; or a desk says so |
|
||||
| `expired` | the link ran out unused | a sweep, every ten minutes |
|
||||
|
||||
A desk that lists the bucket offers the way back with a transition from
|
||||
`expired` to `open`. Pressing it makes a fresh link and mails it:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_invites }
|
||||
requires_group: board
|
||||
transitions:
|
||||
- { from: open, to: done, label: Is in }
|
||||
- { from: expired, to: open, label: Send again }
|
||||
- { from: expired, to: done, label: Is in }
|
||||
```
|
||||
|
||||
## 6. What the people who ask the questions can do
|
||||
|
||||
Signed in, a person a page names as responsible sees a card at the
|
||||
foot of their own pages to suggest a change. The server checks it is
|
||||
really them, and the suggestion is mailed to the site's inbox with them
|
||||
as reply-to, so answering it answers them.
|
||||
|
||||
It is also a case, in the built-in `portal_edit_suggestions` bucket, so
|
||||
it does not depend on somebody reading that inbox. Give a desk the
|
||||
list, and whoever sits there takes it up and settles it:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_edit_suggestions }
|
||||
requires_group: board
|
||||
transitions:
|
||||
- { from: open, to: discussed, label: Looking at it }
|
||||
- { from: open, to: done, label: Changed }
|
||||
- { from: open, to: declined, label: Will not change }
|
||||
- { from: discussed, to: done, label: Changed }
|
||||
- { from: discussed, to: declined, label: Will not change }
|
||||
```
|
||||
|
||||
The person who suggested it is mailed at each move, in the site's
|
||||
language, and the card on their page lists what they have sent and
|
||||
where each one stands. A content repo that declares the bucket itself
|
||||
in `aggregates.yaml`, with a `mail:` on a state, sends that instead.
|
||||
|
||||
Their group can also be the way into other tools. The vel's
|
||||
`editors` group signs in to the project Gitea with the same account:
|
||||
a second OAuth2 client scoped to that group alone.
|
||||
|
||||
## When it does not work
|
||||
|
||||
| what you see | why |
|
||||
|---|---|
|
||||
| an invite fails with `403` setting `mail` | a portal before 0.5.4 set the address in a second call; the onboarding account may create but not modify |
|
||||
| an invite fails with `404` adding to a group | the group is not managed by `portal-onboarding`: `group set-entry-manager <group> portal-onboarding` |
|
||||
| `Item not found` setting the login page's name or logo | those are system settings only `admin` may change, not `idm_admin` - or the CLI and server versions differ |
|
||||
| accounts are made but nobody is mailed | `site.yaml` has no `mail.from`, or gdo is not running on the host |
|
||||
| someone responsible never got a mail | they already had an account (they are only added to `RESPONSIBLE_GROUP`), or their `contact` is not an email address |
|
||||
| an invite stays `open` though the person has set a passkey | Kanidm does not show a passkey to the onboarding account; it becomes `done` when they sign in to the site |
|
||||
| "send again" is not on the desk | the list has no `{ from: expired, to: open }` transition, or the content declares `portal_invites` itself without an `expired` state |
|
||||
| someone left a page and is still in `RESPONSIBLE_GROUP` | they were in the group before portal added them, so it is not portal's to undo |
|
||||
| the first person was invited twice | it cannot be, per address and group; changing `SEED_ADMIN_EMAIL` or `SEED_GROUP` invites the new pair |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
One line per change, grouped by the release that shipped it. Newest
|
||||
first. Each release names the portal tag it is built against.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- `needs.yaml` may name the site's `tones`, one storyline each; the checks read the first storyline, which is what `build_questions` gives.
|
||||
|
||||
## 0.5.10 (2026-09-30) - portal v0.5.10
|
||||
|
||||
- Matches portal v0.5.10 (released by portal's publish job).
|
||||
|
||||
## 0.5.9 (2026-09-30) - portal v0.5.9
|
||||
|
||||
- Matches portal v0.5.9 (released by portal's publish job).
|
||||
|
||||
## 0.5.8 (2026-09-30) - portal v0.5.8
|
||||
|
||||
- Matches portal v0.5.8 (released by portal's publish job).
|
||||
|
||||
## 0.5.7 (2026-09-30) - portal v0.5.7
|
||||
|
||||
- Matches portal v0.5.7 (released by portal's publish job).
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The effort budget counts what a person fills in: a `type: record` field (the case an answer is about, carried on the link) and a `hidden` preset are not asked and no longer count. An objection page whose form carried the application's id used to be one field over.
|
||||
|
||||
## 0.5.6 (2026-09-30) - portal v0.5.6
|
||||
|
||||
- Matches portal v0.5.6 (released by portal's publish job).
|
||||
|
||||
## 0.5.5 (2026-09-30) - portal v0.5.5
|
||||
|
||||
- Matches portal v0.5.5 (released by portal's publish job).
|
||||
|
||||
## 0.5.4 (2026-09-29) - portal v0.5.4
|
||||
|
||||
- Matches portal v0.5.4 (released by portal's publish job).
|
||||
|
||||
## 0.5.3 (2026-09-29) - portal v0.5.3
|
||||
|
||||
- Matches portal v0.5.3 (released by portal's publish job).
|
||||
|
||||
## 0.5.2 (2026-09-28) - portal v0.5.2
|
||||
|
||||
- Matches portal v0.5.2 (released by portal's publish job).
|
||||
|
||||
## 0.5.1 (2026-09-28) - portal v0.5.1
|
||||
|
||||
- Matches portal v0.5.1 (released by portal's publish job).
|
||||
|
||||
## 0.5.0 (2026-09-28) - portal v0.5.0
|
||||
|
||||
- Matches portal v0.5.0 (released by portal's publish job).
|
||||
|
||||
## 0.4.0 (2026-09-24) - portal v0.4.0
|
||||
|
||||
- Matches portal v0.4.0 (released by portal's publish job).
|
||||
|
||||
## 0.3.47 (2026-09-24) - portal v0.3.47
|
||||
|
||||
- Matches portal v0.3.47 (released by portal's publish job).
|
||||
|
||||
## 0.3.46 (2026-09-23) - portal v0.3.46
|
||||
|
||||
- Matches portal v0.3.46 (released by portal's publish job).
|
||||
|
||||
## 0.3.45 (2026-09-23) - portal v0.3.45
|
||||
|
||||
- Matches portal v0.3.45 (released by portal's publish job).
|
||||
|
||||
## 0.3.44 (2026-09-23) - portal v0.3.44
|
||||
|
||||
- Matches portal v0.3.44 (released by portal's publish job).
|
||||
|
||||
## 0.3.43 (2026-09-23) - portal v0.3.43
|
||||
|
||||
- Matches portal v0.3.43 (released by portal's publish job).
|
||||
|
||||
## 0.3.42 (2026-09-23) - portal v0.3.42
|
||||
|
||||
- Matches portal v0.3.42 (released by portal's publish job).
|
||||
|
||||
## 0.3.41 (2026-09-23) - portal v0.3.41
|
||||
|
||||
- Matches portal v0.3.41 (released by portal's publish job).
|
||||
|
||||
## 0.3.39 (2026-09-23) - portal v0.3.39
|
||||
|
||||
- Matches portal v0.3.39 (released by portal's publish job).
|
||||
|
||||
## 0.1.1 (2026-09-22) - portal v0.3.36
|
||||
|
||||
- `check --repo` fetches needs.yaml itself; the v0.1.0 tag never published (a cache permission on the runner, since fixed).
|
||||
|
||||
## 0.1.0 (2026-09-22) - portal v0.3.36
|
||||
|
||||
- First release: the lint moved out of portal (`question_lint` becomes
|
||||
`iris check`, `needs_replay` becomes `iris replay`), with the
|
||||
business-needs pass, the wording task export and scoring, and the
|
||||
simulation model export. Portal is a library dependency pinned to
|
||||
the release iris matches.
|
||||
@@ -0,0 +1,54 @@
|
||||
# The lint
|
||||
|
||||
The iris over a site. A content push is an incoming wormhole; nothing
|
||||
comes through until it checks out.
|
||||
|
||||
```
|
||||
portal lint --path questions # a local checkout
|
||||
portal lint --repo https://project.uhhm.no/uhhm/questions
|
||||
portal lint replay --path questions --nats nats://127.0.0.1:14222
|
||||
```
|
||||
|
||||
It was its own binary, iris, until portal 0.6: the same code, released
|
||||
as portal's twin. Now it is the `portal` binary's `lint` command, so a
|
||||
content repo's CI downloads the release and runs it:
|
||||
|
||||
```yaml
|
||||
- run: |
|
||||
curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o portal
|
||||
chmod +x portal && ./portal lint --path questions
|
||||
```
|
||||
|
||||
`check` loads a content repo exactly the way the running site does,
|
||||
validates it (every action lands on a page, every transition is one
|
||||
the state machine allows, every recorded bucket is read back
|
||||
somewhere), and then holds it to the business needs the repo declares
|
||||
in `needs.yaml`, when it has one:
|
||||
|
||||
- a path of submissions gets each kind of visitor's answer into the
|
||||
bucket it names, within a submission and required-field budget;
|
||||
- the handling group holds a desk over that bucket, and every
|
||||
finished state is reachable through the buttons it (and any
|
||||
`also_moved_by` group) offers; no record strands on the way;
|
||||
- every stage the business named is a state of the machine.
|
||||
|
||||
A need marked `planned` warns instead of failing. Exit status is the
|
||||
verdict, so a content repo's CI is one line.
|
||||
|
||||
Beyond the verdict, `check` exports what a trainer needs to grade the
|
||||
wording: `--needs-tasks` writes the personas as typed choice tasks
|
||||
(`--skim` for headings only), `--needs-score` grades an engine's
|
||||
answers, and `--needs-sim` writes a resolved model of the whole site.
|
||||
`portal lint replay` runs simulated cases through the site's real aggregate
|
||||
engine on a throwaway JetStream and reports every bucket by state;
|
||||
`--report-only` takes the same scorecard from a live site's buckets.
|
||||
See [Training a site](training.md) for what sits around those.
|
||||
|
||||
## Matching the site
|
||||
|
||||
The lint is the site: `portal lint` and `portal serve` are one binary
|
||||
built from one source, so a content shape the site reads is a shape
|
||||
the lint knows, at the same version. A content repo pins
|
||||
`PORTAL_RELEASE` to what its site runs and lints with that release's
|
||||
`portal`. The `IRIS_RELEASE` pins from before portal 0.6 keep working
|
||||
against the iris releases that exist; nothing new is published there.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Memberships
|
||||
|
||||
Who belongs to which group, kept by portal itself.
|
||||
|
||||
A site's groups are named in its content: `qualifies` on a page,
|
||||
`requires_group` on a list, `invite: { group }` on a desk, `grants` on
|
||||
a state. Until now the groups themselves lived in Kanidm, and portal
|
||||
read them from the sign-in token. With memberships they live in portal,
|
||||
as records in the built-in `portal_memberships` bucket, and the
|
||||
identity provider only has to say who someone is. Cedar sees no
|
||||
difference: a person's active memberships are the parents of their
|
||||
`User` entity, exactly as a `groups` claim was.
|
||||
|
||||
## A membership is a case
|
||||
|
||||
One record per group and person:
|
||||
|
||||
| state | means | how it gets there |
|
||||
|---|---|---|
|
||||
| `invited` | a place is held for them | a desk invite, a state that `grants`, the first person, someone the content names as responsible |
|
||||
| `active` | they are in | they signed in, and the provider's subject, phone number or address matched the record |
|
||||
| `removed` | a desk took them out | a button on the desk that lists the bucket; inviting them again reopens the record |
|
||||
|
||||
The record carries `name`, `group`, `by` (who or what put them there:
|
||||
a username, `seed`, `responsible`, or the case as `bucket:id`), and
|
||||
what is known of the person: `phone`, `email`, `sub`. At sign-in
|
||||
everything the provider verified is written onto every record that
|
||||
matched, so a record made from an address learns the phone number,
|
||||
and the next sign-in matches on the subject before anything else.
|
||||
|
||||
Because it is a case, a desk lists it like any other bucket:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_memberships }
|
||||
requires_group: board
|
||||
transitions:
|
||||
- { from: invited, to: removed, label: Withdraw }
|
||||
- { from: active, to: removed, label: Take out }
|
||||
- { from: removed, to: invited, label: Invite again }
|
||||
```
|
||||
|
||||
## A person is their phone number first
|
||||
|
||||
A provider that verifies phone numbers (Vipps) makes the number the
|
||||
surest thing a record can hold. So a person is known by, in order:
|
||||
their phone number, their email address, the provider's subject. A
|
||||
form that collects `phone`, `telefon`, `tel` or `mobil` gives the
|
||||
first; `email` or `epost` the second. Numbers are kept as `+4791234567`
|
||||
whatever way they were typed; a bare national number gets the site's
|
||||
country (`PHONE_COUNTRY`, `+47` unless set). Addresses are lowercased.
|
||||
|
||||
A record matches a person on any one of the three, so a desk that
|
||||
invited by address and a decision that granted by number are talking
|
||||
about one person, and there is one record.
|
||||
|
||||
## Switching a site to memberships
|
||||
|
||||
```
|
||||
PEOPLE_BACKEND=memberships
|
||||
```
|
||||
|
||||
Unset, a `KANIDM_API_TOKEN` means Kanidm and no token means
|
||||
memberships. With memberships:
|
||||
|
||||
- A desk invite writes an `invited` record and mails "log in at the
|
||||
site, and your place is there". The provider makes people, portal
|
||||
makes members: with Vipps Login everyone can already sign in. A
|
||||
Kanidm still configured beside memberships (`KANIDM_API_TOKEN`)
|
||||
makes the account too, with no group and the one-time link in the
|
||||
mail, so a site can switch to memberships before its provider does.
|
||||
- `grants` writes a record for the person the case is about, from the
|
||||
form's name and phone number or address.
|
||||
- `SEED_ADMIN_PHONE` or `SEED_ADMIN_EMAIL` writes the first person's.
|
||||
- The people the content names as responsible get theirs in
|
||||
`RESPONSIBLE_GROUP`, and lose it when the content stops naming them.
|
||||
- The `groups` claim, if the provider sends one, still counts: a
|
||||
person's groups are the union.
|
||||
|
||||
With `OIDC_EXTRA_SCOPES=phoneNumber` the sign-in asks the provider for
|
||||
the number and reads `phone_number` from userinfo. Set it for Vipps;
|
||||
not for Kanidm, which denies a sign-in that asks for a scope the
|
||||
person does not hold. Without it, matching falls back to the address.
|
||||
|
||||
## Asking who belongs
|
||||
|
||||
Other applications ask portal the same access question they always
|
||||
could, over NATS on `portal.access.<site>.may`. A principal that names
|
||||
a person and lists no groups is looked up:
|
||||
|
||||
```json
|
||||
{ "principal": { "kind": "user", "sub": "vipps:abc", "phone": "912 34 567" },
|
||||
"action": "transition",
|
||||
"resource": { "kind": "bucket", "name": "hazards" },
|
||||
"context": { "from": "open", "to": "fixed" } }
|
||||
```
|
||||
|
||||
The answer is Cedar's, with the rule ids that granted it, for a person
|
||||
who may never have opened the site. That is the whole authorization
|
||||
engine: the provider says who, memberships say what they belong to,
|
||||
the policy compiled from content says what they may do.
|
||||
|
||||
## What Kanidm still does
|
||||
|
||||
For a site on Kanidm nothing changes: invites make accounts and add to
|
||||
Kanidm groups, and `portal_invites` tracks the one-time links. The two
|
||||
backends can be swapped by the one variable; the content and the
|
||||
policy are the same either way.
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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<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. 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/<instance>/releases/<tag>`, flips `current`, rewrites the
|
||||
instance env from that repo's own Actions variables and secrets,
|
||||
restarts `app@<instance>` 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, sessions, content reload, access questions |
|
||||
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
|
||||
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
||||
| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise |
|
||||
| `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm |
|
||||
| `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set |
|
||||
| `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
|
||||
| `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
|
||||
| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way |
|
||||
| `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 |
|
||||
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
|
||||
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
|
||||
|
||||
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
|
||||
separate unit on the same host reading the same NATS.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Every request passes a per-caller-IP limit before a session is loaded
|
||||
or a handler is entered: a burst of 30, refilled one every two
|
||||
seconds. A visitor filling in a form never reaches it (a page load
|
||||
fetches several resources at once); a script does.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
|
||||
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
|
||||
|
||||
Either set to `0` turns it off, for an instance behind something that
|
||||
already does this. The caller is read from `X-Forwarded-For` or
|
||||
`X-Real-IP`, falling back to the socket — all of these instances are
|
||||
behind Caddy.
|
||||
|
||||
This is a fence, not an accounting system. What keeps uploads honest
|
||||
is the policy check on the route itself.
|
||||
|
||||
## Attachments
|
||||
|
||||
A `type: file` field stores an object key in the record. The file
|
||||
comes back out at:
|
||||
|
||||
GET /attachment/{bucket}/{record}/{field}
|
||||
|
||||
which reads the key out of that record rather than taking one from the
|
||||
caller, so a file can only be fetched through a record somebody is
|
||||
allowed to see, and a leaked key is a link to nothing. Reading it
|
||||
needs the same policy permission as reading the bucket on a desk.
|
||||
|
||||
Uploads need a bucket and a key in the Garage on the host:
|
||||
|
||||
```sh
|
||||
garage bucket create <site>-attachments
|
||||
garage key create <site>-portal-uploads
|
||||
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads
|
||||
```
|
||||
|
||||
then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
|
||||
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
||||
fails closed rather than appearing to work and storing nowhere.
|
||||
|
||||
## The report
|
||||
|
||||
`GET /report` is public and answers with counts only: per bucket, per
|
||||
[storyline](storylines.md), per state. It is what the trainer reads to
|
||||
compare the storylines on what people did.
|
||||
|
||||
## The docs site
|
||||
|
||||
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
|
||||
with mdBook (`book.toml`, `docs/SUMMARY.md`) by `.gitea/workflows/docs.yml`
|
||||
on every push to main that touches them, and put to git-pages the way
|
||||
every static site on uhhm.no is (uhhm/infrastructure `PAGES.md`). The
|
||||
changelog is the book's last page. `mdbook serve` previews it locally.
|
||||
|
||||
## Who deploys what
|
||||
|
||||
Both hosts deploy from CI now, and a rollout is the same commit either
|
||||
way: bump `PORTAL_RELEASE` in the site's own content repo.
|
||||
|
||||
| | runner | how it is allowed to |
|
||||
|---|---|---|
|
||||
| ergo | `runs-on: bare` | the runner is privileged and writes `/srv/app` and `/etc/app` itself |
|
||||
| kasse | `runs-on: fish` | the runner is not privileged. It may run one root script, `/usr/local/bin/deploy-portal-instance`, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything |
|
||||
|
||||
The kasse grant is a single sudoers line
|
||||
(`/etc/sudoers.d/30-portal-deploy`). It exists so a workflow can pick
|
||||
which of three known instances gets which published portal tag, and
|
||||
nothing else.
|
||||
|
||||
Both paths health-check the instance afterwards **without** a forwarded
|
||||
header. A check that sends the header Caddy would send cannot tell you
|
||||
the site is broken for everything that is not Caddy, which is how
|
||||
v0.5.1 reached a host.
|
||||
|
||||
`IRIS_RELEASE` in a repo's lint workflow should equal its
|
||||
`PORTAL_RELEASE`. Bump it only once iris's own publish job has attached
|
||||
the binary for that tag - portal's release tags iris, but iris builds
|
||||
and uploads on its own run, and a pin bumped in between fails the lint
|
||||
on a download that is not there yet.
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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`.
|
||||
|
||||
A record that belongs to another (a `type: record` field, say
|
||||
`notice`) can write to that record's people: `to: "{notice.email}"`
|
||||
goes to whoever posted the notice, and `{notice.hva}` in the body is
|
||||
what they posted. The lint requires every form filling the bucket to
|
||||
carry that field. This is how an answer to a notice on a board reaches
|
||||
the neighbour who put it there, without anyone in between.
|
||||
|
||||
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. With memberships as the people
|
||||
backend a phone number does as well as an address, and the grant is a
|
||||
record in `portal_memberships` rather than a Kanidm call (see
|
||||
[Memberships](memberships.md)). An instance with neither 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.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Storylines
|
||||
|
||||
The same site, told two ways, and counted.
|
||||
|
||||
Anywhere content says something to a person, it may say it in several
|
||||
ways by giving a list instead of one string:
|
||||
|
||||
```yaml
|
||||
name: Want to help out in the neighbourhood?
|
||||
description:
|
||||
- The work day, the spring fair and the committees need more hands. Say what you can do.
|
||||
- The neighbours do most things themselves. Sign up here for whatever you fancy.
|
||||
alternatives:
|
||||
- name: [Join in, Sign me up]
|
||||
consequence: [[Send], [Count me in]]
|
||||
```
|
||||
|
||||
Each index is a *storyline*. A visitor is given one at their first
|
||||
request, kept in a cookie (`portal_storyline`) for a year, and reads
|
||||
that storyline on every page, in every button and in every mail the
|
||||
site sends them about their case. A single string is every storyline.
|
||||
|
||||
## The rules
|
||||
|
||||
- **Same length everywhere.** Every list of texts in the content has
|
||||
the site's arity, so a storyline never has a hole in it. The lint
|
||||
refuses a list of another length and names it.
|
||||
- **What varies is what a person reads:** `name`, `description`,
|
||||
`label`, `placeholder`, `title`, a mail's `subject` and `body`, a
|
||||
list's `empty` line, `consequence` and `encouragements` (those two as
|
||||
lists of lists).
|
||||
- **What the machine reads never varies:** a field's `name` (the stored
|
||||
key), a select's `options` (the stored values), states, groups,
|
||||
paths, buckets.
|
||||
- **One storyline per site**, not per page. That is what makes it a
|
||||
storyline rather than a scatter of variants: a warm front door leads
|
||||
to a warm form and a warm mail.
|
||||
- A signed-in person keeps the storyline they had as a visitor.
|
||||
- A site that tells one storyline sets no cookie.
|
||||
|
||||
## What is counted
|
||||
|
||||
Every record and every answer event carries the storyline it came
|
||||
from (`storyline` on `Answer` and on `portal.answers.submitted`). The
|
||||
mail a case gets tells the same storyline, so a person who read the
|
||||
warm front door is not thanked in the plain voice.
|
||||
|
||||
`GET /report` gives the counts, per bucket, per storyline, per state:
|
||||
|
||||
```json
|
||||
{ "storylines": 2,
|
||||
"buckets": { "requests": { "0": { "open": 3, "solved": 9 },
|
||||
"1": { "open": 5, "solved": 14 } } } }
|
||||
```
|
||||
|
||||
Storylines are assigned evenly, so those numbers are the comparison:
|
||||
which words brought more people to send, and how their cases went. No
|
||||
record, field, address or time leaves the site. The trainer reads this
|
||||
(`portal report`) next to what its personas scored offline, and
|
||||
the plan's `tones` are where the storylines are written.
|
||||
@@ -0,0 +1,490 @@
|
||||
# Training a site
|
||||
|
||||
A business describes itself in plain words: what it does, who comes to
|
||||
it, what it keeps track of and through which stages, and who handles
|
||||
what. The trainer, `portal plan` and `portal build`, turns that into a
|
||||
working content repo: a question series for every role, the state
|
||||
machines behind them, and a desk per handling role. No software is
|
||||
written for the business; the only thing produced is content, and
|
||||
every line of it is held to the lint (`portal lint`), which is the
|
||||
same code as the site, at the same version.
|
||||
|
||||
The trainer was its own repo, uhhm/portal-trainer, until portal 0.6;
|
||||
its runs (`runs/`, the lab notebook of every site trained so far)
|
||||
stay there.
|
||||
|
||||
## Build: structure from the plan, words from the model
|
||||
|
||||
```
|
||||
portal build --plan plans/contractor.yaml --out out/nordvik \
|
||||
--lint "portal lint" --llm "MODEL=sonnet engines/claude.sh"
|
||||
```
|
||||
|
||||
A `plan.yaml` is a needs spec that also says what each form collects
|
||||
and, per need, `max_fields`: how many required fields that person
|
||||
will tolerate, taken from what the business said about them, never
|
||||
from the form. `build` refuses a form over its budget, and hands the
|
||||
budget to the verifier, so the check still means something after
|
||||
handover. Everything structural is derived from the plan with no
|
||||
model involved: the state machines (read off `stages`: walk the line,
|
||||
be turned down at any point, succeed only from the end, pass over
|
||||
`skippable` stages), the front door, a followup page per need, a gated
|
||||
directory per group with its desk and the pages only that group may
|
||||
open, and every button the machine allows. The verifier accepts the
|
||||
result by construction; what it still checks is the budget.
|
||||
|
||||
The model writes words only, one page at a time, into a JSON object
|
||||
whose shape is fixed. Its strings are merged over built-in fallbacks
|
||||
key by key, so a bad reply costs wording, never a working site. `--llm-for <path prefix>` points the model at some pages only
|
||||
(`aggregates.yaml` for the mails alone); a reply in the wrong language
|
||||
for the plan is refused, and a mail that lost the submitter's link
|
||||
gets it back.
|
||||
|
||||
### Onboarding from the plan
|
||||
|
||||
`invites_by: [board]` on a plan, or `invited_by: [...]` on a group,
|
||||
puts an invite form on the inviting group's desk for each group it may
|
||||
fill, and a list of the invites sent. Portal does the rest (an account
|
||||
in Kanidm, the group, a one-time credential link) through `invite:` on
|
||||
the alternative. Every group with a desk invites into itself by
|
||||
default.
|
||||
|
||||
### Rebuilding a live site
|
||||
|
||||
`--words-from <repo>` lifts the words a repo already has back into the
|
||||
words map by what each card is (its bucket, the page it leads to, the
|
||||
group it invites into), across the whole repo, so a rebuild keeps
|
||||
hand-edited wording even when a card moves page (a door taken away, a
|
||||
need brought onto the front door) and
|
||||
adds only the new slots. Cards are matched by what they are (the
|
||||
bucket they record into, the page they lead to, a field's name, the
|
||||
group they invite into), not by position, so a rebuild that reorders
|
||||
or reshapes a page still hands each card its own words; on uhhm.no
|
||||
the live front door's three headings follow their buckets onto the
|
||||
new page. `--words
|
||||
<json>` overrides slots by page and may repeat, each file layered over
|
||||
the last (the words a build used, then a round of hand changes);
|
||||
`words.json` in the output is the words as used. See
|
||||
`runs/tomtervel-invites-2026-09-23` and `runs/uhhm-2026-09-23`.
|
||||
|
||||
| Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form `generate` | `build` |
|
||||
|---|---|---|
|
||||
| A site the verifier accepts | never, in 6 rounds and 16 min | first try, 130 s |
|
||||
| Held-out personas, with descriptions | - | 8 of 8 |
|
||||
| Held-out personas, headings only | - | 7 of 8 |
|
||||
|
||||
The wording numbers are from the re-score of 2026-09-22, one task per
|
||||
call (`runs/contractor-build-2026-09-21/rescore-2026-09-22/`). The
|
||||
first score, all tasks in one prompt, said 8 of 8 on headings only;
|
||||
the apprentice picking the building survey over "Join our carpentry
|
||||
team" only showed once no other visitor was in the prompt.
|
||||
|
||||
For comparison, a Sonnet-class model writing everything free-form
|
||||
scored 8 of 10 on headings only and silently dropped a stage the owner
|
||||
had described. `build` cannot drop a stage: it never decides one. The
|
||||
denominators differ: the `build` site gives the two crew personas a
|
||||
one-option page, so they generate no task, and 8 of 8 is eight public
|
||||
personas where the `generate` number is ten. Held out means held out
|
||||
from the model: the plan's `who` and `wants` lines and the personas
|
||||
were written by the same person about the same needs, and the fallback
|
||||
heading is the need's `wants`, so the two are cousins.
|
||||
|
||||
### The front door opens modestly
|
||||
|
||||
A front door with more buttons than text leaves a visitor paralysed.
|
||||
So the built front door opens with what the business does, in one or
|
||||
two features and no button at all (`does:` on the plan, at most two,
|
||||
each `for:` a need so it speaks to one role and is headed by what
|
||||
that role gets); then a look-around card, things a visitor may
|
||||
inspect before asking for anything (`shows:`, each a portal resource
|
||||
block passed through verbatim, plus every public view the plan
|
||||
opens); and only then the offers, as more alternatives on the same
|
||||
page. Nothing is layered: doors still exist for a plan that wants
|
||||
them, but a second tier is what this shape is there to avoid, and on
|
||||
uhhm.no a flat front door with an opener routed better than the
|
||||
doored one.
|
||||
|
||||
The house and its players: a role the opener invites in has to see
|
||||
its win, and the machine has to give it something to do after it
|
||||
sits down. `build` warns when a featured need gives the submitter
|
||||
neither a move (`moves_by: {<stage>: [submitter, ...]}`) nor a public
|
||||
view; a player with no move is a spectator.
|
||||
### Doors: a first question before the forms
|
||||
|
||||
Five or six needs on one page is more than a visitor sorts through
|
||||
(the full contractor plan had six). A plan can put public needs
|
||||
behind `doors`: each door is one alternative on the front door
|
||||
leading to a page of its own, where the needs that name it sit.
|
||||
|
||||
```yaml
|
||||
doors:
|
||||
- id: patients
|
||||
who: someone with pain or an injury, or a patient who already has a time here
|
||||
needs:
|
||||
- id: new-patient
|
||||
door: patients
|
||||
...
|
||||
```
|
||||
|
||||
The door's page is a nav page, public, holding the forms. The spec
|
||||
gives such a need `max_steps: 2` and pins `via` to the door's heading,
|
||||
so the verifier walks both pages. A door nobody is behind, a door on
|
||||
an internal need, or a door named like a group is refused, and a
|
||||
front door over five alternatives is warned about. The visitor is
|
||||
scored on both pages: a persona who picks the wrong door is a
|
||||
misroute on `/`, one who picks the wrong form is a misroute on the
|
||||
door's page.
|
||||
|
||||
Cases with doors: `plans/physio.yaml` (a physiotherapy clinic,
|
||||
patients and referrers), `plans/fotballklubb.yaml` (a football club,
|
||||
in Norwegian, parents and helpers), `plans/hire.yaml` (a machine hire
|
||||
yard, hiring and firms), and `plans/contractor-full.yaml` (three
|
||||
doors over six public needs). Each has an intake under `intakes/` in
|
||||
the business's own words and held-out personas.
|
||||
|
||||
### What a plan can say about a form and its readers
|
||||
|
||||
- `multiple: true` on a `select`: pick any number (portal renders
|
||||
toggles). What a volunteer can do, which events, which committees.
|
||||
- `groups:` at the top, a list of `{ id, label }`: the groups with a
|
||||
name the public knows, such as a vel's committees. A select with
|
||||
`options_from: groups` offers exactly those labels, so the
|
||||
application form and the desks cannot drift apart.
|
||||
- `watched_by: [public]`: an ungated read-only page of the bucket at
|
||||
`/status/<bucket>`, linked from the followup page. Everyone sees
|
||||
what was sent in and where it stands. `email` and `tel` fields, and
|
||||
any field marked `private: true`, are projected away before it is
|
||||
shown.
|
||||
- `events: { open: applied, active: onboarded }` on a need: the event
|
||||
name a stage is entered by, where the builder's default (the stage's
|
||||
own name, `received` for the first) is not what a live site already
|
||||
has. Portal replays every record by event name, so a rebuild of a
|
||||
site that holds records keeps the names its aggregates.yaml has, or
|
||||
every record vanishes from its desk. `plans/uhhm.yaml` rebuilds
|
||||
uhhm.no this way.
|
||||
- `mails: { <stage>: submitter | <address> }` on a need: who hears
|
||||
when a case enters that stage, sent by portal the moment it moves
|
||||
(portal 0.3.43, delivered by uhhm/gdo on the host). `submitter` goes
|
||||
to the email the form collected, so the form must collect a required
|
||||
`email`; an address is the treasurer, the board. The plan then needs
|
||||
`mail: { from, reply_to }` at the top, the sender every mail carries
|
||||
into `site.yaml`. The words model writes each mail's subject and
|
||||
body (`aggregates.yaml` in the words map); the fallback says what
|
||||
happened and, where the submitter has a move from that stage, the
|
||||
link to make it.
|
||||
- `stages_text: { <stage>: <words> }` on a need: each stage in the
|
||||
plan's language, where the ASCII id will not do (`ikke_lost` reads
|
||||
as English). Used by the fallback mails, the "what happens next"
|
||||
line, the desk buttons' fallback labels, and written into needs.yaml
|
||||
as `stage_words` so the desk score describes an outcome in the
|
||||
site's own words. With them, the vel's desks score 26 of 26.
|
||||
- `turned_down_from: [<stage>, ...]` on a need: the unfinished stages
|
||||
a case can still be turned down from; omitted, every one. An
|
||||
applicant is declined before they start and departs after, which
|
||||
is what uhhm.no's live machine says and what two near-synonymous
|
||||
desk buttons had been standing in for.
|
||||
- `reached_from: { <stage>: [<stage>, ...] }` on a need: where a stage
|
||||
is entered from, when the line a case walks does not say it. Two
|
||||
shapes need it and nothing else does - a case SENT BACK to a stage it
|
||||
was already past (a building application returned for the drawing it
|
||||
was missing, after the hearing turned something up), and a GOOD ENDING
|
||||
PARTWAY ALONG (an authority that changes its own decision while it is
|
||||
looking at the appeal, rather than passing it up to the board). A
|
||||
stage named there is entered from exactly those stages and nowhere
|
||||
else, so saying where a refusal may come from also says where it may
|
||||
not: an applicant may withdraw from anywhere, and be refused only once
|
||||
the papers are in, which `turned_down_from` could not express because
|
||||
it was one list for every bad ending at once. Found by replaying a
|
||||
building authority's real cases through portal's engine, which
|
||||
refused three legal paths and accepted two illegal ones until the
|
||||
plan could say this; see runs/permits-2026-09-25/README.md.
|
||||
- `attended_by:` on a need: who moves cases that no desk button moves,
|
||||
an automation listening on the bucket's events, written into
|
||||
aggregates.yaml as portal's own annotation for the reader.
|
||||
|
||||
## Plan: the intake, read by a model, checked by a person
|
||||
|
||||
```
|
||||
portal plan --intake intakes/contractor.yaml --out out/nordvik \
|
||||
--personas sectors/construction/needs.yaml \
|
||||
--llm "MODEL=nemotron-3.5-lightning:30b-a3b engines/ollama.sh"
|
||||
```
|
||||
|
||||
This is the one step where a model decides structure: which needs the
|
||||
intake implies, the stages, who handles what, the form, the budget.
|
||||
Its YAML is parsed and validated exactly as `build` will, the errors go
|
||||
back to it, and what passes is written verbatim for a person to read
|
||||
before building from it. Personas are never asked of this model; they
|
||||
are attached from a file, and where their `need:` names an id the plan
|
||||
does not have, `plan` says so and leaves the mapping to the reader.
|
||||
|
||||
`generate` (below) is the older path, a model writing pages free-form
|
||||
against the lint. It is kept for the record of what it measured.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
intake (plain words)
|
||||
│
|
||||
▼ phase 1 frozen after this phase: the generator
|
||||
needs.yaml ─────────────────────── may not weaken the spec to pass
|
||||
│
|
||||
▼ phase 2 ◄── iris findings, until it exits 0
|
||||
aggregates.yaml + questions/** + desks
|
||||
│
|
||||
▼ phase 3 ◄── misroutes from a blind visitor model
|
||||
wording
|
||||
```
|
||||
|
||||
1. **Needs.** The intake becomes `needs.yaml`: one need per kind of
|
||||
person and thing they want, including the roles inside the
|
||||
business. Each names the bucket the answer lands in, the group that
|
||||
handles it, the states that count as finished, and the effort the
|
||||
visitor will tolerate.
|
||||
2. **Structure.** Pages, state machines and desks are generated, then
|
||||
revised against the lint, `iris check`, until every need is met:
|
||||
a path within budget, a desk held by the right group, every
|
||||
finished state reachable, no record stranding. The page-writing
|
||||
model sees the needs and never the personas.
|
||||
3. **Wording.** A separate model plays each persona, blind, first with
|
||||
headings only and then with descriptions. A misroute is a wording
|
||||
defect and goes back to the generator. A rewrite that breaks
|
||||
structure is reverted.
|
||||
|
||||
Model output is only ever written to `aggregates.yaml`, `site.yaml`
|
||||
and `questions/**.yaml` inside the output directory.
|
||||
|
||||
```
|
||||
portal generate --intake intakes/contractor.yaml --out out/nordvik \
|
||||
--lint "portal lint" --llm engines/ollama.sh
|
||||
portal answer --tasks tasks.jsonl --llm engines/ollama.sh > answers.jsonl
|
||||
```
|
||||
|
||||
`answer` puts one task per call to the visitor model, with the options
|
||||
shuffled per task and the call seeded from the task id: no visitor
|
||||
sees another visitor's situation, a heading's place on the page cannot
|
||||
carry the answer, and a rerun on the same site is the same experiment.
|
||||
The earlier runs under `runs/` answered all tasks in one prompt.
|
||||
|
||||
The verifier is uhhm/iris, not code in this repo: it
|
||||
reads portal's own content types and ships in every portal release, so
|
||||
a generated repo's CI keeps enforcing its needs after handover.
|
||||
|
||||
## LLM backends
|
||||
|
||||
A backend is any command that takes a prompt on stdin and prints text.
|
||||
|
||||
| Backend | Use |
|
||||
|---|---|
|
||||
| `engines/ollama.sh` | Local Ollama. `MODEL=qwen3:8b` answers a task in about two seconds on ergo's GPU and is the default visitor. Generation wants a larger model (`MODEL=qwen3.6:27b`), which runs mostly on CPU there. |
|
||||
| `engines/claude.sh` | Claude through Claude Code's own login (a Max or Pro subscription), no API key. `MODEL=sonnet` or `opus`, `EFFORT=low` by default. It drops any `ANTHROPIC_API_KEY` from the environment, because a key outranks the login and a stale one fails every call. Log in once with `claude auth login`. |
|
||||
| `engines/anthropic.sh` | The Anthropic API, given a working `ANTHROPIC_API_KEY` from the Console. That is separate, prepaid billing; a subscription does not cover it. |
|
||||
| `engines/mailbox.sh` | Writes each prompt to a directory and waits for a reply file: drive the generator from an agent session, or by hand. |
|
||||
|
||||
The visitor and the generator should be different models. A model
|
||||
grading its own wording agrees with itself.
|
||||
|
||||
## The onboarding site
|
||||
|
||||
`portal-portal/` is the front end of this: a portal content repo whose
|
||||
front door is the intake interview. A submission lands in
|
||||
`onboardings` (`open → drafted ⇄ revising → live | declined`) and is
|
||||
worked from an owners' desk. Today the desk step is a person running
|
||||
`generate` and sending the link; the intake record already carries
|
||||
every field the generator reads, so the step that remains is a
|
||||
listener on `portal.answers.submitted` that runs it and pushes the
|
||||
draft to a new repo. It passes its own `needs.yaml`.
|
||||
|
||||
## The whole card, not the heading
|
||||
|
||||
A visitor never sees a bare heading and description. Portal renders a
|
||||
card: a button, an encouragement, one or more sections each with an
|
||||
icon, a title and a line of guidance, and the fields with their
|
||||
labels and placeholders. Since 2026-09-23 the builder fills all of it
|
||||
and the scorers read all of it.
|
||||
|
||||
- **Slots.** Beside the heading and description, each form alternative
|
||||
gets a button, an encouragement, an icon, a form title and guidance
|
||||
line, and a placeholder per field. A door gets an icon and a "for
|
||||
whom" line. Icons are picked from a fixed list of Iconify lucide
|
||||
names; anything else is dropped.
|
||||
- **Context from the plan.** Every form alternative also carries two
|
||||
sections built from what the plan already knows, before the form:
|
||||
what happens next (the stages, in order, as one sentence) and who
|
||||
reads it (the handling group and the contact). Their fallbacks are
|
||||
true before a model has written a word; the model rewords them.
|
||||
- **Scoring.** `answer --repo <content dir>` shows the visitor each
|
||||
option as its whole card (headings-only tasks stay headings only).
|
||||
`rate --repo` does the same and asks a fourth question, "where": do
|
||||
you know where you are, who this is for, and what the button does.
|
||||
All four are controlled against the gutted twin.
|
||||
|
||||
## Scoring a built site
|
||||
|
||||
```
|
||||
portal score --site out/nordvik --out out/scored \
|
||||
--iris /var/local/cargo-target/release/iris \
|
||||
--llm "MODEL=qwen3:8b engines/ollama.sh"
|
||||
```
|
||||
|
||||
One command for the whole grading, which every run under `runs/`
|
||||
before 2026-09-23 carried as its own `score.sh`: iris writes the
|
||||
visitor tasks and the simulation model, the blind visitor answers
|
||||
them with descriptions and with headings only, iris scores the
|
||||
answers (the misroutes are printed), and `rate` reads the cards. The
|
||||
two routing numbers land in `summary.json`, the rating in
|
||||
`rated/rate.json`. The pieces still run alone: `answer --tasks` for
|
||||
one visitor model against tasks iris wrote, and:
|
||||
|
||||
```
|
||||
iris check --path out/nordvik/questions --needs-sim model.json
|
||||
portal rate --model model.json --out out/rated --repo out/nordvik \
|
||||
--llm "MODEL=qwen3:8b engines/ollama.sh"
|
||||
```
|
||||
|
||||
Four yes-or-no questions per persona (which option is mine, where am
|
||||
I and what does the button do, what will I be asked, what happens
|
||||
after I send it), each also put to a gutted
|
||||
twin of the page with headings, descriptions, the form and the
|
||||
followup removed. A question the twin still gets a yes on is not
|
||||
measuring the page, and `rate` says which. The followup page is read
|
||||
from the repo (`--repo`); without it "what happens next" is reported
|
||||
as not measured rather than as zero. LIX is over the prose only
|
||||
(question, descriptions, followup): headings and labels are not
|
||||
sentences and would pull every page down to "very easy". The
|
||||
`rate.json` in `runs/contractor-build-2026-09-21/` predates this and
|
||||
its `asked` and `next` columns are the old, pinned ones.
|
||||
|
||||
`rate --repo` also scores the **desks**, the inside of the business,
|
||||
which reads button labels and nothing else: for every desk and bucket
|
||||
with two or more buttons, a worker is told what has just happened to
|
||||
a case (in the state's own words, never the label's) and picks the
|
||||
button among the ones a row in that state shows, in a rotated order.
|
||||
Scored per desk as right of total, with what each miss was mistaken
|
||||
for. Measured on 2026-09-24 on the live vel: 24 of 26, the misses
|
||||
one button each on the neighbour-help desk and the notice board. Two
|
||||
limits: the outcome is put from the state id, and a Norwegian id with
|
||||
its accents stripped (`ikke_lost`) reads as English even to a strong
|
||||
model; and a first version that offered every button of the bucket,
|
||||
not the row's, scored the same desks 17 of 26 and sent a rewrite
|
||||
chasing an instrument fault. A desk that scores full marks has labels
|
||||
a stranger to the business could press right.
|
||||
`prefer` has a positive control on record
|
||||
(`runs/tomtervel-opener-2026-09-23`, "The friendliness judge"): the
|
||||
live vel front door against a bureaucratic twin, friendlier 9 of 10
|
||||
to the live page and 0 to the twin. A pair that comes back "no
|
||||
preference" throughout is alike in tone, not an instrument that
|
||||
cannot see; and the rejected door rewrite that was judged clearer on
|
||||
5 of 10 needs routed worse across three seeds, so clarity as judged
|
||||
and routing as measured are different things, and routing decides.
|
||||
## Measured so far
|
||||
|
||||
**Generated: a building contractor** (`runs/contractor-2026-09-21/`),
|
||||
from `intakes/contractor.yaml`, a Sonnet-class model generating.
|
||||
|
||||
- Phase 1 derived six needs from the owner's prose, including one
|
||||
nobody asked for: a project manager logging a phone enquiry, taken
|
||||
from "enquiries arrive by phone, email and text and get lost".
|
||||
- Phase 2 was green on the first round: 14 files, four state machines
|
||||
(`enquiries: open → quoted → won | lost`, `subcontractors` with an
|
||||
`awaiting_papers` loop, `applicants`, `site_issues`), two desks.
|
||||
- Phase 3, its own personas: 10 of 10 in both modes. That number is
|
||||
flattering, because in that run the pages were written with the
|
||||
personas in view. Against ten **held-out** personas, with qwen3:8b
|
||||
as the visitor:
|
||||
|
||||
| Visitor could read | Routed right |
|
||||
|---|---|
|
||||
| Headings and descriptions | 10 of 10 |
|
||||
| Headings only | 8 of 10 |
|
||||
|
||||
A scaffolding firm asking "how do I get to do jobs for you?" chose
|
||||
"Ask about working with us" (the hiring door). An attic conversion
|
||||
chose "Get someone to look at a building". The generator has since
|
||||
been changed to hide personas from the page-writing phase.
|
||||
|
||||
**Built: the same contractor from a plan** (`runs/contractor-build-2026-09-21/`),
|
||||
nemotron writing words only, and re-scored one task per call the day
|
||||
after (`rescore-2026-09-22/` in it): 8 of 8 with descriptions, 7 of 8
|
||||
on headings alone, the miss being an apprentice bricklayer reading
|
||||
"Join our carpentry team" as not for them. `rate` on the same site,
|
||||
with the followup shown and every question controlled, passes its
|
||||
controls and puts the pages at LIX 18 to 35.
|
||||
|
||||
**Built behind doors, 2026-09-22.** Five plans with a first question
|
||||
before the forms, each built and then scored by qwen3:8b one task per
|
||||
call, against held-out personas. Behind a door a persona is asked
|
||||
twice, on the front door and on the door's page.
|
||||
|
||||
| Case | Words by | With descriptions | Headings only | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Machine hire (`hire`) | nemotron | 10 of 10 | 10 of 10 | doors that ask a question the visitor already knows the answer to |
|
||||
| Physio clinic (`physio`) | nemotron | 8 of 10 | 9 of 10 | "Book your first appointment" fails a daughter booking for her mother |
|
||||
| Football club, Norwegian (`fotballklubb`) | nemotron | 14 of 16 | 11 of 16 | three parents walk past "Er det på tide å melde inn?" |
|
||||
| Football club, Norwegian | Claude Sonnet | 13 of 16 | 12 of 16 | better sentences, same driver/sponsor blur |
|
||||
| Whole contractor (`contractor-full`) | nemotron | 18 of 22 | 15 of 22 | "Apply to work with us" and "Find a job with us" cross over for all four |
|
||||
| Tomter Vel, Norwegian (`tomtervel`), first plan | Claude Sonnet | 25 of 30 | 22 of 30 | "utstyr" for a swing, hearing input behind the wrong door |
|
||||
| Tomter Vel, plan revised 2026-09-23, whole card | Claude Sonnet | 31 of 38 | 30 of 38 | committees as groups, public lists, hearing input on the front door |
|
||||
|
||||
Each run's README names the misroutes and what in the plan or the
|
||||
heading caused them. A stronger word model did not route better on
|
||||
the one plan built with both: routing is decided by which distinctions
|
||||
sit on one page, and the visitor grades that the same whoever wrote
|
||||
the words.
|
||||
|
||||
**Planned: the contractor intake read by a local model**
|
||||
(`runs/contractor-plan-2026-09-22/`), nemotron, one round, 41 s. The
|
||||
plan passed every check `build` makes and a reader finds three
|
||||
structural misses in it the checks cannot: the crew's report made
|
||||
public, the "papers missing" stage dropped, and no need for the
|
||||
project manager logging a call. The person reading the plan is not
|
||||
optional.
|
||||
|
||||
**Existing site: uhhm.no** (`runs/uhhm-2026-09-21/`), eight tasks.
|
||||
|
||||
| Visitor model | With descriptions | Headings only |
|
||||
|---|---|---|
|
||||
| small LLM, blind | 7 of 8 | 5 of 8 |
|
||||
| qwen3:8b (Ollama) | 7 of 8 | 7 of 8 |
|
||||
| granite4.1:8b (Ollama) | 7 of 8 | 6 of 8 |
|
||||
|
||||
All three send a paying client through **"Work happens through
|
||||
dialogue, not tickets"**, the collaborator door. Different models,
|
||||
same defect: that is what a wording problem looks like.
|
||||
|
||||
**The noise floor, 2026-09-23** (`runs/spread.sh`, `answer --seed`).
|
||||
The same tasks answered by qwen3:8b with four seeds (0 is the run
|
||||
every score above used):
|
||||
|
||||
| Run | With descriptions | Headings only |
|
||||
|---|---|---|
|
||||
| uhhm.no round 6, 15 tasks | 11, 10, 10, 10 | 12, 12, 7, 7 |
|
||||
| Tomter Vel with the opener, 38 tasks | 30, 29, 29, 31 | 26, 24, 26, 23 |
|
||||
|
||||
With descriptions the visitor is steady: one task in fifteen, two in
|
||||
thirty-eight. On headings alone it is not: five of fifteen on uhhm.no
|
||||
between seeds, three of thirty-eight on the vel. A headings-only
|
||||
difference smaller than that is the visitor's mood. Compare
|
||||
descriptions-mode scores; read headings-only as a sanity check, and
|
||||
run several seeds before believing it.
|
||||
Small numbers, one run each. Leads, not measurements.
|
||||
|
||||
## What else is here
|
||||
|
||||
- `skill/SKILL.md` - the same procedure for an agent or a person
|
||||
working by hand.
|
||||
- `sectors/` - needs specs to start from. `construction/` doubles as
|
||||
the held-out persona set above.
|
||||
- `generator/reference.md` - everything the generating model is told
|
||||
about portal content. When the lint rejects something the reference
|
||||
did not warn about, the fix belongs there.
|
||||
|
||||
## Toward a trained model
|
||||
|
||||
Nothing here trains weights yet. What would make it possible is
|
||||
accumulating: every run leaves the intake, the needs, the pages at
|
||||
each revision and the scores. With a fast local visitor as the scorer,
|
||||
generating many variants per need and keeping the winners is cheap,
|
||||
and those winners are the fine-tuning set for a small generator. The
|
||||
verifier has to be trusted before any of that: a generator trained
|
||||
against a judge that is often wrong learns the judge.
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# LLM backend for portal-trainer: prompt on stdin, text on stdout.
|
||||
# Needs ANTHROPIC_API_KEY, curl and jq. MODEL and MAX_TOKENS override.
|
||||
set -eu
|
||||
: "${ANTHROPIC_API_KEY:?set ANTHROPIC_API_KEY}"
|
||||
jq -Rs --arg model "${MODEL:-claude-sonnet-5}" --argjson max "${MAX_TOKENS:-16000}" \
|
||||
'{model: $model, max_tokens: $max, messages: [{role: "user", content: .}]}' |
|
||||
curl -sS --fail-with-body --max-time "${TIMEOUT:-600}" https://api.anthropic.com/v1/messages \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json" --data-binary @- |
|
||||
jq -er '[.content[] | select(.type == "text") | .text] | join("")'
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# LLM backend for portal-trainer on Claude Code's own login (a Max or
|
||||
# Pro subscription): prompt on stdin, text on stdout. No API key
|
||||
# needed: an ANTHROPIC_API_KEY in the environment would outrank the
|
||||
# login, so it is dropped here. Log the CLI in once with
|
||||
# `claude auth login`; `claude auth status` shows what it uses.
|
||||
# MODEL is an alias or id (sonnet, opus, claude-sonnet-5); EFFORT is
|
||||
# low, medium or high. Not --bare: that mode skips the stored login.
|
||||
#
|
||||
# The generator's SEED and TEMPERATURE hints cannot be honoured;
|
||||
# FORMAT=json is turned into an instruction.
|
||||
set -eu
|
||||
unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDECODE CLAUDE_CODE_ENTRYPOINT
|
||||
prompt=$(cat)
|
||||
if [ "${FORMAT:-}" = json ]; then
|
||||
prompt="$prompt
|
||||
|
||||
Reply with the JSON object only, no prose, no code fences."
|
||||
fi
|
||||
printf '%s' "$prompt" | claude -p --model "${MODEL:-sonnet}" --effort "${EFFORT:-low}" \
|
||||
--output-format text --permission-mode dontAsk --no-session-persistence \
|
||||
--tools ""
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# LLM backend that hands each prompt to whoever watches $MAILBOX:
|
||||
# prompt-N.md goes out, the reply is read from reply-N.md. For driving
|
||||
# the generator from an agent session, or by hand.
|
||||
set -eu
|
||||
: "${MAILBOX:?set MAILBOX to a directory}"
|
||||
mkdir -p "$MAILBOX"
|
||||
n=$(( $(ls "$MAILBOX" | grep -c '^prompt-' || true) + 1 ))
|
||||
cat > "$MAILBOX/prompt-$n.md"
|
||||
until [ -s "$MAILBOX/reply-$n.md" ]; do sleep 2; done
|
||||
sleep 1
|
||||
cat "$MAILBOX/reply-$n.md"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# LLM backend for portal-trainer on a local Ollama: prompt on stdin,
|
||||
# text on stdout. MODEL, OLLAMA_HOST, NUM_CTX and TIMEOUT override.
|
||||
# Thinking is off: the generator wants files, the answerer wants JSONL.
|
||||
# The simulator sets SEED (so the same case replays the same way
|
||||
# against two versions of a site), FORMAT=json and TEMPERATURE.
|
||||
set -eu
|
||||
jq -Rs --arg model "${MODEL:-qwen3:8b}" --argjson ctx "${NUM_CTX:-32768}" \
|
||||
--argjson temp "${TEMPERATURE:-0.2}" --arg seed "${SEED:-}" --arg format "${FORMAT:-}" \
|
||||
'{model: $model, stream: false, think: false,
|
||||
options: ({num_ctx: $ctx, temperature: $temp}
|
||||
+ (if $seed == "" then {} else {seed: ($seed | tonumber)} end)),
|
||||
messages: [{role: "user", content: .}]}
|
||||
+ (if $format == "json" then {format: "json"} else {} end)' |
|
||||
curl -sS --fail-with-body --max-time "${TIMEOUT:-1800}" \
|
||||
"${OLLAMA_HOST:-http://127.0.0.1:11434}/api/chat" \
|
||||
-H "content-type: application/json" --data-binary @- |
|
||||
jq -er '.message.content'
|
||||
@@ -0,0 +1,28 @@
|
||||
# Held out from every model that writes words. Each is one real person
|
||||
# arriving with one real thing, in their own register: a homeowner who
|
||||
# has never read a regulation, a firm that files for a living, an
|
||||
# inspector on a roof in the wind, a politician reading a bundle.
|
||||
personas:
|
||||
- { id: garage-owner, need: pre-application-meeting, text: "We want to put a double garage where the old shed is. Before I pay an architect I'd like to know whether you'll even allow it, because the neighbour says it's too close to the road." }
|
||||
- { id: shop-to-flats, need: pre-application-meeting, text: "I own the old grocery on Torget and want to make four flats out of it. There are a lot of questions - parking, fire, the facade is listed. Can we sit down with someone?" }
|
||||
- { id: firm-files-extension, need: permit-application, text: "We are the responsible applicant for an extension at Bjorkeveien 14. Drawings, site plan and the neighbour notices are all ready. We file maybe forty of these a year." }
|
||||
- { id: owner-small-window, need: permit-application, text: "I want to put a dormer window in my roof. The man at the counter said I have to apply even though it's small." }
|
||||
- { id: missing-drawings, need: permit-application, text: "You wrote that our section drawing is missing and the case is on hold. I've got it here now, where do I send it so the clock starts again?" }
|
||||
- { id: too-close-to-boundary, need: exemption-request, text: "The garage ends up one and a half metres from the boundary and the plan says four. We are asking you to allow it anyway - the ground falls away steeply everywhere else on the plot." }
|
||||
- { id: neighbour-view, need: objection, text: "I got a notice about the extension at number 14. It takes the afternoon sun off my entire terrace and I want that on the record before you decide anything." }
|
||||
- { id: neighbour-parking, need: objection, text: "The flats they want to build in the old grocery have four parking places for four flats and everyone here already parks on the street. I object." }
|
||||
- { id: road-authority, need: hearing-answer, text: "Regarding your case 2026/418: the access onto the county road is within the sight line and we cannot accept it as drawn. Our formal reply is attached." }
|
||||
- { id: fire-service, need: hearing-answer, text: "We have looked at the conversion on Torget. We have no objection provided the stairwell is upgraded. Reference your case 2026/501." }
|
||||
- { id: refused-applicant, need: appeal, text: "You refused our exemption on the ninth. We don't accept the reasoning - you've allowed the same thing twice in the same street. We're appealing." }
|
||||
- { id: neighbour-appeals, need: appeal, text: "You approved the extension next door even though I objected. I'm appealing the decision. What do I need to send?" }
|
||||
- { id: board-member, need: appeal, text: "I sit on the appeals board. I need the case, our own decision, and what the neighbours wrote, before Tuesday." }
|
||||
- { id: inspector-on-roof, need: inspection-finding, text: "I'm up on the scaffold at Bjorkeveien. The fire wall isn't built to the drawing at all. I've got gloves on and it's blowing. I want this logged before I climb down." }
|
||||
- { id: inspector-all-good, need: inspection-finding, text: "Been out at Torget. Everything is as approved. Nothing to report but I have to record that I was there." }
|
||||
- { id: firm-wants-certificate, need: completion-request, text: "The work at Bjorkeveien 14 is finished and inspected. We're asking for the completion certificate so the owners can get their mortgage released." }
|
||||
- { id: wants-to-move-in, need: completion-request, text: "The house is done apart from the outside steps, which we can't pour until spring. The family has sold their flat. Can we get a temporary permission to move in?" }
|
||||
- { id: something-behind-fence, need: illegal-building-report, text: "There's a large structure going up behind the fence at Solbakken 7. Nobody has had a notice about it and it's been going on for two weeks." }
|
||||
- { id: owner-explains, need: illegal-building-report, text: "I got your letter about the terrace. I genuinely did not know it needed an application, it's been there since we moved in. What happens now?" }
|
||||
- { id: new-firm, need: firm-approval, text: "We are a three-person architecture firm that has just started. We want to be approved so we can file applications for our clients in your municipality." }
|
||||
- { id: firm-insurance-lapsed, need: firm-approval, text: "You wrote that our liability insurance certificate has expired. We have renewed it, here it is." }
|
||||
- { id: counter-phone-call, need: log-counter-enquiry, text: "A woman rang about the case behind her house. Wants to know where it stands. I'm at the counter and there's a queue - I need to write it down and send it to whoever has that case." }
|
||||
- { id: where-is-my-case, need: case-status-enquiry, text: "I sent in my application five weeks ago and I've heard nothing. Is anyone looking at it? Is it in the six weeks with the neighbours or is it waiting on me?" }
|
||||
@@ -0,0 +1,91 @@
|
||||
# A building authority's intake: the hardest sector the trainer has
|
||||
# been given. Cases that pause and resume, cases that belong to other
|
||||
# cases, a decision two roles sign, a deadline nobody in the building
|
||||
# controls, and an appeal that reopens something already finished.
|
||||
# Written the way the department head would write it, in plain words.
|
||||
business: Vestmark kommune - Building and Planning
|
||||
contact: { name: Solveig Hauge, email: byggesak@vestmark.example }
|
||||
language: en
|
||||
|
||||
what_we_do: >
|
||||
We are the building authority for a municipality of twenty-one
|
||||
thousand people. Anyone who wants to build, demolish, divide a
|
||||
property or change what a building is used for has to come to us
|
||||
first. We check the drawings against the zoning map and the building
|
||||
regulations, we let the neighbours and the other authorities have
|
||||
their say, and then we approve or refuse. We also decide exemptions
|
||||
from the zoning rules, we inspect what is actually built, we sign off
|
||||
finished work, and we deal with building that was never applied for.
|
||||
|
||||
who_comes_to_us: >
|
||||
Homeowners who want a garage, an extension or a new window, and who
|
||||
have never read a regulation in their life. Architects and
|
||||
engineering firms who file on behalf of owners; they have to be
|
||||
approved by us before they may file anything. Neighbours who got a
|
||||
notice in the post and do not agree with what is planned next door.
|
||||
The other authorities we are obliged to ask: the road authority, the
|
||||
fire service, the heritage office, the county. People who ring to say
|
||||
something is going up behind their fence without permission. And
|
||||
anyone who wants to sit down with us before they spend eighty
|
||||
thousand on drawings.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Pre-application meetings: someone asks for one, we find a time, we
|
||||
meet, and we write down what we told them so they can hold us to it.
|
||||
Applications: they arrive, someone checks whether the papers are all
|
||||
there, and if something is missing we write and ask for it, and the
|
||||
twelve-week clock stops until they answer. Often we have to ask
|
||||
twice. When it is complete we send it out to the neighbours and to
|
||||
the agencies and wait six weeks. Then a case officer writes a
|
||||
recommendation, and the head of section signs the decision: approved,
|
||||
approved with conditions, or refused. People can withdraw at any
|
||||
point, and quite a few do once they hear from the neighbours.
|
||||
Exemptions: if what someone wants breaks the zoning rules they can
|
||||
ask for an exemption. It rides along with the application but it is a
|
||||
decision of its own, and we are not allowed to grant one before the
|
||||
county has been asked.
|
||||
Objections: a neighbour who got a notice writes to us within the
|
||||
hearing period. Each objection belongs to an application. We register
|
||||
it, the applicant gets to answer it, and the case officer has to
|
||||
address every single one in the decision, by name.
|
||||
Hearing answers: we ask an agency, they answer, or they do not, and
|
||||
when the deadline has passed we go on without them.
|
||||
Appeals: for three weeks after a decision anyone with an interest can
|
||||
appeal it. We go through it ourselves first, and if we change our own
|
||||
mind that is the end of it. If we do not, it goes to the appeals
|
||||
board, who meet once a month and either uphold us or overturn us.
|
||||
Inspections: we pick cases and go and look, on site. Either it is as
|
||||
approved, or we write up what is wrong and set a date for it to be
|
||||
put right, and then we go back.
|
||||
Finishing: when the work is done the firm asks for a completion
|
||||
certificate. If they want to move in before everything is done they
|
||||
can ask for a temporary permission instead. We check what was
|
||||
inspected and what is still outstanding before we sign anything.
|
||||
Illegal building: someone reports it, we go and look, we write to the
|
||||
owner and they have to explain themselves, and then they either apply
|
||||
for it properly, take it down, or we fine them weekly until they do.
|
||||
Approved firms: a firm sends us their registration, their insurance
|
||||
and what they are qualified for, we check it, and then they are on
|
||||
the list and may file applications.
|
||||
|
||||
who_is_involved: >
|
||||
The front desk logs what comes in over the counter and on the phone,
|
||||
and keeps the archive. Four case officers carry the cases. The head
|
||||
of section signs every decision and every exemption; a case officer
|
||||
may not sign their own. Two inspectors are out on site most of the
|
||||
week and write up what they find on a phone in a cold wind. The
|
||||
appeals board is seven elected politicians who meet on the third
|
||||
Tuesday of the month and read what we send them. The agencies are
|
||||
outside the building altogether and only ever answer hearings. The
|
||||
approved firms file applications and answer objections on behalf of
|
||||
their clients.
|
||||
|
||||
what_slows_us_down: >
|
||||
The twelve-week clock, and the fact that stopping it and starting it
|
||||
again is done by hand in a spreadsheet. Applications that arrive
|
||||
missing half the drawings. Objections that come in on paper and get
|
||||
stapled to the wrong case. Neighbours ringing to ask what is
|
||||
happening with the case behind their house, which we are obliged to
|
||||
tell them and which takes ten minutes every time. Agencies that never
|
||||
answer at all. And nobody outside this building can see a case
|
||||
without asking us for it.
|
||||
@@ -0,0 +1,44 @@
|
||||
# An intake as the owner of a small building contractor would fill it
|
||||
# in: plain words, no portal vocabulary. These are the fields the
|
||||
# onboarding site asks for.
|
||||
business: Nordvik Bygg
|
||||
contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example }
|
||||
language: en
|
||||
|
||||
what_we_do: >
|
||||
We are twelve people. We survey old buildings, draw up rehabilitation
|
||||
plans, handle the permit application, and do the building work with
|
||||
our own carpenters plus subcontractors for electrical, plumbing and
|
||||
scaffolding.
|
||||
|
||||
who_comes_to_us: >
|
||||
Housing boards and private owners who have a problem with a building
|
||||
and want someone to look at it. Owners who already know what they
|
||||
want built and need drawings, a permit and a price. Trade firms who
|
||||
want to be on our list of subcontractors. Carpenters and apprentices
|
||||
asking if we are hiring.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Enquiries: they come in, we go and look at the building, we send a
|
||||
quote, and then we either win it or lose it. Sometimes it is simply
|
||||
not for us and we say so straight away.
|
||||
Subcontractors: they apply, we check their company registration,
|
||||
trade approval and insurance. Often papers are missing and we have to
|
||||
ask again. Then they are approved or turned down.
|
||||
Job applications: we read them, invite some to an interview, and hire
|
||||
or decline.
|
||||
Things that go wrong on site: a hazard, a near miss, wrong materials
|
||||
delivered. Someone reports it, the safety officer decides what is
|
||||
done about it, and closes it when it is fixed.
|
||||
|
||||
who_is_involved: >
|
||||
Two project managers handle enquiries, subcontractors and hiring.
|
||||
One safety officer handles everything reported from site.
|
||||
The crew, ours and the subcontractors' people, are the ones on site
|
||||
who report things. They are on their phones, with gloves on, and
|
||||
will not fill in a long form.
|
||||
|
||||
what_slows_us_down: >
|
||||
Enquiries arrive by phone, email and text and get lost. We never know
|
||||
which subcontractors have valid papers. Site problems get mentioned
|
||||
at lunch and forgotten.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Slik lederen i en fotballgruppe ville fylt det ut.
|
||||
business: Lia IL Fotball
|
||||
contact: { name: Kari Lien, email: kari@liail.example }
|
||||
language: no
|
||||
|
||||
what_we_do: >
|
||||
Fotballgruppa i et idrettslag på bygda. Rundt hundre og tjue unger
|
||||
fra seks til sekstenår, fordelt på lag etter årskull. Alt drives av
|
||||
foreldre på fritida: trenere, lagledere, en kasserer og et styre.
|
||||
|
||||
who_comes_to_us: >
|
||||
Foreldre som vil ha ungen med på et lag. Foreldre som sliter med
|
||||
treningsavgiften og vil dele den opp eller slippe. Folk som vil
|
||||
hjelpe til, som trener, lagleder, sjåfør eller på dugnad. Bedrifter
|
||||
i bygda som vil sponse.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Innmeldinger: en forelder melder inn, treneren på årskullet
|
||||
godkjenner at det er plass, kassereren ser at avgiften er betalt.
|
||||
Noen trekker seg underveis.
|
||||
Avgiftssaker: noen ber om å dele opp eller slippe avgiften; styret
|
||||
vurderer og sier ja eller nei.
|
||||
Frivillige: noen tilbyr seg, styret tar kontakt og plasserer dem der
|
||||
det trengs, eller takker nei.
|
||||
Sponsorer: en bedrift melder interesse, vi tar en prat, og det blir
|
||||
en avtale eller ikke.
|
||||
Ting på banen: lys som ikke virker, mål som er ødelagt, drakter som
|
||||
mangler. Trenerne melder fra, styret tar tak i det og ordner det.
|
||||
|
||||
who_is_involved: >
|
||||
Trenerne godkjenner innmeldinger på sitt årskull og melder fra om
|
||||
banen. Kassereren følger opp betaling. Styret tar frivillige,
|
||||
sponsorer, avgiftssaker og alt som gjelder anlegget.
|
||||
|
||||
what_slows_us_down: >
|
||||
Alt går i en gruppechat og et regneark. Trenerne vet ikke hvem som
|
||||
har betalt, kassereren vet ikke hvem som er godkjent, og en som
|
||||
tilbød seg å kjøre i høst fikk aldri svar.
|
||||
@@ -0,0 +1,38 @@
|
||||
# An intake as the owner of a machine hire yard would fill it in.
|
||||
business: Fjellstad Hire
|
||||
contact: { name: Ola Fjellstad, email: ola@fjellstadhire.example }
|
||||
language: en
|
||||
|
||||
what_we_do: >
|
||||
We hire out machines: mini diggers, lifts, compactors, generators,
|
||||
that kind of thing. A counter with two people, a yard with three who
|
||||
deliver, collect and fix, and me in the office doing accounts and
|
||||
the business customers.
|
||||
|
||||
who_comes_to_us: >
|
||||
Private people who need a machine for a weekend and have often never
|
||||
driven one. Building and landscaping firms who need something on a
|
||||
site from a date for a period. Anyone whose hired machine has
|
||||
stopped working while they have it. Firms who hire often and want
|
||||
an account so they can send anyone to collect and get one invoice a
|
||||
month.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Hires: someone asks, the counter reserves it, the yard hands it out
|
||||
or delivers it, and the yard takes it back. Some are cancelled
|
||||
before they go out.
|
||||
Breakdowns: a machine stops during a hire; the yard takes it on and
|
||||
either fixes it on site or swaps it.
|
||||
Account applications: a firm applies, I check the company and its
|
||||
credit, and it is approved or turned down.
|
||||
Damage on return: the yard finds damage when a machine comes back; I
|
||||
either invoice the customer or write it off.
|
||||
|
||||
who_is_involved: >
|
||||
The counter handles hires. The yard handles breakdowns and the
|
||||
handing out and taking back. The office does accounts and damage.
|
||||
|
||||
what_slows_us_down: >
|
||||
The yard finds out about breakdowns from whoever picks up the phone.
|
||||
Damage found on return is written on a whiteboard and half of it is
|
||||
never invoiced.
|
||||
@@ -0,0 +1,42 @@
|
||||
# An intake as the manager of a physiotherapy clinic would fill it in.
|
||||
business: Bakken Fysioterapi
|
||||
contact: { name: Marit Bakken, email: marit@bakkenfysio.example }
|
||||
language: en
|
||||
|
||||
what_we_do: >
|
||||
Six physiotherapists and a receptionist. People come to us with
|
||||
pain, injuries and after operations; we assess them and treat them
|
||||
over a course of sessions. Some come on their own, some are sent by
|
||||
a GP or a hospital, and some have insurance that pays if the insurer
|
||||
approves the course first.
|
||||
|
||||
who_comes_to_us: >
|
||||
People with something that hurts who want to see the right
|
||||
therapist. Patients who already have a time and need to move or
|
||||
drop it. GPs and hospital wards sending us someone, who want to
|
||||
hear how it went. Insurers, through us, when a course has to be
|
||||
approved before it starts.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Requests for a first appointment: they come in, we offer a time with
|
||||
a therapist who suits, and it is either booked or they go elsewhere.
|
||||
Cancellations: someone can't come; we either find them a new time or
|
||||
give the slot to someone waiting.
|
||||
Referrals: a doctor sends someone, we accept it, book them in, treat
|
||||
them, and send the doctor a report at the end. Sometimes we send it
|
||||
back because it isn't something we treat.
|
||||
Insurance approvals: for some patients we have to ask the insurer
|
||||
before we start; we send the request and it comes back approved or
|
||||
refused.
|
||||
No-shows: someone doesn't turn up and doesn't call. The receptionist
|
||||
rings them and either rebooks or, after a couple of times, closes it.
|
||||
|
||||
who_is_involved: >
|
||||
The receptionist handles every request, cancellation, referral and
|
||||
approval. The therapists treat and write the report back to the
|
||||
doctor, and they are the ones who notice a no-show.
|
||||
|
||||
what_slows_us_down: >
|
||||
Referrals, cancellations and insurance approvals all go through one
|
||||
inbox that only the receptionist understands. Therapists tell her
|
||||
about no-shows in the corridor and they get forgotten.
|
||||
@@ -0,0 +1,48 @@
|
||||
# En fiktiv norsk rehabiliteringsentreprenør, inspirert av hva
|
||||
# Klingenberg Bygg driver med: tilstandsrapporter, prosjektering,
|
||||
# byggesøknader og utførelse på eldre murgårder. Skrevet slik eieren
|
||||
# ville sagt det, uten portal-ord.
|
||||
business: Åsrud Bygg og Rehabilitering AS
|
||||
contact: { name: Marte Åsrud, email: marte@asrudbygg.example }
|
||||
language: "no"
|
||||
|
||||
what_we_do: >
|
||||
Vi er fjorten ansatte og jobber mest med eldre murgårder og
|
||||
trehus. Vi lager tilstandsrapporter, prosjekterer rehabilitering,
|
||||
sender byggesøknad med ansvarsrett, og utfører arbeidet med egne
|
||||
tømrere og murere. Elektriker, rørlegger, stillas og blikkenslager
|
||||
leier vi inn som underentreprenører.
|
||||
|
||||
who_comes_to_us: >
|
||||
Styreledere i borettslag og sameier, og private huseiere, som har
|
||||
et problem med bygget og vil at noen skal se på det. Eiere som
|
||||
allerede vet hva de vil bygge eller endre, og trenger tegninger,
|
||||
søknad til kommunen og en pris. Håndverksfirmaer som vil stå på
|
||||
lista vår over underentreprenører. Tømrere, murere og lærlinger som
|
||||
lurer på om vi trenger folk.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Henvendelser: de kommer inn, vi avtaler og drar på befaring, vi
|
||||
sender tilbud, og så vinner vi jobben eller taper den. Noen ganger
|
||||
er det rett og slett ikke noe for oss, og da sier vi det med en gang.
|
||||
Underentreprenører: de melder seg, vi sjekker organisasjonsnummer,
|
||||
sentral godkjenning, forsikring og at folka har HMS-kort. Ofte
|
||||
mangler det papirer og vi må etterlyse dem. Så blir de godkjent
|
||||
eller avslått.
|
||||
Jobbsøknader: vi leser dem, kaller inn noen til intervju, og
|
||||
ansetter eller takker nei.
|
||||
Avvik og uønskede hendelser på byggeplass: løse rekkverk,
|
||||
nestenulykker, feil materialer levert. Noen melder fra, HMS-ansvarlig
|
||||
bestemmer tiltak, og lukker saken når det er utbedret.
|
||||
|
||||
who_is_involved: >
|
||||
To prosjektledere tar seg av henvendelser, underentreprenører og
|
||||
ansettelser. En HMS-ansvarlig tar alt som meldes fra byggeplass.
|
||||
Mannskapet, både våre egne og underentreprenørenes folk, er de som
|
||||
står på plassen og melder fra. De sitter med telefonen, har hansker
|
||||
på, og fyller ikke ut lange skjemaer.
|
||||
|
||||
what_slows_us_down: >
|
||||
Henvendelser kommer på telefon, e-post og SMS og blir borte. Vi vet
|
||||
aldri sikkert hvilke underentreprenører som har papirene i orden.
|
||||
Avvik blir nevnt i lunsjen og glemt.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Tomter Vel, slik en i styret ville fylt det ut. Det som står om
|
||||
# velet er hentet fra tomtervel.no (komiteer, kontingent, medlemstall,
|
||||
# arrangementer); det som står om deling av utstyr er et forslag til
|
||||
# en ny tjeneste.
|
||||
business: Tomter Vel
|
||||
contact: { name: Styret i Tomter Vel, email: post@tomtervel.no }
|
||||
language: no
|
||||
|
||||
what_we_do: >
|
||||
Velforeningen for Tomter, stiftet i 1947. Vi er et styre på sju og
|
||||
sju komiteer som alle drives av folk på fritida: arrangementer
|
||||
(17. mai-frokost, juletregranstenning, skøytedag), elvestien langs
|
||||
Hobølelva, to lekeplasser vi holder i stand og får kontrollert hvert
|
||||
år, høringssvar til kommunen, miljøgata i sentrum, pendlerforhold
|
||||
mot Ruter, og trafikkforhold. 157 husstander betaler kontingent på
|
||||
250 kroner i året.
|
||||
|
||||
who_comes_to_us: >
|
||||
Husstander som vil bli medlem, eller som har flyttet eller byttet
|
||||
e-post og vil at vi skal ha det riktig. Folk som har sett noe i
|
||||
nabolaget som ikke er som det skal: en farlig sving, en huske som
|
||||
henger i én kjetting, en råtten planke på stien. Beboere som vil
|
||||
si sin mening om en sak kommunen har på høring. Folk som vil hjelpe
|
||||
til på et arrangement eller en dugnad, eller sitte i en komité.
|
||||
Og naboer som har en tilhenger, en stige eller en hekksaks som står
|
||||
ubrukt, og naboer som trenger å låne en. Eldre og nyopererte som
|
||||
trenger en hånd med snømåking, handling eller en skyss, og naboer
|
||||
som gjerne stiller opp hvis noen spør.
|
||||
|
||||
what_we_keep_track_of: >
|
||||
Medlemskap: en husstand melder seg inn, kassereren ser at
|
||||
kontingenten er betalt, og noen melder seg ut når de flytter.
|
||||
Meldinger om trafikk: noen melder fra, trafikkomiteen tar det opp
|
||||
med kommunen eller veieier, og det blir ordnet eller avvist.
|
||||
Feil på lekeplass og sti: noen melder fra, komiteen tar tak i det
|
||||
og utbedrer det.
|
||||
Høringsinnspill: en beboer sender inn, styret tar det med i svaret
|
||||
eller lar være.
|
||||
Frivillige: noen tilbyr seg, arrangementskomiteen tar kontakt og
|
||||
setter dem opp, eller takker nei.
|
||||
Komitésøknader: noen vil være med, styret plasserer dem eller sier
|
||||
nei.
|
||||
Utlegg: en i en komité har lagt ut for noe, kassereren godkjenner og
|
||||
betaler ut, eller avviser.
|
||||
Nabohjelp: noen ber om en hånd, nabohjelpsgruppa finner en som kan,
|
||||
og den som fikk hjelp sier fra når det er ordnet. Naboer som vil
|
||||
hjelpe melder seg, og kan trekke seg igjen.
|
||||
Deling av utstyr: en nabo melder at de har noe å låne ut, eller
|
||||
trenger å låne noe; styret legger det ut på oppslagstavla og tar
|
||||
det ned når det er gjort.
|
||||
|
||||
who_is_involved: >
|
||||
Kassereren har medlemskap og utlegg. Trafikkomiteen har
|
||||
trafikkmeldinger, lekeplasskomiteen har feil på anlegg,
|
||||
arrangementskomiteen har frivillige. Styret har høringsinnspill,
|
||||
komitésøknader og oppslagstavla. Nabohjelpsgruppa kobler dem som
|
||||
trenger hjelp med dem som kan. Komiteene leverer utlegg.
|
||||
|
||||
what_slows_us_down: >
|
||||
Medlemslista er en bankutskrift og et regneark. Ting som meldes på
|
||||
Facebook eller i butikken blir husket av den som hørte det. Folk
|
||||
som tilbyr seg å hjelpe får svar sent eller aldri.
|
||||
@@ -0,0 +1,128 @@
|
||||
// Glue for `type: location` requirement fields (see AlternativeCard in
|
||||
// src/app.rs) - the same hidden-input bridge every widget kind uses,
|
||||
// and the same typed handle with stop() that gesture.js and voice.js
|
||||
// use so SPA navigation tears the map down.
|
||||
//
|
||||
// The pin is the centre of the map, not a draggable marker: panning
|
||||
// works the same with a finger, a mouse or the arrow keys, and there
|
||||
// is nothing to miss by a few pixels. Whatever the map is looking at
|
||||
// when it stops moving is the answer, written into the paired hidden
|
||||
// <input> as {"lat","lon","zoom"} with a real `input` event, so the
|
||||
// Rust side reuses the signal wiring every other field already has.
|
||||
//
|
||||
// MapLibre is vendored and served from this origin (/maplibre-gl.js),
|
||||
// and every tile comes from this origin too (/map/tiles/...), so a
|
||||
// page with a map on it still talks to nobody but us.
|
||||
|
||||
const WAIT_MS = 15000;
|
||||
|
||||
async function maplibre() {
|
||||
const started = Date.now();
|
||||
while (!window.maplibregl) {
|
||||
if (Date.now() - started > WAIT_MS) throw new Error('maplibre-gl did not load');
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return window.maplibregl;
|
||||
}
|
||||
|
||||
// The value as stored: five decimals is about a metre, which is as
|
||||
// much as a pin on a map means.
|
||||
function place(map) {
|
||||
const c = map.getCenter();
|
||||
return {
|
||||
lat: Number(c.lat.toFixed(5)),
|
||||
lon: Number(c.lng.toFixed(5)),
|
||||
zoom: Number(map.getZoom().toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
// Where to open: what the field already holds, else what the content
|
||||
// gave as `value` ("lat,lon,zoom"), else the whole world, which is
|
||||
// honest about knowing nothing rather than guessing a country.
|
||||
function opening(hidden, initial) {
|
||||
try {
|
||||
const held = JSON.parse(hidden.value || 'null');
|
||||
if (held && typeof held.lat === 'number' && typeof held.lon === 'number') {
|
||||
return { center: [held.lon, held.lat], zoom: held.zoom ?? 13, pinned: true };
|
||||
}
|
||||
} catch {}
|
||||
const parts = String(initial || '').split(',').map((p) => Number(p.trim()));
|
||||
if (parts.length >= 2 && parts.every((n) => Number.isFinite(n))) {
|
||||
return { center: [parts[1], parts[0]], zoom: parts[2] ?? 13, pinned: false };
|
||||
}
|
||||
return { center: [0, 20], zoom: 1, pinned: false };
|
||||
}
|
||||
|
||||
class MapWidget {
|
||||
constructor(container, hidden, initial) {
|
||||
this.container = container;
|
||||
this.map = null;
|
||||
this.dead = false;
|
||||
this.start(container, hidden, initial);
|
||||
}
|
||||
|
||||
async start(container, hidden, initial) {
|
||||
let gl;
|
||||
try {
|
||||
gl = await maplibre();
|
||||
} catch (e) {
|
||||
container.textContent = 'The map could not be loaded. ' + (hidden.value ? 'Your saved place is kept.' : '');
|
||||
return;
|
||||
}
|
||||
if (this.dead) return;
|
||||
|
||||
const at = opening(hidden, initial);
|
||||
const canvas = document.createElement('div');
|
||||
canvas.className = 'map-canvas';
|
||||
const pin = document.createElement('div');
|
||||
pin.className = 'map-pin';
|
||||
pin.setAttribute('aria-hidden', 'true');
|
||||
const readout = document.createElement('p');
|
||||
readout.className = 'map-readout';
|
||||
container.append(canvas, pin, readout);
|
||||
|
||||
const map = new gl.Map({
|
||||
container: canvas,
|
||||
style: '/map/style.json',
|
||||
center: at.center,
|
||||
zoom: at.zoom,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
this.map = map;
|
||||
map.addControl(new gl.NavigationControl({ showCompass: false }), 'top-right');
|
||||
map.addControl(
|
||||
new gl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false }),
|
||||
'top-right',
|
||||
);
|
||||
|
||||
// Nothing is answered until the map has been moved, or was opened
|
||||
// on an answer it already had: an untouched map means "no place",
|
||||
// not "the middle of the ocean".
|
||||
let answered = at.pinned;
|
||||
const write = () => {
|
||||
if (!answered) return;
|
||||
const value = place(map);
|
||||
hidden.value = JSON.stringify(value);
|
||||
hidden.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
readout.textContent = `${value.lat}, ${value.lon}`;
|
||||
};
|
||||
map.on('moveend', () => {
|
||||
answered = true;
|
||||
write();
|
||||
});
|
||||
map.on('load', write);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.dead = true;
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
this.container.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
export function mountMap(container, hidden, initial) {
|
||||
return new MapWidget(container, hidden, initial);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
# A building authority, written by hand to find where the plan language
|
||||
# stops. 14 needs, 7 roles, 12 buckets, two needs sharing a bucket in
|
||||
# each direction (public and internal), a decision no case officer may
|
||||
# sign, a group outside the building that moves records and nothing
|
||||
# else, a stage a deadline moves rather than a person, and four things
|
||||
# the plan language cannot say at all - each named in the run's README
|
||||
# rather than smuggled past.
|
||||
business: Vestmark kommune - Building and Planning
|
||||
language: en
|
||||
contact: { name: Solveig Hauge, email: byggesak@vestmark.example }
|
||||
mail: { from: "Vestmark Building and Planning <byggesak@vestmark.example>", reply_to: byggesak@vestmark.example }
|
||||
about: >
|
||||
The building authority for twenty-one thousand people. Anything built,
|
||||
demolished, divided or changed in use is decided here: the drawings are
|
||||
checked against the zoning map, the neighbours and the other
|
||||
authorities have their say, and then the head of section signs. The
|
||||
same office inspects what is actually built, signs off finished work,
|
||||
and deals with building nobody applied for.
|
||||
|
||||
# Two features, two roles, no button. The authority exists for the
|
||||
# person who wants to build; the next one it serves is the neighbour
|
||||
# who finds out what is planned next door. Both have a move of their
|
||||
# own after sending: the applicant withdraws, the objector withdraws,
|
||||
# and both buckets are open to read.
|
||||
does:
|
||||
- id: we-decide-what-may-be-built
|
||||
for: small-works-application
|
||||
text: >
|
||||
Everything built in Vestmark is decided here. You send the drawings,
|
||||
the neighbours and the road, fire and heritage authorities are heard,
|
||||
and you get a written decision you can hold us to.
|
||||
- id: your-neighbours-case-is-readable
|
||||
for: objection
|
||||
text: >
|
||||
If a notice landed in your postbox, the case behind it is open to
|
||||
read here, and what you write to us has to be answered by name in
|
||||
the decision.
|
||||
|
||||
doors:
|
||||
- id: want-to-build
|
||||
who: an owner or a firm with something to build, divide or change
|
||||
- id: building-near-me
|
||||
who: someone who lives next to what is being built
|
||||
|
||||
groups:
|
||||
- { id: front_desk, label: Front desk }
|
||||
- { id: case_officers, label: Case officers }
|
||||
- { id: section_head, label: Head of section }
|
||||
- { id: inspectors, label: Inspectors }
|
||||
- { id: appeals_board, label: Appeals board, invited_by: [section_head] }
|
||||
- { id: agencies, label: Hearing bodies, invited_by: [case_officers, section_head] }
|
||||
- { id: approved_firms, label: Approved firms, invited_by: [section_head] }
|
||||
invites_by: [section_head, case_officers]
|
||||
|
||||
needs:
|
||||
# --- the public: something they want to build ----------------------
|
||||
- id: pre-application-meeting
|
||||
door: want-to-build
|
||||
who: someone with an idea for a building and no drawings yet
|
||||
wants: to hear what we will allow before paying an architect
|
||||
lands_in: meetings
|
||||
handled_by: case_officers
|
||||
stages: [open, scheduled, met, advised, cancelled]
|
||||
done_when: [advised, cancelled]
|
||||
success: [advised]
|
||||
# They can call it off themselves; the front desk does it when they ring.
|
||||
moves_by: { cancelled: [submitter, front_desk] }
|
||||
watched_by: [front_desk]
|
||||
mails: { open: submitter, scheduled: submitter, advised: submitter }
|
||||
stages_text: { met: we have met, advised: written advice sent }
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
- { name: property_address }
|
||||
- { name: what_you_want, type: textarea }
|
||||
- { name: phone, type: tel, optional: true }
|
||||
- { name: site, type: location, optional: true }
|
||||
|
||||
- id: small-works-application
|
||||
door: want-to-build
|
||||
who: an owner applying for something small enough to file themselves
|
||||
wants: permission to build it, and to know where the case stands
|
||||
lands_in: applications
|
||||
handled_by: case_officers
|
||||
stages: [open, incomplete, complete, on_hearing, recommended, approved, approved_with_conditions, refused, withdrawn]
|
||||
done_when: [approved, approved_with_conditions, refused, withdrawn]
|
||||
success: [approved, approved_with_conditions]
|
||||
skippable: [incomplete]
|
||||
# A case is sent back for the drawing it was missing, and the
|
||||
# hearing turns things up that send it back again. It may be
|
||||
# withdrawn from anywhere, and refused only once we have seen
|
||||
# everything: not on the day it arrives.
|
||||
reached_from:
|
||||
incomplete: [open, complete, on_hearing]
|
||||
refused: [complete, on_hearing, recommended]
|
||||
# No case officer signs their own case: only the head of section
|
||||
# moves one into a decision. The applicant answers a request for
|
||||
# missing papers, and may withdraw right up to the signature.
|
||||
moves_by:
|
||||
complete: [submitter, case_officers]
|
||||
approved: [section_head]
|
||||
approved_with_conditions: [section_head]
|
||||
refused: [section_head]
|
||||
withdrawn: [submitter]
|
||||
watched_by: [public, front_desk, inspectors, section_head]
|
||||
mails:
|
||||
open: submitter
|
||||
incomplete: submitter
|
||||
on_hearing: submitter
|
||||
approved: submitter
|
||||
approved_with_conditions: submitter
|
||||
refused: submitter
|
||||
stages_text:
|
||||
incomplete: papers missing
|
||||
complete: papers complete
|
||||
on_hearing: out to the neighbours and the agencies
|
||||
recommended: recommendation written
|
||||
approved_with_conditions: approved on conditions
|
||||
max_fields: 6
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: property_address }
|
||||
- { name: property_number }
|
||||
- { name: what_is_planned, type: textarea }
|
||||
- { name: neighbours_notified, type: select, options: ["Yes", Not yet] }
|
||||
- { name: site, type: location, optional: true }
|
||||
- { name: drawings_url, optional: true }
|
||||
|
||||
- id: exemption-request
|
||||
door: want-to-build
|
||||
who: someone whose plans break a rule in the zoning map
|
||||
wants: to be allowed it anyway, with reasons on the record
|
||||
requires: An application already with us, and the case number from its receipt.
|
||||
lands_in: exemptions
|
||||
handled_by: case_officers
|
||||
stages: [open, county_asked, county_answered, recommended, granted, denied, withdrawn]
|
||||
done_when: [granted, denied, withdrawn]
|
||||
success: [granted]
|
||||
# The county answers from outside the building; nothing may be
|
||||
# granted before it has. The head of section signs, as for a permit.
|
||||
moves_by:
|
||||
county_answered: [agencies]
|
||||
granted: [section_head]
|
||||
denied: [section_head]
|
||||
withdrawn: [submitter]
|
||||
watched_by: [public, section_head]
|
||||
mails: { open: submitter, county_asked: submitter, granted: submitter, denied: submitter }
|
||||
stages_text: { county_asked: with the county, county_answered: the county has answered }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: case_number }
|
||||
- { name: which_rule }
|
||||
- { name: why_it_should_be_allowed, type: textarea }
|
||||
- { name: site, type: location, optional: true }
|
||||
|
||||
- id: completion-request
|
||||
door: want-to-build
|
||||
who: an owner or firm whose building work is finished
|
||||
wants: the certificate that lets the building be used
|
||||
requires: A case number, and the work finished or close enough to move in.
|
||||
lands_in: completions
|
||||
handled_by: case_officers
|
||||
stages: [open, checked, temporary_permission, certificate_issued, refused]
|
||||
done_when: [certificate_issued, refused]
|
||||
success: [certificate_issued]
|
||||
# Most go straight through; the ones who want to move in before the
|
||||
# last of it is done detour past a temporary permission.
|
||||
skippable: [temporary_permission]
|
||||
moves_by:
|
||||
temporary_permission: [section_head]
|
||||
certificate_issued: [section_head]
|
||||
refused: [section_head]
|
||||
watched_by: [public, inspectors]
|
||||
turned_down_from: [open, checked]
|
||||
mails: { open: submitter, temporary_permission: submitter, certificate_issued: submitter, refused: submitter }
|
||||
stages_text: { checked: checked against the inspections, temporary_permission: temporary permission to use, certificate_issued: certificate issued }
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: case_number }
|
||||
- { name: property_address }
|
||||
- { name: work_finished, type: date }
|
||||
- { name: asking_for, type: select, options: [Completion certificate, Temporary permission to move in] }
|
||||
- { name: what_is_outstanding, type: textarea, optional: true }
|
||||
|
||||
# --- the public: someone else is building ---------------------------
|
||||
- id: objection
|
||||
door: building-near-me
|
||||
who: a neighbour who got a notice about what is planned next door
|
||||
wants: what they think on the record, and answered in the decision
|
||||
requires: The notice that came in the post, which has the case number on it.
|
||||
lands_in: objections
|
||||
handled_by: case_officers
|
||||
stages: [open, registered, answered_by_applicant, addressed, withdrawn]
|
||||
done_when: [addressed, withdrawn]
|
||||
success: [addressed]
|
||||
# The firm that filed the case answers the objection; the neighbour
|
||||
# can take it back if they are satisfied.
|
||||
moves_by: { answered_by_applicant: [approved_firms, case_officers], withdrawn: [submitter] }
|
||||
watched_by: [public, section_head]
|
||||
mails: { open: submitter, answered_by_applicant: submitter, addressed: submitter }
|
||||
stages_text: { registered: on the case file, answered_by_applicant: the applicant has answered, addressed: answered in the decision }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: case_number }
|
||||
- { name: your_property }
|
||||
- { name: your_objection, type: textarea }
|
||||
|
||||
- id: case-status-enquiry
|
||||
door: building-near-me
|
||||
who: someone waiting on a case and getting nothing back
|
||||
wants: to be told where it actually stands
|
||||
lands_in: enquiries
|
||||
handled_by: front_desk
|
||||
stages: [open, in_hand, answered]
|
||||
done_when: [answered]
|
||||
success: [answered]
|
||||
moves_by: { in_hand: [front_desk, case_officers] }
|
||||
watched_by: [case_officers]
|
||||
mails: { open: submitter, answered: submitter }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: case_number_or_address }
|
||||
- { name: what_you_want_to_know, type: textarea }
|
||||
|
||||
- id: illegal-building-report
|
||||
door: building-near-me
|
||||
who: someone who can see building going up that nobody was told about
|
||||
wants: us to go and look, and to hear that we did
|
||||
lands_in: illegal_building
|
||||
handled_by: inspectors
|
||||
stages: [open, inspected, owner_asked, explained, resolved, fined, dropped]
|
||||
done_when: [resolved, fined, dropped]
|
||||
success: [resolved, dropped]
|
||||
# An inspector goes and looks; the letter to the owner is a case
|
||||
# officer's, and so is the fine.
|
||||
moves_by: { owner_asked: [case_officers], explained: [case_officers], fined: [section_head] }
|
||||
watched_by: [case_officers, section_head]
|
||||
turned_down_from: [open, inspected]
|
||||
mails: { open: submitter, inspected: submitter, resolved: submitter, dropped: submitter }
|
||||
stages_text: { owner_asked: the owner has been written to, explained: the owner has answered, dropped: nothing to pursue }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: address }
|
||||
- { name: what_you_saw, type: textarea }
|
||||
- { name: where, type: location, optional: true }
|
||||
|
||||
# --- the public: after a decision ------------------------------------
|
||||
- id: appeal
|
||||
who: anyone with an interest who will not accept a decision of ours
|
||||
wants: the decision looked at again, by us and then by the board
|
||||
requires: A decision from us less than three weeks old, and its case number.
|
||||
lands_in: appeals
|
||||
handled_by: case_officers
|
||||
stages: [open, reconsidered, changed_by_us, before_the_board, upheld, overturned, withdrawn]
|
||||
done_when: [changed_by_us, upheld, overturned, withdrawn]
|
||||
success: [changed_by_us, upheld]
|
||||
# If we change our own mind that is the end of it, and it happens
|
||||
# while we are looking - not after the board has the case. The
|
||||
# board can only decide what has reached it.
|
||||
reached_from:
|
||||
changed_by_us: [reconsidered]
|
||||
upheld: [before_the_board]
|
||||
overturned: [before_the_board]
|
||||
# We look at ourselves first and the head of section decides whether
|
||||
# we were wrong. What we will not change goes to seven elected
|
||||
# people who meet once a month and move nothing else in this office.
|
||||
moves_by:
|
||||
changed_by_us: [section_head]
|
||||
before_the_board: [section_head]
|
||||
upheld: [appeals_board]
|
||||
overturned: [appeals_board]
|
||||
withdrawn: [submitter]
|
||||
watched_by: [public, front_desk, section_head]
|
||||
mails:
|
||||
open: submitter
|
||||
reconsidered: submitter
|
||||
changed_by_us: submitter
|
||||
before_the_board: submitter
|
||||
upheld: submitter
|
||||
overturned: submitter
|
||||
stages_text:
|
||||
reconsidered: we are looking at it again
|
||||
changed_by_us: we changed our own decision
|
||||
before_the_board: with the appeals board
|
||||
upheld: the board kept our decision
|
||||
overturned: the board overturned us
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: case_number }
|
||||
- { name: property }
|
||||
- { name: what_you_disagree_with, type: textarea }
|
||||
- { name: your_interest, type: select, options: [I applied, I am a neighbour, Other] }
|
||||
|
||||
# --- the public: a firm that wants to file here -----------------------
|
||||
- id: firm-approval
|
||||
who: an architect or engineering firm that files for its clients
|
||||
wants: to be on the list, and then to file without asking again
|
||||
lands_in: firms
|
||||
handled_by: case_officers
|
||||
stages: [open, papers_missing, checked, approved, declined]
|
||||
done_when: [approved, declined]
|
||||
success: [approved]
|
||||
skippable: [papers_missing]
|
||||
moves_by: { checked: [submitter, case_officers], approved: [section_head], declined: [section_head] }
|
||||
watched_by: [section_head, front_desk]
|
||||
turned_down_from: [open, papers_missing, checked]
|
||||
mails: { open: submitter, papers_missing: submitter, approved: submitter, declined: submitter }
|
||||
stages_text: { papers_missing: waiting on papers, checked: papers checked }
|
||||
max_fields: 6
|
||||
collects:
|
||||
- { name: firm_name }
|
||||
- { name: org_number }
|
||||
- { name: email, type: email }
|
||||
- { name: qualified_for, type: select, multiple: true, options: [Design, Execution, Independent control, Site management] }
|
||||
- { name: insurance_expires, type: date }
|
||||
- { name: insurance_document, optional: true }
|
||||
|
||||
# --- approved firms ----------------------------------------------------
|
||||
- id: permit-application
|
||||
who: an approved firm filing on behalf of an owner
|
||||
wants: a decision inside twelve weeks, and to know when the clock stops
|
||||
enters_as: approved_firms
|
||||
lands_in: applications
|
||||
handled_by: case_officers
|
||||
stages: [open, incomplete, complete, on_hearing, recommended, approved, approved_with_conditions, refused, withdrawn]
|
||||
done_when: [approved, approved_with_conditions, refused, withdrawn]
|
||||
success: [approved, approved_with_conditions]
|
||||
skippable: [incomplete]
|
||||
# A case is sent back for the drawing it was missing, and the
|
||||
# hearing turns things up that send it back again. It may be
|
||||
# withdrawn from anywhere, and refused only once we have seen
|
||||
# everything: not on the day it arrives.
|
||||
reached_from:
|
||||
incomplete: [open, complete, on_hearing]
|
||||
refused: [complete, on_hearing, recommended]
|
||||
moves_by:
|
||||
complete: [submitter, case_officers]
|
||||
approved: [section_head]
|
||||
approved_with_conditions: [section_head]
|
||||
refused: [section_head]
|
||||
withdrawn: [submitter]
|
||||
watched_by: [public, front_desk, inspectors, section_head]
|
||||
mails:
|
||||
open: submitter
|
||||
incomplete: submitter
|
||||
on_hearing: submitter
|
||||
approved: submitter
|
||||
approved_with_conditions: submitter
|
||||
refused: submitter
|
||||
stages_text:
|
||||
incomplete: papers missing
|
||||
complete: papers complete
|
||||
on_hearing: out to the neighbours and the agencies
|
||||
recommended: recommendation written
|
||||
approved_with_conditions: approved on conditions
|
||||
max_fields: 6
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: property_address }
|
||||
- { name: property_number }
|
||||
- { name: what_is_planned, type: textarea }
|
||||
- { name: neighbours_notified, type: select, options: ["Yes", Not yet] }
|
||||
- { name: site, type: location, optional: true }
|
||||
- { name: drawings_url, optional: true }
|
||||
|
||||
# --- the front desk -----------------------------------------------------
|
||||
- id: log-counter-enquiry
|
||||
who: whoever is on the counter with a queue and a caller on the line
|
||||
wants: the question written down and sent to whoever has the case
|
||||
enters_as: front_desk
|
||||
lands_in: enquiries
|
||||
handled_by: front_desk
|
||||
stages: [open, in_hand, answered]
|
||||
done_when: [answered]
|
||||
success: [answered]
|
||||
moves_by: { in_hand: [front_desk, case_officers] }
|
||||
watched_by: [case_officers]
|
||||
mails: { open: submitter, answered: submitter }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: case_number_or_address }
|
||||
- { name: what_you_want_to_know, type: textarea }
|
||||
|
||||
# --- case officers ------------------------------------------------------
|
||||
- id: request-hearing
|
||||
who: a case officer sending a case out to the authority that must see it
|
||||
wants: an answer by the deadline, or a record that none came
|
||||
enters_as: case_officers
|
||||
lands_in: hearings
|
||||
handled_by: case_officers
|
||||
stages: [open, answered, no_answer, cancelled]
|
||||
done_when: [answered, no_answer, cancelled]
|
||||
success: [answered]
|
||||
# This is how an agency sees what it has been asked. Nobody presses
|
||||
# a button for the deadline passing.
|
||||
moves_by: { answered: [agencies] }
|
||||
watched_by: [agencies, section_head, front_desk]
|
||||
attended_by: >
|
||||
the six-week deadline: when it passes with nothing from the agency
|
||||
the case goes to no_answer and the application goes on without one
|
||||
stages_text: { no_answer: the deadline passed with no answer }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: case_number }
|
||||
- { name: which_authority, type: select, options: [Road authority, Fire service, Heritage office, The county] }
|
||||
- { name: what_we_ask, type: textarea }
|
||||
- { name: answer_by, type: date }
|
||||
- { name: site, type: location, optional: true }
|
||||
|
||||
# --- the authorities outside the building --------------------------------
|
||||
- id: hearing-answer
|
||||
who: a road, fire, heritage or county officer who has been asked about a case
|
||||
wants: their answer on the file, in their own words
|
||||
requires: The hearing letter we sent you, which has the case number on it.
|
||||
enters_as: agencies
|
||||
lands_in: hearing_answers
|
||||
handled_by: case_officers
|
||||
stages: [open, read, followed, set_aside]
|
||||
done_when: [followed, set_aside]
|
||||
success: [followed]
|
||||
watched_by: [section_head, front_desk]
|
||||
stages_text: { read: read by the case officer, followed: followed in the decision, set_aside: set aside with reasons }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: case_number }
|
||||
- { name: which_authority, type: select, options: [Road authority, Fire service, Heritage office, The county] }
|
||||
- { name: our_answer, type: select, options: [No objection, Objection, Conditions required] }
|
||||
- { name: what_we_say, type: textarea }
|
||||
|
||||
# --- inspectors -----------------------------------------------------------
|
||||
- id: inspection-finding
|
||||
who: an inspector on a scaffold with gloves on and the wind blowing
|
||||
wants: what they found logged before they climb down
|
||||
enters_as: inspectors
|
||||
lands_in: inspections
|
||||
handled_by: inspectors
|
||||
stages: [open, as_approved, deviation, put_right, escalated]
|
||||
done_when: [as_approved, put_right, escalated]
|
||||
success: [as_approved, put_right]
|
||||
# Anything that has to become a fine or a refusal leaves the
|
||||
# inspectors and becomes a case officer's problem.
|
||||
moves_by: { escalated: [case_officers, section_head] }
|
||||
watched_by: [case_officers, section_head]
|
||||
stages_text: { as_approved: built as approved, deviation: built differently, put_right: put right, escalated: handed to a case officer }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: case_number }
|
||||
- { name: site_address }
|
||||
- { name: what_i_found, type: textarea }
|
||||
- { name: where, type: location, optional: true }
|
||||
|
||||
# Held out from every model that writes words.
|
||||
# arriving with one real thing, in their own register: a homeowner who
|
||||
# has never read a regulation, a firm that files for a living, an
|
||||
# inspector on a roof in the wind, a politician reading a bundle.
|
||||
personas:
|
||||
- { id: garage-owner, need: pre-application-meeting, text: "We want to put a double garage where the old shed is. Before I pay an architect I'd like to know whether you'll even allow it, because the neighbour says it's too close to the road." }
|
||||
- { id: shop-to-flats, need: pre-application-meeting, text: "I own the old grocery on Torget and want to make four flats out of it. There are a lot of questions - parking, fire, the facade is listed. Can we sit down with someone?" }
|
||||
- { id: firm-files-extension, need: permit-application, text: "We are the responsible applicant for an extension at Bjorkeveien 14. Drawings, site plan and the neighbour notices are all ready. We file maybe forty of these a year." }
|
||||
- { id: owner-small-window, need: permit-application, text: "I want to put a dormer window in my roof. The man at the counter said I have to apply even though it's small." }
|
||||
- { id: missing-drawings, need: permit-application, text: "You wrote that our section drawing is missing and the case is on hold. I've got it here now, where do I send it so the clock starts again?" }
|
||||
- { id: too-close-to-boundary, need: exemption-request, text: "The garage ends up one and a half metres from the boundary and the plan says four. We are asking you to allow it anyway - the ground falls away steeply everywhere else on the plot." }
|
||||
- { id: neighbour-view, need: objection, text: "I got a notice about the extension at number 14. It takes the afternoon sun off my entire terrace and I want that on the record before you decide anything." }
|
||||
- { id: neighbour-parking, need: objection, text: "The flats they want to build in the old grocery have four parking places for four flats and everyone here already parks on the street. I object." }
|
||||
- { id: road-authority, need: hearing-answer, text: "Regarding your case 2026/418: the access onto the county road is within the sight line and we cannot accept it as drawn. Our formal reply is attached." }
|
||||
- { id: fire-service, need: hearing-answer, text: "We have looked at the conversion on Torget. We have no objection provided the stairwell is upgraded. Reference your case 2026/501." }
|
||||
- { id: refused-applicant, need: appeal, text: "You refused our exemption on the ninth. We don't accept the reasoning - you've allowed the same thing twice in the same street. We're appealing." }
|
||||
- { id: neighbour-appeals, need: appeal, text: "You approved the extension next door even though I objected. I'm appealing the decision. What do I need to send?" }
|
||||
- { id: board-member, need: appeal, text: "I sit on the appeals board. I need the case, our own decision, and what the neighbours wrote, before Tuesday." }
|
||||
- { id: inspector-on-roof, need: inspection-finding, text: "I'm up on the scaffold at Bjorkeveien. The fire wall isn't built to the drawing at all. I've got gloves on and it's blowing. I want this logged before I climb down." }
|
||||
- { id: inspector-all-good, need: inspection-finding, text: "Been out at Torget. Everything is as approved. Nothing to report but I have to record that I was there." }
|
||||
- { id: firm-wants-certificate, need: completion-request, text: "The work at Bjorkeveien 14 is finished and inspected. We're asking for the completion certificate so the owners can get their mortgage released." }
|
||||
- { id: wants-to-move-in, need: completion-request, text: "The house is done apart from the outside steps, which we can't pour until spring. The family has sold their flat. Can we get a temporary permission to move in?" }
|
||||
- { id: something-behind-fence, need: illegal-building-report, text: "There's a large structure going up behind the fence at Solbakken 7. Nobody has had a notice about it and it's been going on for two weeks." }
|
||||
- { id: owner-explains, need: illegal-building-report, text: "I got your letter about the terrace. I genuinely did not know it needed an application, it's been there since we moved in. What happens now?" }
|
||||
- { id: new-firm, need: firm-approval, text: "We are a three-person architecture firm that has just started. We want to be approved so we can file applications for our clients in your municipality." }
|
||||
- { id: firm-insurance-lapsed, need: firm-approval, text: "You wrote that our liability insurance certificate has expired. We have renewed it, here it is." }
|
||||
- { id: counter-phone-call, need: log-counter-enquiry, text: "A woman rang about the case behind her house. Wants to know where it stands. I'm at the counter and there's a queue - I need to write it down and send it to whoever has that case." }
|
||||
- { id: where-is-my-case, need: case-status-enquiry, text: "I sent in my application five weeks ago and I've heard nothing. Is anyone looking at it? Is it in the six weeks with the neighbours or is it waiting on me?" }
|
||||
@@ -0,0 +1,294 @@
|
||||
# A contractor's whole operation, to stress the builder and the
|
||||
# harness: 13 needs, 6 roles, 9 buckets, needs sharing a bucket,
|
||||
# skippable stages, and a front door that had to be tiered.
|
||||
business: Nordvik Bygg
|
||||
language: en
|
||||
contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example }
|
||||
about: >
|
||||
Twelve people who survey old buildings, draw up rehabilitation plans,
|
||||
handle the permit application, and do the building work with their
|
||||
own carpenters plus subcontractors for electrical, plumbing and
|
||||
scaffolding. Project managers win and run the jobs, site managers run
|
||||
each building site day to day, and one safety officer owns everything
|
||||
reported from site.
|
||||
|
||||
# Six public needs is one over what a front door carries, so the
|
||||
# public comes through three doors: a building of theirs, work with
|
||||
# us, and a site of ours next to them (that one is a need on its own).
|
||||
doors:
|
||||
- id: your-building
|
||||
who: an owner, a housing board or a past customer with a building
|
||||
- id: work-with-us
|
||||
who: a trade firm, a carpenter or an apprentice who wants work from us
|
||||
|
||||
needs:
|
||||
# --- the public ---------------------------------------------------
|
||||
- id: survey-enquiry
|
||||
door: your-building
|
||||
who: a housing board or private owner with a problem in a building
|
||||
wants: someone qualified to come and look at it and say what it takes
|
||||
lands_in: enquiries
|
||||
handled_by: project_managers
|
||||
stages: [open, surveyed, quoted, won, lost]
|
||||
done_when: [won, lost]
|
||||
success: [won]
|
||||
# Whoever went to the building marks it surveyed; site managers
|
||||
# see what is coming their way.
|
||||
moves_by: { surveyed: [project_managers, site_managers] }
|
||||
watched_by: [site_managers]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: problem, type: textarea }
|
||||
- { name: access, optional: true }
|
||||
|
||||
- id: build-enquiry
|
||||
door: your-building
|
||||
who: an owner who already knows what they want built or changed
|
||||
wants: drawings, a permit application and a price
|
||||
lands_in: enquiries
|
||||
handled_by: project_managers
|
||||
stages: [open, surveyed, quoted, won, lost]
|
||||
done_when: [won, lost]
|
||||
success: [won]
|
||||
# Whoever went to the building marks it surveyed; site managers
|
||||
# see what is coming their way.
|
||||
moves_by: { surveyed: [project_managers, site_managers] }
|
||||
watched_by: [site_managers]
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: project, type: textarea }
|
||||
- { name: timeframe, type: select, options: [This year, Next year, No fixed date] }
|
||||
|
||||
- id: warranty-claim
|
||||
door: your-building
|
||||
who: a past customer who has found a fault in work we handed over
|
||||
wants: us to come back, look at it, and put it right
|
||||
lands_in: claims
|
||||
handled_by: project_managers
|
||||
stages: [open, inspected, accepted, fixed, rejected]
|
||||
done_when: [fixed, rejected]
|
||||
success: [fixed]
|
||||
# The site manager inspects and puts it right; whether it is ours
|
||||
# to fix is the project manager's call.
|
||||
moves_by: { inspected: [site_managers], fixed: [site_managers] }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: fault, type: textarea }
|
||||
- { name: handed_over, type: date, optional: true }
|
||||
|
||||
- id: neighbour-complaint
|
||||
who: someone living or working next to one of our building sites
|
||||
wants: the noise, dust or blocked access dealt with, and an answer
|
||||
lands_in: complaints
|
||||
handled_by: site_managers
|
||||
stages: [open, answered]
|
||||
done_when: [answered]
|
||||
success: [answered]
|
||||
watched_by: [project_managers]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: site_address }
|
||||
- { name: what_is_wrong, type: textarea }
|
||||
- { name: reach_me_on }
|
||||
|
||||
- id: subcontractor-application
|
||||
door: work-with-us
|
||||
who: a trade firm doing electrical, plumbing or scaffolding work
|
||||
wants: to be approved once, and then be called when a job needs them
|
||||
lands_in: subcontractors
|
||||
handled_by: project_managers
|
||||
stages: [open, awaiting_papers, approved, declined]
|
||||
done_when: [approved, declined]
|
||||
success: [approved]
|
||||
watched_by: [site_managers]
|
||||
skippable: [awaiting_papers]
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: company }
|
||||
- { name: registration_number }
|
||||
- { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] }
|
||||
- { name: contact_email, type: email }
|
||||
- { name: insurance_confirmed, type: select, options: ["Yes", Not yet] }
|
||||
- { name: trade_approval, optional: true }
|
||||
|
||||
- id: job-application
|
||||
door: work-with-us
|
||||
who: a carpenter, bricklayer or apprentice looking for work
|
||||
wants: to be considered, without writing a letter
|
||||
lands_in: applicants
|
||||
handled_by: project_managers
|
||||
stages: [open, interviewing, offered, hired, declined]
|
||||
done_when: [hired, declined]
|
||||
success: [hired]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] }
|
||||
- { name: experience, type: textarea, optional: true }
|
||||
|
||||
# --- clients with a job under way ---------------------------------
|
||||
- id: change-request
|
||||
who: a client whose job is under way and who wants something changed
|
||||
wants: a price for the change, and the change done if they accept it
|
||||
enters_as: clients
|
||||
lands_in: change_orders
|
||||
handled_by: project_managers
|
||||
stages: [open, priced, approved, completed, declined]
|
||||
done_when: [completed, declined]
|
||||
success: [completed]
|
||||
# The project manager prices it, the client says yes or no through
|
||||
# their own link, the site manager does the work.
|
||||
moves_by: { approved: [submitter], declined: [submitter, project_managers], completed: [site_managers] }
|
||||
watched_by: [site_managers]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: job_address }
|
||||
- { name: change, type: textarea }
|
||||
- { name: needed_by, type: date, optional: true }
|
||||
|
||||
# --- project managers ---------------------------------------------
|
||||
- id: log-phone-enquiry
|
||||
who: a project manager who just took a call or a text about a building
|
||||
wants: the enquiry written down before it gets lost
|
||||
enters_as: project_managers
|
||||
lands_in: enquiries
|
||||
handled_by: project_managers
|
||||
stages: [open, surveyed, quoted, won, lost]
|
||||
done_when: [won, lost]
|
||||
success: [won]
|
||||
# Whoever went to the building marks it surveyed; site managers
|
||||
# see what is coming their way.
|
||||
moves_by: { surveyed: [project_managers, site_managers] }
|
||||
watched_by: [site_managers]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: caller }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: problem, type: textarea }
|
||||
|
||||
- id: post-job-request
|
||||
who: a project manager with work to hand to a subcontractor
|
||||
wants: approved subcontractors to see the job and answer with a price
|
||||
enters_as: project_managers
|
||||
lands_in: job_requests
|
||||
handled_by: project_managers
|
||||
stages: [open, awarded, withdrawn]
|
||||
done_when: [awarded, withdrawn]
|
||||
success: [awarded]
|
||||
# This is how subcontractors see what there is to bid on.
|
||||
watched_by: [subcontractors, site_managers]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: site_address }
|
||||
- { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] }
|
||||
- { name: work, type: textarea }
|
||||
- { name: start, type: date }
|
||||
|
||||
# --- approved subcontractors --------------------------------------
|
||||
- id: submit-bid
|
||||
who: an approved subcontractor who has seen a job they can take
|
||||
wants: to say yes with a price and a start date
|
||||
enters_as: subcontractors
|
||||
lands_in: bids
|
||||
handled_by: project_managers
|
||||
stages: [open, shortlisted, accepted, declined]
|
||||
done_when: [accepted, declined]
|
||||
success: [accepted]
|
||||
watched_by: [site_managers]
|
||||
skippable: [shortlisted]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: job }
|
||||
- { name: price }
|
||||
- { name: can_start, type: date }
|
||||
|
||||
# --- on site --------------------------------------------------------
|
||||
- id: report-site-issue
|
||||
who: anyone on site, ours or a subcontractor's, on a phone with gloves on
|
||||
wants: to report a hazard, a near miss or wrong materials before they forget
|
||||
enters_as: crew
|
||||
lands_in: site_issues
|
||||
handled_by: safety_officer
|
||||
stages: [open, in_hand, closed]
|
||||
done_when: [closed]
|
||||
success: [closed]
|
||||
# A site manager can take an issue in hand on the spot; only the
|
||||
# safety officer closes it.
|
||||
moves_by: { in_hand: [safety_officer, site_managers] }
|
||||
watched_by: [project_managers]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] }
|
||||
- { name: where }
|
||||
- { name: what_happened, type: textarea }
|
||||
|
||||
- id: order-materials
|
||||
who: a carpenter or bricklayer who is about to run out of something
|
||||
wants: it ordered and on site before work stops
|
||||
enters_as: crew
|
||||
lands_in: material_orders
|
||||
handled_by: site_managers
|
||||
stages: [open, ordered, delivered, refused]
|
||||
done_when: [delivered, refused]
|
||||
success: [delivered]
|
||||
# The crew confirms it arrived.
|
||||
moves_by: { delivered: [crew, site_managers] }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: site_address }
|
||||
- { name: what, type: textarea }
|
||||
- { name: needed_by, type: date }
|
||||
|
||||
- id: safety-round-finding
|
||||
who: the safety officer walking a site on the weekly round
|
||||
wants: each thing found written down where the reported issues already are
|
||||
enters_as: safety_officer
|
||||
lands_in: site_issues
|
||||
handled_by: safety_officer
|
||||
stages: [open, in_hand, closed]
|
||||
done_when: [closed]
|
||||
success: [closed]
|
||||
# A site manager can take an issue in hand on the spot; only the
|
||||
# safety officer closes it.
|
||||
moves_by: { in_hand: [safety_officer, site_managers] }
|
||||
watched_by: [project_managers]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] }
|
||||
- { name: where }
|
||||
- { name: what_happened, type: textarea }
|
||||
|
||||
# No model that writes words ever sees these.
|
||||
personas:
|
||||
- { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." }
|
||||
- { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." }
|
||||
- { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." }
|
||||
- { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" }
|
||||
- { id: leaking-bathroom, need: warranty-claim, text: "You redid our bathroom two years ago. Now water is coming through the ceiling of the flat below. I want you to come and fix it." }
|
||||
- { id: cracked-render, need: warranty-claim, text: "The render you put on our gable wall last summer has cracked right across. That can't be right after one winter." }
|
||||
- { id: noisy-mornings, need: neighbour-complaint, text: "I live next door to the block you are working on. You start drilling at half past six every morning and I have a baby. Who do I talk to?" }
|
||||
- { id: blocked-driveway, need: neighbour-complaint, text: "Your skip and a van have been parked across the entrance to our shop's loading bay for three days. We can't get deliveries." }
|
||||
- { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." }
|
||||
- { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" }
|
||||
- { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" }
|
||||
- { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." }
|
||||
- { id: extra-window, need: change-request, text: "You are in the middle of our attic conversion. We have decided we want a roof window over the stairs as well. What would that cost?" }
|
||||
- { id: pm-took-a-call, need: log-phone-enquiry, text: "A woman from a housing board in Storgata just rang about water in their basement. I'm in the van. I need to note it before I forget her number." }
|
||||
- { id: pm-needs-plumber, need: post-job-request, text: "The bathroom job in Kirkeveien needs a plumber for two weeks from the first of next month. I want prices from the ones we have approved." }
|
||||
- { id: plumber-bidding, need: submit-bid, text: "We are an approved plumbing firm. I saw you need someone in Kirkeveien next month. We can do it, I have a price." }
|
||||
- { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." }
|
||||
- { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." }
|
||||
- { id: out-of-battens, need: order-materials, text: "We have enough roof battens for today and maybe half of tomorrow. Need another two hundred metres on site by Thursday." }
|
||||
- { id: weekly-round, need: safety-round-finding, text: "On my Friday round I found the first-aid box on level two empty and an extension lead run through a puddle." }
|
||||
@@ -0,0 +1,122 @@
|
||||
# The contractor from intakes/contractor.yaml as a plan: the needs spec
|
||||
# plus what each form collects. Everything structural in the generated
|
||||
# site comes from this file; a model only writes the words.
|
||||
business: Nordvik Bygg
|
||||
language: en
|
||||
contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example }
|
||||
about: >
|
||||
Twelve people who survey old buildings, draw up rehabilitation plans,
|
||||
handle the permit application, and do the building work with their
|
||||
own carpenters plus subcontractors for electrical, plumbing and
|
||||
scaffolding.
|
||||
|
||||
needs:
|
||||
- id: survey-enquiry
|
||||
who: a housing board or private owner with a problem in a building
|
||||
wants: someone qualified to come and look at it and say what it takes
|
||||
lands_in: enquiries
|
||||
handled_by: project_managers
|
||||
stages: [open, surveyed, quoted, won, lost]
|
||||
done_when: [won, lost]
|
||||
success: [won]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: problem, type: textarea }
|
||||
- { name: access, optional: true }
|
||||
|
||||
- id: build-enquiry
|
||||
who: an owner who already knows what they want built or changed
|
||||
wants: drawings, a permit application and a price
|
||||
lands_in: enquiries
|
||||
handled_by: project_managers
|
||||
stages: [open, surveyed, quoted, won, lost]
|
||||
done_when: [won, lost]
|
||||
success: [won]
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: project, type: textarea }
|
||||
- { name: timeframe, type: select, options: [This year, Next year, No fixed date] }
|
||||
|
||||
- id: log-phone-enquiry
|
||||
who: a project manager who just took a call or a text about a building
|
||||
wants: the enquiry written down before it gets lost
|
||||
enters_as: project_managers
|
||||
lands_in: enquiries
|
||||
handled_by: project_managers
|
||||
stages: [open, surveyed, quoted, won, lost]
|
||||
done_when: [won, lost]
|
||||
success: [won]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: caller }
|
||||
- { name: phone, type: tel }
|
||||
- { name: address }
|
||||
- { name: problem, type: textarea }
|
||||
|
||||
- id: subcontractor-application
|
||||
who: a trade firm doing electrical, plumbing or scaffolding work
|
||||
wants: to be approved once, and then be called when a job needs them
|
||||
lands_in: subcontractors
|
||||
handled_by: project_managers
|
||||
stages: [open, awaiting_papers, approved, declined]
|
||||
done_when: [approved, declined]
|
||||
success: [approved]
|
||||
skippable: [awaiting_papers]
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: company }
|
||||
- { name: registration_number }
|
||||
- { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] }
|
||||
- { name: contact_email, type: email }
|
||||
- { name: insurance_confirmed, type: select, options: ["Yes", Not yet] }
|
||||
- { name: trade_approval, optional: true }
|
||||
|
||||
- id: job-application
|
||||
who: a carpenter, bricklayer or apprentice looking for work
|
||||
wants: to be considered, without writing a letter
|
||||
lands_in: applicants
|
||||
handled_by: project_managers
|
||||
stages: [open, interviewing, hired, declined]
|
||||
done_when: [hired, declined]
|
||||
success: [hired]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] }
|
||||
- { name: experience, type: textarea, optional: true }
|
||||
|
||||
- id: report-site-issue
|
||||
who: anyone on site, ours or a subcontractor's, on a phone with gloves on
|
||||
wants: to report a hazard, a near miss or wrong materials before they forget
|
||||
enters_as: crew
|
||||
lands_in: site_issues
|
||||
handled_by: safety_officer
|
||||
stages: [open, in_hand, closed]
|
||||
done_when: [closed]
|
||||
success: [closed]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] }
|
||||
- { name: where }
|
||||
- { name: what_happened, type: textarea }
|
||||
|
||||
# Held out from every model that writes words: these are the personas
|
||||
# from sectors/construction, which no generator has seen.
|
||||
personas:
|
||||
- { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." }
|
||||
- { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." }
|
||||
- { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." }
|
||||
- { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" }
|
||||
- { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." }
|
||||
- { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" }
|
||||
- { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" }
|
||||
- { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." }
|
||||
- { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." }
|
||||
- { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." }
|
||||
@@ -0,0 +1,88 @@
|
||||
# Vedtekter
|
||||
|
||||
### av 23.04.2018
|
||||
|
||||
##### § 1
|
||||
|
||||
Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd, forskjønnelse og utvikling og ellers ivareta Tomters interesser.
|
||||
|
||||
##### § 2
|
||||
|
||||
Alle som er bosatt i Tomter kan bli medlem av foreningen. Midlertidig utflyttede medlemmer kan beholde sitt medlemskap.
|
||||
|
||||
##### § 3
|
||||
|
||||
Årsmøte, som er vellets øverste myndighet, holdes innen utgangen av mars hvert år. Møtet kunngjøres med minst tre ukers varsel. Forslag skal også gjøres kjent minst to uker før møtet. Årsmøte godkjenner beretning, regnskap og budsjett. Den velger styre, komitéledere, og valgkomite.
|
||||
|
||||
##### § 4.
|
||||
|
||||
Ekstraordinært årsmøte holdes når styret, eller minst 15 medlemmer krever det i skriftlig form. Gjennomføringen følger samme prosedyre som ordinært årsmøte
|
||||
|
||||
##### § 5
|
||||
|
||||
Tomter Vel ledes av et styre på seks medlemmer. Leder, nestleder, sekretær, kasserer og to styremedlemmer, som alle velges på årsmøtet. Funksjonstiden er to år. Leder, sekretær og ett styremedlem velges det ene året, nestleder, kasserer og det andre styremedlemmet det andre året. Det skal dessuten på årsmøte velges ett varamedlem til styret med funksjonstid to år. Styret engasjerer revisor eller ekstern regnskapsfører. Oppnevning av andre komitémedlemmer og representanter foretas av styret etter behov. Komitéledere har møterett på styremøter.
|
||||
|
||||
##### § 6
|
||||
|
||||
Medlemsmøte avholdes når styret finner det nødvendig og skal bekjentgjøres minst en uke i forveien. Det bør avholdes minst ett medlemmsmøte mellom hvert årsmøte.
|
||||
|
||||
##### § 7.1
|
||||
|
||||
Styret skal:
|
||||
|
||||
iverksette årsmøtets vedtak og bestemmelser.
|
||||
|
||||
Stå for velforeningens daglige ledelse og ivareta medlemmenes interesser overfor andre instanser.
|
||||
|
||||
Stå for den alminnelige forvaltningen av foreningen, herunder forvalte velforeningens eiendeler og føre kontroll med foreningens økonomi.
|
||||
|
||||
etter behov opprette komitéer eller bemyndige personer for å løse spesielle oppgaver.
|
||||
|
||||
etter beste evne søke å oppfylle foreningens formål eller gjeldende regler.
|
||||
|
||||
sørge for at foreningen er forsvarlig forsikret.
|
||||
|
||||
##### § 7.2
|
||||
|
||||
Styret har disposisjonsrett over velforeningens midler til alminnelige administrative formål og rett til å bevilge beløp til gjennomføring av vedtak på årsmøtet. Alle utbetalinger skal attesteres av leder og kasserer før utbetaling. Styret har imidlertid ikke adgang til uten samtykke fra årsmøtet å treffe beslutninger av større betydning for foreningen.
|
||||
|
||||
##### § 7.3
|
||||
|
||||
Styret holder møte når leder bestemmer eller et flertall av styremedlemmene forlanger det. Varamedlemmet kan møte på samtlige styremøter, men har kun stemmerett når styret ikke er fulltallig.
|
||||
|
||||
Alle saker og all korrespondanse skal legges fram på styremøtet. Styrets vedtak skal protokolleres. Det skal fremgå av protokollen om vedtaket er enstemmig, og eventuelle dissenser protokolleres. Alle medlemmene har rett til innsyn i protokollene fra styremøtene. Innsyn kan nektes av styret i enkeltsaker, dersom hensynet til tredjemann tilsier det eller dersom andre saklige grunner for nektelse foreligger.
|
||||
|
||||
##### § 7.4
|
||||
|
||||
Styret er beslutningsdyktig når et flertall av styremedlemmene, deriblant leder eller nestleder, er tilstede. Leder eller i hans/hennes fravær nestleder, leder styrets møter. Styrebeslutninger krever alminnelig flertall. Ved stemmelikhet har fungerende leder dobbeltstemme.
|
||||
|
||||
Et styremedlem kan ikke være med å stemme i saker hvor dette har særinteresser.
|
||||
|
||||
##### § 8
|
||||
|
||||
Regnskapsåret går fra 01.01–31.12. Styret skal påse at regnskapet blir ført.
|
||||
|
||||
##### § 9
|
||||
|
||||
Medlemskontingenten er den som til enhver tid er/blir fastsatt av Årsmøte. Kontingenten innkreves av styret. Den betales forskuddsvis.
|
||||
|
||||
##### § 10
|
||||
|
||||
Tomter Vel kan bare oppløses ved vedtak på Årsmøte etter forslag fra styret. Til slikt vedtak kreves to tredjedels stemmeflertall blant de frammøtte medlemmer. Forslag om oppløsning skal gjøres kjent 3 uker før årsmøtet holdes. Eventuelle midler som Tomter Vel sitter inne med, anvendes da straks til formål som kommer Tomter til gode.
|
||||
|
||||
##### § 11
|
||||
|
||||
Styret eller årsmøtet oppretter komiteer for definerte formål. Komitéer skal bestå av komiteleder som rapporterer til styret. Komiteleder tilknytter et egnet antall personer til formålet og organiserer arbeidet.
|
||||
|
||||
##### § 12
|
||||
|
||||
Valgkomiteen består av to medlemmer med funksjonstid ett år. Valgkomiteen foreslår overfor Årsmøte medlemmer og varamedlemmer til styre, komitéer og valgkomite, samt revisor.
|
||||
|
||||
##### § 13
|
||||
|
||||
Tomter Vel skal ha en revisorer med funksjonstid to år. Revisoren skal revidere regnskapet og legge fram revisjonsberetning for Årsmøtet.
|
||||
|
||||
##### § 14
|
||||
|
||||
Forandringer i disse lover kan bare skje ved vedtak på Årsmøte. Slikt vedtak krever to tredjedels stemmeflertall blant de frammøtte medlemmene.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Fotballgruppa i et idrettslag, på norsk: to dører på forsiden
|
||||
# (foresatte, og de som vil bidra), roller som overlapper på én
|
||||
# innmelding (treneren godkjenner, kassereren ser betalingen), og en
|
||||
# intern melding fra trenerne til styret.
|
||||
business: Lia IL Fotball
|
||||
language: no
|
||||
contact: { name: Kari Lien, email: kari@liail.example }
|
||||
mail: { from: "Liail IL <post@liail.example>", reply_to: kari@liail.example }
|
||||
about: >
|
||||
Fotballgruppa i et idrettslag på bygda. Rundt hundre og tjue unger
|
||||
fra seks til seksten år, fordelt på lag etter årskull, drevet av
|
||||
foreldre på fritida.
|
||||
|
||||
doors:
|
||||
- id: foresatte
|
||||
who: en forelder eller foresatt til et barn som vil spille, eller allerede spiller
|
||||
- id: bidra
|
||||
who: noen som vil hjelpe til i klubben, eller en bedrift som vil støtte den
|
||||
|
||||
needs:
|
||||
- id: innmelding
|
||||
who: en forelder som vil ha ungen med på et lag
|
||||
wants: at barnet får plass på laget for sitt årskull
|
||||
door: foresatte
|
||||
lands_in: innmeldinger
|
||||
handled_by: trenere
|
||||
stages: [open, godkjent, betalt, trukket]
|
||||
stages_text: { open: "mottatt", godkjent: "godkjent av treneren", betalt: "betalt", trukket: "trukket" }
|
||||
mails: { open: submitter, godkjent: submitter, betalt: submitter }
|
||||
done_when: [betalt, trukket]
|
||||
success: [betalt]
|
||||
# Treneren godkjenner plassen, kassereren ser at det er betalt.
|
||||
moves_by: { betalt: [kasserer] }
|
||||
watched_by: [kasserer]
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: barnets_navn }
|
||||
- { name: fodselsar, type: select, options: ["2010", "2011", "2012", "2013", "2014", "2015", "2016", "2017", "2018", "2019", "2020"] }
|
||||
- { name: foresatt }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: email, type: email }
|
||||
- { name: spilt_for, optional: true }
|
||||
|
||||
- id: avgiftssak
|
||||
who: en forelder som sliter med treningsavgiften
|
||||
wants: å dele den opp, eller slippe den
|
||||
door: foresatte
|
||||
lands_in: avgiftssaker
|
||||
handled_by: styret
|
||||
stages: [open, vurdert, innvilget, avslatt]
|
||||
stages_text: { open: "mottatt", vurdert: "vurdert", innvilget: "innvilget", avslatt: "avslått" }
|
||||
done_when: [innvilget, avslatt]
|
||||
success: [innvilget]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: barnets_navn }
|
||||
- { name: foresatt }
|
||||
- { name: hva_gjelder_det, type: textarea }
|
||||
- { name: telefon, type: tel, optional: true }
|
||||
|
||||
- id: frivillig
|
||||
who: en som vil hjelpe til, som trener, lagleder, sjåfør eller på dugnad
|
||||
wants: å bli brukt der det trengs
|
||||
door: bidra
|
||||
lands_in: frivillige
|
||||
handled_by: styret
|
||||
stages: [open, kontaktet, plassert, avslatt]
|
||||
stages_text: { open: "mottatt", kontaktet: "kontaktet", plassert: "satt opp", avslatt: "takket nei" }
|
||||
done_when: [plassert, avslatt]
|
||||
success: [plassert]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: hva_kan_du, type: select, options: [Trene et lag, Være lagleder, Kjøre til kamper, Dugnad, Annet] }
|
||||
|
||||
- id: sponsor
|
||||
who: en bedrift i bygda som vil støtte klubben
|
||||
wants: en prat om hva de kan bidra med, og en avtale
|
||||
door: bidra
|
||||
lands_in: sponsorer
|
||||
handled_by: styret
|
||||
stages: [open, i_dialog, avtalt, avslatt]
|
||||
stages_text: { open: "mottatt", i_dialog: "i dialog", avtalt: "avtalt", avslatt: "avslått" }
|
||||
done_when: [avtalt, avslatt]
|
||||
success: [avtalt]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: bedrift }
|
||||
- { name: kontaktperson }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: hva_tenker_dere, type: textarea, optional: true }
|
||||
|
||||
- id: baneproblem
|
||||
who: en trener som ser at noe på anlegget ikke er i orden
|
||||
wants: at styret får vite det og ordner det
|
||||
enters_as: trenere
|
||||
lands_in: baneproblemer
|
||||
handled_by: styret
|
||||
stages: [open, tatt_tak_i, ordnet]
|
||||
stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", ordnet: "ordnet" }
|
||||
done_when: [ordnet]
|
||||
success: [ordnet]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: hvor }
|
||||
- { name: hva, type: textarea }
|
||||
|
||||
# Holdt unna alle modeller som skriver ord.
|
||||
personas:
|
||||
- { id: ny-i-bygda, need: innmelding, text: "Vi flyttet hit i sommer og sønnen vår på ni har spilt fotball før. Hvordan får vi ham med på et lag her?" }
|
||||
- { id: tvillinger, need: innmelding, text: "Jeg har tvillingjenter på sju som maser om å begynne. Er det plass, og hva koster det?" }
|
||||
- { id: alenefar, need: avgiftssak, text: "Jeg er alene med tre unger og treningsavgiften for alle tre blir for mye på en gang. Går det an å dele den opp?" }
|
||||
- { id: permittert, need: avgiftssak, text: "Jeg ble permittert i vinter. Kan dattera mi fortsette å spille selv om vi ikke klarer avgiften akkurat nå?" }
|
||||
- { id: pensjonist, need: frivillig, text: "Jeg er pensjonert og spilte selv i mange år. Har dere bruk for en som kan ta de minste et par kvelder i uka?" }
|
||||
- { id: stor-bil, need: frivillig, text: "Jeg kan ikke trene noen, men jeg har stor bil og kan kjøre til bortekamper." }
|
||||
- { id: rorlegger, need: sponsor, text: "Jeg driver rørleggerfirma her i bygda og vil gjerne ha logoen vår på draktene til et lag. Hvem snakker jeg med?" }
|
||||
- { id: kafeen, need: sponsor, text: "Vi har kafé i sentrum og kan bidra med noe til cupen i mai, kanskje vafler eller litt penger." }
|
||||
- { id: lyset-slukker, need: baneproblem, text: "Lyset på kunstgresset slår seg av halv åtte og vi trener til åtte. Ungene løper i mørket." }
|
||||
@@ -0,0 +1,94 @@
|
||||
# A machine hire yard: two doors (hiring, and an account for firms),
|
||||
# one aggregate walked by two roles (the counter reserves, the yard
|
||||
# hands out and takes back), and the yard's own damage report.
|
||||
business: Fjellstad Hire
|
||||
language: en
|
||||
contact: { name: Ola Fjellstad, email: ola@fjellstadhire.example }
|
||||
about: >
|
||||
Machine hire: mini diggers, lifts, compactors, generators. A counter,
|
||||
a yard that delivers, collects and fixes, and an office.
|
||||
|
||||
doors:
|
||||
- id: hiring
|
||||
who: someone who needs a machine, or has one of ours that has stopped
|
||||
- id: firms
|
||||
who: a firm that hires often and wants an account with us
|
||||
|
||||
needs:
|
||||
- id: hire-request
|
||||
who: a private person or a firm who needs a machine for a while
|
||||
wants: the right machine, from a date, for as long as they need it
|
||||
door: hiring
|
||||
lands_in: hires
|
||||
handled_by: counter
|
||||
stages: [open, reserved, out, returned, cancelled]
|
||||
done_when: [returned, cancelled]
|
||||
success: [returned]
|
||||
# The counter reserves; the yard hands it out and takes it back.
|
||||
moves_by: { out: [yard], returned: [yard] }
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: what, type: textarea }
|
||||
- { name: from, type: date }
|
||||
- { name: until, type: date }
|
||||
- { name: deliver_to, optional: true }
|
||||
|
||||
- id: breakdown
|
||||
who: someone whose hired machine has stopped working
|
||||
wants: it running again, or swapped, today
|
||||
door: hiring
|
||||
lands_in: breakdowns
|
||||
handled_by: yard
|
||||
stages: [open, in_hand, resolved]
|
||||
done_when: [resolved]
|
||||
success: [resolved]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: phone, type: tel }
|
||||
- { name: machine }
|
||||
- { name: what_happened, type: textarea }
|
||||
- { name: where, optional: true }
|
||||
|
||||
- id: account-request
|
||||
who: a firm that hires often
|
||||
wants: to send anyone to collect, and get one invoice a month
|
||||
door: firms
|
||||
lands_in: accounts
|
||||
handled_by: office
|
||||
stages: [open, checked, approved, declined]
|
||||
done_when: [approved, declined]
|
||||
success: [approved]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: company }
|
||||
- { name: registration_number }
|
||||
- { name: contact }
|
||||
- { name: phone, type: tel }
|
||||
- { name: invoice_email, type: email, optional: true }
|
||||
|
||||
- id: damage-on-return
|
||||
who: someone in the yard who finds damage when a machine comes back
|
||||
wants: it written down before the customer has left
|
||||
enters_as: yard
|
||||
lands_in: damage_reports
|
||||
handled_by: office
|
||||
stages: [open, invoiced, written_off]
|
||||
done_when: [invoiced, written_off]
|
||||
success: [invoiced]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: machine }
|
||||
- { name: hire_or_customer }
|
||||
- { name: damage, type: textarea }
|
||||
|
||||
# Held out from every model that writes words.
|
||||
personas:
|
||||
- { id: garden-wall, need: hire-request, text: "I'm building a stone wall in the garden and need a small digger for a weekend. I've never driven one." }
|
||||
- { id: roofer-lift, need: hire-request, text: "We're a roofing crew and need a scissor lift on site in Dalen from Monday for about two weeks." }
|
||||
- { id: stuck-compactor, need: breakdown, text: "The plate compactor I picked up this morning won't start. I've got a crew standing around." }
|
||||
- { id: lift-error, need: breakdown, text: "The lift you delivered keeps showing an error and stops halfway up. It's on our site at the school." }
|
||||
- { id: landscaper-account, need: account-request, text: "We're a landscaping firm hiring from you almost every month and I'm tired of paying by card each time. Can we get invoiced monthly?" }
|
||||
- { id: builder-account, need: account-request, text: "Small building firm, six people. We'd like to be able to send whoever is nearest to pick things up and have it on our account." }
|
||||
- { id: yard-window, need: damage-on-return, text: "The mini digger from the Berg job came back with a smashed rear window and hydraulic oil everywhere." }
|
||||
@@ -0,0 +1,113 @@
|
||||
# A physiotherapy clinic: two doors on the front, patients and the
|
||||
# people who send them; the reception handles everything, therapists
|
||||
# write the report back and notice who did not turn up.
|
||||
business: Bakken Fysioterapi
|
||||
language: en
|
||||
contact: { name: Marit Bakken, email: marit@bakkenfysio.example }
|
||||
about: >
|
||||
Six physiotherapists and a receptionist. People come with pain,
|
||||
injuries and after operations, on their own or sent by a doctor,
|
||||
and we treat them over a course of sessions.
|
||||
|
||||
doors:
|
||||
- id: patients
|
||||
who: someone with pain or an injury, or a patient who already has a time here
|
||||
- id: referrers
|
||||
who: a doctor, a hospital ward or an insurer sending someone to us
|
||||
|
||||
needs:
|
||||
- id: new-patient
|
||||
who: someone with something that hurts
|
||||
wants: a first appointment with a therapist who suits them
|
||||
door: patients
|
||||
lands_in: requests
|
||||
handled_by: reception
|
||||
stages: [open, offered, booked, declined]
|
||||
done_when: [booked, declined]
|
||||
success: [booked]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: what_hurts, type: textarea }
|
||||
- { name: when, type: select, options: [Mornings, Afternoons, Any time] }
|
||||
- { name: sent_by_doctor, type: select, options: ["Yes", "No"], optional: true }
|
||||
|
||||
- id: move-or-cancel
|
||||
who: a patient who already has a time and cannot come
|
||||
wants: the time moved, or the slot freed
|
||||
door: patients
|
||||
lands_in: cancellations
|
||||
handled_by: reception
|
||||
stages: [open, rebooked, released]
|
||||
done_when: [rebooked, released]
|
||||
success: [rebooked]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: appointment_day, type: date }
|
||||
- { name: new_time_wish, optional: true }
|
||||
|
||||
- id: referral
|
||||
who: a GP or a hospital ward sending a patient
|
||||
wants: the patient seen, and a report back when the course ends
|
||||
door: referrers
|
||||
lands_in: referrals
|
||||
handled_by: reception
|
||||
stages: [open, accepted, booked, reported, returned]
|
||||
done_when: [reported, returned]
|
||||
success: [reported]
|
||||
# The therapist who treated them writes the report; they see the
|
||||
# referral from the start.
|
||||
moves_by: { reported: [therapists] }
|
||||
watched_by: [therapists]
|
||||
max_fields: 5
|
||||
collects:
|
||||
- { name: patient_name }
|
||||
- { name: patient_phone, type: tel }
|
||||
- { name: referrer }
|
||||
- { name: what_for, type: textarea }
|
||||
- { name: urgency, type: select, options: [This week, Within a month, Routine] }
|
||||
|
||||
- id: insurance-approval
|
||||
who: the receptionist, before a course that an insurer has to approve
|
||||
wants: the request sent and its answer kept where the patient's case is
|
||||
enters_as: reception
|
||||
lands_in: approvals
|
||||
handled_by: reception
|
||||
stages: [open, sent, approved, refused]
|
||||
done_when: [approved, refused]
|
||||
success: [approved]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: patient_name }
|
||||
- { name: insurer }
|
||||
- { name: sessions }
|
||||
- { name: case_number, optional: true }
|
||||
|
||||
- id: no-show
|
||||
who: a therapist whose patient did not turn up and did not call
|
||||
wants: the reception to ring them and get them back in
|
||||
enters_as: therapists
|
||||
lands_in: no_shows
|
||||
handled_by: reception
|
||||
stages: [open, contacted, rebooked, closed]
|
||||
done_when: [rebooked, closed]
|
||||
success: [rebooked]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: patient_name }
|
||||
- { name: appointment_day, type: date }
|
||||
- { name: note, optional: true }
|
||||
|
||||
# Held out from every model that writes words.
|
||||
personas:
|
||||
- { id: runner-knee, need: new-patient, text: "I've been running for years and the outside of my right knee has started hurting after about five kilometres. I'd like someone to have a look, preferably in the mornings." }
|
||||
- { id: post-surgery-mother, need: new-patient, text: "My mother had a hip replacement three weeks ago and the hospital said she needs training. She can't do much on her own and I'll drive her." }
|
||||
- { id: sick-child, need: move-or-cancel, text: "I have a session on Thursday at ten but my daughter has a fever and I can't come. Can I move it?" }
|
||||
- { id: sent-abroad, need: move-or-cancel, text: "Work is sending me abroad next week so my appointment on the 3rd is off. Just free the slot, I'll be in touch when I'm back." }
|
||||
- { id: gp-shoulder, need: referral, text: "I'm a GP. A patient of mine has a frozen shoulder that isn't improving with what I can offer. I'd like her to start with you and hear how it goes." }
|
||||
- { id: hospital-discharge, need: referral, text: "Orthopaedic ward here. We're discharging a man after an ankle fracture on Friday and need follow-up rehab arranged near where he lives. He'll need a report for his sick leave." }
|
||||
- { id: reception-approval, need: insurance-approval, text: "The patient in room two has a policy that covers twelve sessions if the insurer signs off first. I need to send that off and keep track of the answer." }
|
||||
- { id: therapist-no-show, need: no-show, text: "My two o'clock didn't turn up and didn't call. Second time. Someone needs to ring him." }
|
||||
@@ -0,0 +1,339 @@
|
||||
# Tomter Vel, en velforening: fire dører på forsiden (medlemskap,
|
||||
# melde fra, bidra, naboer imellom) og høringsinnspill rett på
|
||||
# forsiden, siden den som har en mening om en sak ikke ser seg selv
|
||||
# som frivillig. Komiteene er gruppene: en søknad om plass i en
|
||||
# komité velger blant de samme sju som har hver sin pult. Trafikk,
|
||||
# anlegg og oppslagstavla kan alle lese; kontaktopplysninger vises
|
||||
# ikke der. Nabohjelp begge veier. Oppslagstavla for deling av utstyr
|
||||
# står ute med en gang: en nabo som kan låne ut, eller vil låne, sier
|
||||
# fra rett fra oppslaget, og den som la det ut hører fra dem. Køen er
|
||||
# innboksen til den som la ut; siden holder ikke rede på hvem som ble
|
||||
# valgt. Redaktøren, de som står som ansvarlige, tar ned det som står
|
||||
# igjen.
|
||||
business: Tomter Vel
|
||||
language: no
|
||||
# Styret svarer for sidene som gruppe: ingen konto for innboksen, og
|
||||
# hvert styremedlem kan foreslå endringer på dem.
|
||||
contact: { name: Styret i Tomter Vel, email: post@tomtervel.no, group: styret }
|
||||
# Portal holder gruppene selv: en invitasjon er en plass som venter til
|
||||
# personen logger inn, og en person er telefonnummeret sitt først. Med
|
||||
# Vipps-innlogging kan alle logge inn med en gang; til da lager Kanidm
|
||||
# kontoen ved siden av.
|
||||
people: memberships
|
||||
# Avsenderen for all e-post fra siden (gdo på kasse leverer); det
|
||||
# kasses postfix kan sende for i dag.
|
||||
mail: { from: "Tomter Vel <tomtervel@klingenbergbygg.no>", reply_to: post@tomtervel.no }
|
||||
about: >
|
||||
Velforeningen for Tomter, stiftet i 1947. Sju komiteer drevet på
|
||||
fritida: arrangementer, elvestien, lekeplassene, høringssvar,
|
||||
miljøgata, pendlerforhold og trafikk. 157 husstander er medlemmer.
|
||||
|
||||
# Komiteene, som gruppene heter, med navnet folk kjenner dem under.
|
||||
groups:
|
||||
- { id: arrangementer, label: Arrangementer }
|
||||
- { id: elvesti, label: Elvesti }
|
||||
- { id: horingsinstans, label: Høringsinstans }
|
||||
- { id: lekeplasser, label: Leke- og møteplasser }
|
||||
- { id: miljogate, label: Miljøgate }
|
||||
- { id: pendlerforhold, label: Pendlerforhold }
|
||||
- { id: trafikk, label: Trafikkforhold }
|
||||
|
||||
# Styret kan invitere inn i alle grupper som har en pult; hver gruppe kan
|
||||
# invitere inn i seg selv. Portal oppretter kontoen og en engangslenke.
|
||||
invites_by: [styret]
|
||||
|
||||
# Forsiden åpner beskjedent: to trekk, én rolle hver, ingen knapp,
|
||||
# før tilbudene lenger ned på samme side. De to velet er til for: en
|
||||
# husstand som blir medlem, og en nabo som trenger en hånd. Begge har
|
||||
# noe å gjøre etterpå: medlemmet melder seg ut selv, den som fikk
|
||||
# hjelp sier selv fra at det er ordnet.
|
||||
does:
|
||||
- id: medlem
|
||||
for: bli-medlem
|
||||
text: >
|
||||
Velet taler for Tomter overfor kommunen, holder lekeplassene og
|
||||
stien i stand og samler naboene på 17. mai og dugnad. 157
|
||||
husstander er med. Du melder deg inn her, og ut igjen selv når
|
||||
du vil.
|
||||
- id: nabo
|
||||
for: trenger-hjelp
|
||||
text: >
|
||||
Trenger du en hånd med snømåking, handling eller skyss, kobler
|
||||
nabohjelpsgruppa deg med en nabo som kan. Du sier selv fra når
|
||||
det er ordnet.
|
||||
|
||||
# Vedtektene, ord for ord slik årsmøtet vedtok dem: en side der hver
|
||||
# paragraf er sitt eget kort. Ingen modell rører teksten.
|
||||
documents:
|
||||
- id: vedtekter
|
||||
question: Hva sier vedtektene?
|
||||
description: Vedtektene for Tomter Vel, av 23.04.2018.
|
||||
file: documents/tomtervel-vedtekter.md
|
||||
|
||||
doors:
|
||||
- id: medlem
|
||||
who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding
|
||||
- id: melde-fra
|
||||
who: noen som har sett noe farlig i trafikken, eller noe ødelagt på lekeplassen eller stien
|
||||
- id: bidra
|
||||
who: noen som vil stille opp på 17. mai, dugnad eller et arrangement, eller sitte i en komité
|
||||
- id: naboer
|
||||
who: en nabo som trenger en hånd med snømåking, handling eller skyss, kan gi en, eller har noe å låne ut
|
||||
|
||||
|
||||
needs:
|
||||
- id: bli-medlem
|
||||
who: en husstand i Tomter
|
||||
wants: å bli medlem og få betalt kontingenten
|
||||
lands_in: husstander
|
||||
door: medlem
|
||||
handled_by: kasserer
|
||||
stages: [open, betalt, utmeldt]
|
||||
stages_text: { open: "mottatt", betalt: "betalt", utmeldt: "meldt ut" }
|
||||
done_when: [betalt, utmeldt]
|
||||
success: [betalt]
|
||||
# Medlemmet melder seg ut selv, gjennom sin egen lenke.
|
||||
moves_by: { utmeldt: [submitter, kasserer] }
|
||||
mails: { open: submitter, betalt: submitter }
|
||||
# Betalt kontingent er medlemskapet: husstanden får konto og plass
|
||||
# blant medlemmene i samme trekk, og det som er medlemmenes eget
|
||||
# (komitéplass) ligger bak innloggingen.
|
||||
grants: { betalt: medlemmer }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: adresse }
|
||||
- { name: email, type: email }
|
||||
- { name: telefon, type: tel, optional: true }
|
||||
|
||||
- id: endre-medlemskap
|
||||
who: et medlem som har flyttet, byttet e-post eller vil melde seg ut
|
||||
wants: at velet har riktige opplysninger om dem
|
||||
lands_in: medlemsendringer
|
||||
door: medlem
|
||||
handled_by: kasserer
|
||||
stages: [open, oppdatert]
|
||||
stages_text: { open: "mottatt", oppdatert: "oppdatert" }
|
||||
done_when: [oppdatert]
|
||||
success: [oppdatert]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: adresse }
|
||||
- { name: hva_er_nytt, type: textarea }
|
||||
|
||||
- id: trafikkfare
|
||||
who: en som har sett et farlig sted i trafikken
|
||||
wants: at velet tar det opp med kommunen eller den som eier veien
|
||||
lands_in: trafikkmeldinger
|
||||
door: melde-fra
|
||||
handled_by: trafikk
|
||||
stages: [open, tatt_opp, ordnet, avvist]
|
||||
stages_text: { open: "mottatt", tatt_opp: "tatt opp med kommunen", ordnet: "ordnet", avvist: "avvist" }
|
||||
done_when: [ordnet, avvist]
|
||||
success: [ordnet]
|
||||
# Alle kan se hva som er meldt og hvor det står; e-posten vises ikke.
|
||||
watched_by: [styret, public]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: hvor }
|
||||
- { name: hva, type: textarea }
|
||||
- { name: epost, type: email, optional: true }
|
||||
|
||||
- id: anleggsfeil
|
||||
who: en som ser at noe på lekeplassen eller stien er ødelagt
|
||||
wants: at det blir fikset før noen skader seg
|
||||
lands_in: anleggsfeil
|
||||
door: melde-fra
|
||||
handled_by: lekeplasser
|
||||
stages: [open, tatt_tak_i, utbedret]
|
||||
stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", utbedret: "utbedret" }
|
||||
done_when: [utbedret]
|
||||
success: [utbedret]
|
||||
watched_by: [public]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: hvor, type: select, options: [Lekeplassen, Elvestien, Skøytebanen, Et annet sted] }
|
||||
- { name: hva, type: textarea }
|
||||
|
||||
# Rett på forsiden: den som har en mening om en sak er ikke frivillig.
|
||||
- id: horingsinnspill
|
||||
who: en beboer som har en mening om en sak kommunen har på høring
|
||||
wants: at velet tar det med i sitt svar
|
||||
lands_in: innspill
|
||||
handled_by: horingsinstans
|
||||
watched_by: [styret]
|
||||
stages: [open, tatt_med, ikke_tatt_med]
|
||||
stages_text: { open: "mottatt", tatt_med: "tatt med i høringssvaret", ikke_tatt_med: "ikke tatt med" }
|
||||
done_when: [tatt_med, ikke_tatt_med]
|
||||
success: [tatt_med]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: sak }
|
||||
- { name: innspill, type: textarea }
|
||||
|
||||
- id: hjelpe-til
|
||||
who: en som vil hjelpe til på et arrangement eller en dugnad
|
||||
wants: å bli satt opp på noe som passer dem
|
||||
lands_in: frivillige
|
||||
door: bidra
|
||||
handled_by: arrangementer
|
||||
stages: [open, kontaktet, satt_opp, takket_nei]
|
||||
stages_text: { open: "mottatt", kontaktet: "kontaktet", satt_opp: "satt opp", takket_nei: "takket nei" }
|
||||
done_when: [satt_opp, takket_nei]
|
||||
success: [satt_opp]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: hva, type: select, multiple: true, options: [17. mai, Juletregranstenning, Skøytedag, Dugnad på elvestien, Det som trengs] }
|
||||
|
||||
# Komitéplass er for medlemmer: døra viser veien, skjemaet ligger på
|
||||
# medlemmenes side bak innloggingen.
|
||||
- id: komite
|
||||
who: et medlem som vil sitte i en komité
|
||||
wants: en plass i den komiteen som driver med det de bryr seg om
|
||||
lands_in: komitesoknader
|
||||
door: bidra
|
||||
enters_as: medlemmer
|
||||
handled_by: styret
|
||||
stages: [open, plassert, avslatt]
|
||||
stages_text: { open: "mottatt", plassert: "plassert i en komité", avslatt: "avslått" }
|
||||
done_when: [plassert, avslatt]
|
||||
success: [plassert]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: epost, type: email }
|
||||
- { name: komite, type: select, multiple: true, options_from: groups }
|
||||
|
||||
# Nabohjelp: den som trenger en hånd, og den som kan gi en. En
|
||||
# nabohjelpsgruppe kobler dem; den som fikk hjelp sier fra selv at
|
||||
# det er ordnet.
|
||||
- id: trenger-hjelp
|
||||
who: en beboer som ikke får til noe selv for tiden, som snømåking, handling eller en skyss
|
||||
wants: at en nabo stiller opp
|
||||
lands_in: hjelpebehov
|
||||
# Et behov ingen har tatt tak i på en uke går opp til styret: samme
|
||||
# sak, samme knapper, på styrets pult i tillegg.
|
||||
escalate: { open: { days: 7, to: styret } }
|
||||
door: naboer
|
||||
handled_by: nabohjelp
|
||||
stages: [open, formidlet, ordnet, ikke_lost]
|
||||
stages_text: { open: "mottatt", open_escalated: "hos styret, siden ingen tok den på en uke", formidlet: "formidlet til en nabo", ordnet: "ordnet", ikke_lost: "ikke løst" }
|
||||
done_when: [ordnet, ikke_lost]
|
||||
success: [ordnet]
|
||||
moves_by: { ordnet: [submitter, nabohjelp] }
|
||||
mails: { open: submitter, formidlet: submitter, ordnet: submitter }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: email, type: email }
|
||||
- { name: adresse }
|
||||
- { name: hva, type: textarea }
|
||||
- { name: telefon, type: tel, optional: true }
|
||||
|
||||
- id: kan-hjelpe
|
||||
who: en nabo som kan stille opp når noen trenger det
|
||||
wants: å bli spurt når det passer
|
||||
lands_in: hjelpere
|
||||
door: naboer
|
||||
handled_by: nabohjelp
|
||||
stages: [open, registrert, trukket]
|
||||
stages_text: { open: "mottatt", registrert: "registrert som hjelper", trukket: "trukket" }
|
||||
done_when: [registrert, trukket]
|
||||
success: [registrert]
|
||||
moves_by: { trukket: [submitter, nabohjelp] }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: email, type: email }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: kan, type: select, multiple: true, options: [Snømåking, Handling, Kjøring, Småfiks i huset, En prat og litt selskap, Det som trengs] }
|
||||
|
||||
# Oppslagstavla: ut med en gang, styret hører om det. Svar går
|
||||
# rett til den som la ut; køen er innboksen deres. Tas ned av den
|
||||
# som la ut, av redaktøren, eller av klokka etter en måned.
|
||||
- id: dele-utstyr
|
||||
who: en nabo som har noe å låne ut, eller trenger å låne noe
|
||||
wants: et oppslag naboene ser med en gang
|
||||
lands_in: oppslag
|
||||
door: naboer
|
||||
handled_by: redaktor
|
||||
stages: [open, tatt_ned]
|
||||
stages_text: { open: "ute", tatt_ned: "tatt ned" }
|
||||
done_when: [tatt_ned]
|
||||
success: [tatt_ned]
|
||||
moves_by: { tatt_ned: [submitter, redaktor] }
|
||||
mails: { open: post@tomtervel.no }
|
||||
deadlines: { open: { days: 30, to: tatt_ned } }
|
||||
# Tavla er offentlig: det er den naboene leser; navn og e-post
|
||||
# vises ikke der.
|
||||
watched_by: [public]
|
||||
public_states: [open]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: navn, private: true }
|
||||
- { name: email, type: email }
|
||||
- { name: har_eller_trenger, type: select, options: [Jeg har noe å låne ut, Jeg trenger å låne noe] }
|
||||
- { name: hva, type: textarea }
|
||||
|
||||
# Svaret på et oppslag: fra oppslagets egen rad på tavla, med
|
||||
# e-post eller innlogget, rett til den som la ut. Redaktøren ser
|
||||
# svarene og merker dem lest.
|
||||
- id: melde-seg
|
||||
who: en nabo som kan låne ut, eller vil låne, det et oppslag gjelder
|
||||
wants: at den som la ut oppslaget får vite det
|
||||
about: dele-utstyr
|
||||
lands_in: svar
|
||||
handled_by: redaktor
|
||||
stages: [open, lest]
|
||||
stages_text: { open: "sendt", lest: "lest" }
|
||||
done_when: [lest]
|
||||
success: [lest]
|
||||
mails: { open: owner }
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: email, type: email }
|
||||
- { name: hilsen, type: textarea, optional: true }
|
||||
|
||||
- id: utlegg
|
||||
who: en i en komité som har lagt ut for noe
|
||||
wants: pengene tilbake
|
||||
enters_as: komiteer
|
||||
lands_in: utlegg
|
||||
handled_by: kasserer
|
||||
stages: [open, godkjent, utbetalt, avvist]
|
||||
stages_text: { open: "mottatt", godkjent: "godkjent", utbetalt: "utbetalt", avvist: "avvist" }
|
||||
done_when: [utbetalt, avvist]
|
||||
success: [utbetalt]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: navn }
|
||||
- { name: belop }
|
||||
- { name: hva, type: textarea }
|
||||
|
||||
# Holdt unna alle modeller som skriver ord.
|
||||
personas:
|
||||
- { id: nyinnflyttet, need: bli-medlem, text: "Vi flyttet til Tomter i mars og har hørt at det finnes et vel. Hvordan blir vi med, og hva koster det?" }
|
||||
- { id: glemte-a-betale, need: bli-medlem, text: "Jeg har vært medlem før men fikk ikke betalt i fjor. Kan jeg bare vippse, og til hvem?" }
|
||||
- { id: ny-epost, need: endre-medlemskap, text: "Vi har byttet e-postadresse og får ikke lenger noe fra dere." }
|
||||
- { id: flytter, need: endre-medlemskap, text: "Vi selger huset og flytter til Ski i august, så vi må ut av medlemslista." }
|
||||
- { id: skolevei, need: trafikkfare, text: "Bilene kjører altfor fort i svingen ved skolen om morgenen, og det er ikke fortau der ungene går." }
|
||||
- { id: busslomme, need: trafikkfare, text: "Det er umulig å se om det kommer noen når man skal ut fra busslomma ved butikken, hekken har blitt kjempehøy." }
|
||||
- { id: huske, need: anleggsfeil, text: "Huska på lekeplassen henger i én kjetting. Sønnen min dro i den i går og den holdt så vidt." }
|
||||
- { id: bru, need: anleggsfeil, text: "Planken på den lille brua på stien langs elva er råtten, jeg gikk nesten gjennom." }
|
||||
- { id: blokker, need: horingsinnspill, text: "Kommunen skal bygge blokker på jordet bak stasjonen, og jeg synes det er alt for høyt. Kan velet si ifra?" }
|
||||
- { id: togtider, need: horingsinnspill, text: "Ruter vil kutte det tidlige toget. Jeg pendler til Oslo hver dag og vil at velet skriver til dem om det." }
|
||||
- { id: vafler, need: hjelpe-til, text: "Jeg kan steke vafler på 17. mai-frokosten hvis dere trenger folk." }
|
||||
- { id: motorsag, need: hjelpe-til, text: "Har motorsag og ledig helg, kan ta trær langs elvestien hvis det er en dugnad." }
|
||||
- { id: pendler, need: komite, text: "Jeg pendler og er lei av at ingen snakker med Ruter. Jeg vil gjerne være med i gjengen som jobber med det." }
|
||||
- { id: planlegger, need: komite, text: "Jeg er arealplanlegger av yrke og kunne bidratt når kommunen sender saker på høring." }
|
||||
- { id: tilhenger, need: dele-utstyr, text: "Jeg har en tilhenger som står ubrukt mesteparten av året, naboer kan gjerne låne den for en hundrelapp." }
|
||||
- { id: stige, need: dele-utstyr, text: "Trenger en lang stige en dag i helga for å rense takrenna, er det noen i nærheten som har?" }
|
||||
- { id: snomaking, need: trenger-hjelp, text: "Jeg er 84 og klarer ikke å måke innkjørselen lenger. Er det noen som kan hjelpe meg når det snør?" }
|
||||
- { id: nyoperert, need: trenger-hjelp, text: "Jeg er nyoperert i hofta og kommer meg ikke til butikken på noen uker. Kunne noen handlet for meg?" }
|
||||
- { id: hjemmekontor, need: kan-hjelpe, text: "Jeg jobber hjemmefra og kan godt måke eller handle for noen som ikke får det til selv." }
|
||||
- { id: pensjonert-elektriker, need: kan-hjelpe, text: "Pensjonert elektriker, har bil og god tid. Si fra om noen trenger skyss eller en hånd med småting." }
|
||||
- { id: kvittering, need: utlegg, text: "Jeg kjøpte pølser og kakao til skøytedagen for 1 840 kroner, her er kvitteringa, vil ha det igjen." }
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# UHHM, uhhm.no: the studio that builds portal and carries redoal.
|
||||
# Rebuilt from the live site (uhhm/questions) on 2026-09-23. The five
|
||||
# buckets the site already holds keep their live state and event
|
||||
# names (`events:`), so every record on the owners' desk stays where
|
||||
# it is. Two are new: a business trying the trainer on its own
|
||||
# workflow (`trials`, approved by a person before anything is built),
|
||||
# and what reaches UHHM as the custodian of the redoal network
|
||||
# (`custody_requests`, a desk of its own).
|
||||
business: UHHM
|
||||
language: en
|
||||
contact: { name: Bendik Aagaard Lynghaug, email: bl@uhhm.no }
|
||||
# Every mail the site sends comes from here (gdo on ergo delivers).
|
||||
mail: { from: "UHHM <portal@uhhm.no>", reply_to: bl@uhhm.no }
|
||||
about: >
|
||||
UHHM builds software around a clear argument: infrastructure, tools
|
||||
and products for organizations that can say what problem they have.
|
||||
It also builds and carries the redoal network - the gesture math,
|
||||
the relay, the first node, and the portal this site runs on - and
|
||||
answers for what that network keeps.
|
||||
|
||||
groups:
|
||||
- { id: owners, label: Owners }
|
||||
# The people who answer for the redoal network: they host its first
|
||||
# node and relay, keep what is left there, and decide what stays.
|
||||
- { id: custodians, label: redoal custodians, invited_by: [owners] }
|
||||
# Owners onboard into every desk; a custodian can vouch for another.
|
||||
invites_by: [owners]
|
||||
|
||||
# The front door opens modestly: one or two features, one role each,
|
||||
# and no button, before the offers further down the same page. The
|
||||
# two roles UHHM exists for: an organization with a problem, and the
|
||||
# people on the redoal network it keeps.
|
||||
does:
|
||||
- id: software
|
||||
for: commission
|
||||
text: >
|
||||
Software built around a clear argument. Infrastructure, tools
|
||||
and products for organizations that can say what problem they
|
||||
have - and a working draft of your own workflow as a site,
|
||||
before you pay for anything.
|
||||
- id: redoal
|
||||
for: custody
|
||||
text: >
|
||||
The redoal network, built and carried here: the gesture math,
|
||||
the relay, the first node, and the portal this site runs on.
|
||||
UHHM answers for what that network keeps.
|
||||
|
||||
# Things to look at before asking for anything.
|
||||
shows:
|
||||
- id: built
|
||||
text: A few things already out in the world.
|
||||
resource:
|
||||
public: true
|
||||
source: { kind: gitea_starred, username: bl }
|
||||
jq: >
|
||||
.[] | {name: .name, description: .description,
|
||||
url: (if .website != null and .website != "" then .website
|
||||
elif .private then null
|
||||
else .html_url end)}
|
||||
- id: voices
|
||||
text: Live from the redoal relay - what has been left at places so far.
|
||||
resource:
|
||||
public: true
|
||||
source: { kind: url, url: https://relay.redoal.com/pulse }
|
||||
jq: >
|
||||
{ "name": (.voices_echoed | tostring),
|
||||
"description": "voices echoed so far" }
|
||||
empty: The first voice has not echoed yet.
|
||||
|
||||
needs:
|
||||
- id: commission
|
||||
who: an organization with a problem it wants software to solve
|
||||
wants: a considered reply to a brief, from people who read the whole thing
|
||||
lands_in: projects
|
||||
handled_by: owners
|
||||
stages: [open, accepted, declined]
|
||||
done_when: [accepted, declined]
|
||||
success: [accepted]
|
||||
events: { open: submitted }
|
||||
stages_text: { open: "received", accepted: "taken on", declined: "not a fit for us" }
|
||||
# The client plays too: they can withdraw the brief themselves.
|
||||
moves_by: { declined: [submitter, owners] }
|
||||
mails: { open: submitter, accepted: submitter, declined: submitter }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
- { name: project, type: textarea }
|
||||
- { name: organization }
|
||||
- { name: org_contact, optional: true }
|
||||
|
||||
# The client feature: the trainer tried on a real business. A person
|
||||
# approves each intake before anything is built from it.
|
||||
- id: trial
|
||||
who: someone who runs a business where requests, approvals and handovers get lost, and wants to see it as a site before committing to anything
|
||||
wants: a first draft of a site for their own roles, built from their own words and approved by a person first
|
||||
lands_in: trials
|
||||
handled_by: owners
|
||||
stages: [open, approved, previewed, declined]
|
||||
done_when: [previewed, declined]
|
||||
success: [previewed]
|
||||
stages_text: { open: "received", approved: "approved for a draft", previewed: "a preview is ready", declined: "not a fit for us" }
|
||||
mails: { open: submitter, approved: submitter, previewed: submitter, declined: submitter }
|
||||
attended_by: >
|
||||
approved -> previewed: the n8n workflow that builds the draft
|
||||
site from an approved intake (docker, rauthy, portal content)
|
||||
and sends the preview link. Until it exists, owners run
|
||||
portal-trainer by hand and press the button.
|
||||
max_fields: 6
|
||||
collects:
|
||||
- { name: business }
|
||||
- { name: email, type: email }
|
||||
- { name: what_we_do, type: textarea }
|
||||
- { name: who_comes_to_us, type: textarea }
|
||||
- { name: what_we_keep_track_of, type: textarea }
|
||||
- { name: who_is_involved, type: textarea }
|
||||
- { name: what_slows_us_down, type: textarea, optional: true }
|
||||
|
||||
- id: collaborate
|
||||
who: someone who wants to work with us on what we build
|
||||
wants: to be read as a person, and invited in if there is a fit
|
||||
lands_in: applicants
|
||||
handled_by: owners
|
||||
stages: [open, invited, active, departed, declined]
|
||||
done_when: [departed, declined]
|
||||
success: [departed]
|
||||
events: { open: applied, active: onboarded }
|
||||
# Declined before starting; after that a person departs.
|
||||
turned_down_from: [open, invited]
|
||||
stages_text: { open: "received", invited: "invited in", active: "working with us", departed: "moved on", declined: "not a fit for us" }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
- { name: interest, type: textarea }
|
||||
- { name: note, type: textarea, optional: true }
|
||||
|
||||
- id: subscribe
|
||||
who: someone who wants to hear from us when there is something to say
|
||||
wants: a note now and then, and an easy way out
|
||||
lands_in: subscribers
|
||||
handled_by: owners
|
||||
stages: [open, unsubscribed]
|
||||
done_when: [unsubscribed]
|
||||
events: { open: subscribed }
|
||||
stages_text: { open: "on the list", unsubscribed: "off the list" }
|
||||
moves_by: { unsubscribed: [submitter, owners] }
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
|
||||
# This site proposes its own changes through itself.
|
||||
- id: propose
|
||||
who: a visitor who sees how a page on this site should change
|
||||
wants: the change read by a person and, if approved, made
|
||||
lands_in: development_proposals
|
||||
handled_by: owners
|
||||
stages: [open, approved, rejected]
|
||||
done_when: [approved, rejected]
|
||||
success: [approved]
|
||||
events: { open: proposed }
|
||||
stages_text: { open: "received", approved: "approved", rejected: "turned down" }
|
||||
# The site changes in the open: anyone can read what was approved,
|
||||
# without who proposed it.
|
||||
watched_by: [public]
|
||||
public_states: [approved]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: submitted_by, private: true }
|
||||
- { name: email, type: email, optional: true }
|
||||
- { name: page, optional: true }
|
||||
- { name: rationale, type: textarea }
|
||||
- { name: proposed_yaml, type: textarea }
|
||||
|
||||
# The custodian role, on the front door: UHHM answers for the redoal
|
||||
# network it hosts. Owners see these too, and move nothing.
|
||||
- id: custody
|
||||
who: someone who runs, builds on, or is affected by the redoal network and needs the people who keep its first node
|
||||
wants: an answer from a custodian, and action where the network keeps something it should not
|
||||
lands_in: custody_requests
|
||||
handled_by: custodians
|
||||
watched_by: [owners]
|
||||
stages: [open, in_dialogue, resolved, declined]
|
||||
done_when: [resolved, declined]
|
||||
success: [resolved]
|
||||
stages_text: { open: "received", in_dialogue: "in dialogue with a custodian", resolved: "resolved", declined: "closed without action" }
|
||||
# The person affected says when it is resolved, not only the custodian.
|
||||
moves_by: { resolved: [submitter, custodians] }
|
||||
mails: { open: submitter, resolved: submitter, declined: submitter }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
- { name: about, type: select, options: [Running a node of my own, Building on the network, Something the network keeps about me, Something else] }
|
||||
- { name: what, type: textarea }
|
||||
|
||||
# Internal: a relationship worth tracking before it is a project.
|
||||
- id: prospect
|
||||
who: an owner who has met an organization worth following up
|
||||
wants: it written down and walked through its states from the desk
|
||||
enters_as: owners
|
||||
lands_in: organizations
|
||||
handled_by: owners
|
||||
stages: [prospect, client, past_client]
|
||||
done_when: [past_client]
|
||||
skippable: [client]
|
||||
stages_text: { prospect: "a prospect", client: "a client", past_client: "a past client" }
|
||||
events: { prospect: identified, client: became_client, past_client: churned }
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: contact }
|
||||
- { name: note, type: textarea, optional: true }
|
||||
|
||||
# Held out from every model that writes words. The first eight are the
|
||||
# 2026-09-21 set (runs/uhhm-2026-09-21); the rest are for what is new.
|
||||
personas:
|
||||
- { id: architecture-firm, need: commission, text: "We run a small architecture firm and our project files are scattered across three cloud drives. We want to pay someone to set up proper storage and backups we control ourselves." }
|
||||
- { id: climbing-gym-founder, need: commission, text: "I have an idea for a booking app for climbing gyms and I'm looking for a team that can build the first version with me." }
|
||||
- { id: municipality-buyer, need: commission, text: "Our municipality needs an internal tool for tracking building permits. We are looking for a vendor who can scope it and deliver it." }
|
||||
- { id: exhibition-visitor, need: subscribe, text: "I saw your drawing network at an exhibition and liked it. I don't want to get involved, I'd just like to know when there is news." }
|
||||
- { id: tech-journalist, need: subscribe, text: "I write about Nordic technology. Keep me posted on what you release so I can cover it." }
|
||||
- { id: rust-developer, need: collaborate, text: "I'm a Rust developer between jobs and I like that you build in the open. I would love to contribute, and maybe join you." }
|
||||
- { id: interaction-designer, need: collaborate, text: "I'm an interaction designer. I want to work with you on the drawing interface." }
|
||||
- { id: student-intern, need: collaborate, text: "I'm a computer science student looking for a summer internship doing infrastructure work." }
|
||||
- { id: plumber-owner, need: trial, text: "I run a plumbing company with four vans. Jobs come in by phone and text and some get lost. I'd like to see how this would look for us before I spend any money." }
|
||||
- { id: dance-school, need: trial, text: "We are a dance school. Enrolments, cancellations and invoices all go through one email inbox that only I understand. Can you show me a version made for us?" }
|
||||
- { id: node-runner, need: custody, text: "I run a small server in Berlin and want to host a redoal node that peers with yours. Who do I talk to?" }
|
||||
- { id: recorded-name, need: custody, text: "Someone left a recording on redoal that says my full name and where I live. I want it gone, and I want to know who is responsible for that network." }
|
||||
- { id: app-builder, need: custody, text: "I'm building a music app and would like to use redoal's gesture addresses in it. Is that possible, and who decides?" }
|
||||
- { id: wording-fix, need: propose, text: "The subscribe page should say how often you actually write. I'd like to suggest that exact change to the page." }
|
||||
- { id: rewrite, need: propose, text: "One page on this site reads wrong to me and I know exactly how I'd rewrite it. Where do I send the new text?" }
|
||||
@@ -0,0 +1,17 @@
|
||||
# One onboarding per business. `open` is the intake as submitted; the
|
||||
# generator (portal-trainer generate) turns it into a draft content
|
||||
# repo; the business reviews the draft and either it goes live or it
|
||||
# comes back for another pass.
|
||||
aggregates:
|
||||
- bucket: onboardings
|
||||
initial: open
|
||||
states:
|
||||
open: { event: intake_received }
|
||||
drafted: { event: draft_generated }
|
||||
revising: { event: revision_requested }
|
||||
live: { event: went_live }
|
||||
declined: { event: declined }
|
||||
transitions:
|
||||
open: [drafted, declined]
|
||||
drafted: [live, revising, declined]
|
||||
revising: [drafted, declined]
|
||||
@@ -0,0 +1,41 @@
|
||||
# The onboarding site is a portal site, so it is held to a needs spec
|
||||
# like any other.
|
||||
needs:
|
||||
- id: onboard-business
|
||||
who: someone who runs a business where requests, approvals and handovers get lost
|
||||
wants: a working site for their own roles, without commissioning software
|
||||
via: Describe your business and get a first draft
|
||||
lands_in: onboardings
|
||||
handled_by: owners
|
||||
done_when: [live, declined]
|
||||
max_steps: 1
|
||||
# The intake is the interview: two to reach them, four about the work.
|
||||
max_fields: 6
|
||||
|
||||
personas:
|
||||
- id: contractor-owner
|
||||
need: onboard-business
|
||||
text: >
|
||||
I run a building firm. Enquiries come by phone, text and email
|
||||
and we lose track of who promised what. I want one place where
|
||||
they land and my project managers pick them up.
|
||||
|
||||
- id: clinic-manager
|
||||
need: onboard-business
|
||||
text: >
|
||||
We are a physiotherapy clinic with six therapists. Referrals,
|
||||
cancellations and insurance approvals all go through one inbox
|
||||
that only our receptionist understands. I'd like that sorted.
|
||||
|
||||
- id: sports-club
|
||||
need: onboard-business
|
||||
text: >
|
||||
I volunteer for a youth football club. Parents sign kids up,
|
||||
coaches need to approve them, and the treasurer chases fees.
|
||||
Right now it's a spreadsheet and a group chat.
|
||||
|
||||
- id: sceptical-founder
|
||||
need: onboard-business
|
||||
text: >
|
||||
I've been burned by agencies before. If I can explain my
|
||||
workflow and see something real before paying anyone, I'm in.
|
||||
@@ -0,0 +1 @@
|
||||
qualifies: owners
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Which businesses are waiting on us?
|
||||
responsible:
|
||||
name: Bendik Aagaard Lynghaug
|
||||
contact: bl@uhhm.no
|
||||
alternatives:
|
||||
- name: Onboardings
|
||||
description: >
|
||||
Run `portal-trainer generate` on an open intake, push the draft,
|
||||
send the link, then mark it drafted. Revisions come back here.
|
||||
action: /desk
|
||||
consequence: [Confirm]
|
||||
features:
|
||||
- name: ""
|
||||
description: ""
|
||||
resource:
|
||||
source: { kind: kv, bucket: onboardings }
|
||||
requires_group: owners
|
||||
transitions:
|
||||
- { to: drafted, label: Draft sent }
|
||||
- { to: declined, label: Decline }
|
||||
- { from: drafted, to: live, label: Went live }
|
||||
- { from: drafted, to: revising, label: Revision asked }
|
||||
- { from: drafted, to: declined, label: Decline }
|
||||
- { from: revising, to: drafted, label: Revised draft sent }
|
||||
- { from: revising, to: declined, label: Decline }
|
||||
@@ -0,0 +1,21 @@
|
||||
name: How does an answer become a finished job?
|
||||
description: >
|
||||
Every page asks one question. Every answer lands with the person
|
||||
whose job it is, and carries its own next steps.
|
||||
alternatives:
|
||||
- name: A visitor answers
|
||||
description: >
|
||||
They pick what they came for and fill in the few fields that
|
||||
person needs. They are told who reads it and when.
|
||||
- name: The right role sees it
|
||||
description: >
|
||||
Each role has a desk listing what is theirs, with one button per
|
||||
step your process allows - quoted, approved, closed.
|
||||
- name: Nothing waits unseen
|
||||
description: >
|
||||
Every case has a state and an owner, so what is open, and with
|
||||
whom, is always one page away.
|
||||
- name: Describe your business and get a first draft
|
||||
description: Five answers in your own words.
|
||||
action: /
|
||||
consequence: [Start]
|
||||
@@ -0,0 +1,49 @@
|
||||
name: What does your business keep doing by hand?
|
||||
description: >
|
||||
Tell us who comes to you, what you keep track of, and who handles
|
||||
what. You get a site that asks each of those people the right
|
||||
question and moves every answer to the person who acts on it.
|
||||
responsible:
|
||||
name: Bendik Aagaard Lynghaug
|
||||
contact: bl@uhhm.no
|
||||
alternatives:
|
||||
- name: Describe your business and get a first draft
|
||||
description: >
|
||||
Five answers in your own words. We turn them into pages, forms
|
||||
and a desk for each role, and send you a link to try it.
|
||||
action: /received
|
||||
consequence: [Send it]
|
||||
record_as: onboardings
|
||||
encouragements:
|
||||
- Write it the way you would explain it to a new colleague.
|
||||
features:
|
||||
- name: You
|
||||
requirements:
|
||||
- { name: business, label: Business name }
|
||||
- { name: email, label: Your email, type: email }
|
||||
- name: The work
|
||||
description: Plain words are best. Stages, handovers and waiting are what we listen for.
|
||||
requirements:
|
||||
- name: what_we_do
|
||||
label: What you do
|
||||
type: textarea
|
||||
- name: who_comes_to_us
|
||||
label: Who comes to you, and what each of them wants
|
||||
type: textarea
|
||||
- name: what_we_keep_track_of
|
||||
label: What you keep track of, and the stages it goes through
|
||||
type: textarea
|
||||
- name: who_is_involved
|
||||
label: Who on your side handles what
|
||||
type: textarea
|
||||
- name: what_slows_us_down
|
||||
label: Where things get lost or wait today
|
||||
type: textarea
|
||||
optional: true
|
||||
|
||||
- name: See how a question becomes a finished job
|
||||
description: >
|
||||
One enquiry followed from the visitor's answer to the desk that
|
||||
closes it.
|
||||
action: /how
|
||||
consequence: [Show me]
|
||||
@@ -0,0 +1,15 @@
|
||||
followup: true
|
||||
name: What happens with what you sent?
|
||||
description: >
|
||||
Bendik reads every intake. Within two working days you get a link to
|
||||
a working draft built from your answers.
|
||||
alternatives:
|
||||
- name: You try the draft as each of your roles
|
||||
description: >
|
||||
Click through it as a customer, then as the colleague who handles
|
||||
that customer. Tell us what reads wrong and the draft comes back
|
||||
revised.
|
||||
- name: Back to the start
|
||||
description: ""
|
||||
action: /
|
||||
consequence: [Back]
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Portal
|
||||
lang: en
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,158 @@
|
||||
# A building contractor's needs, as a portal needs spec: a small to
|
||||
# mid-sized firm doing surveys, design and rehabilitation, run by a few
|
||||
# project managers with its own crew and a ring of subcontractors.
|
||||
#
|
||||
# This file is a target, not a description: there are no pages behind
|
||||
# it yet, so `question_lint` fails every need. The trainer's job is to
|
||||
# write the pages, aggregates.yaml and desks that turn it green, then
|
||||
# word them until the personas route right.
|
||||
#
|
||||
# Groups (Kanidm): project_managers, hse, crew, subcontractors.
|
||||
# Everyone else arrives anonymous.
|
||||
#
|
||||
# What the sector demands of the flows, and why the budgets are what
|
||||
# they are:
|
||||
# - Winning work is a pipeline, not an inbox: a lead is surveyed, then
|
||||
# quoted, then won or lost. "answered" is not an outcome.
|
||||
# - A subcontractor must be vetted before they set foot on site
|
||||
# (company registration, trade approval, insurance, tax standing,
|
||||
# site ID cards for their people). Documents go missing; the state
|
||||
# machine has to say so and let them come back.
|
||||
# - Deviation and near-miss reporting only works when it costs the
|
||||
# reporter seconds. Three required fields, one submission, from a
|
||||
# phone, and the safety officer owns closing it.
|
||||
# - People move between firms constantly; onboarding a worker is the
|
||||
# same shape as onboarding a subcontractor, one size smaller.
|
||||
needs:
|
||||
- id: win-survey-work
|
||||
who: a building owner or housing board with a problem in their building
|
||||
wants: someone qualified to look at it and say what it takes
|
||||
lands_in: leads
|
||||
handled_by: project_managers
|
||||
done_when: [won, lost]
|
||||
max_steps: 2
|
||||
max_fields: 5
|
||||
|
||||
- id: win-design-work
|
||||
who: an owner who knows what they want built or changed
|
||||
wants: drawings, a permit application, and a price
|
||||
lands_in: leads
|
||||
handled_by: project_managers
|
||||
done_when: [won, lost]
|
||||
max_steps: 2
|
||||
max_fields: 6
|
||||
|
||||
- id: vet-subcontractor
|
||||
who: a trade firm that wants to work for us
|
||||
wants: to be approved once, and then be asked
|
||||
lands_in: subcontractors
|
||||
handled_by: project_managers
|
||||
done_when: [approved, rejected]
|
||||
max_steps: 2
|
||||
max_fields: 7
|
||||
|
||||
- id: request-bids
|
||||
who: a project manager with a job to staff
|
||||
wants: approved subcontractors to see it and answer
|
||||
enters: /jobs/post
|
||||
enters_as: project_managers
|
||||
lands_in: job_requests
|
||||
handled_by: project_managers
|
||||
done_when: [awarded, withdrawn]
|
||||
max_steps: 1
|
||||
max_fields: 5
|
||||
|
||||
- id: answer-bid
|
||||
who: an approved subcontractor
|
||||
wants: to say yes to a job, with a price and a start date
|
||||
enters: /jobs
|
||||
enters_as: subcontractors
|
||||
lands_in: bids
|
||||
handled_by: project_managers
|
||||
done_when: [accepted, declined]
|
||||
max_steps: 2
|
||||
max_fields: 3
|
||||
|
||||
- id: hire-crew
|
||||
who: a tradesperson looking for work
|
||||
wants: to be considered, without writing a letter
|
||||
lands_in: applicants
|
||||
handled_by: project_managers
|
||||
done_when: [hired, declined]
|
||||
max_steps: 2
|
||||
max_fields: 4
|
||||
|
||||
- id: report-deviation
|
||||
who: anyone on site - crew or a subcontractor's people
|
||||
wants: to report a hazard, a near miss or a defect before they forget
|
||||
enters: /site
|
||||
enters_as: crew
|
||||
lands_in: deviations
|
||||
handled_by: hse
|
||||
done_when: [closed]
|
||||
max_steps: 1
|
||||
max_fields: 3
|
||||
|
||||
personas:
|
||||
- id: housing-board-damp
|
||||
need: win-survey-work
|
||||
text: >
|
||||
I chair the board of a housing cooperative from the seventies.
|
||||
Two ground-floor flats have damp in the walls and we don't know
|
||||
if it's drainage or the facade. We need someone to tell us.
|
||||
|
||||
- id: buyer-before-purchase
|
||||
need: win-survey-work
|
||||
text: >
|
||||
We are about to buy an old timber house. Before we sign we want
|
||||
a professional to go through it and tell us what is wrong with it.
|
||||
|
||||
- id: attic-conversion
|
||||
need: win-design-work
|
||||
text: >
|
||||
We want to turn our attic into two bedrooms and a bathroom. We
|
||||
need drawings and someone who handles the application to the
|
||||
municipality.
|
||||
|
||||
- id: shop-to-flat
|
||||
need: win-design-work
|
||||
text: >
|
||||
I own a former shop on the ground floor and want to make it a
|
||||
flat. I have no idea what is allowed. Can you sort that out and
|
||||
give me a price?
|
||||
|
||||
- id: electrician-firm
|
||||
need: vet-subcontractor
|
||||
text: >
|
||||
We are a four-person electrical firm, fully certified. We would
|
||||
like to get on your list so you call us when you need
|
||||
electricians.
|
||||
|
||||
- id: scaffolder
|
||||
need: vet-subcontractor
|
||||
text: >
|
||||
I run a scaffolding company. How do I get to do jobs for you?
|
||||
|
||||
- id: carpenter-jobseeker
|
||||
need: hire-crew
|
||||
text: >
|
||||
I'm a certified carpenter with eight years on rehabilitation
|
||||
jobs. Are you hiring?
|
||||
|
||||
- id: apprentice
|
||||
need: hire-crew
|
||||
text: >
|
||||
I finish vocational school this spring and need an
|
||||
apprenticeship as a bricklayer.
|
||||
|
||||
- id: loose-railing
|
||||
need: report-deviation
|
||||
text: >
|
||||
The railing on the third level of the scaffold on the north
|
||||
side is loose. Nobody got hurt but someone will.
|
||||
|
||||
- id: wrong-membrane
|
||||
need: report-deviation
|
||||
text: >
|
||||
The bathroom membrane delivered today is not the one in the
|
||||
specification. We stopped work on it.
|
||||
@@ -0,0 +1,127 @@
|
||||
# What UHHM, as a business, needs this site to do - stated apart from
|
||||
# how the pages word it. `question_lint` checks every need against the
|
||||
# pages and aggregates.yaml (portal `src/needs.rs`): a path of
|
||||
# submissions gets the visitor's answer into `lands_in` within the
|
||||
# effort budget, `handled_by` has a desk over that bucket, and every
|
||||
# `done_when` state is reachable through the moves the desk offers.
|
||||
#
|
||||
# The personas are visitors in their own words - never the site's.
|
||||
# `question_lint --needs-tasks` turns each page on their path into a
|
||||
# typed choice for an answering engine; `--needs-score` grades its
|
||||
# picks. A misroute means the page said one thing and they heard
|
||||
# another. The runtime never reads this file.
|
||||
needs:
|
||||
- id: commission
|
||||
who: someone with a problem software could solve, and a budget
|
||||
wants: UHHM to take the work on
|
||||
via: Good software begins with a clear argument
|
||||
lands_in: projects
|
||||
handled_by: owners
|
||||
done_when: [accepted, declined]
|
||||
max_steps: 1
|
||||
# name, email, the build, the organization - the page argues for
|
||||
# each of the four; a fifth would need the same argument.
|
||||
max_fields: 4
|
||||
|
||||
- id: follow
|
||||
who: someone curious who isn't ready to get involved
|
||||
wants: to hear when something ships
|
||||
via: Attention is earned
|
||||
lands_in: subscribers
|
||||
handled_by: owners
|
||||
# Being on the list is the finished state; leaving is theirs to do.
|
||||
done_when: [open, unsubscribed]
|
||||
max_steps: 1
|
||||
max_fields: 2
|
||||
|
||||
- id: collaborate
|
||||
who: a developer, designer or researcher
|
||||
wants: to work with UHHM
|
||||
via: Work happens through dialogue, not tickets
|
||||
lands_in: applicants
|
||||
handled_by: owners
|
||||
done_when: [active, declined]
|
||||
max_steps: 1
|
||||
max_fields: 3
|
||||
|
||||
- id: improve-site
|
||||
who: anyone using this site
|
||||
wants: the site itself to change
|
||||
enters: /develop/proposal
|
||||
# Announced, not open: the automation that turns a proposal into a
|
||||
# pull request is still being built, so the alternative is disabled.
|
||||
planned: true
|
||||
lands_in: development_proposals
|
||||
handled_by: owners
|
||||
done_when: [approved, rejected]
|
||||
max_steps: 1
|
||||
max_fields: 3
|
||||
|
||||
- id: track-prospect
|
||||
who: an owner who met an organization worth following up
|
||||
wants: the relationship written down and walked to an outcome
|
||||
enters: /review
|
||||
enters_as: owners
|
||||
via: Record a prospect
|
||||
lands_in: organizations
|
||||
handled_by: owners
|
||||
done_when: [client, past_client]
|
||||
max_steps: 1
|
||||
max_fields: 2
|
||||
|
||||
personas:
|
||||
- id: architecture-firm
|
||||
need: commission
|
||||
text: >
|
||||
We run a small architecture firm and our project files are
|
||||
scattered across three cloud drives. We want to pay someone to
|
||||
set up proper storage and backups we control ourselves.
|
||||
|
||||
- id: climbing-gym-founder
|
||||
need: commission
|
||||
text: >
|
||||
I have an idea for a booking app for climbing gyms and I'm
|
||||
looking for a team that can build the first version with me.
|
||||
|
||||
- id: municipality-buyer
|
||||
need: commission
|
||||
text: >
|
||||
Our municipality needs an internal tool for tracking building
|
||||
permits. We are looking for a vendor who can scope it and
|
||||
deliver it.
|
||||
|
||||
- id: exhibition-visitor
|
||||
need: follow
|
||||
text: >
|
||||
I saw your drawing network at an exhibition and liked it. I don't
|
||||
want to get involved, I'd just like to know when there is news.
|
||||
|
||||
- id: tech-journalist
|
||||
need: follow
|
||||
text: >
|
||||
I write about Nordic technology. Keep me posted on what you
|
||||
release so I can cover it.
|
||||
|
||||
- id: rust-developer
|
||||
need: collaborate
|
||||
text: >
|
||||
I'm a Rust developer between jobs and I like that you build in
|
||||
the open. I would love to contribute, and maybe join you.
|
||||
|
||||
- id: interaction-designer
|
||||
need: collaborate
|
||||
text: >
|
||||
I'm an interaction designer. I want to work with you on the
|
||||
drawing interface.
|
||||
|
||||
- id: student-intern
|
||||
need: collaborate
|
||||
text: >
|
||||
I'm a computer science student looking for a summer internship
|
||||
doing infrastructure work.
|
||||
|
||||
- id: site-critic
|
||||
need: improve-site
|
||||
text: >
|
||||
Your contact form never says how long a reply takes. You should
|
||||
add that.
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: portal-trainer
|
||||
description: Onboard a business onto portal - interview them, write a plan, build the content repo (pages, aggregates.yaml, desks, mails) with the words written by a model, and score it until iris proves every need is met and a blind visitor routes the personas right. Use when someone wants a new portal site, a new flow on an existing one, a rebuild of a live one, or asks why visitors pick the wrong alternative.
|
||||
---
|
||||
|
||||
# Portal trainer
|
||||
|
||||
You are onboarding a business onto [portal](https://project.uhhm.no/uhhm/portal):
|
||||
a site that is one question per page, a few alternatives per question,
|
||||
each alternative optionally a form whose answer is recorded into a
|
||||
bucket with a state machine, worked from a group-gated desk, with a
|
||||
mail to the person when their case moves.
|
||||
|
||||
You have a verifier. Use it instead of your judgment wherever it can
|
||||
speak: `iris check --path <repo>/questions` (uhhm/iris, the release
|
||||
matching the portal the site runs) exits non-zero until the structure
|
||||
meets every need in `<repo>/needs.yaml`.
|
||||
|
||||
## The order of work
|
||||
|
||||
1. **Interview, then write the plan.** Never pages first. `plan`
|
||||
reads a filled-in intake into `plan.yaml` (a model decides needs,
|
||||
stages, forms and budgets; a person reads the result before
|
||||
anything is built). By hand, the same questions: who comes to the
|
||||
business and what each of them wants, who inside handles it, what
|
||||
"handled" means (the states a case ends in), what it costs the
|
||||
visitor to ask (`max_fields`, from the person, never from counting
|
||||
the form you would like). Start from a `sectors/` file when there
|
||||
is one.
|
||||
2. **Say what the plan can say.** `does:` (one or two features, one
|
||||
role each, the front door's opener that asks for nothing);
|
||||
`shows:` (things to look at before asking); `doors:` only when the
|
||||
offers are more than a page holds - measure it, a flat page with
|
||||
ten offers scored worse than four doors; `mails:` per need (who
|
||||
hears when a case enters a stage) with `mail: { from }` on the
|
||||
plan; `moves_by: submitter` so the person has something to play
|
||||
after sending; `invites_by` for onboarding; `events:` when
|
||||
rebuilding a live site, so its records keep replaying.
|
||||
3. **Write personas in the visitor's words, not the business's.** Two
|
||||
or three per need, one who would not use the trade's vocabulary.
|
||||
Never reuse a phrase you intend to put on a page. They are held
|
||||
out from every model that writes words.
|
||||
4. **Build.** `build --plan ... --lint iris` derives every file with
|
||||
no model deciding structure; `--llm` lets a model write the words,
|
||||
`--llm-for <path prefix>` only some of them (the mails alone);
|
||||
`--words-from <live repo>` keeps a live site's hand-edited words
|
||||
through a rebuild, following each card across pages; `--words
|
||||
<json>` overrides slots. The builder refuses a reply in the wrong
|
||||
language and puts back a submitter's link a model dropped.
|
||||
5. **Score.** `iris check --needs-tasks` then `answer`, then
|
||||
`--needs-score`, with and without descriptions; `rate --repo` for
|
||||
readability and the four understanding questions, mails included.
|
||||
A card with no button is context, never a choice. Trust the
|
||||
descriptions score; the headings-only score swings by a third
|
||||
between seeds. Before believing a change, `runs/spread.sh` it over
|
||||
three seeds and require it to beat the spread. A change that does
|
||||
not is rejected and kept as a run.
|
||||
- A **misroute** is a wording defect until the same one survives
|
||||
every rewording; then it is a seam in the plan.
|
||||
- Change the page or the plan, never the persona.
|
||||
6. **Keep the run.** `runs/<business>-<date>/` with the words as used
|
||||
(`words.json`), tasks, answers, scores per seed, and a README with
|
||||
what moved and why. Runs are the record of what worked.
|
||||
|
||||
## What a page is
|
||||
|
||||
Full reference: portal's `README.md`; a live example is
|
||||
`uhhm/questions`. The tree under `questions/` is the router
|
||||
(`index.yaml` is `/`, `_section.yaml` applies `qualifies` to a
|
||||
directory, nested non-index files are followups).
|
||||
|
||||
```yaml
|
||||
# questions/index.yaml
|
||||
name: What brings you here? # the question - always a question
|
||||
responsible: { name: ..., contact: ... }
|
||||
alternatives:
|
||||
- name: What we do # the opener: features, no button
|
||||
features:
|
||||
- { name: <a role's win>, description: <what we do for them> }
|
||||
- name: <heading the visitor picks> # graded alone under --skim
|
||||
description: <one or two sentences>
|
||||
action: /received # where the submission lands them
|
||||
consequence: [Send] # the button
|
||||
record_as: leads # bucket - must exist in aggregates.yaml
|
||||
features:
|
||||
- name: <form section>
|
||||
requirements:
|
||||
- { name: email, label: Email, type: email }
|
||||
- { name: problem, label: What's wrong, type: textarea }
|
||||
```
|
||||
|
||||
```yaml
|
||||
# aggregates.yaml
|
||||
aggregates:
|
||||
- bucket: leads
|
||||
initial: open
|
||||
states:
|
||||
open: { event: received, mail: { to: submitter, subject: "We have it", body: "..." } }
|
||||
quoted: { event: quoted }
|
||||
won: { event: won, mail: { to: submitter, subject: "...", body: "..." } }
|
||||
lost: { event: lost }
|
||||
transitions:
|
||||
open: [quoted, lost]
|
||||
quoted: [won, lost]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# site.yaml
|
||||
mail: { from: "Nordvik <post@nordvik.example>", reply_to: ola@nordvik.example }
|
||||
```
|
||||
|
||||
```yaml
|
||||
# questions/desk/_section.yaml
|
||||
qualifies: project_managers
|
||||
# questions/desk/index.yaml
|
||||
name: What came in?
|
||||
alternatives:
|
||||
- name: Leads
|
||||
action: /desk
|
||||
consequence: [Confirm]
|
||||
features:
|
||||
- name: ""
|
||||
resource:
|
||||
source: { kind: kv, bucket: leads }
|
||||
requires_group: project_managers
|
||||
transitions:
|
||||
- { to: quoted, label: Quote sent } # from defaults to open
|
||||
- { from: quoted, to: won, label: Won }
|
||||
```
|
||||
|
||||
## House rules for the copy
|
||||
|
||||
- The page name is a question the visitor could ask themselves.
|
||||
- The front door opens modestly: what the business does for one or
|
||||
two roles, then what there is to look at, then the offers. No
|
||||
second tier.
|
||||
- Headings say what the visitor gets, in the visitor's words. Lead
|
||||
with what a thing enables; never define it by what it isn't.
|
||||
- Every required field must be one the business will use to answer.
|
||||
- A followup page says who reads it, when they answer, and what the
|
||||
visitor can do meanwhile. A mail says what happened and, where the
|
||||
person has a move, the link to make it.
|
||||
- Code in English; the site in the business's language.
|
||||
|
||||
## Engines
|
||||
|
||||
Any engine that reads a task's `state` (`body` is the persona,
|
||||
`page` what the page says before its options) and `questions` and
|
||||
returns `{"id","choice","confidence"}` per line works. The visitor
|
||||
and the writer must be different models. See the repo README for what
|
||||
has been measured about each, and for the noise floor.
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
//! The one place a request is allowed or refused.
|
||||
//!
|
||||
//! Content already says who may do what: `qualifies` on a page,
|
||||
//! `requires_group`/`public` on a resource, the transitions a desk
|
||||
//! offers, a `self_transition` a submitter may fire with their own
|
||||
//! link, an `invite` a group may extend. Until now those were five
|
||||
//! checks in five files. Here they are compiled, on every content
|
||||
//! load, into one Cedar policy set, and every server entry point asks
|
||||
//! the same question: may this principal take this action on this
|
||||
//! resource? The answer carries the ids of the rules that granted it,
|
||||
//! and every decision that matters is published on NATS.
|
||||
//!
|
||||
//! The policy text is derived, never hand-written: content is the
|
||||
//! source, this module is the compiler, iris prints the result as a
|
||||
//! matrix and validates it against the schema.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
|
||||
use cedar_policy::{
|
||||
Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet, Request,
|
||||
RestrictedExpression, Schema, ValidationMode, Validator,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::aggregates::AggregateSchema;
|
||||
use crate::content::{path_matches, Question};
|
||||
|
||||
/// What every entity and action may look like. The generated policies
|
||||
/// are validated against this, so a content shape the compiler does
|
||||
/// not know how to express fails loudly instead of granting nothing.
|
||||
pub const SCHEMA: &str = r#"
|
||||
entity Group;
|
||||
entity User in [Group];
|
||||
entity Submitter { email: String };
|
||||
entity Service;
|
||||
entity Page;
|
||||
entity Bucket;
|
||||
entity Feature;
|
||||
action open, submit appliesTo { principal: [User], resource: [Page] };
|
||||
action read appliesTo { principal: [User], resource: [Feature] };
|
||||
action transition appliesTo { principal: [User], resource: [Bucket], context: { from: String, to: String } };
|
||||
action self_transition appliesTo { principal: [Submitter], resource: [Bucket], context: { from: String, to: String, stored_email: String } };
|
||||
action automation_read appliesTo { principal: [Service], resource: [Bucket] };
|
||||
action invite appliesTo { principal: [User], resource: [Group] };
|
||||
"#;
|
||||
|
||||
/// Who is asking.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Principal {
|
||||
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
|
||||
/// Over NATS, a question may name the person by phone number or
|
||||
/// address instead of listing their groups, and portal looks their
|
||||
/// memberships up (see `serve`).
|
||||
User {
|
||||
sub: String,
|
||||
groups: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
phone: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
},
|
||||
/// Whoever holds one record's own link and knows the email on it.
|
||||
Submitter { record: String, email: String },
|
||||
/// A machine with a shared token.
|
||||
Service { name: String },
|
||||
}
|
||||
|
||||
impl Principal {
|
||||
pub fn anonymous() -> Self {
|
||||
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
|
||||
}
|
||||
|
||||
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
||||
match user {
|
||||
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
|
||||
None => Principal::anonymous(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
Open,
|
||||
Submit,
|
||||
Read,
|
||||
Transition,
|
||||
SelfTransition,
|
||||
AutomationRead,
|
||||
Invite,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Open => "open",
|
||||
Action::Submit => "submit",
|
||||
Action::Read => "read",
|
||||
Action::Transition => "transition",
|
||||
Action::SelfTransition => "self_transition",
|
||||
Action::AutomationRead => "automation_read",
|
||||
Action::Invite => "invite",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Resource {
|
||||
Page { id: String },
|
||||
Bucket { name: String },
|
||||
/// One resource block on one alternative of one page. `feature` is
|
||||
/// the feature name, or `feature|requirement` (`|bind` appended)
|
||||
/// for a requirement's own resource.
|
||||
Feature { page: String, alternative: String, feature: String },
|
||||
Group { name: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Decision {
|
||||
pub allow: bool,
|
||||
/// The ids of the rules that granted it (empty on a refusal).
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
/// One row of the matrix iris prints: who may do what to what.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Rule {
|
||||
pub id: String,
|
||||
pub who: String,
|
||||
pub action: String,
|
||||
pub resource: String,
|
||||
pub when: String,
|
||||
}
|
||||
|
||||
pub struct Policy {
|
||||
policies: PolicySet,
|
||||
schema: Schema,
|
||||
pub rules: Vec<Rule>,
|
||||
pub text: String,
|
||||
/// The dynamic pages' pattern ids, so a concrete `/shape/abc`
|
||||
/// resolves to the policy written for `/shape/[key]`.
|
||||
patterns: Vec<String>,
|
||||
}
|
||||
|
||||
fn quote(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// Compiles content into policy. Infallible on valid content: every
|
||||
/// rule below is a shape the schema declares; the validation in
|
||||
/// `validate` is what iris runs to prove that stays true.
|
||||
pub fn from_content(
|
||||
questions: &HashMap<String, Question>,
|
||||
aggregates: &HashMap<String, AggregateSchema>,
|
||||
) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut rules = Vec::new();
|
||||
let mut ids: HashMap<String, usize> = HashMap::new();
|
||||
// Each rule is parsed on its own under its own id, so a
|
||||
// decision's reasons name rules, not `policy17`.
|
||||
let mut sources: Vec<(String, String)> = Vec::new();
|
||||
let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec<Rule>| {
|
||||
let n = ids.entry(id_base.clone()).or_insert(0);
|
||||
*n += 1;
|
||||
let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") };
|
||||
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
|
||||
rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when });
|
||||
sources.push((id, policy));
|
||||
};
|
||||
|
||||
let mut pages: Vec<&Question> = questions.values().collect();
|
||||
pages.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
for q in &pages {
|
||||
let page = quote(&q.id);
|
||||
let (who, principal) = match &q.qualifies {
|
||||
None => ("anyone".to_string(), "principal".to_string()),
|
||||
Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
||||
};
|
||||
emit(
|
||||
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
|
||||
format!("permit({principal}, action in [Action::\"open\", Action::\"submit\"], resource == Page::{page});"),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
for alt in &q.alternatives {
|
||||
// A feature's own resource, and the resources its
|
||||
// requirements read (select options, bound fields):
|
||||
// each is a Feature entity of its own.
|
||||
let mut specs: Vec<(String, &crate::content::ResourceSpec)> = Vec::new();
|
||||
for feature in &alt.features {
|
||||
if let Some(spec) = &feature.resource {
|
||||
specs.push((format!("{}|{}|{}", q.id, alt.name, feature.name), spec));
|
||||
}
|
||||
for req in &feature.requirements {
|
||||
if let Some(spec) = &req.resource {
|
||||
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
|
||||
}
|
||||
if let Some(bind) = &req.bind {
|
||||
specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (fid, spec) in specs {
|
||||
let (who, principal) = match (&spec.requires_group, spec.public) {
|
||||
(Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
||||
(None, true) => ("anyone".to_string(), "principal".to_string()),
|
||||
// Neither: unreachable by design (fail closed).
|
||||
(None, false) => continue,
|
||||
};
|
||||
emit(
|
||||
format!("read:{fid}"), who.clone(), "read", format!("feature {fid}"), String::new(),
|
||||
format!("permit({principal}, action == Action::\"read\", resource == Feature::{});", quote(&fid)),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
if let (Some(bucket), Some(g)) = (spec.bucket(), &spec.requires_group) {
|
||||
for t in &spec.transitions {
|
||||
emit(
|
||||
format!("move:{bucket}:{}>{}", t.from, t.to), format!("group {g}"), "transition",
|
||||
format!("bucket {bucket}"), format!("{} -> {}", t.from, t.to),
|
||||
format!(
|
||||
"permit(principal in Group::{}, action == Action::\"transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} }};",
|
||||
quote(g), quote(bucket), quote(&t.from), quote(&t.to)
|
||||
),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(st) = &alt.self_transition {
|
||||
// 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. Saying nothing means the bucket's own
|
||||
// initial state - the bucket's, not the word
|
||||
// `open`, since a bucket may start anywhere.
|
||||
let initial = aggregates
|
||||
.get(&st.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
|
||||
for from in &st.from_states(&initial) {
|
||||
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(
|
||||
format!("invite:{}", invite.group), format!("group {g}"), "invite", format!("group {}", invite.group), String::new(),
|
||||
format!("permit(principal in Group::{}, action == Action::\"invite\", resource == Group::{});", quote(g), quote(&invite.group)),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The one machine principal: the automation read endpoint, which
|
||||
// authenticates by shared token and may read any bucket.
|
||||
let mut buckets: Vec<&String> = aggregates.keys().collect();
|
||||
buckets.sort();
|
||||
emit(
|
||||
"automation".to_string(), "service automation".to_string(), "automation_read", "any bucket".to_string(), String::new(),
|
||||
"permit(principal == Service::\"automation\", action == Action::\"automation_read\", resource);".to_string(),
|
||||
&mut text, &mut rules,
|
||||
);
|
||||
let _ = buckets;
|
||||
|
||||
let mut policies = PolicySet::new();
|
||||
for (id, source) in &sources {
|
||||
let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), source)
|
||||
.expect("generated policy text always parses");
|
||||
policies.add(policy).expect("rule ids are unique");
|
||||
}
|
||||
let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant");
|
||||
let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect();
|
||||
Policy { policies, schema, rules, text, patterns }
|
||||
}
|
||||
|
||||
/// Every generated rule type-checks against the schema. A rule the
|
||||
/// schema cannot express is a compiler bug, and this is where it
|
||||
/// shows.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
|
||||
if result.validation_passed() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(result.validation_errors().map(|e| e.to_string()).collect::<Vec<_>>().join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn page_entity(&self, id: &str) -> String {
|
||||
if self.patterns.iter().any(|p| p == id) {
|
||||
return id.to_string();
|
||||
}
|
||||
self.patterns
|
||||
.iter()
|
||||
.find(|p| path_matches(p, id).is_some())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// The decision. `context` holds what the action needs: `from`/`to`
|
||||
/// for a transition, plus `stored_email` for a self transition.
|
||||
pub fn decide(
|
||||
&self,
|
||||
principal: &Principal,
|
||||
action: Action,
|
||||
resource: &Resource,
|
||||
context: serde_json::Value,
|
||||
) -> Decision {
|
||||
let uid = |ty: &str, id: &str| {
|
||||
EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id))
|
||||
};
|
||||
let mut entities: Vec<Entity> = Vec::new();
|
||||
let principal_uid = match principal {
|
||||
Principal::User { sub, groups, .. } => {
|
||||
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
|
||||
for g in &parents {
|
||||
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
|
||||
}
|
||||
let me = uid("User", sub);
|
||||
entities.push(Entity::new_no_attrs(me.clone(), parents));
|
||||
me
|
||||
}
|
||||
Principal::Submitter { record, email } => {
|
||||
let me = uid("Submitter", record);
|
||||
let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]);
|
||||
entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates"));
|
||||
me
|
||||
}
|
||||
Principal::Service { name } => {
|
||||
let me = uid("Service", name);
|
||||
entities.push(Entity::new_no_attrs(me.clone(), HashSet::new()));
|
||||
me
|
||||
}
|
||||
};
|
||||
let resource_uid = match resource {
|
||||
Resource::Page { id } => uid("Page", &self.page_entity(id)),
|
||||
Resource::Bucket { name } => uid("Bucket", name),
|
||||
Resource::Feature { page, alternative, feature } => {
|
||||
uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page)))
|
||||
}
|
||||
Resource::Group { name } => uid("Group", name),
|
||||
};
|
||||
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
|
||||
let action_uid = uid("Action", action.name());
|
||||
|
||||
let Ok(entities) = Entities::from_entities(entities, None) else {
|
||||
return Decision::default();
|
||||
};
|
||||
// An absent context is an empty one, not a malformed one.
|
||||
let context = if context.is_object() { context } else { serde_json::json!({}) };
|
||||
let Ok(context) = Context::from_json_value(context, None) else {
|
||||
return Decision::default();
|
||||
};
|
||||
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else {
|
||||
return Decision::default();
|
||||
};
|
||||
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
|
||||
Decision {
|
||||
allow: response.decision() == cedar_policy::Decision::Allow,
|
||||
reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
|
||||
self.decide(principal, action, resource, serde_json::json!({})).allow
|
||||
}
|
||||
}
|
||||
|
||||
/// What gets published on `portal.access.decided`: the question and
|
||||
/// the answer. Page opens and reads are published only when refused;
|
||||
/// every mutation is published either way.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Decided<'a> {
|
||||
pub principal: &'a Principal,
|
||||
pub action: Action,
|
||||
pub resource: &'a Resource,
|
||||
pub context: &'a serde_json::Value,
|
||||
pub allow: bool,
|
||||
pub reasons: &'a [String],
|
||||
pub at_ms: i64,
|
||||
}
|
||||
|
||||
pub const DECIDED_SUBJECT: &str = "portal.access.decided";
|
||||
|
||||
pub fn worth_publishing(action: Action, allow: bool) -> bool {
|
||||
!allow || !matches!(action, Action::Open | Action::Read)
|
||||
}
|
||||
|
||||
/// Decides and, when it matters, publishes. Fire and forget: a NATS
|
||||
/// hiccup never changes an answer.
|
||||
pub fn decide_and_publish(
|
||||
nats: &async_nats::Client,
|
||||
policy: &Policy,
|
||||
principal: &Principal,
|
||||
action: Action,
|
||||
resource: &Resource,
|
||||
context: serde_json::Value,
|
||||
) -> Decision {
|
||||
let decision = policy.decide(principal, action, resource, context.clone());
|
||||
if worth_publishing(action, decision.allow) {
|
||||
let event = Decided {
|
||||
principal,
|
||||
action,
|
||||
resource,
|
||||
context: &context,
|
||||
allow: decision.allow,
|
||||
reasons: &decision.reasons,
|
||||
at_ms: chrono::Utc::now().timestamp_millis(),
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&event) {
|
||||
let nats = nats.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = nats.publish(DECIDED_SUBJECT, bytes.into()).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
decision
|
||||
}
|
||||
|
||||
/// The request-reply face for other apps: a JSON question in, a
|
||||
/// decision out. Same policy, same answer as the site gives itself.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Query {
|
||||
pub principal: Principal,
|
||||
pub action: Action,
|
||||
pub resource: Resource,
|
||||
#[serde(default)]
|
||||
pub context: serde_json::Value,
|
||||
}
|
||||
|
||||
pub fn subject_for(site: &str) -> String {
|
||||
format!("portal.access.{site}.may")
|
||||
}
|
||||
|
||||
pub fn policy_subject_for(site: &str) -> String {
|
||||
format!("portal.access.{site}.policy")
|
||||
}
|
||||
|
||||
/// Answers `portal.access.<site>.may` for as long as the process runs,
|
||||
/// with the policy as it is at the moment of each question (it is
|
||||
/// swapped on content reload). A malformed question gets a refusal
|
||||
/// with the parse error as its reason, never silence.
|
||||
pub async fn serve(
|
||||
nats: async_nats::Client,
|
||||
site: String,
|
||||
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
|
||||
state: Option<crate::server::AppState>,
|
||||
) {
|
||||
use futures::StreamExt;
|
||||
let subject = subject_for(&site);
|
||||
let mut sub = match nats.subscribe(subject.clone()).await {
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, %subject, "cannot serve access decisions");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(%subject, "answering access questions");
|
||||
while let Some(msg) = sub.next().await {
|
||||
let Some(reply) = msg.reply else { continue };
|
||||
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
|
||||
Ok(mut q) => {
|
||||
// A question that names a person and lists no groups is
|
||||
// asking portal what they belong to: their memberships,
|
||||
// matched on subject, phone number or address.
|
||||
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
|
||||
if groups.is_empty() && sub != "anonymous" {
|
||||
let who = crate::memberships::Identity {
|
||||
sub: Some(sub.clone()),
|
||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||
email: email.as_deref().map(|e| e.trim().to_lowercase()),
|
||||
};
|
||||
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
|
||||
}
|
||||
}
|
||||
decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context)
|
||||
}
|
||||
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
|
||||
};
|
||||
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||||
let _ = nats.publish(reply, bytes.into()).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aggregates::parse_aggregates_yaml;
|
||||
use crate::content::build_questions;
|
||||
use serde_json::json;
|
||||
|
||||
fn site() -> Policy {
|
||||
let files = vec![
|
||||
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: Apply\n action: /applied\n record_as: applicants\n - name: Unsubscribe\n self_transition: { bucket: subscribers, to: unsubscribed, label: Go }\n".to_string()),
|
||||
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
||||
("shape/[key].yaml".to_string(), "name: Shape\n".to_string()),
|
||||
("review/_section.yaml".to_string(), "qualifies: owners\n".to_string()),
|
||||
("review/index.yaml".to_string(), "name: Review\nalternatives:\n - name: Applicants\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: applicants }\n requires_group: owners\n transitions:\n - { to: invited, label: Invite }\n - { from: invited, to: active, label: Onboard }\n - name: Releases\n resource:\n public: true\n source: { kind: gitea_releases, owner: o, repo: r }\n - name: Invite a colleague\n invite: { group: owners }\n features:\n - name: \"\"\n requirements:\n - { name: name }\n - { name: email, type: email }\n".to_string()),
|
||||
];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
let aggregates = parse_aggregates_yaml("aggregates:\n - bucket: applicants\n initial: open\n states:\n open: { event: a }\n invited: { event: b }\n active: { event: c }\n transitions:\n open: [invited]\n invited: [active]\n").unwrap();
|
||||
Policy::from_content(&questions, &aggregates)
|
||||
}
|
||||
|
||||
fn user(groups: &[&str]) -> Principal {
|
||||
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_policy_validates_against_the_schema() {
|
||||
let p = site();
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_open_to_anyone_unless_qualified() {
|
||||
let p = site();
|
||||
let page = |id: &str| Resource::Page { id: id.into() };
|
||||
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
|
||||
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
|
||||
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
|
||||
let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({}));
|
||||
assert!(d.allow);
|
||||
assert_eq!(d.reasons, vec!["open:/review"]);
|
||||
// A concrete dynamic path resolves to its pattern's rule.
|
||||
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/shape/abc")));
|
||||
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/nowhere")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_follow_requires_group_or_public() {
|
||||
let p = site();
|
||||
let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() };
|
||||
let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() };
|
||||
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
|
||||
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
|
||||
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_are_exactly_the_desks_buttons() {
|
||||
let p = site();
|
||||
let bucket = Resource::Bucket { name: "applicants".into() };
|
||||
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
|
||||
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
||||
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow);
|
||||
// Right target, wrong starting state: no rule.
|
||||
assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow);
|
||||
assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
|
||||
let p = site();
|
||||
let bucket = Resource::Bucket { name: "subscribers".into() };
|
||||
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
||||
let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow);
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow);
|
||||
// A signed-in owner has no such rule: the link is the credential.
|
||||
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);
|
||||
}
|
||||
|
||||
// "On arrival" is the bucket's own first state, not the word
|
||||
// `open`. redoal's watchers arrive at `pending` and confirm
|
||||
// themselves into `open`; reading the literal made that rule
|
||||
// `open -> open`, which the engine declares nowhere, so the
|
||||
// confirm link in the mail could never have worked.
|
||||
#[test]
|
||||
fn arrival_is_wherever_the_bucket_actually_starts() {
|
||||
let files = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
let aggregates = parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let p = Policy::from_content(&questions, &aggregates);
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
assert!(
|
||||
p.rules.iter().any(|r| r.id == "self:watchers:pending>open"),
|
||||
"the rule must start where the records do: {:?}",
|
||||
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
||||
);
|
||||
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
|
||||
let bucket = Resource::Bucket { name: "watchers".into() };
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invites_and_automation() {
|
||||
let p = site();
|
||||
let owners = Resource::Group { name: "owners".into() };
|
||||
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
|
||||
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
|
||||
let bucket = Resource::Bucket { name: "applicants".into() };
|
||||
assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket));
|
||||
assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket));
|
||||
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_over_nats_is_the_same_question() {
|
||||
let p = site();
|
||||
let q: Query = serde_json::from_str(
|
||||
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
||||
let q: Query = serde_json::from_str(
|
||||
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
||||
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_refusals_and_mutations_are_worth_publishing() {
|
||||
assert!(!worth_publishing(Action::Open, true));
|
||||
assert!(worth_publishing(Action::Open, false));
|
||||
assert!(worth_publishing(Action::Transition, true));
|
||||
assert!(!worth_publishing(Action::Read, true));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,59 @@ pub struct AggregateSchema {
|
||||
/// rule has an explicit, auditable opt-out instead of a silent
|
||||
/// one, and so the next reader knows where the answers go.
|
||||
pub attended_by: Option<String>,
|
||||
/// The mail a record's arrival in a state sends (see `mail.rs`):
|
||||
/// declared on the state in aggregates.yaml, next to the event
|
||||
/// 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<String, MailSpec>,
|
||||
/// 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<String, String>,
|
||||
/// 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<String, AfterSpec>,
|
||||
}
|
||||
|
||||
/// A mail to send when a record enters a state. `to` is `submitter`
|
||||
/// (the record's `email` field, which the lint then requires the
|
||||
/// forms to collect), one fixed address, or a field of the record
|
||||
/// this one belongs to: `{notice.email}` when the form has a
|
||||
/// `type: record` field called `notice`. That is how an answer to a
|
||||
/// notice reaches whoever posted it. Subject and body are templates:
|
||||
/// `{field}` for any field of the record, `{notice.field}` for one of
|
||||
/// the record it belongs to, plus `{site}` (the instance's public
|
||||
/// URL), `{chain}` (the record id), `{email}`, `{bucket}` and
|
||||
/// `{state}`. The body is markdown; the mailer sends it as plain text
|
||||
/// with an HTML alternative.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MailSpec {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl MailSpec {
|
||||
pub fn to_submitter(&self) -> bool {
|
||||
self.to == "submitter"
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -43,6 +96,18 @@ impl AggregateSchema {
|
||||
self.event_for_state.contains_key(state)
|
||||
}
|
||||
|
||||
pub fn mail_for(&self, state: &str) -> Option<&MailSpec> {
|
||||
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)
|
||||
@@ -54,6 +119,12 @@ impl AggregateSchema {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawState {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
mail: Option<MailSpec>,
|
||||
#[serde(default)]
|
||||
grants: Option<String>,
|
||||
#[serde(default)]
|
||||
after: Option<AfterSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -123,6 +194,45 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
}
|
||||
}
|
||||
|
||||
let mut mail_for_state = HashMap::new();
|
||||
let mut grants_for_state = HashMap::new();
|
||||
let mut after_for_state = HashMap::new();
|
||||
for (state, raw_state) in raw_schema.states {
|
||||
if let Some(group) = raw_state.grants {
|
||||
if group.trim().is_empty() {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
|
||||
}
|
||||
grants_for_state.insert(state.clone(), group);
|
||||
}
|
||||
if let Some(after) = raw_state.after {
|
||||
if after.days == 0 {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: after.days must be at least one");
|
||||
}
|
||||
// A clock may only make a move a person could have
|
||||
// made, so it is held to the same graph.
|
||||
if !raw_schema.transitions.get(&state).is_some_and(|tos| tos.contains(&after.to)) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it",
|
||||
after.to
|
||||
);
|
||||
}
|
||||
after_for_state.insert(state.clone(), after);
|
||||
}
|
||||
if let Some(mail) = raw_state.mail {
|
||||
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
||||
}
|
||||
let through_a_record = crate::mail::parent_of(&mail.to).is_some();
|
||||
if !mail.to_submitter() && !through_a_record && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}",
|
||||
mail.to
|
||||
);
|
||||
}
|
||||
mail_for_state.insert(state, mail);
|
||||
}
|
||||
}
|
||||
|
||||
out.insert(
|
||||
bucket.clone(),
|
||||
AggregateSchema {
|
||||
@@ -132,6 +242,9 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
state_for_event,
|
||||
transitions: raw_schema.transitions,
|
||||
attended_by: raw_schema.attended_by,
|
||||
mail_for_state,
|
||||
grants_for_state,
|
||||
after_for_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -349,6 +462,37 @@ aggregates:
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_may_carry_the_mail_its_arrival_sends() {
|
||||
let schemas = parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: reports
|
||||
initial: open
|
||||
states:
|
||||
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
|
||||
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
|
||||
dropped: { event: dropped }
|
||||
transitions:
|
||||
open: [fixed, dropped]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = &schemas["reports"];
|
||||
assert!(s.mail_for("open").unwrap().to_submitter());
|
||||
assert_eq!(s.mail_for("fixed").unwrap().to, "board@example.no");
|
||||
assert!(s.mail_for("dropped").is_none());
|
||||
|
||||
for bad in [
|
||||
"mail: { to: submitter, subject: \"\", body: b }",
|
||||
"mail: { to: \"the board\", subject: s, body: b }",
|
||||
"mail: { to: submitter, subject: s, body: b, cc: x }",
|
||||
] {
|
||||
let yaml = format!("aggregates:\n - bucket: r\n initial: open\n states:\n open: {{ event: received, {bad} }}\n");
|
||||
assert!(parse_aggregates_yaml(&yaml).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
fn event(seq: u64, event_type: &str) -> Event {
|
||||
Event {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
|
||||
@@ -84,6 +84,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
EVENT_ALTERNATIVE,
|
||||
&responses,
|
||||
now_ms,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(question = %q.id, "event record created");
|
||||
@@ -137,6 +138,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
alternative: SUMMARY_DUE_LABEL.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: 0,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
+84
-15
@@ -24,10 +24,34 @@ pub struct Answer {
|
||||
pub responses: serde_json::Value,
|
||||
pub state: String,
|
||||
pub submitted_ms: i64,
|
||||
/// The storyline the record was sent from, so the mail it gets
|
||||
/// tells the same one (see `content/variants.rs`).
|
||||
#[serde(default)]
|
||||
pub storyline: u32,
|
||||
#[serde(default)]
|
||||
pub decided_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub decided_by: Option<String>,
|
||||
/// Replies that came back by mail, oldest first (see
|
||||
/// `mail::note_for`). A note never moves a record; it is the
|
||||
/// conversation beside the case, which is the thing a desk could
|
||||
/// not see before.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<Note>,
|
||||
}
|
||||
|
||||
/// One reply on a record. It lives here rather than with the mail
|
||||
/// code because it is part of what a record is: the desks render it,
|
||||
/// and the browser has to know its shape even though it will never
|
||||
/// speak to a mail server.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
pub received_ms: i64,
|
||||
}
|
||||
|
||||
/// Writes a submitted answer into `bucket`, creating it on first use -
|
||||
@@ -44,6 +68,7 @@ pub async fn store_answer(
|
||||
alternative: &str,
|
||||
responses: &serde_json::Value,
|
||||
submitted_ms: i64,
|
||||
storyline: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
let store = match js.get_key_value(bucket).await {
|
||||
Ok(store) => store,
|
||||
@@ -69,7 +94,9 @@ pub async fn store_answer(
|
||||
responses: responses.clone(),
|
||||
state: initial_state,
|
||||
submitted_ms,
|
||||
storyline,
|
||||
decided_ms: None,
|
||||
notes: Vec::new(),
|
||||
decided_by: None,
|
||||
};
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
@@ -111,9 +138,11 @@ pub async fn transition_answers(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let storyline = crate::server::storyline::current().await;
|
||||
let alt = state
|
||||
.questions
|
||||
.load()
|
||||
.at(storyline)
|
||||
.get(&question_id)
|
||||
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
||||
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
||||
@@ -146,7 +175,7 @@ pub async fn transition_answers(
|
||||
/// transition retries. Keeps "zap the whole event stream" a safe
|
||||
/// operation instead of one that strands every pre-wipe record.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn transition_or_reseed(
|
||||
pub(crate) async fn transition_or_reseed(
|
||||
js: &async_nats::jetstream::Context,
|
||||
schema: &crate::aggregates::AggregateSchema,
|
||||
item_id: &str,
|
||||
@@ -195,14 +224,6 @@ async fn apply_transition(
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||
let group = resource
|
||||
.requires_group
|
||||
.as_deref()
|
||||
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
|
||||
if !user.groups.iter().any(|g| g == group) {
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
}
|
||||
|
||||
let bucket = resource
|
||||
.bucket()
|
||||
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
|
||||
@@ -232,6 +253,19 @@ async fn apply_transition(
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
// The policy holds exactly the buttons this desk offers to this
|
||||
// group, keyed on (from, to); it is the one check.
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::from_user(Some(user)),
|
||||
crate::access::Action::Transition,
|
||||
&crate::access::Resource::Bucket { name: bucket.to_string() },
|
||||
serde_json::json!({ "from": answer.state, "to": item.to }),
|
||||
);
|
||||
if !decision.allow {
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
let decision_payload_for_event =
|
||||
@@ -263,6 +297,12 @@ async fn apply_transition(
|
||||
.put(&item.item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses, answer.storyline).await;
|
||||
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||
// An expired invite sent again: the move back is the asking.
|
||||
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
||||
crate::people::on_reopened(state, &item.item_id).await;
|
||||
}
|
||||
|
||||
// The decision extends the DAG as a child of the answer's own
|
||||
// submission hash, published like any other answer.
|
||||
@@ -275,6 +315,7 @@ async fn apply_transition(
|
||||
alternative: transition.label,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
.await
|
||||
@@ -329,12 +370,33 @@ pub async fn self_transition_answer(
|
||||
let mut answer: Answer =
|
||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let stored_email = answer.responses.get("email").and_then(|v| v.as_str());
|
||||
if stored_email != Some(email.as_str()) {
|
||||
return Err(ServerFnError::new("not found"));
|
||||
}
|
||||
if answer.state != OPEN_STATE {
|
||||
return Err(ServerFnError::new("already processed"));
|
||||
// The link is the credential and the email its second factor; the
|
||||
// policy compares them and knows the one move a submitter may make.
|
||||
// Refusal reads "not found" so the endpoint cannot be used to probe
|
||||
// which records or emails exist.
|
||||
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
|
||||
crate::access::Action::SelfTransition,
|
||||
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
|
||||
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
||||
);
|
||||
if !decision.allow {
|
||||
// Told apart by which half of the rule failed. If the record is
|
||||
// somewhere this button may be fired from, the email was wrong,
|
||||
// and the answer must not confirm that the record exists. If it
|
||||
// is not, the case has moved past this move and saying so is no
|
||||
// longer a way to learn anything about it.
|
||||
let initial = state
|
||||
.aggregates
|
||||
.load()
|
||||
.get(&self_transition.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| OPEN_STATE.to_string());
|
||||
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
||||
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
@@ -364,6 +426,8 @@ pub async fn self_transition_answer(
|
||||
.put(&item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses, answer.storyline).await;
|
||||
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
||||
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
@@ -374,6 +438,7 @@ pub async fn self_transition_answer(
|
||||
alternative: self_transition.label,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
.await
|
||||
@@ -401,8 +466,10 @@ mod tests {
|
||||
responses: serde_json::json!({"name": "Ada"}),
|
||||
state: OPEN_STATE.to_string(),
|
||||
submitted_ms: 1_700_000_000_000,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
@@ -411,8 +478,10 @@ mod tests {
|
||||
responses: serde_json::json!({"name": "Grace"}),
|
||||
state: "invited".to_string(),
|
||||
submitted_ms: 1_700_000_001_000,
|
||||
storyline: 0,
|
||||
decided_ms: Some(1_700_000_002_000),
|
||||
decided_by: Some("bl".to_string()),
|
||||
notes: Vec::new(),
|
||||
},
|
||||
];
|
||||
let value = serde_json::Value::Array(
|
||||
|
||||
+784
-46
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,14 @@ pub struct User {
|
||||
/// live, so a group change only takes effect on the next login.
|
||||
#[serde(default)]
|
||||
pub groups: Vec<String>,
|
||||
/// The phone number the provider verified (`phone_number`, from
|
||||
/// userinfo), when it gives one: what a membership is keyed by first.
|
||||
#[serde(default)]
|
||||
pub phone: Option<String>,
|
||||
/// The address Kanidm holds for them (the `email` claim), so a page
|
||||
/// can know it is talking to the person it names as responsible.
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
pub const SESSION_USER_KEY: &str = "user";
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
//! Headless schema-check binary - loads content and `aggregates.yaml`
|
||||
//! (from a Gitea repo URL or a local directory) and validates them
|
||||
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
|
||||
//! do, with no NATS, OIDC, web server, or JetStream connection
|
||||
//! involved. Built once by portal's own deploy workflow and run
|
||||
//! directly by `questions`' own CI (same bare-metal runner/host,
|
||||
//! published to a stable path - no artifact download needed), rather
|
||||
//! than compiled there - keeps that repo's CI coupling to "run a
|
||||
//! static binary," not "build a Rust workspace."
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use portal::{aggregates, content};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mut repo: Option<String> = None;
|
||||
let mut branch = "main".to_string();
|
||||
let mut subdir = "questions".to_string();
|
||||
let mut path: Option<String> = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--repo" => repo = args.next(),
|
||||
"--branch" => branch = args.next().unwrap_or(branch),
|
||||
"--subdir" => subdir = args.next().unwrap_or(subdir),
|
||||
"--path" => path = args.next(),
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (questions, aggregates_map, site) = if let Some(dir) = path {
|
||||
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?)
|
||||
} else if let Some(repo_url) = repo {
|
||||
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
|
||||
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
|
||||
// load_site_from_gitea already validates; a missing file is
|
||||
// the default config, same as at portal boot.
|
||||
let site = content::load_site_from_gitea(&repo_url, &branch).await?;
|
||||
(questions, aggregates_map, site)
|
||||
} else {
|
||||
eprintln!(
|
||||
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
|
||||
);
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
if let Err(e) = site.validate() {
|
||||
eprintln!("FAIL: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
|
||||
match content::validate_questions(&questions, &aggregates_map) {
|
||||
Ok(()) => {
|
||||
// The runtime's own event desk: a repo announcing pages
|
||||
// (Question.event) should read portal_events somewhere, or
|
||||
// "post what happened" tasks pile up unseen. Warn, don't
|
||||
// fail - the runtime creates the records either way.
|
||||
if questions.values().any(|q| q.event.is_some())
|
||||
&& !content::bucket_is_read(&questions, content::EVENTS_BUCKET)
|
||||
{
|
||||
eprintln!(
|
||||
"WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted",
|
||||
content::EVENTS_BUCKET
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"OK: {} question(s), {} aggregate(s) valid",
|
||||
questions.len(),
|
||||
aggregates_map.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The offline counterpart to `content::load_site_from_gitea` -
|
||||
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
|
||||
/// just as optional locally: missing means default branding.
|
||||
fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
|
||||
let site_path = std::path::Path::new(dir)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("site.yaml");
|
||||
if !site_path.exists() {
|
||||
return Ok(content::SiteConfig::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&site_path)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
|
||||
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
||||
}
|
||||
|
||||
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
||||
/// `aggregates.yaml` lives at the repo root, one level up from the
|
||||
/// pages directory `--path` names, so `dir`'s parent is where it's
|
||||
/// looked for. Missing entirely is not an error here (unlike
|
||||
/// `--repo` mode, where `aggregates.yaml` is required) - a local
|
||||
/// checkout being linted may not have one, and every declared
|
||||
/// transition still gets checked against whatever *is* found; an
|
||||
/// empty map just means nothing is checked.
|
||||
fn load_aggregates_from_dir(
|
||||
dir: &str,
|
||||
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
|
||||
let aggregates_path = std::path::Path::new(dir)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("aggregates.yaml");
|
||||
if !aggregates_path.exists() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&aggregates_path)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
|
||||
aggregates::parse_aggregates_yaml(&raw)
|
||||
}
|
||||
|
||||
/// The offline counterpart to `content::load_questions_from_gitea` -
|
||||
/// the same recursive tree walk and `content::build_questions`
|
||||
/// pipeline (derived ids, relative refs, sections, followup
|
||||
/// inference), just reading a local checkout instead of Gitea's API,
|
||||
/// for linting a branch that hasn't been pushed yet.
|
||||
fn load_from_dir(
|
||||
dir: &str,
|
||||
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
|
||||
let base = std::path::Path::new(dir);
|
||||
let mut files = Vec::new();
|
||||
collect_yaml(base, base, &mut files)?;
|
||||
content::build_questions(&files)
|
||||
}
|
||||
|
||||
fn collect_yaml(
|
||||
base: &std::path::Path,
|
||||
dir: &std::path::Path,
|
||||
out: &mut Vec<(String, String)>,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
collect_yaml(base, &path, out)?;
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
||||
let rel = path
|
||||
.strip_prefix(base)
|
||||
.expect("walked paths sit under their base")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
|
||||
out.push((rel, raw));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -20,6 +20,11 @@ pub const CHAIN_BUCKET: &str = "portal_chains";
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct ChainNode {
|
||||
pub question_id: String,
|
||||
/// Which alternative was answered - what decides where the visitor
|
||||
/// was sent next, and so which followup the chain has earned. Nodes
|
||||
/// indexed before this field existed carry `None`.
|
||||
#[serde(default)]
|
||||
pub alternative: Option<String>,
|
||||
pub timestamp_ms: i64,
|
||||
}
|
||||
|
||||
@@ -30,6 +35,7 @@ pub async fn record_node(
|
||||
js: &async_nats::jetstream::Context,
|
||||
chain_hash: &str,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
timestamp_ms: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
let store = match js.get_key_value(CHAIN_BUCKET).await {
|
||||
@@ -44,6 +50,7 @@ pub async fn record_node(
|
||||
};
|
||||
let node = ChainNode {
|
||||
question_id: question_id.to_string(),
|
||||
alternative: Some(alternative.to_string()),
|
||||
timestamp_ms,
|
||||
};
|
||||
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Loading a content repo from Gitea, and the NATS-signalled reload
|
||||
//! that hot-swaps it (last-good content is kept on any failure). Every
|
||||
//! fetch goes through `crate::http`: bounded in time and size.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Fetches and parses `site.yaml` from the content repo root. A fetch
|
||||
/// failure (typically 404 - the file is optional) yields the default
|
||||
/// config; a file that exists but doesn't parse or validate is a real
|
||||
/// error, surfaced at boot rather than papered over.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Storylines<SiteConfig>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => {
|
||||
tracing::info!("no site.yaml in content repo ({e}), using default branding");
|
||||
return Ok(Storylines::one(SiteConfig::default()));
|
||||
}
|
||||
};
|
||||
// The invite mail's words may be several (see `variants.rs`).
|
||||
let files = vec![("site.yaml".to_string(), raw)];
|
||||
let arity = super::variants::arity(&files)?;
|
||||
let mut each = Vec::with_capacity(arity);
|
||||
for n in 0..arity {
|
||||
let site: SiteConfig =
|
||||
serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
||||
site.validate()?;
|
||||
each.push(site);
|
||||
}
|
||||
Ok(Storylines { each })
|
||||
}
|
||||
|
||||
/// Extracts `scheme://host` from a repo's normal browser URL - the
|
||||
/// Gitea API base every helper in this module builds requests against.
|
||||
#[cfg(feature = "ssr")]
|
||||
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
|
||||
/// `site_asset_handler` proxies content-shipped files from.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
|
||||
let parsed = url::Url::parse(repo_url)
|
||||
.map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?;
|
||||
Ok(format!(
|
||||
"{}://{}",
|
||||
parsed.scheme(),
|
||||
parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))?
|
||||
))
|
||||
}
|
||||
|
||||
/// Splits a repo's normal browser URL into `(owner, repo)` - shared by
|
||||
/// every loader in this module that needs to build a Gitea contents API
|
||||
/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> {
|
||||
let parsed = url::Url::parse(repo_url)
|
||||
.map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?;
|
||||
let mut segments = parsed
|
||||
.path_segments()
|
||||
.ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?;
|
||||
let owner = segments
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))?
|
||||
.to_string();
|
||||
let repo = segments
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))?
|
||||
.to_string();
|
||||
Ok((owner, repo))
|
||||
}
|
||||
|
||||
/// Fetches one file from a Gitea repo's contents API and returns its raw
|
||||
/// text - the single-file counterpart to `load_questions_from_gitea`'s
|
||||
/// directory-listing loop, used by `load_aggregates_from_gitea` for the
|
||||
/// one `aggregates.yaml` file at the repo root.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) async fn fetch_gitea_file(
|
||||
api_base: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
branch: &str,
|
||||
path: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}");
|
||||
let meta_text = crate::http::get_text(&meta_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))?;
|
||||
let meta: serde_json::Value = serde_json::from_str(&meta_text)
|
||||
.map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?;
|
||||
let download_url = meta
|
||||
.get("download_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?;
|
||||
crate::http::get_text(download_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Fetches and parses `aggregates.yaml` from the repo root (sibling to
|
||||
/// the pages `subdir`, so the "every yaml under subdir is a page"
|
||||
/// convention needs no exclusion). A malformed file fails here, before
|
||||
/// ever reaching `AppState`.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn load_aggregates_from_gitea(
|
||||
repo_url: &str,
|
||||
branch: &str,
|
||||
) -> anyhow::Result<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
||||
// A mail's subject and body may be several (see `variants.rs`);
|
||||
// the runtime's own buckets are added to every storyline.
|
||||
let files = vec![("aggregates.yaml".to_string(), raw)];
|
||||
let arity = super::variants::arity(&files)?;
|
||||
let each = (0..arity)
|
||||
.map(|n| crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1).map(with_builtin_aggregates))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok(Storylines { each })
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn load_questions_from_gitea(
|
||||
repo_url: &str,
|
||||
branch: &str,
|
||||
subdir: &str,
|
||||
) -> anyhow::Result<Storylines<std::collections::HashMap<String, Question>>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
|
||||
// One recursive git-trees call for the whole repo instead of a
|
||||
// contents listing per directory - the tree IS the router now, so
|
||||
// nested files matter.
|
||||
let tree_url =
|
||||
format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true");
|
||||
let listing = crate::http::get_text(&tree_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?;
|
||||
let tree: serde_json::Value = serde_json::from_str(&listing)
|
||||
.map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?;
|
||||
if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) {
|
||||
anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large");
|
||||
}
|
||||
let prefix = format!("{subdir}/");
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in tree
|
||||
.get("tree")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if entry.get("type").and_then(|v| v.as_str()) != Some("blob")
|
||||
|| !path.starts_with(&prefix)
|
||||
|| !path.ends_with(".yaml")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Brackets (dynamic pages like `[record].yaml`) must be
|
||||
// percent-encoded in the raw URL's path.
|
||||
let encoded = path.replace('[', "%5B").replace(']', "%5D");
|
||||
let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}");
|
||||
let raw = crate::http::get_text(&raw_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
|
||||
files.push((path[prefix.len()..].to_string(), raw));
|
||||
}
|
||||
build_storylines(&files)
|
||||
}
|
||||
|
||||
/// Every storyline's arity is the site's or one: a mail told in two
|
||||
/// voices on a site whose pages are told in three has a hole in it.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
|
||||
let arity = questions.max(aggregates).max(site);
|
||||
for (what, n) in [("questions", questions), ("aggregates.yaml", aggregates), ("site.yaml", site)] {
|
||||
if n != 1 && n != arity {
|
||||
anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
|
||||
}
|
||||
}
|
||||
Ok(arity)
|
||||
}
|
||||
|
||||
/// Published by the content repo's own CI (after it lints a push) to
|
||||
/// tell every running instance to pick up the change - a plain fire
|
||||
/// and forget NATS publish, no payload, matching `events.rs`'s
|
||||
/// `ANSWERS_SUBJECT` pattern.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
|
||||
|
||||
/// Published by `watch_for_reload` once new content is in place, for
|
||||
/// whatever acts on what the content says rather than serving it: the
|
||||
/// accounts it mandates (`seed.rs`). No payload; the content is read
|
||||
/// from where it always is.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const CONTENT_RELOADED_SUBJECT: &str = "portal.content.reloaded";
|
||||
|
||||
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
|
||||
/// atomically swaps both `questions` and `aggregates` on every
|
||||
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
|
||||
/// after both have loaded and validated successfully, so a reader never
|
||||
/// sees pages that reference a half-updated state graph. A fetch/parse
|
||||
/// failure logs and keeps serving the last-good content rather than
|
||||
/// clearing it - a bad push to the content repo (which should already
|
||||
/// have been caught by its own lint step) doesn't take the site down.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn watch_for_reload(
|
||||
nats: async_nats::Client,
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
subdir: String,
|
||||
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
|
||||
aggregates: std::sync::Arc<
|
||||
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>>,
|
||||
>,
|
||||
site: std::sync::Arc<arc_swap::ArcSwap<Storylines<SiteConfig>>>,
|
||||
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
|
||||
) {
|
||||
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to subscribe to content reload subject");
|
||||
return;
|
||||
}
|
||||
};
|
||||
use futures::StreamExt;
|
||||
while sub.next().await.is_some() {
|
||||
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Branding swaps with the same all-or-nothing rule: a
|
||||
// present-but-broken site.yaml keeps last-good everything
|
||||
// (load_site distinguishes "absent" - a normal default - from
|
||||
// "present but invalid").
|
||||
let loaded_site = match load_site_from_gitea(&repo_url, &branch).await {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
||||
Ok(loaded) => {
|
||||
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) {
|
||||
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
|
||||
continue;
|
||||
}
|
||||
let count = loaded.len();
|
||||
// The policy is compiled from the content it gates, so
|
||||
// it swaps in the same breath.
|
||||
access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates)));
|
||||
questions.store(std::sync::Arc::new(loaded));
|
||||
aggregates.store(std::sync::Arc::new(loaded_aggregates));
|
||||
site.store(std::sync::Arc::new(loaded_site));
|
||||
tracing::info!(count, "reloaded content");
|
||||
let _ = nats.publish(CONTENT_RELOADED_SUBJECT, "".into()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "content reload failed, keeping last-good content");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The Axum routes that expose content and buckets outside the Leptos
|
||||
//! app: content-shipped assets proxied same-origin, repo info for the
|
||||
//! showcase, and the automation read API over KV buckets.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A repo-relative asset path portal is willing to proxy from the
|
||||
/// content repo: plain segments only - no traversal, no leading slash,
|
||||
/// no scheme - so `/site/<path>` can only ever reach the content repo.
|
||||
pub fn is_safe_site_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& !path.starts_with('/')
|
||||
&& path.split('/').all(|seg| {
|
||||
!seg.is_empty()
|
||||
&& seg != ".."
|
||||
&& seg
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
})
|
||||
}
|
||||
|
||||
/// A public Gitea repo's basic info - what `resolve_gitea_repo` returns
|
||||
/// for the prosekit editor's repo-embed node to render as a static
|
||||
/// card, baked in once at embed time rather than re-fetched by every
|
||||
/// reader (an emailed newsletter can't run JS to do that anyway).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GiteaRepoInfo {
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub description: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[cfg(feature = "ssr")]
|
||||
pub struct GiteaRepoQuery {
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
/// `GET /site/{*path}` - serves a file from the content repo's default
|
||||
/// branch same-origin, so content-shipped assets (the hero module,
|
||||
/// its stylesheet) are importable by the page: Gitea's raw endpoint
|
||||
/// sends no CORS headers, and an ES module import across origins is
|
||||
/// refused without them. Path is validated to plain segments
|
||||
/// (`is_safe_site_path`), the response revalidates like /pkg
|
||||
/// (Cache-Control: no-cache) so a content push shows up on reload.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn site_asset_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
if !is_safe_site_path(&path) {
|
||||
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
|
||||
}
|
||||
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
|
||||
let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("site asset {path}: {e}");
|
||||
return (StatusCode::NOT_FOUND, "no such site asset").into_response();
|
||||
}
|
||||
};
|
||||
let bytes = match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
|
||||
Ok(b) => axum::body::Bytes::from(b),
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
|
||||
};
|
||||
let mime = match path.rsplit('.').next() {
|
||||
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("json") => "application/json",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
// Photographs are large and do not change under a name, so a
|
||||
// browser may keep them for an hour; everything else revalidates,
|
||||
// so a content push shows on reload.
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Looks up `owner/repo` on the same Gitea instance content is loaded
|
||||
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
|
||||
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
|
||||
/// caller here is the prosekit editor's own paste-to-embed rule (see
|
||||
/// `prosekit-editor.js`) doing a plain `fetch`, the same reason
|
||||
/// `/upload` (`src/upload.rs`) is a raw handler rather than a `#[server]`
|
||||
/// fn. Keeping this server-resolved (rather than having the browser
|
||||
/// call Gitea's API directly) is consistent with every other backing
|
||||
/// store in this app, and sidesteps needing a CORS allowance on Gitea's
|
||||
/// side just for this. No auth, same as content loading - resolves
|
||||
/// only what's already public.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn gitea_repo_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Query(query): axum::extract::Query<GiteaRepoQuery>,
|
||||
) -> Result<axum::Json<GiteaRepoInfo>, (axum::http::StatusCode, String)> {
|
||||
let is_safe_segment = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
};
|
||||
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
|
||||
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
|
||||
}
|
||||
let GiteaRepoQuery { owner, repo } = query;
|
||||
|
||||
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
|
||||
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("parsing repo info from {api_url}: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(axum::Json(GiteaRepoInfo {
|
||||
owner: owner.clone(),
|
||||
repo: repo.clone(),
|
||||
description: json
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
url: json
|
||||
.get("html_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("{}/{owner}/{repo}", state.gitea_base)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lists every entry in a NATS KV bucket as JSON - a raw Axum handler
|
||||
/// (mounted at `/automation/kv/{bucket}` in `main.rs`), for backing
|
||||
/// automations (e.g. an n8n workflow reading `portal_subscribers` to
|
||||
/// send a newsletter) that aren't a signed-in browser session and so
|
||||
/// can't go through `resource::get_resource`'s Kanidm-group check.
|
||||
/// Gated by a single shared bearer token (`AUTOMATION_READ_TOKEN`) -
|
||||
/// deliberately not per-caller/per-bucket scoped, since every current
|
||||
/// caller is a trusted internal automation, not a third party. Read
|
||||
/// only, matching `get_resource`'s own "reads can be public/shared,
|
||||
/// mutations always need real identity" split - nothing here writes.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn automation_kv_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(bucket): axum::extract::Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<axum::Json<serde_json::Value>, (axum::http::StatusCode, String)> {
|
||||
let expected = std::env::var("AUTOMATION_READ_TOKEN").unwrap_or_default();
|
||||
let presented = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if expected.is_empty() || presented != expected {
|
||||
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
|
||||
}
|
||||
// The token authenticates the automation service; what it may read
|
||||
// is the policy's to say, like every other principal.
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::Service { name: "automation".to_string() },
|
||||
crate::access::Action::AutomationRead,
|
||||
&crate::access::Resource::Bucket { name: bucket.clone() },
|
||||
serde_json::json!({}),
|
||||
);
|
||||
if !decision.allow {
|
||||
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
|
||||
}
|
||||
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&bucket)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let keys: Vec<String> = store
|
||||
.keys()
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?
|
||||
.try_collect()
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
let mut items = Vec::new();
|
||||
for key in keys {
|
||||
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
items.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Inline markdown for descriptions: links on a short allow-list of
|
||||
//! schemes, images gated and framed, with layout hints read from the
|
||||
//! image title. Never raw HTML.
|
||||
|
||||
/// Descriptions are inline markdown: `[text](https://…)` links,
|
||||
/// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is
|
||||
/// flattened (a description is one paragraph) and raw HTML in the
|
||||
/// source is dropped, so content can link out without being able to
|
||||
/// inject markup. Link targets are limited to https, mailto and
|
||||
/// site-relative paths; anything else renders as plain text.
|
||||
pub fn render_inline_markdown(text: &str) -> String {
|
||||
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
|
||||
let parser = Parser::new_ext(text, Options::empty());
|
||||
let mut in_link = 0usize;
|
||||
let mut in_dropped_image = 0usize;
|
||||
let filtered = parser.filter_map(|event| match event {
|
||||
Event::Html(_) | Event::InlineHtml(_) => None,
|
||||
Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None,
|
||||
Event::SoftBreak => Some(Event::Text(" ".into())),
|
||||
Event::Start(Tag::Link { dest_url, .. })
|
||||
if !(dest_url.starts_with("https://")
|
||||
|| dest_url.starts_with("mailto:")
|
||||
|| dest_url.starts_with("tel:")
|
||||
|| dest_url.starts_with('/')) =>
|
||||
{
|
||||
in_link += 1;
|
||||
None
|
||||
}
|
||||
Event::End(TagEnd::Link) if in_link > 0 => {
|
||||
in_link -= 1;
|
||||
None
|
||||
}
|
||||
// Images take the same gate as links: https or same-origin
|
||||
// only - no data:, no plain http. Unlike a dropped link (whose
|
||||
// label is real text worth keeping), a dropped image's alt
|
||||
// text is a caption for something that isn't there - swallow it.
|
||||
Event::Start(Tag::Image { dest_url, .. })
|
||||
if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) =>
|
||||
{
|
||||
in_dropped_image += 1;
|
||||
None
|
||||
}
|
||||
Event::End(TagEnd::Image) if in_dropped_image > 0 => {
|
||||
in_dropped_image -= 1;
|
||||
None
|
||||
}
|
||||
Event::Text(_) if in_dropped_image > 0 => None,
|
||||
other => Some(other),
|
||||
});
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, filtered);
|
||||
apply_image_hints(out.trim())
|
||||
}
|
||||
|
||||
/// Translate a markdown image's title (``)
|
||||
/// into layout: `left`/`right` float via a class, a bare number
|
||||
/// (optionally with `rem`) into a validated `max-width`. Unknown
|
||||
/// tokens are ignored; the title attribute is dropped either way.
|
||||
/// Only touches `<img>` tags in our own render output, and only ever
|
||||
/// emits a numeric max-width - no arbitrary CSS reaches the page.
|
||||
pub(crate) fn apply_image_hints(html: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = html;
|
||||
while let Some(pos) = rest.find("<img ") {
|
||||
out.push_str(&rest[..pos]);
|
||||
let after = &rest[pos..];
|
||||
let end = after.find('>').map(|e| e + 1).unwrap_or(after.len());
|
||||
out.push_str(&rewrite_img_tag(&after[..end]));
|
||||
rest = &after[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_img_tag(tag: &str) -> String {
|
||||
// Pull the title value, if any.
|
||||
let title = tag
|
||||
.find("title=\"")
|
||||
.map(|i| &tag[i + 7..])
|
||||
.and_then(|r| r.find('"').map(|e| &r[..e]))
|
||||
.unwrap_or("");
|
||||
|
||||
let mut class = String::new();
|
||||
let mut max_rem: Option<f32> = None;
|
||||
for tok in title.split_whitespace() {
|
||||
match tok {
|
||||
"left" => class = "md-float-left".into(),
|
||||
"right" => class = "md-float-right".into(),
|
||||
other => {
|
||||
if let Ok(n) = other.trim_end_matches("rem").parse::<f32>() {
|
||||
if n > 0.0 && n <= 60.0 {
|
||||
max_rem = Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the title attribute from the tag.
|
||||
let mut cleaned = tag.to_string();
|
||||
if let Some(i) = cleaned.find(" title=\"") {
|
||||
if let Some(e) = cleaned[i + 8..].find('"') {
|
||||
cleaned.replace_range(i..i + 8 + e + 1, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Inject class/style right after `<img`.
|
||||
let mut attrs = String::new();
|
||||
if !class.is_empty() {
|
||||
attrs.push_str(&format!(" class=\"{class}\""));
|
||||
}
|
||||
if let Some(n) = max_rem {
|
||||
attrs.push_str(&format!(" style=\"max-width:{n}rem\""));
|
||||
}
|
||||
if attrs.is_empty() {
|
||||
return cleaned;
|
||||
}
|
||||
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
||||
}
|
||||
|
||||
+681
-872
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
//! What a content repo must satisfy before it serves: every action
|
||||
//! lands on a page, every transition is one the state machine allows,
|
||||
//! every recorded bucket is read back somewhere, and the built-in
|
||||
//! aggregates the runtime owns are always present.
|
||||
|
||||
#[allow(unused_imports)] // the browser build sees fewer items
|
||||
use super::*;
|
||||
|
||||
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
|
||||
/// hero and `Requirement.relay` validation.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
|
||||
let parsed = url::Url::parse(relay)
|
||||
.map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?;
|
||||
if !matches!(parsed.scheme(), "ws" | "wss") {
|
||||
anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates every declared transition target (`SelfTransition.to`,
|
||||
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
|
||||
/// state graph loaded from `aggregates.yaml` - for every bucket that
|
||||
/// has one declared. A bucket with no entry in `aggregates` is left
|
||||
/// alone entirely (no validation applied): not every bucket needs to
|
||||
/// be event-sourced to keep working. Called on every content
|
||||
/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the
|
||||
/// standalone `question-lint` binary, so a YAML typo becomes a caught,
|
||||
/// logged rejection instead of a silently-accepted, later-broken
|
||||
/// string.
|
||||
/// Bucket the runtime keeps for announced pages (`Question.event`):
|
||||
/// one record per event question, `announced` while the window is
|
||||
/// open, `awaiting_summary` once it closes - the "post what happened"
|
||||
/// task a desk reading this bucket picks up - and `summarized` when an
|
||||
/// owner marks it done. Declared here, not in content, because the
|
||||
/// runtime's sweeper (`announce.rs`) is what moves the records.
|
||||
pub const EVENTS_BUCKET: &str = "portal_events";
|
||||
|
||||
/// Bucket for changes suggested by the people the content names as
|
||||
/// responsible (`app::suggest_page_edit`): a case like any other, so a
|
||||
/// suggestion is taken up, carried out or turned down by someone at a
|
||||
/// desk, and the person who made it hears which.
|
||||
pub const EDIT_SUGGESTIONS_BUCKET: &str = "portal_edit_suggestions";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
const BUILTIN_AGGREGATES_YAML: &str = "\
|
||||
aggregates:
|
||||
- bucket: portal_events
|
||||
initial: announced
|
||||
states:
|
||||
announced: { event: announced }
|
||||
awaiting_summary: { event: ended }
|
||||
summarized: { event: summarized }
|
||||
transitions:
|
||||
announced: [awaiting_summary, summarized]
|
||||
awaiting_summary: [summarized]
|
||||
- bucket: portal_invites
|
||||
attended_by: the inviting group's own desk, or the link in the invite event
|
||||
initial: open
|
||||
states:
|
||||
open: { event: invited }
|
||||
expired: { event: expired }
|
||||
done: { event: done }
|
||||
transitions:
|
||||
open: [done, expired]
|
||||
expired: [open, done]
|
||||
- bucket: portal_memberships
|
||||
attended_by: the desks that list who is in a group; portal itself moves invited to active at sign-in
|
||||
initial: invited
|
||||
states:
|
||||
invited: { event: invited }
|
||||
active: { event: active }
|
||||
removed: { event: removed }
|
||||
transitions:
|
||||
invited: [active, removed]
|
||||
active: [removed]
|
||||
removed: [invited, active]
|
||||
- bucket: portal_edit_suggestions
|
||||
attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too
|
||||
initial: open
|
||||
states:
|
||||
open: { event: suggested }
|
||||
discussed: { event: discussed }
|
||||
done: { event: done }
|
||||
declined: { event: declined }
|
||||
transitions:
|
||||
open: [discussed, done, declined]
|
||||
discussed: [done, declined]
|
||||
";
|
||||
|
||||
/// Adds the runtime's own state graphs to a content repo's. Content
|
||||
/// wins if it declares the same bucket (say, to add states).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn with_builtin_aggregates(
|
||||
mut aggregates: std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
||||
let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML)
|
||||
.expect("builtin aggregates yaml is valid");
|
||||
for (bucket, schema) in builtin {
|
||||
aggregates.entry(bucket).or_insert(schema);
|
||||
}
|
||||
aggregates
|
||||
}
|
||||
|
||||
/// True when some kv resource in the content reads `bucket` - the
|
||||
/// attended-bucket check, exposed so the lint can warn about the
|
||||
/// runtime's own `portal_events` (a warning, not a failure: a repo
|
||||
/// with no event pages has nothing to attend).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
|
||||
questions.values().any(|q| {
|
||||
q.alternatives.iter().any(|a| {
|
||||
a.features
|
||||
.iter()
|
||||
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn validate_questions(
|
||||
questions: &std::collections::HashMap<String, Question>,
|
||||
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
) -> anyhow::Result<()> {
|
||||
// A mail to the submitter needs an address to go to: every form
|
||||
// that records into that bucket has to collect a required email.
|
||||
for (bucket, schema) in aggregates {
|
||||
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
|
||||
if submitter_states.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let collects_email = alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == "email" && r.kind == "email" && !r.optional);
|
||||
if !collects_email {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email",
|
||||
question.id, alternative.name, submitter_states
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A slideshow shows something, at a height that leaves the page
|
||||
// its question: between a fifth and the whole of the screen.
|
||||
for question in questions.values() {
|
||||
if let Some(show) = &question.slideshow {
|
||||
if show.slides.is_empty() {
|
||||
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
|
||||
}
|
||||
if !(20..=100).contains(&show.max_height) {
|
||||
anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height);
|
||||
}
|
||||
for slide in &show.slides {
|
||||
let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/'));
|
||||
if !ok {
|
||||
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mail to the record this one belongs to goes through a field of
|
||||
// the form: `{notice.email}` needs a `type: record` field called
|
||||
// `notice` on every form that records into the bucket, or the mail
|
||||
// has nowhere to go.
|
||||
for (bucket, schema) in aggregates {
|
||||
for (state, spec) in &schema.mail_for_state {
|
||||
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
||||
if !has {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
||||
question.id, alternative.name, spec.to
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
|
||||
}
|
||||
if let Err(e) = event.window_ms() {
|
||||
anyhow::bail!("question {:?}: event: {e}", question.id);
|
||||
}
|
||||
}
|
||||
if let Some(place) = &question.place {
|
||||
if let Err(e) = place.validate() {
|
||||
anyhow::bail!("question {:?}: place: {e}", question.id);
|
||||
}
|
||||
}
|
||||
if let Some(target) = &question.requires_chain {
|
||||
if !questions.contains_key(target) {
|
||||
anyhow::bail!(
|
||||
"question {:?}: requires_chain {:?} does not match any declared question id",
|
||||
question.id,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
for alternative in &question.alternatives {
|
||||
// A dangling action is a literal dead end: the submit
|
||||
// button navigates to "Nothing here".
|
||||
if let Some(action) = &alternative.action {
|
||||
if action.contains('{') {
|
||||
// Placeholders are filled at submit time; the
|
||||
// shape must land on a dynamic page once filled.
|
||||
let probe = template_action(action, &serde_json::json!({}));
|
||||
let filled = {
|
||||
let mut a = action.clone();
|
||||
while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) {
|
||||
a.replace_range(i..=j, "x");
|
||||
}
|
||||
a
|
||||
};
|
||||
let _ = probe;
|
||||
// Match the pattern itself: resolve_question would
|
||||
// hand back a clone whose id is already concrete.
|
||||
let lands = questions
|
||||
.values()
|
||||
.any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some());
|
||||
if lands {
|
||||
continue;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)",
|
||||
question.id, alternative.name, action
|
||||
);
|
||||
}
|
||||
match questions.get(action) {
|
||||
None => anyhow::bail!(
|
||||
"question {:?} alternative {:?}: action {:?} does not match any declared question id",
|
||||
question.id, alternative.name, action
|
||||
),
|
||||
// A dynamic page needs a concrete segment value to
|
||||
// be a URL - a submit button can't supply one.
|
||||
Some(target) if target.is_dynamic() => anyhow::bail!(
|
||||
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question",
|
||||
question.id, alternative.name, action
|
||||
),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if let Some(invite) = &alternative.invite {
|
||||
if question.qualifies.is_none() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: an invite into {:?} must sit on a page gated by `qualifies` - that group is who may extend it",
|
||||
question.id, alternative.name, invite.group
|
||||
);
|
||||
}
|
||||
let has = |name: &str| {
|
||||
alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == name && !r.optional)
|
||||
};
|
||||
// A person is known by a phone number or an address;
|
||||
// either will do, since memberships key on the number
|
||||
// first and Kanidm on the address.
|
||||
if !has("name") || !(has("email") || has("phone")) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: an invite must collect a required `name` and a required `email` or `phone` field",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(st) = &alternative.self_transition {
|
||||
if let Some(schema) = aggregates.get(&st.bucket) {
|
||||
if !schema.has_state(&st.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
for from in &st.from_states(&schema.initial) {
|
||||
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
|
||||
// scope as the signal maps the renderer builds.
|
||||
let sibling_names: std::collections::HashSet<&str> = alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| f.requirements.iter())
|
||||
.map(|r| r.name.as_str())
|
||||
.collect();
|
||||
for feature in &alternative.features {
|
||||
for requirement in &feature.requirements {
|
||||
if requirement.kind == "select"
|
||||
&& requirement.resource.is_none()
|
||||
&& requirement.options.is_empty()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if let Some(relay) = &requirement.relay {
|
||||
if requirement.kind != "gesture" && requirement.kind != "voice" {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
|
||||
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
||||
);
|
||||
}
|
||||
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
))?;
|
||||
}
|
||||
if requirement.kind == "gesture"
|
||||
&& (requirement.resource.is_some() || requirement.bind.is_some())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if requirement.kind == "location" {
|
||||
if requirement.resource.is_some() || requirement.bind.is_some() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - the visitor puts the pin, it can't also load one from a resource or bind",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
// `value` opens the map somewhere useful. It is
|
||||
// the content author's own coordinates, so a
|
||||
// typo here is a map of the Atlantic.
|
||||
if let Some(value) = &requirement.value {
|
||||
let parts: Vec<&str> = value.split(',').map(str::trim).collect();
|
||||
let ok = (2..=3).contains(&parts.len())
|
||||
&& parts.iter().all(|p| p.parse::<f64>().is_ok())
|
||||
&& parts[0].parse::<f64>().is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
|
||||
&& parts[1].parse::<f64>().is_ok_and(|lon| (-180.0..=180.0).contains(&lon));
|
||||
if !ok {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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: <bucket>`, 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())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative",
|
||||
question.id, alternative.name, feature.name, requirement.name, bind.field
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(resource) = &feature.resource else {
|
||||
continue;
|
||||
};
|
||||
let Some(bucket) = resource.bucket() else {
|
||||
continue;
|
||||
};
|
||||
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!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, bucket
|
||||
);
|
||||
}
|
||||
if !schema.has_state(&transition.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.to, bucket
|
||||
);
|
||||
}
|
||||
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attended buckets: no publicly collected answer may land somewhere
|
||||
// nothing reads. Every `record_as` bucket must either be read back
|
||||
// by some Kv resource in this same content repo (a desk or listing)
|
||||
// or carry an explicit `attended_by:` annotation in aggregates.yaml
|
||||
// naming the automation that consumes it. This is a contract, not a
|
||||
// convention, because convention already failed once: a question
|
||||
// was dropped and its bucket - answers included - silently fell out
|
||||
// of every reader's view.
|
||||
let mut read_buckets = std::collections::HashSet::new();
|
||||
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
|
||||
if let ResourceSource::Kv { bucket } = &spec.source {
|
||||
set.insert(bucket.clone());
|
||||
}
|
||||
};
|
||||
for question in questions.values() {
|
||||
for alternative in &question.alternatives {
|
||||
for feature in &alternative.features {
|
||||
if let Some(spec) = &feature.resource {
|
||||
note_resource(spec, &mut read_buckets);
|
||||
}
|
||||
for requirement in &feature.requirements {
|
||||
if let Some(spec) = &requirement.resource {
|
||||
note_resource(spec, &mut read_buckets);
|
||||
}
|
||||
if let Some(bind) = &requirement.bind {
|
||||
note_resource(&bind.resource, &mut read_buckets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in &question.alternatives {
|
||||
if let Some(bucket) = &alternative.record_as {
|
||||
let attended = read_buckets.contains(bucket)
|
||||
|| aggregates
|
||||
.get(bucket)
|
||||
.is_some_and(|schema| schema.attended_by.is_some());
|
||||
if !attended {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Storylines: a text may be several, and `[n]` runs through the site.
|
||||
//!
|
||||
//! Anywhere content says something to a person - a page's `name` and
|
||||
//! `description`, an alternative's, a feature's, a `label`, a
|
||||
//! `placeholder`, a mail's `subject` and `body`, a list's `empty` line,
|
||||
//! the `consequence` buttons and `encouragements` - it may give a list
|
||||
//! instead of one string:
|
||||
//!
|
||||
//! ```yaml
|
||||
//! description:
|
||||
//! - Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
|
||||
//! - Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
|
||||
//! ```
|
||||
//!
|
||||
//! Every such list has the same length, the site's *arity*, and a
|
||||
//! visitor is given one index for the whole site (a cookie, see
|
||||
//! `main.rs`), so they read one storyline from the front door to the
|
||||
//! mail they get. A single string is every storyline. What a visitor
|
||||
//! did is recorded with the index (`AnswerSubmitted.variant`), so the
|
||||
//! storylines can be compared on what people actually did, and nothing
|
||||
//! else about them.
|
||||
//!
|
||||
//! This is done to the YAML before the structs see it: the files are
|
||||
//! expanded once per storyline, and everything downstream keeps its one
|
||||
//! string per field. Keys that name things to the machine - a field's
|
||||
//! `name`, a select's `options`, states, groups, paths - never vary.
|
||||
//!
|
||||
//! The lint holds every list to the arity, so a storyline cannot have
|
||||
//! a hole in it.
|
||||
|
||||
use serde_yaml::Value;
|
||||
|
||||
/// Keys whose value is one string to a person.
|
||||
const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"];
|
||||
/// Keys whose value is a list of strings to a person; their storylines
|
||||
/// are a list of lists.
|
||||
const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
|
||||
|
||||
/// Where in a document a key is not text but an identifier: a
|
||||
/// requirement's `name` is the stored field, never varied.
|
||||
fn is_identifier(path: &[String], key: &str) -> bool {
|
||||
// `path` ends with the key itself, then the item's index, then the
|
||||
// list it is in.
|
||||
key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements")
|
||||
}
|
||||
|
||||
fn is_strings(seq: &[Value]) -> bool {
|
||||
!seq.is_empty() && seq.iter().all(|v| v.is_string())
|
||||
}
|
||||
|
||||
fn is_lists_of_strings(seq: &[Value]) -> bool {
|
||||
!seq.is_empty() && seq.iter().all(|v| v.as_sequence().is_some_and(|s| s.iter().all(|x| x.is_string())))
|
||||
}
|
||||
|
||||
/// The storyline counts found in a document, with where each was.
|
||||
fn counts(value: &Value, path: &mut Vec<String>, out: &mut Vec<(String, usize)>) {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
for (k, v) in map {
|
||||
let key = k.as_str().unwrap_or_default().to_string();
|
||||
path.push(key.clone());
|
||||
let variants = match v {
|
||||
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq.len()),
|
||||
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq.len()),
|
||||
_ => None,
|
||||
};
|
||||
match variants {
|
||||
Some(n) => out.push((path.join("."), n)),
|
||||
None => counts(v, path, out),
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
for (i, v) in seq.iter().enumerate() {
|
||||
path.push(i.to_string());
|
||||
counts(v, path, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One storyline of a document: every list of texts becomes its `n`th.
|
||||
fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
for (k, v) in map.iter_mut() {
|
||||
let key = k.as_str().unwrap_or_default().to_string();
|
||||
path.push(key.clone());
|
||||
let chosen = match v {
|
||||
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
|
||||
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
|
||||
_ => None,
|
||||
};
|
||||
match chosen {
|
||||
Some(c) => *v = c,
|
||||
None => pick(v, path, n),
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
for (i, v) in seq.iter_mut().enumerate() {
|
||||
path.push(i.to_string());
|
||||
pick(v, path, n);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many storylines these files tell: the length every list of
|
||||
/// texts has, or 1 when there is none. A list of another length is an
|
||||
/// error that names it.
|
||||
pub fn arity(files: &[(String, String)]) -> anyhow::Result<usize> {
|
||||
let mut found: Vec<(String, String, usize)> = Vec::new();
|
||||
for (rel, raw) in files {
|
||||
let Ok(value) = serde_yaml::from_str::<Value>(raw) else { continue };
|
||||
let mut out = Vec::new();
|
||||
counts(&value, &mut Vec::new(), &mut out);
|
||||
found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n)));
|
||||
}
|
||||
let arity = found.iter().map(|(_, _, n)| *n).max().unwrap_or(1);
|
||||
if let Some((rel, p, n)) = found.iter().find(|(_, _, n)| *n != arity) {
|
||||
anyhow::bail!("{rel}: {p} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
|
||||
}
|
||||
Ok(arity.max(1))
|
||||
}
|
||||
|
||||
/// The files as storyline `n` tells them.
|
||||
pub fn storyline(files: &[(String, String)], n: usize) -> Vec<(String, String)> {
|
||||
files
|
||||
.iter()
|
||||
.map(|(rel, raw)| {
|
||||
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else { return (rel.clone(), raw.clone()) };
|
||||
let mut out = Vec::new();
|
||||
counts(&value, &mut Vec::new(), &mut out);
|
||||
if out.is_empty() {
|
||||
return (rel.clone(), raw.clone());
|
||||
}
|
||||
pick(&mut value, &mut Vec::new(), n);
|
||||
(rel.clone(), serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One thing per storyline, and the first as what the machinery reads
|
||||
/// when nobody in particular is asking: a seed, a lint, a desk sweep.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Storylines<T> {
|
||||
pub each: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> Storylines<T> {
|
||||
pub fn one(item: T) -> Self {
|
||||
Storylines { each: vec![item] }
|
||||
}
|
||||
|
||||
pub fn arity(&self) -> usize {
|
||||
self.each.len().max(1)
|
||||
}
|
||||
|
||||
/// Storyline `n`, or the last for an index from a site that told
|
||||
/// more storylines than this one does.
|
||||
pub fn at(&self, n: usize) -> &T {
|
||||
&self.each[n.min(self.each.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Storylines<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &T {
|
||||
&self.each[0]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PAGE: &str = "name: [Vil du hjelpe til?, Trenger naboene deg?]\ndescription: Alle trenger flere hender.\nalternatives:\n - name: [Bli med, Meld deg]\n description: [Si hva du kan., Si hva du har lyst på.]\n consequence: [[Send], [Meld meg]]\n features:\n - name: Skjema\n requirements:\n - name: [navn]\n label: [Navn, Hva heter du?]\n - name: hva\n type: select\n options: [Dugnad, Komité]\n";
|
||||
|
||||
#[test]
|
||||
fn every_list_of_texts_is_a_storyline_and_identifiers_are_not() {
|
||||
let files = vec![("index.yaml".to_string(), PAGE.to_string())];
|
||||
assert_eq!(arity(&files).unwrap(), 2);
|
||||
let second = storyline(&files, 1);
|
||||
let v: Value = serde_yaml::from_str(&second[0].1).unwrap();
|
||||
assert_eq!(v["name"], "Trenger naboene deg?");
|
||||
assert_eq!(v["description"], "Alle trenger flere hender.");
|
||||
assert_eq!(v["alternatives"][0]["name"], "Meld deg");
|
||||
assert_eq!(v["alternatives"][0]["consequence"], serde_yaml::to_value(vec!["Meld meg"]).unwrap());
|
||||
// A requirement's name is the stored field: left as written.
|
||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["name"], serde_yaml::to_value(vec!["navn"]).unwrap());
|
||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["label"], "Hva heter du?");
|
||||
// Options are values people choose from and are stored: untouched.
|
||||
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][1]["options"], serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused() {
|
||||
let plain = vec![("index.yaml".to_string(), "name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string())];
|
||||
assert_eq!(arity(&plain).unwrap(), 1);
|
||||
assert_eq!(storyline(&plain, 0)[0].1, plain[0].1);
|
||||
let uneven = vec![
|
||||
("index.yaml".to_string(), PAGE.to_string()),
|
||||
("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()),
|
||||
];
|
||||
let err = arity(&uneven).unwrap_err().to_string();
|
||||
assert!(err.contains("b.yaml: name tells 3") && err.contains("tells 2") || err.contains("tells 3"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_storyline_is_what_the_machinery_reads() {
|
||||
let s = Storylines { each: vec!["a", "b"] };
|
||||
assert_eq!(*s, "a");
|
||||
assert_eq!(*s.at(1), "b");
|
||||
assert_eq!(*s.at(7), "b");
|
||||
assert_eq!(s.arity(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! 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<String> {
|
||||
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<String> = {
|
||||
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::<Answer>(&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, answer.storyline).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,
|
||||
storyline: answer.storyline,
|
||||
},
|
||||
)
|
||||
.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![],
|
||||
storyline: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ pub struct AnswerSubmitted {
|
||||
pub alternative: String,
|
||||
pub responses: serde_json::Value,
|
||||
pub timestamp_ms: i64,
|
||||
/// The storyline the person read (see `content/variants.rs`): what
|
||||
/// the storylines are compared on, and nothing else about them.
|
||||
#[serde(default)]
|
||||
pub storyline: u32,
|
||||
}
|
||||
|
||||
pub const ANSWERS_SUBJECT: &str = "portal.answers.submitted";
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
//! The one outbound HTTP client, bounded. Every server-side fetch -
|
||||
//! Gitea content at boot and on reload, the raw-asset proxy, the
|
||||
//! repo-info endpoint, and the content-declared `url` resource - goes
|
||||
//! through here, so a slow or oversized upstream can cost a request at
|
||||
//! most `TIMEOUT` and `cap` bytes, never a thread or the heap. Before
|
||||
//! this module each call site built its own client with no timeout and
|
||||
//! read whole bodies with `.text()`.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use openidconnect::reqwest;
|
||||
|
||||
/// Whole-request budget: connect, headers, and body.
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A content-declared URL resource: JSON for one card.
|
||||
pub const URL_RESOURCE_CAP: usize = 2 * 1024 * 1024;
|
||||
/// A Gitea API reply or one content file (a tree listing, a YAML page).
|
||||
pub const GITEA_CAP: usize = 8 * 1024 * 1024;
|
||||
/// A content-shipped asset proxied to the browser (an image, a font).
|
||||
pub const ASSET_CAP: usize = 32 * 1024 * 1024;
|
||||
|
||||
pub fn client() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.timeout(TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent(concat!("portal/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.expect("a client with static options always builds")
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads a body up to `cap` bytes; one byte more is an error, not a
|
||||
/// truncation, so a caller never parses half a document.
|
||||
pub async fn bytes(mut response: reqwest::Response, cap: usize) -> anyhow::Result<Vec<u8>> {
|
||||
let hint = response.content_length().unwrap_or(0) as usize;
|
||||
if hint > cap {
|
||||
anyhow::bail!("response of {hint} bytes exceeds the cap of {cap}");
|
||||
}
|
||||
let mut out = Vec::with_capacity(hint.min(cap));
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
if out.len() + chunk.len() > cap {
|
||||
anyhow::bail!("response exceeds the cap of {cap} bytes");
|
||||
}
|
||||
out.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn text(response: reqwest::Response, cap: usize) -> anyhow::Result<String> {
|
||||
Ok(String::from_utf8(bytes(response, cap).await?)?)
|
||||
}
|
||||
|
||||
/// `GET url`, status checked, body read under `cap`.
|
||||
pub async fn get_text(url: &str, cap: usize) -> anyhow::Result<String> {
|
||||
let response = client().get(url).send().await?.error_for_status()?;
|
||||
text(response, cap).await
|
||||
}
|
||||
+33
@@ -33,6 +33,17 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
"This part of the site is for organizational owners — sign in with that account to take a look."
|
||||
}
|
||||
("en", "announcements") => "Announcements",
|
||||
("en", "down_to") => "Back to ",
|
||||
("en", "suggest_edit") => "Suggest a change to this page",
|
||||
("en", "suggest_edit_help") => "You are listed as responsible for this question. Say what should change, and it goes to the people who keep the site.",
|
||||
("en", "suggest_edit_send") => "Send the suggestion",
|
||||
("en", "suggest_edit_sent") => "Thank you - your suggestion is sent.",
|
||||
("en", "suggestions_yours") => "What you have suggested here",
|
||||
("en", "suggestion_open") => "Sent",
|
||||
("en", "suggestion_discussed") => "Being looked at",
|
||||
("en", "suggestion_done") => "Done",
|
||||
("en", "suggestion_declined") => "Not done",
|
||||
("en", "the_start") => "the start",
|
||||
|
||||
("no", "also_worth_asking") => "Også verdt å spørre",
|
||||
("no", "asked_by") => "Stilt av ",
|
||||
@@ -51,6 +62,17 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
|
||||
}
|
||||
("no", "announcements") => "Kunngjøringer",
|
||||
("no", "down_to") => "Tilbake til ",
|
||||
("no", "suggest_edit") => "Foreslå en endring på denne siden",
|
||||
("no", "suggest_edit_help") => "Du står oppført som ansvarlig for dette spørsmålet. Skriv hva som bør endres, så går det til dem som har siden.",
|
||||
("no", "suggest_edit_send") => "Send forslaget",
|
||||
("no", "suggest_edit_sent") => "Takk - forslaget er sendt.",
|
||||
("no", "suggestions_yours") => "Det du har foreslått her",
|
||||
("no", "suggestion_open") => "Sendt",
|
||||
("no", "suggestion_discussed") => "Blir sett på",
|
||||
("no", "suggestion_done") => "Gjort",
|
||||
("no", "suggestion_declined") => "Ikke gjort",
|
||||
("no", "the_start") => "starten",
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
@@ -83,6 +105,17 @@ mod tests {
|
||||
"follows_answer",
|
||||
"owners_only",
|
||||
"announcements",
|
||||
"down_to",
|
||||
"the_start",
|
||||
"suggest_edit",
|
||||
"suggest_edit_help",
|
||||
"suggest_edit_send",
|
||||
"suggest_edit_sent",
|
||||
"suggestions_yours",
|
||||
"suggestion_open",
|
||||
"suggestion_discussed",
|
||||
"suggestion_done",
|
||||
"suggestion_declined",
|
||||
] {
|
||||
assert!(lookup("en", key).is_some(), "en missing {key}");
|
||||
assert!(lookup("no", key).is_some(), "no missing {key}");
|
||||
|
||||
+34
@@ -13,6 +13,40 @@ pub mod aggregates;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod announce;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod deadlines;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod sessions;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod http;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod access;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod people;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod mail;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod seed;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod memberships;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod report;
|
||||
/// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod lint;
|
||||
/// The trainer: `portal plan`, `portal build`, `portal report` and the
|
||||
/// rest (once uhhm/portal-trainer).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod trainer;
|
||||
|
||||
pub mod maps;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Headless schema-check binary - loads content and `aggregates.yaml`
|
||||
//! (from a Gitea repo URL or a local directory) and validates them
|
||||
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
|
||||
//! do, with no NATS, OIDC, web server, or JetStream connection
|
||||
//! involved. Built once by portal's own deploy workflow and run
|
||||
//! directly by `questions`' own CI (same bare-metal runner/host,
|
||||
//! published to a stable path - no artifact download needed), rather
|
||||
//! than compiled there - keeps that repo's CI coupling to "run a
|
||||
//! static binary," not "build a Rust workspace."
|
||||
|
||||
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir};
|
||||
use crate::lint::needs;
|
||||
use crate::{aggregates, content};
|
||||
|
||||
pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let mut args = argv.into_iter();
|
||||
let mut repo: Option<String> = None;
|
||||
let mut branch = "main".to_string();
|
||||
let mut subdir = "questions".to_string();
|
||||
let mut path: Option<String> = None;
|
||||
let mut tasks_out: Option<String> = None;
|
||||
let mut answers_in: Option<String> = None;
|
||||
let mut min_accuracy: f64 = 0.0;
|
||||
let mut skim = false;
|
||||
let mut sim_out: Option<String> = None;
|
||||
let mut show_access = false;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--repo" => repo = args.next(),
|
||||
"--branch" => branch = args.next().unwrap_or(branch),
|
||||
"--subdir" => subdir = args.next().unwrap_or(subdir),
|
||||
"--path" => path = args.next(),
|
||||
"--needs-tasks" => tasks_out = args.next(),
|
||||
"--skim" => skim = true,
|
||||
"--needs-sim" => sim_out = args.next(),
|
||||
"--access" => show_access = true,
|
||||
"--needs-score" => answers_in = args.next(),
|
||||
"--min-accuracy" => {
|
||||
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (questions, aggregates_map, site, needs_raw) = if let Some(dir) = path {
|
||||
(
|
||||
load_from_dir(&dir)?,
|
||||
load_aggregates_from_dir(&dir)?,
|
||||
load_site_from_dir(&dir)?,
|
||||
load_needs_from_dir(&dir)?,
|
||||
)
|
||||
} else if let Some(repo_url) = repo {
|
||||
// The first storyline of each, as the checks read it; the
|
||||
// loaders have held every list of texts to the site's arity.
|
||||
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?.at(0).clone();
|
||||
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?.at(0).clone();
|
||||
// load_site_from_gitea already validates; a missing file is
|
||||
// the default config, same as at portal boot.
|
||||
let site = content::load_site_from_gitea(&repo_url, &branch).await?.at(0).clone();
|
||||
// Optional: a fetch failure means the repo declares no needs.
|
||||
let needs_raw = crate::http::get_text(&format!("{}/needs.yaml?ref={branch}", content::gitea_raw_base(&repo_url)?), crate::http::GITEA_CAP).await.ok();
|
||||
(questions, aggregates_map, site, needs_raw)
|
||||
} else {
|
||||
eprintln!(
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
if let Err(e) = site.validate() {
|
||||
eprintln!("FAIL: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
|
||||
match content::validate_questions(&questions, &aggregates_map) {
|
||||
Ok(()) => {
|
||||
// The runtime's own event desk: a repo announcing pages
|
||||
// (Question.event) should read portal_events somewhere, or
|
||||
// "post what happened" tasks pile up unseen. Warn, don't
|
||||
// fail - the runtime creates the records either way.
|
||||
if questions.values().any(|q| q.event.is_some())
|
||||
&& !content::bucket_is_read(&questions, content::EVENTS_BUCKET)
|
||||
{
|
||||
eprintln!(
|
||||
"WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted",
|
||||
content::EVENTS_BUCKET
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"OK: {} question(s), {} aggregate(s) valid",
|
||||
questions.len(),
|
||||
aggregates_map.len()
|
||||
);
|
||||
// The access policy the site will compile from this content.
|
||||
// It must type-check against portal's schema: a rule the
|
||||
// schema cannot express would grant nothing at runtime.
|
||||
let policy = crate::access::Policy::from_content(&questions, &aggregates_map);
|
||||
if let Err(e) = policy.validate() {
|
||||
eprintln!("FAIL: access policy does not validate: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
|
||||
if show_access {
|
||||
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
|
||||
let mut rows = policy.rules.clone();
|
||||
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
|
||||
for r in rows {
|
||||
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
check_needs(
|
||||
needs_raw.as_deref(),
|
||||
&questions,
|
||||
&aggregates_map,
|
||||
tasks_out.as_deref(),
|
||||
skim,
|
||||
sim_out.as_deref(),
|
||||
answers_in.as_deref(),
|
||||
min_accuracy,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
||||
/// then the wording tasks written out and/or an engine's answers
|
||||
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
||||
fn check_needs(
|
||||
raw: Option<&str>,
|
||||
questions: &std::collections::HashMap<String, content::Question>,
|
||||
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
||||
tasks_out: Option<&str>,
|
||||
skim: bool,
|
||||
sim_out: Option<&str>,
|
||||
answers_in: Option<&str>,
|
||||
min_accuracy: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(raw) = raw else {
|
||||
if tasks_out.is_some() || answers_in.is_some() {
|
||||
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let file = match needs::parse(raw) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let findings = needs::check(&file, questions, aggregates_map);
|
||||
let mut failed = 0;
|
||||
for finding in &findings {
|
||||
let level = match finding.level {
|
||||
needs::Level::Fail => {
|
||||
failed += 1;
|
||||
"FAIL"
|
||||
}
|
||||
needs::Level::Warn => "WARN",
|
||||
};
|
||||
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
|
||||
}
|
||||
for need in &file.needs {
|
||||
if let Some(path) = needs::path_for(questions, need) {
|
||||
let steps: Vec<String> = path
|
||||
.iter()
|
||||
.map(|s| format!("{} [{}]", s.page, s.alternative))
|
||||
.collect();
|
||||
println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in);
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
let planned = file.needs.iter().filter(|n| n.planned).count();
|
||||
println!(
|
||||
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
|
||||
file.needs.len() - planned,
|
||||
planned,
|
||||
file.personas.len()
|
||||
);
|
||||
}
|
||||
// The wording pass still runs for the needs that do have a path -
|
||||
// a closed flow elsewhere is no reason to stop grading the rest.
|
||||
|
||||
if let Some(out) = sim_out {
|
||||
let model = needs::sim_model(&file, questions, aggregates_map);
|
||||
std::fs::write(out, serde_json::to_string_pretty(&model)?)
|
||||
.map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
|
||||
println!("wrote the simulation model to {out}");
|
||||
}
|
||||
let tasks = needs::tasks(&file, questions, skim);
|
||||
if let Some(out) = tasks_out {
|
||||
let mut lines = String::new();
|
||||
for task in &tasks {
|
||||
lines.push_str(&serde_json::to_string(task)?);
|
||||
lines.push('\n');
|
||||
}
|
||||
std::fs::write(out, lines).map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
|
||||
println!("wrote {} wording task(s) to {out}", tasks.len());
|
||||
}
|
||||
if let Some(path) = answers_in {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||
let mut answers = Vec::new();
|
||||
for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
answers.push(
|
||||
serde_json::from_str::<needs::Answer>(line)
|
||||
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
|
||||
);
|
||||
}
|
||||
let score = needs::score(&tasks, &answers, 0.6);
|
||||
for (id, picked, expected, confidence) in &score.misroutes {
|
||||
eprintln!(
|
||||
"MISROUTE: {id}: picked {picked:?}{}, the need is met by {expected:?}",
|
||||
confidence.map(|c| format!(" ({c:.2})")).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
for (id, confidence) in &score.hesitant {
|
||||
eprintln!("HESITANT: {id}: right, at {confidence:.2}");
|
||||
}
|
||||
for id in &score.unanswered {
|
||||
eprintln!("UNANSWERED: {id}");
|
||||
}
|
||||
println!(
|
||||
"wording: {}/{} routed right ({:.0}%)",
|
||||
score.correct,
|
||||
score.total,
|
||||
score.accuracy() * 100.0
|
||||
);
|
||||
if score.accuracy() < min_accuracy {
|
||||
eprintln!("FAIL: below --min-accuracy {min_accuracy}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
eprintln!("FAIL: {failed} unmet business need finding(s)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Loading a content repo from a local checkout - the offline
|
||||
//! counterparts to the Gitea loaders in `content`, for tools that lint
|
||||
//! or replay a branch that has not been pushed (question_lint,
|
||||
//! needs_replay). `dir` is always the pages directory (`questions/`);
|
||||
//! `site.yaml`, `aggregates.yaml` and `needs.yaml` sit one level up.
|
||||
|
||||
use crate::{aggregates, content};
|
||||
|
||||
/// The offline counterpart to `content::load_site_from_gitea` -
|
||||
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
|
||||
/// just as optional locally: missing means default branding.
|
||||
pub fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
|
||||
let site_path = std::path::Path::new(dir)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("site.yaml");
|
||||
if !site_path.exists() {
|
||||
return Ok(content::SiteConfig::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&site_path)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
|
||||
// The first storyline, held to the arity (content::variants).
|
||||
let files = vec![("site.yaml".to_string(), raw)];
|
||||
content::arity(&files)?;
|
||||
serde_yaml::from_str(&content::storyline(&files, 0)[0].1).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
||||
}
|
||||
|
||||
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
||||
/// `aggregates.yaml` lives at the repo root, one level up from the
|
||||
/// pages directory `--path` names, so `dir`'s parent is where it's
|
||||
/// looked for. Missing entirely is not an error here (unlike
|
||||
/// `--repo` mode, where `aggregates.yaml` is required) - a local
|
||||
/// checkout being linted may not have one, and every declared
|
||||
/// transition still gets checked against whatever *is* found; an
|
||||
/// empty map just means nothing is checked.
|
||||
pub fn load_aggregates_from_dir(
|
||||
dir: &str,
|
||||
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
|
||||
let aggregates_path = std::path::Path::new(dir)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("aggregates.yaml");
|
||||
if !aggregates_path.exists() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&aggregates_path)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
|
||||
// The first storyline, held to the arity (content::variants).
|
||||
let files = vec![("aggregates.yaml".to_string(), raw)];
|
||||
content::arity(&files)?;
|
||||
aggregates::parse_aggregates_yaml(&content::storyline(&files, 0)[0].1)
|
||||
}
|
||||
|
||||
/// The offline counterpart to `content::load_questions_from_gitea` -
|
||||
/// the same recursive tree walk and `content::build_questions`
|
||||
/// pipeline (derived ids, relative refs, sections, followup
|
||||
/// inference), just reading a local checkout instead of Gitea's API,
|
||||
/// for linting a branch that hasn't been pushed yet.
|
||||
pub fn load_from_dir(
|
||||
dir: &str,
|
||||
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
|
||||
let base = std::path::Path::new(dir);
|
||||
let mut files = Vec::new();
|
||||
collect_yaml(base, base, &mut files)?;
|
||||
content::build_questions(&files)
|
||||
}
|
||||
|
||||
fn collect_yaml(
|
||||
base: &std::path::Path,
|
||||
dir: &std::path::Path,
|
||||
out: &mut Vec<(String, String)>,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
collect_yaml(base, &path, out)?;
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
||||
let rel = path
|
||||
.strip_prefix(base)
|
||||
.expect("walked paths sit under their base")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
|
||||
out.push((rel, raw));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The offline counterpart to `check`'s needs.yaml fetch -
|
||||
/// `needs.yaml` sits at the repo root beside `aggregates.yaml`.
|
||||
pub fn load_needs_from_dir(dir: &str) -> anyhow::Result<Option<String>> {
|
||||
let needs_path = std::path::Path::new(dir)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("needs.yaml");
|
||||
if !needs_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
std::fs::read_to_string(&needs_path)
|
||||
.map(Some)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! iris - the shield over a portal site. A content push is an incoming
|
||||
//! wormhole; nothing comes through until it checks out.
|
||||
//!
|
||||
//! `iris check` loads a content repo (a Gitea URL or a local checkout)
|
||||
//! exactly the way the running site does, validates it, and holds it to
|
||||
//! the business needs it declares in `needs.yaml` - a path within
|
||||
//! budget for every kind of visitor, a desk for every handling group,
|
||||
//! every finished state reachable, no record stranding. It also exports
|
||||
//! what a trainer needs to grade the wording: personas as typed choice
|
||||
//! tasks, and a resolved simulation model.
|
||||
//!
|
||||
//! `iris replay` runs simulated cases through the site's real aggregate
|
||||
//! engine on a JetStream and reports every bucket by state; with
|
||||
//! `--report-only` it takes the same scorecard from a live site's
|
||||
//! buckets.
|
||||
//!
|
||||
//! Every type iris reads is portal's own, pinned to the portal release
|
||||
//! it matches, so the lint and the site never disagree about what a
|
||||
//! page is.
|
||||
|
||||
mod check;
|
||||
mod local;
|
||||
mod needs;
|
||||
mod replay;
|
||||
|
||||
/// The lint, as `portal lint ...`: `argv` is everything after `portal`.
|
||||
pub async fn run(mut argv: Vec<String>) -> anyhow::Result<()> {
|
||||
// `iris --path questions` reads as `iris check --path questions`,
|
||||
// so the content repos' one-line CI keeps working as it was.
|
||||
match argv.first().map(String::as_str) {
|
||||
Some("check") => {
|
||||
argv.remove(0);
|
||||
check::run(argv).await
|
||||
}
|
||||
Some("replay") => {
|
||||
argv.remove(0);
|
||||
replay::run(argv).await
|
||||
}
|
||||
Some("--version") | Some("-V") => {
|
||||
println!("portal lint v{}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(flag) if flag.starts_with("--") => check::run(argv).await,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)",
|
||||
v = env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
std::process::exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1036
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
//! Makes a simulation's cases real: every case becomes an aggregate in
|
||||
//! its bucket, created and moved by the same code the running site
|
||||
//! uses - `answers::store_answer` for the submission,
|
||||
//! `aggregates::transition` for each desk move, against a real
|
||||
//! JetStream with its event log and its refusal of undeclared moves.
|
||||
//! Then the buckets are read back by replaying each record's events,
|
||||
//! and reported per need against `done_when` and `success`.
|
||||
//!
|
||||
//! The report half runs on its own (`--report-only`) against any
|
||||
//! JetStream, which is how the same scorecard is taken from a live
|
||||
//! site's buckets.
|
||||
//!
|
||||
//! Point it at a throwaway NATS, never a site's own: it writes.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use futures::StreamExt;
|
||||
use crate::events::store::{ensure_stream, load_events};
|
||||
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
|
||||
use crate::lint::needs;
|
||||
use crate::{aggregates, answers, content};
|
||||
|
||||
pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
|
||||
let usage = "usage: iris replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
|
||||
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
|
||||
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
|
||||
let cases_path = value("--cases");
|
||||
if cases_path.is_none() && !argv.iter().any(|a| a == "--report-only") {
|
||||
anyhow::bail!(usage);
|
||||
}
|
||||
|
||||
let questions = load_from_dir(&dir)?;
|
||||
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
|
||||
let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?;
|
||||
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
|
||||
// The same call the site makes at boot.
|
||||
ensure_stream(&js).await?;
|
||||
|
||||
if let Some(path) = cases_path {
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||
let (mut created, mut moved, mut refused) = (0, 0, 0);
|
||||
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
|
||||
let case: serde_json::Value = serde_json::from_str(line)?;
|
||||
let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue };
|
||||
let Some(schema) = schemas.get(&need.lands_in) else { continue };
|
||||
// Only a case that got as far as submitting leaves a record.
|
||||
let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect();
|
||||
if states.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
|
||||
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
|
||||
let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now, 0).await?;
|
||||
created += 1;
|
||||
for target in &states[1..] {
|
||||
match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await {
|
||||
Ok(_) => moved += 1,
|
||||
Err(e) => {
|
||||
refused += 1;
|
||||
eprintln!("REFUSED by the engine: {id} -> {target}: {e:?}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{created} aggregate(s) created, {moved} transition(s) accepted, {refused} refused by the engine\n");
|
||||
}
|
||||
|
||||
// Read every bucket back the way the engine knows it: by replaying
|
||||
// each record's own event log.
|
||||
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
|
||||
for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::<std::collections::BTreeSet<_>>() {
|
||||
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue };
|
||||
let mut keys = store.keys().await?;
|
||||
while let Some(Ok(id)) = keys.next().await {
|
||||
let events = load_events(&js, &bucket, &id).await?;
|
||||
if let Some(record) = aggregates::replay(schema, &id, &events) {
|
||||
*by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state");
|
||||
let mut buckets: Vec<_> = by_bucket.iter().collect();
|
||||
buckets.sort();
|
||||
for (bucket, states) in buckets {
|
||||
let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect();
|
||||
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| needs_here.iter().any(|n| pick(n).contains(state));
|
||||
let total: usize = states.values().sum();
|
||||
let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum();
|
||||
let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum();
|
||||
let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) };
|
||||
println!(
|
||||
"{:<18} {:>8} {:>9} {:>9} {}",
|
||||
bucket,
|
||||
total,
|
||||
pct(finished),
|
||||
pct(success),
|
||||
states.iter().map(|(s, c)| format!("{s} {c}")).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
//! Portal's side of the mail gate. Content decides when a person
|
||||
//! hears something (`mail:` on a state in aggregates.yaml, and the
|
||||
//! built-in invite mail); portal renders the message the moment a
|
||||
//! record enters that state and publishes it on one JetStream subject.
|
||||
//! gdo (uhhm/gdo) holds a durable consumer on that subject and hands
|
||||
//! each message to the host's SMTP. Portal never talks SMTP, gdo never
|
||||
//! reads content, and a message survives either of them being down.
|
||||
//!
|
||||
//! The message is the contract between the two, `Outgoing` below,
|
||||
//! versioned by `v`. Its id is the JetStream message id, so a
|
||||
//! transition that fires twice (a retry, a reseed) sends once.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The stream both sides create on boot, with the same config, so
|
||||
/// whichever starts first owns nothing the other lacks.
|
||||
pub const STREAM: &str = "mail";
|
||||
pub const SUBJECTS: &str = "portal.mail.>";
|
||||
pub const SUBJECT: &str = "portal.mail.send";
|
||||
/// Where a reply to a case mail arrives, published by whatever reads
|
||||
/// the mailbox (uhhm/gdo over JMAP). Portal turns it into a note on
|
||||
/// the record it names and nothing else: no state moves on a reply.
|
||||
pub const RECEIVED_SUBJECT: &str = "portal.mail.received";
|
||||
/// Bumped when `Outgoing` changes shape; gdo refuses what it does not
|
||||
/// know rather than guessing.
|
||||
pub const CONTRACT: u8 = 1;
|
||||
|
||||
/// One mail, fully rendered: everything gdo needs and nothing it has
|
||||
/// to look up.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Outgoing {
|
||||
pub v: u8,
|
||||
/// `<bucket>:<record>:<state>` or `invite:<user>:<group>:<ms>` -
|
||||
/// the dedup key.
|
||||
pub id: String,
|
||||
pub site: String,
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// A reply that came back, already matched to a record by whatever
|
||||
/// read the mailbox. Portal trusts the match no further than it can
|
||||
/// check it: the record must exist, the bucket must be one this site
|
||||
/// has, and the sender must be the address the record itself holds.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Incoming {
|
||||
pub v: u8,
|
||||
/// The mail's own id, so the same reply read twice lands once.
|
||||
pub id: String,
|
||||
pub bucket: String,
|
||||
pub record: String,
|
||||
/// Who sent it, as the mail server saw it.
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
/// The reply itself, already stripped of quoted history and
|
||||
/// signatures by the reader; portal stores what it is given.
|
||||
pub text: String,
|
||||
pub received_ms: i64,
|
||||
}
|
||||
|
||||
/// What a reply may add to a record, and what it may never do. Returns
|
||||
/// the note to append, or why the reply is not one.
|
||||
///
|
||||
/// The address a reply comes back on is unguessable, so it is proof of
|
||||
/// having been sent the mail - but a mailbox is a public door, and an
|
||||
/// address can be forged. The sender must therefore also match the
|
||||
/// address the record already holds, which is the one portal wrote to
|
||||
/// in the first place.
|
||||
pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<crate::answers::Note, String> {
|
||||
if incoming.v != CONTRACT {
|
||||
return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v));
|
||||
}
|
||||
let text = incoming.text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("the reply has no text".to_string());
|
||||
}
|
||||
let held = answer
|
||||
.responses
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|e| e.trim().to_lowercase())
|
||||
.filter(|e| !e.is_empty())
|
||||
.ok_or_else(|| "the record holds no address to compare the sender with".to_string())?;
|
||||
if address_of(&incoming.from) != held {
|
||||
return Err(format!("{} is not the address this record was written to", incoming.from));
|
||||
}
|
||||
if answer.notes.iter().any(|n| n.id == incoming.id) {
|
||||
return Err("this reply is already on the record".to_string());
|
||||
}
|
||||
Ok(crate::answers::Note {
|
||||
id: incoming.id.clone(),
|
||||
from: address_of(&incoming.from),
|
||||
subject: incoming.subject.trim().to_string(),
|
||||
text: text.to_string(),
|
||||
received_ms: incoming.received_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// The bare address out of `Name <a@b>` or `a@b`, folded for
|
||||
/// comparison. Mail addresses are case-insensitive in the part that
|
||||
/// matters here, and a display name is not identity.
|
||||
pub fn address_of(from: &str) -> String {
|
||||
let inner = match (from.rfind('<'), from.rfind('>')) {
|
||||
(Some(open), Some(close)) if close > open => &from[open + 1..close],
|
||||
_ => from,
|
||||
};
|
||||
inner.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Listens for replies and writes each one onto the record it names.
|
||||
/// A reply is a note and never a decision: nothing here moves a case,
|
||||
/// so a mailbox - the one door anyone may knock on - cannot drive a
|
||||
/// state machine even if someone forges their way past the checks.
|
||||
pub async fn receive(state: crate::server::AppState) {
|
||||
use futures::StreamExt;
|
||||
let mut sub = match state.nats.subscribe(RECEIVED_SUBJECT).await {
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "cannot listen for replies");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail");
|
||||
while let Some(msg) = sub.next().await {
|
||||
let incoming: Incoming = match serde_json::from_slice(&msg.payload) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::warn!("unreadable reply on {RECEIVED_SUBJECT}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match note_onto(&state, &incoming).await {
|
||||
Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"),
|
||||
Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The record's own bucket is the only one a reply may touch, and the
|
||||
/// note is appended under the same read-modify-write the desks use.
|
||||
async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> {
|
||||
if !state.aggregates.load().contains_key(&incoming.bucket) && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) {
|
||||
anyhow::bail!("{} is not a bucket of this site", incoming.bucket);
|
||||
}
|
||||
let store = state.jetstream.get_key_value(&incoming.bucket).await?;
|
||||
let bytes = store
|
||||
.get(&incoming.record)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("no such record"))?;
|
||||
let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?;
|
||||
let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?;
|
||||
answer.notes.push(note);
|
||||
store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotent, called by portal at boot and by gdo. A week of
|
||||
/// retention: a mail nobody delivered in a week is stale, not owed.
|
||||
/// The duplicate window has to fit inside it.
|
||||
pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
||||
Ok(js
|
||||
.get_or_create_stream(async_nats::jetstream::stream::Config {
|
||||
name: STREAM.to_string(),
|
||||
subjects: vec![SUBJECTS.to_string()],
|
||||
max_age: Duration::from_secs(7 * 24 * 3600),
|
||||
duplicate_window: Duration::from_secs(24 * 3600),
|
||||
..Default::default()
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `{key}` in a template becomes the variable's value, an unknown key
|
||||
/// becomes nothing, and a brace that opens no key stays a brace.
|
||||
pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find('{') {
|
||||
out.push_str(&rest[..start]);
|
||||
let after = &rest[start + 1..];
|
||||
let key_len = after
|
||||
.char_indices()
|
||||
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
if key_len > 0 && after[key_len..].starts_with('}') {
|
||||
let key = &after[..key_len];
|
||||
match vars.get(key) {
|
||||
Some(serde_json::Value::String(s)) => out.push_str(s),
|
||||
Some(serde_json::Value::Null) | None => {}
|
||||
Some(other) => out.push_str(&other.to_string()),
|
||||
}
|
||||
rest = &after[key_len + 1..];
|
||||
} else {
|
||||
out.push('{');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The HTML alternative: the markdown body as a single column with
|
||||
/// inline styles, which is as much as mail clients agree on. Raw HTML
|
||||
/// in the body is dropped, the same rule as page descriptions.
|
||||
pub fn to_html(markdown: &str) -> String {
|
||||
use pulldown_cmark::{html, Event, Options, Parser};
|
||||
let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
|
||||
let mut body = String::new();
|
||||
html::push_html(&mut body, parser);
|
||||
format!(
|
||||
"<!doctype html><html><body style=\"margin:0;padding:1.5em;background:#f4f4f2;color:#1c1c1a;font:16px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif\"><div style=\"max-width:36em;margin:0 auto;background:#fff;padding:1.5em 2em;border-radius:0.5em\">{body}</div></body></html>"
|
||||
)
|
||||
}
|
||||
|
||||
fn public_url() -> String {
|
||||
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn site_name(site: &crate::content::SiteConfig) -> String {
|
||||
site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string())
|
||||
}
|
||||
|
||||
/// The variables a state mail may use: every field of the record,
|
||||
/// then the runtime's own.
|
||||
pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut vars = serde_json::Map::new();
|
||||
if let Some(fields) = responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
vars.insert(k.clone(), match v {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||
serde_json::Value::Null => serde_json::Value::String(String::new()),
|
||||
other => serde_json::Value::String(other.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
vars.insert("email".into(), email.into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("chain".into(), id.into());
|
||||
vars.insert("bucket".into(), bucket.into());
|
||||
vars.insert("state".into(), state.into());
|
||||
vars
|
||||
}
|
||||
|
||||
/// The record field a `to` reaches through, when it is neither
|
||||
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||
pub fn parent_of(to: &str) -> Option<&str> {
|
||||
let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?;
|
||||
let (field, _) = inner.split_once('.')?;
|
||||
(!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field)
|
||||
}
|
||||
|
||||
/// The fields of every record this one belongs to, as `{field.name}`:
|
||||
/// for each `type: record` field of the forms that record into
|
||||
/// `bucket`, the record it names, read from its own bucket. A
|
||||
/// reference that does not resolve adds nothing, and a template that
|
||||
/// needed it renders empty there.
|
||||
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
|
||||
let questions = state.questions.load();
|
||||
let refs: Vec<(String, String)> = questions
|
||||
.values()
|
||||
.flat_map(|q| q.alternatives.iter())
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket))
|
||||
.flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter()))
|
||||
.filter(|r| r.kind == "record")
|
||||
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
|
||||
.collect();
|
||||
for (field, of) in refs {
|
||||
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
|
||||
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { continue };
|
||||
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
if let Some(fields) = parent.responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
let text = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
vars.insert(format!("{field}.{k}"), text.into());
|
||||
}
|
||||
}
|
||||
vars.insert(format!("{field}.id"), parent.id.into());
|
||||
vars.insert(format!("{field}.state"), parent.state.into());
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
|
||||
let mut headers = async_nats::HeaderMap::new();
|
||||
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
||||
let bytes = serde_json::to_vec(mail)?;
|
||||
js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A record entered `state` in `bucket`: send what the schema says,
|
||||
/// if anything. Best-effort by design - the transition is already
|
||||
/// committed, and a mail that cannot be rendered is logged, never a
|
||||
/// failed request.
|
||||
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) {
|
||||
// The mail tells the storyline the person read.
|
||||
let storyline = storyline as usize;
|
||||
let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
||||
// A suggestion has its own words for this unless the content
|
||||
// gave the state a mail, which then is the one sent.
|
||||
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
||||
on_suggestion_state(state, id, to_state, responses).await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let site_storylines = state.site.load();
|
||||
let site = site_storylines.at(storyline);
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
|
||||
return;
|
||||
};
|
||||
let mut vars = vars_for(bucket, id, to_state, responses);
|
||||
parent_vars(state, bucket, responses, &mut vars).await;
|
||||
let to = if spec.to_submitter() {
|
||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
||||
Some(e) => e.to_string(),
|
||||
None => {
|
||||
tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A field of the record this one belongs to, or an address.
|
||||
let to = render(&spec.to, &vars);
|
||||
if !to.contains('@') {
|
||||
tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent");
|
||||
return;
|
||||
}
|
||||
to
|
||||
};
|
||||
let text = render(&spec.body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("{bucket}:{id}:{to_state}"),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to,
|
||||
subject: render(&spec.subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(bucket, id, to_state, "publishing the mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The questions a person answers for, as a mail names them: each in
|
||||
/// quotes, and past the first three only how many more - someone who
|
||||
/// answers for a whole site is not read its table of contents.
|
||||
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
||||
let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
|
||||
let and = if norwegian { "og" } else { "and" };
|
||||
match questions {
|
||||
[] => String::new(),
|
||||
[one] => quote(one),
|
||||
few if few.len() <= 3 => {
|
||||
let quoted: Vec<String> = few.iter().map(quote).collect();
|
||||
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
|
||||
}
|
||||
many => {
|
||||
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
|
||||
let more = many.len() - 3;
|
||||
let others = match (norwegian, more) {
|
||||
(true, 1) => "ett spørsmål til".to_string(),
|
||||
(true, n) => format!("{n} spørsmål til"),
|
||||
(false, 1) => "one more question".to_string(),
|
||||
(false, n) => format!("{n} more questions"),
|
||||
};
|
||||
format!("{} {and} {others}", quoted.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mail a person gets when the content names them responsible for
|
||||
/// a question and they had no account: who they are to the site (the
|
||||
/// person listed as asking that question), and the link that lets them
|
||||
/// sign in and suggest changes to it. Built in, in the site's language.
|
||||
pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let list = questions_listed(questions, norwegian);
|
||||
let (subject, body) = if norwegian {
|
||||
(
|
||||
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
|
||||
"Hei {name},\n\nDu står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak.\n\nVelg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"You are listed as responsible for a question at {site_name}".to_string(),
|
||||
"Hello {name},\n\nYou are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind.\n\nSet your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}. Afterwards you sign in at {site}.".to_string(),
|
||||
)
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let expires = invited
|
||||
.link_expires_ms
|
||||
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_default();
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("name".into(), invited.display_name.clone().into());
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("questions".into(), list.into());
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("responsible:{}:{now_ms}", invited.username),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to: invited.email.clone(),
|
||||
subject: render(&subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(user = %invited.username, "publishing the responsible mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the person who suggested a change hears when it moves, as
|
||||
/// subject and body; `None` for a state that says nothing new to them
|
||||
/// (it arriving, or one the content added). `{name}`, `{question}`,
|
||||
/// `{page}`, `{quoted}`.
|
||||
pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str, &'static str)> {
|
||||
Some(match (to_state, norwegian) {
|
||||
("discussed", true) => (
|
||||
"Forslaget ditt om «{question}» blir sett på",
|
||||
"Hei {name},\n\nForslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("done", true) => (
|
||||
"Endringen du foreslo i «{question}» er gjort",
|
||||
"Hei {name},\n\nEndringen du foreslo på {page} er gjort. Takk for at du sa fra.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("declined", true) => (
|
||||
"Forslaget ditt om «{question}» blir ikke gjort",
|
||||
"Hei {name},\n\nForslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("discussed", false) => (
|
||||
"Your suggestion about \u{201c}{question}\u{201d} is being looked at",
|
||||
"Hello {name},\n\nYour suggested change to {page} has been taken up and is being looked at. You will hear when it is settled.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
("done", false) => (
|
||||
"The change you suggested to \u{201c}{question}\u{201d} is made",
|
||||
"Hello {name},\n\nThe change you suggested to {page} is made. Thank you for saying so.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
("declined", false) => (
|
||||
"Your suggestion about \u{201c}{question}\u{201d} will not be made",
|
||||
"Hello {name},\n\nYour suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A suggestion moved: tell whoever made it. Replies go to the site's
|
||||
/// inbox, which is who decided.
|
||||
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
|
||||
let site = state.site.load();
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
|
||||
return;
|
||||
};
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
|
||||
return;
|
||||
};
|
||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
let to = field("email");
|
||||
if !to.contains('@') {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
|
||||
return;
|
||||
}
|
||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("name".into(), field("name").into());
|
||||
vars.insert("question".into(), field("question").into());
|
||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
||||
vars.insert("quoted".into(), quoted.into());
|
||||
let text = render(body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to,
|
||||
subject: render(subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(id, to_state, "publishing the suggestion mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A change suggested by the person responsible for a page, to the
|
||||
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
|
||||
/// suggester as reply-to so answering the mail answers them.
|
||||
pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
|
||||
return;
|
||||
};
|
||||
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, lead) = if norwegian {
|
||||
(format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
|
||||
} else {
|
||||
(format!("Suggested change: \u{201c}{question_name}\u{201d}"), format!("{by_name} ({by_email}), listed as responsible for the question, suggests a change to {}{question_id}:", public_url()))
|
||||
};
|
||||
let body = format!("{lead}\n\n{text}");
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("suggestion:{question_id}:{now_ms}"),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: Some(by_email.to_string()),
|
||||
to,
|
||||
subject,
|
||||
html: to_html(&body),
|
||||
text: body,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(question_id, "publishing the suggestion mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The invite mail: the one-time link to the person just onboarded.
|
||||
/// Built in, since portal knows everything about it; `mail.invite` in
|
||||
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
||||
/// `{site}`, `{expires}`.
|
||||
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
|
||||
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||
(None, true, true) => (
|
||||
"Du er invitert inn i {group} på {site_name}".to_string(),
|
||||
"Hei {name},\n\nDu har fått en konto på {site} og en plass i {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}.".to_string(),
|
||||
),
|
||||
(None, false, true) => (
|
||||
"Du har fått plass i {group} på {site_name}".to_string(),
|
||||
"Hei {name},\n\nDu har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der.".to_string(),
|
||||
),
|
||||
(None, true, false) => (
|
||||
"You are invited into {group} at {site_name}".to_string(),
|
||||
"Hello {name},\n\nYou have an account at {site} and a place in {group}. Set your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}.".to_string(),
|
||||
),
|
||||
(None, false, false) => (
|
||||
"You now have a place in {group} at {site_name}".to_string(),
|
||||
"Hello {name},\n\nYou now have a place in {group} at {site_name}. Sign in at {site}, and it is there.".to_string(),
|
||||
),
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let expires = invited
|
||||
.link_expires_ms
|
||||
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_default();
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("name".into(), invited.display_name.clone().into());
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("email".into(), invited.email.clone().into());
|
||||
vars.insert("group".into(), invited.group.clone().into());
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("invite:{}:{}:{now_ms}", invited.username, invited.group),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to: invited.email.clone(),
|
||||
subject: render(&subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(user = %invited.username, "publishing the invite mail failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mail_names_three_questions_and_counts_the_rest() {
|
||||
let q = |n: usize| (1..=n).map(|i| format!("Q{i}?")).collect::<Vec<_>>();
|
||||
assert_eq!(questions_listed(&q(0), true), "");
|
||||
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
|
||||
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
|
||||
assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
|
||||
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
|
||||
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
|
||||
assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival() {
|
||||
for norwegian in [true, false] {
|
||||
for state in ["discussed", "done", "declined"] {
|
||||
let (subject, body) = suggestion_words(state, norwegian).expect(state);
|
||||
assert!(subject.contains("{question}"), "{state}: the subject names the question");
|
||||
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
|
||||
}
|
||||
// Arriving is told to the inbox, not back to who sent it.
|
||||
assert!(suggestion_words("open", norwegian).is_none());
|
||||
assert!(suggestion_words("a_state_the_content_added", norwegian).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_reaches_through_a_record_field_and_nothing_else() {
|
||||
assert_eq!(parent_of("{notice.email}"), Some("notice"));
|
||||
assert_eq!(parent_of(" {case.contact} "), Some("case"));
|
||||
assert_eq!(parent_of("submitter"), None);
|
||||
assert_eq!(parent_of("post@vel.no"), None);
|
||||
assert_eq!(parent_of("{email}"), None);
|
||||
assert_eq!(parent_of("{.email}"), None);
|
||||
assert_eq!(parent_of("{not a.field}"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_take_fields_and_leave_stray_braces_alone() {
|
||||
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
|
||||
let vars = vars_for("reports", "abc", "fixed", &responses);
|
||||
assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no");
|
||||
assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {");
|
||||
assert_eq!(vars["bucket"], "reports");
|
||||
assert_eq!(vars["state"], "fixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
|
||||
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
|
||||
assert!(html.contains("<strong>there</strong>"));
|
||||
assert!(html.contains("<a href=\"https://x.no\">link</a>"));
|
||||
assert!(!html.contains("<script>"));
|
||||
assert!(html.starts_with("<!doctype html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_contract_round_trips() {
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: "reports:abc:fixed".into(),
|
||||
site: "Tomter Vel".into(),
|
||||
from: "Tomter Vel <vel@example.no>".into(),
|
||||
reply_to: None,
|
||||
to: "kari@x.no".into(),
|
||||
subject: "Fixed".into(),
|
||||
text: "Done.".into(),
|
||||
html: to_html("Done."),
|
||||
};
|
||||
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
|
||||
assert_eq!(back, mail);
|
||||
// A gdo that predates reply addresses sends none, and reads
|
||||
// one from a portal that does: the field is optional on the
|
||||
// wire in both directions.
|
||||
let older: Outgoing = serde_json::from_str(
|
||||
r#"{"v":1,"id":"a","site":"s","from":"f@x.no","to":"t@x.no","subject":"s","text":"t","html":"h"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(older.subject, "s");
|
||||
assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\""));
|
||||
}
|
||||
|
||||
fn record(email: &str) -> crate::answers::Answer {
|
||||
crate::answers::Answer {
|
||||
id: "abc".into(),
|
||||
question_id: "/report".into(),
|
||||
alternative: "Report a hazard".into(),
|
||||
responses: serde_json::json!({ "email": email, "where": "The bend" }),
|
||||
state: "open".into(),
|
||||
submitted_ms: 1,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(from: &str, text: &str) -> Incoming {
|
||||
Incoming {
|
||||
v: CONTRACT,
|
||||
id: "mail-1".into(),
|
||||
bucket: "hazards".into(),
|
||||
record: "abc".into(),
|
||||
from: from.into(),
|
||||
subject: "Re: We have it".into(),
|
||||
text: text.into(),
|
||||
received_ms: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_from_the_address_on_the_record_becomes_a_note() {
|
||||
let note = note_for(&reply("Kari <KARI@x.no>", " It is fixed now. "), &record("kari@x.no")).unwrap();
|
||||
assert_eq!(note.from, "kari@x.no");
|
||||
assert_eq!(note.text, "It is fixed now.");
|
||||
assert_eq!(note.id, "mail-1");
|
||||
assert_eq!(note.received_ms, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_from_anyone_else_is_refused() {
|
||||
// The address a reply arrives on is unguessable, but a From is
|
||||
// a claim, and a mailbox is a door anyone may knock on.
|
||||
let err = note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err();
|
||||
assert!(err.contains("not the address this record was written to"), "{err}");
|
||||
|
||||
// Nothing to compare against is also a refusal, not a pass.
|
||||
let mut anonymous = record("kari@x.no");
|
||||
anonymous.responses = serde_json::json!({ "where": "The bend" });
|
||||
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_reply_and_a_repeated_one_add_nothing() {
|
||||
assert!(note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")).unwrap_err().contains("no text"));
|
||||
|
||||
let mut already = record("kari@x.no");
|
||||
already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap());
|
||||
let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err();
|
||||
assert!(err.contains("already on the record"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() {
|
||||
let mut future = reply("kari@x.no", "hello");
|
||||
future.v = CONTRACT + 1;
|
||||
assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_is_read_out_of_whatever_the_mail_server_said() {
|
||||
assert_eq!(address_of("Kari Nordmann <Kari@X.no>"), "kari@x.no");
|
||||
assert_eq!(address_of(" kari@x.no "), "kari@x.no");
|
||||
assert_eq!(address_of("<kari@x.no>"), "kari@x.no");
|
||||
// A display name that looks like an address is not the address.
|
||||
assert_eq!(address_of("\"admin@bank.no\" <kari@x.no>"), "kari@x.no");
|
||||
}
|
||||
}
|
||||
+152
-10
@@ -1,6 +1,31 @@
|
||||
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
|
||||
/// `portal lint` checks a content repo the way the site reads it;
|
||||
/// `portal plan`, `build`, `report` and the rest train one. The lint
|
||||
/// and the trainer are the same code as the server, at the same
|
||||
/// version, so they never disagree about what content means.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match argv.first().map(String::as_str) {
|
||||
Some("lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
||||
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
|
||||
// The trainer runs models through shell commands and waits
|
||||
// on them; off the runtime's threads, as a CLI should.
|
||||
return tokio::task::spawn_blocking(move || portal::trainer::run(argv)).await?;
|
||||
}
|
||||
Some("--version" | "-V") => {
|
||||
println!("portal v{}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
Some("serve") | None => {}
|
||||
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
||||
}
|
||||
serve().await
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn serve() -> anyhow::Result<()> {
|
||||
use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
@@ -10,7 +35,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
use portal::upload::{self, Garage};
|
||||
use std::sync::Arc;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_sessions::{MemoryStore, SessionManagerLayer};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
@@ -30,13 +55,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
|
||||
let gitea_base = content::gitea_api_base(&content_repo)?;
|
||||
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
||||
let aggregates = content::with_builtin_aggregates(
|
||||
content::load_aggregates_from_gitea(&content_repo, &content_branch).await?,
|
||||
);
|
||||
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
|
||||
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||
content::validate_questions(&questions, &aggregates)?;
|
||||
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
||||
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
|
||||
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?;
|
||||
tracing::info!(count = questions.len(), storylines, repo = %content_repo, branch = %content_branch, "loaded content");
|
||||
let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates)));
|
||||
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
|
||||
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
||||
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
|
||||
@@ -59,6 +84,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
// into, and `answers::store_answer` creates it on first use.
|
||||
let jetstream = async_nats::jetstream::new(nats.clone());
|
||||
portal::events::store::ensure_stream(&jetstream).await?;
|
||||
// Outgoing mail waits here for gdo; a mail is never lost to gdo
|
||||
// being down, and never sent by portal itself.
|
||||
// Best-effort: a stream that already owns these subjects under an
|
||||
// older name (a rename mid-upgrade) must not keep a site from
|
||||
// booting; mail waits until the old stream is removed.
|
||||
if let Err(e) = portal::mail::ensure_stream(&jetstream).await {
|
||||
tracing::warn!("the mail stream could not be created: {e} - outgoing mail is off until a stream owns portal.mail.>");
|
||||
}
|
||||
|
||||
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
|
||||
|
||||
@@ -75,8 +108,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
questions.clone(),
|
||||
aggregates.clone(),
|
||||
site.clone(),
|
||||
access.clone(),
|
||||
));
|
||||
|
||||
let state = AppState {
|
||||
leptos_options: leptos_options.clone(),
|
||||
nats,
|
||||
@@ -84,6 +117,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
questions,
|
||||
aggregates,
|
||||
site,
|
||||
access,
|
||||
gitea_base,
|
||||
content_raw_base,
|
||||
content_branch: content_branch.clone(),
|
||||
@@ -91,9 +125,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
garage,
|
||||
};
|
||||
|
||||
// The same decisions, answerable by other apps over NATS, for a
|
||||
// person named by phone number or address as well as by groups.
|
||||
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
|
||||
// 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()));
|
||||
// The accounts the content mandates: the first person in, and
|
||||
// everyone a page names as responsible, at start and whenever the
|
||||
// content changes (seed.rs).
|
||||
tokio::spawn(portal::seed::run(state.clone()));
|
||||
// Invites whose link ran out, or whose person is in (people.rs).
|
||||
tokio::spawn(portal::people::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
|
||||
// site with no mailbox simply never sees a message on the subject.
|
||||
tokio::spawn(portal::mail::receive(state.clone()));
|
||||
|
||||
// Dev-friendly defaults: in-memory sessions, secure cookies only when
|
||||
// COOKIE_SECURE=true (set it behind TLS in production) - same
|
||||
@@ -108,11 +157,50 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cookie_secure = std::env::var("COOKIE_SECURE")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
||||
// Sessions live in the same JetStream as everything else, so a
|
||||
// deploy no longer signs out every desk on the site it deploys.
|
||||
let session_store = portal::sessions::NatsStore::open(&state.jetstream).await?;
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(cookie_secure)
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_name("portal_session");
|
||||
|
||||
// What a person does costs a little; what a script does costs a
|
||||
// lot. A burst of 30 with one refilled every two seconds is well
|
||||
// above anything a visitor filling in a form will reach - a page
|
||||
// load fetches several resources at once - and well below what it
|
||||
// takes to be a nuisance. Per caller IP, in memory, no store: this
|
||||
// is a fence, not an accounting system, and the fence that matters
|
||||
// for uploads is the policy check on the route itself.
|
||||
//
|
||||
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
|
||||
// 0 for either turns it off, for an instance behind something that
|
||||
// already does this.
|
||||
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2);
|
||||
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
|
||||
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
|
||||
// what every one of these instances is behind; it falls back to
|
||||
// the socket address when neither is set.
|
||||
let conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.per_second(rate_seconds)
|
||||
.burst_size(rate_burst)
|
||||
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("a rate limit of at least one per second and a burst of at least one");
|
||||
std::sync::Arc::new(conf)
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
@@ -138,6 +226,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
|
||||
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
|
||||
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
|
||||
// Vendored, not loaded from a CDN: a map must not be the one thing
|
||||
// on the page that phones a stranger.
|
||||
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
|
||||
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
|
||||
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
|
||||
|
||||
let app = Router::new()
|
||||
@@ -145,11 +237,29 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/auth/callback", get(oidc::callback))
|
||||
.route("/auth/logout", get(oidc::logout))
|
||||
.route("/api/{*fn_name}", any(server_fn_handler))
|
||||
.route("/upload", post(upload::upload))
|
||||
// axum caps request bodies at 2 MB by default, which had made the
|
||||
// 20 MB upload limit unreachable; raise the fence on this route only,
|
||||
// with room for the multipart framing.
|
||||
.route(
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
get(upload::attachment),
|
||||
)
|
||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
||||
// What the storylines did, as counts (report.rs).
|
||||
.route("/report", get(portal::report::handler))
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||
// Maps, proxied: the browser asks this origin for every tile
|
||||
// and every still, so nothing third-party loads in a page and
|
||||
// the token stays here (see maps.rs).
|
||||
.route("/map/style.json", get(portal::maps::style))
|
||||
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
|
||||
.route("/map/still/{place}/{size}", get(portal::maps::still))
|
||||
// no-cache = "revalidate before reuse", not "don't cache":
|
||||
// pkg files keep the same names across releases (portal.js,
|
||||
// portal.wasm), and without this browsers heuristically cache
|
||||
@@ -174,6 +284,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/swiper-element-bundle.min.js",
|
||||
ServeFile::new(swiper_path),
|
||||
)
|
||||
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
|
||||
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
|
||||
.leptos_routes_with_context(
|
||||
&state,
|
||||
routes,
|
||||
@@ -188,11 +300,29 @@ async fn main() -> anyhow::Result<()> {
|
||||
)
|
||||
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||
.layer(session_layer)
|
||||
.with_state(state);
|
||||
// Which storyline this visitor reads, before anything renders.
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
|
||||
// Outermost, so a caller who is over the limit is turned away
|
||||
// before a session is loaded or a handler is entered.
|
||||
let app = match governor {
|
||||
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
|
||||
None => app,
|
||||
}
|
||||
.with_state(state);
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app.into_make_service()).await?;
|
||||
// With connect info, not without: the rate limiter reads the
|
||||
// caller from `X-Forwarded-For`/`X-Real-IP` and falls back to the
|
||||
// socket, and without this there is no socket to fall back to. A
|
||||
// request that arrives without those headers - a health check on
|
||||
// localhost, anything not through Caddy - then has no key at all,
|
||||
// and the limiter answers 500 to every one of them.
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -201,3 +331,15 @@ fn main() {
|
||||
// The browser build is a cdylib; this stub only exists so `cargo check`
|
||||
// without features still succeeds.
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// The site's name in NATS subjects (`portal.access.<site>.may`):
|
||||
/// `SITE_NAME`, folded to what a subject token allows.
|
||||
fn site_name_for_subjects() -> String {
|
||||
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
|
||||
let folded: String = raw
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' })
|
||||
.collect();
|
||||
if folded.is_empty() { "portal".to_string() } else { folded }
|
||||
}
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
//! Maps, served entirely from this origin.
|
||||
//!
|
||||
//! A `type: location` field lets someone put a pin on a map, and a
|
||||
//! record that holds one shows where it is. Both need tiles, and tiles
|
||||
//! come from Mapbox - but the visitor's browser never talks to Mapbox:
|
||||
//! portal fetches every tile and every static image itself, with the
|
||||
//! token kept server-side. Nothing third-party loads in the page, so
|
||||
//! there is no third-party cookie, no consent banner, and no address
|
||||
//! book of who looked at which hazard.
|
||||
//!
|
||||
//! `MAPBOX_TOKEN` unset means the whole feature is off: the field
|
||||
//! degrades to two number inputs and a stored location renders as its
|
||||
//! coordinates, the same fail-closed shape uploads take without
|
||||
//! `GARAGE_*`.
|
||||
//!
|
||||
//! Only the proxy handlers are server-side; reading a stored location
|
||||
//! and addressing its still are needed in the browser too, where the
|
||||
//! desks and the public lists render.
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use axum::extract::Path;
|
||||
#[cfg(feature = "ssr")]
|
||||
use axum::http::{header, StatusCode};
|
||||
#[cfg(feature = "ssr")]
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// Where the still of one location lives on this origin. The path is
|
||||
/// the same shape the handler parses back out.
|
||||
pub fn still_url(lat: f64, lon: f64, zoom: f64, width: u32, height: u32) -> String {
|
||||
format!("/map/still/{lon:.5},{lat:.5},{zoom:.2}/{width}x{height}")
|
||||
}
|
||||
|
||||
/// A Mapbox style, as `<owner>/<style-id>`. Overridable per instance
|
||||
/// (`MAPBOX_STYLE`); anything that is not two plain segments is
|
||||
/// ignored, since it goes into a URL path.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn style_id() -> String {
|
||||
std::env::var("MAPBOX_STYLE")
|
||||
.ok()
|
||||
.filter(|s| {
|
||||
let mut parts = s.split('/');
|
||||
let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
[owner, id].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
|
||||
})
|
||||
.unwrap_or_else(|| "mapbox/streets-v12".to_string())
|
||||
}
|
||||
|
||||
/// The instance's Mapbox token, or `None` when maps are off.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn token() -> Option<String> {
|
||||
std::env::var("MAPBOX_TOKEN").ok().filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn enabled() -> bool {
|
||||
token().is_some()
|
||||
}
|
||||
|
||||
/// Everything MapLibre needs, pointed back at this origin: raster
|
||||
/// tiles, so there are no sprite or glyph URLs to rewrite and no
|
||||
/// second round of proxying. The labels are baked into the tiles.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn style() -> Response {
|
||||
if !enabled() {
|
||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
||||
}
|
||||
let style = serde_json::json!({
|
||||
"version": 8,
|
||||
"sources": {
|
||||
"base": {
|
||||
"type": "raster",
|
||||
"tiles": ["/map/tiles/{z}/{x}/{y}"],
|
||||
"tileSize": 512,
|
||||
"attribution": "© <a href=\"https://www.mapbox.com/about/maps/\">Mapbox</a> © <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>",
|
||||
}
|
||||
},
|
||||
"layers": [{ "id": "base", "type": "raster", "source": "base" }],
|
||||
});
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")],
|
||||
style.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// One raster tile. The path is three integers and nothing else, so
|
||||
/// there is no way to steer the upstream request anywhere but a tile.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
|
||||
let Some(token) = token() else {
|
||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
||||
};
|
||||
if z > 22 || x >= 1 << z.min(22) || y >= 1 << z.min(22) {
|
||||
return (StatusCode::BAD_REQUEST, "no such tile").into_response();
|
||||
}
|
||||
let url = format!(
|
||||
"https://api.mapbox.com/styles/v1/{}/tiles/512/{z}/{x}/{y}?access_token={token}",
|
||||
style_id()
|
||||
);
|
||||
fetch(&url, "image/png", "public, max-age=86400").await
|
||||
}
|
||||
|
||||
/// A still map of one point, for a record that holds a location: an
|
||||
/// image, so a list of twenty of them costs no JavaScript at all.
|
||||
/// `{place}` is `<lon>,<lat>,<zoom>` and `{size}` is `<w>x<h>`.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
|
||||
let Some(token) = token() else {
|
||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
||||
};
|
||||
let Some((lon, lat, zoom)) = parse_place(&place) else {
|
||||
return (StatusCode::BAD_REQUEST, "expected <lon>,<lat>,<zoom>").into_response();
|
||||
};
|
||||
let Some((w, h)) = parse_size(&size) else {
|
||||
return (StatusCode::BAD_REQUEST, "expected <width>x<height>, at most 1280").into_response();
|
||||
};
|
||||
// The pin is drawn by Mapbox so the image is self-contained; the
|
||||
// marker colour is portal's ink, not a brand colour.
|
||||
let url = format!(
|
||||
"https://api.mapbox.com/styles/v1/{}/static/pin-s+3c3c3a({lon},{lat})/{lon},{lat},{zoom},0/{w}x{h}@2x?access_token={token}",
|
||||
style_id()
|
||||
);
|
||||
fetch(&url, "image/png", "public, max-age=604800").await
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
||||
let resp = match crate::http::client().get(url).send().await.and_then(|r| r.error_for_status()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("map upstream: {e}");
|
||||
return (StatusCode::BAD_GATEWAY, "the map service did not answer").into_response();
|
||||
}
|
||||
};
|
||||
let upstream = resp
|
||||
.headers()
|
||||
.get(openidconnect::reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|v| v.starts_with("image/"))
|
||||
.map(str::to_string);
|
||||
match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
|
||||
Ok(bytes) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())),
|
||||
(header::CACHE_CONTROL, cache.to_string()),
|
||||
],
|
||||
axum::body::Bytes::from(bytes),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<lon>,<lat>,<zoom>`, each a plain number in range. Formatted back
|
||||
/// out rather than passed through, so nothing of the caller's string
|
||||
/// reaches the upstream URL.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn parse_place(place: &str) -> Option<(String, String, String)> {
|
||||
let mut parts = place.split(',');
|
||||
let (Some(lon), Some(lat), Some(zoom), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
|
||||
return None;
|
||||
};
|
||||
let lon: f64 = lon.parse().ok()?;
|
||||
let lat: f64 = lat.parse().ok()?;
|
||||
let zoom: f64 = zoom.parse().ok()?;
|
||||
if !(-180.0..=180.0).contains(&lon) || !(-85.0511..=85.0511).contains(&lat) || !(0.0..=22.0).contains(&zoom) {
|
||||
return None;
|
||||
}
|
||||
Some((format!("{lon:.5}"), format!("{lat:.5}"), format!("{zoom:.2}")))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn parse_size(size: &str) -> Option<(u32, u32)> {
|
||||
let (w, h) = size.split_once('x')?;
|
||||
let (w, h): (u32, u32) = (w.parse().ok()?, h.parse().ok()?);
|
||||
(1..=1280).contains(&w).then_some(())?;
|
||||
(1..=1280).contains(&h).then_some(())?;
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
/// The `{lat, lon, zoom}` a `type: location` field stores, read back
|
||||
/// out of a record. Anything else is not a location.
|
||||
pub fn read(value: &serde_json::Value) -> Option<(f64, f64, f64)> {
|
||||
let obj = value.as_object()?;
|
||||
let num = |k: &str| obj.get(k).and_then(serde_json::Value::as_f64);
|
||||
let (lat, lon) = (num("lat")?, num("lon")?);
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
return None;
|
||||
}
|
||||
Some((lat, lon, num("zoom").unwrap_or(13.0).clamp(0.0, 22.0)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "ssr")]
|
||||
fn a_place_is_three_numbers_in_range_and_is_reformatted() {
|
||||
assert_eq!(
|
||||
parse_place("11.123456,59.987654,13.5"),
|
||||
Some(("11.12346".into(), "59.98765".into(), "13.50".into()))
|
||||
);
|
||||
for bad in ["11,59", "11,59,13,0", "200,59,13", "11,95,13", "11,59,30", "11,59,x", "11,59,13?a=b"] {
|
||||
assert_eq!(parse_place(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "ssr")]
|
||||
fn a_size_is_two_numbers_within_what_the_service_draws() {
|
||||
assert_eq!(parse_size("600x300"), Some((600, 300)));
|
||||
for bad in ["600", "0x300", "600x0", "2000x300", "600x2000", "600x300x2", "sixx300"] {
|
||||
assert_eq!(parse_size(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_is_a_lat_and_a_lon_and_a_zoom_it_may_leave_out() {
|
||||
let v = serde_json::json!({ "lat": 59.5, "lon": 11.0, "zoom": 16 });
|
||||
assert_eq!(read(&v), Some((59.5, 11.0, 16.0)));
|
||||
assert_eq!(read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })), Some((59.5, 11.0, 13.0)));
|
||||
for not in [
|
||||
serde_json::json!({ "lat": 91.0, "lon": 11.0 }),
|
||||
serde_json::json!({ "lat": 59.5 }),
|
||||
serde_json::json!("59.5, 11.0"),
|
||||
serde_json::Value::Null,
|
||||
] {
|
||||
assert_eq!(read(¬), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "ssr")]
|
||||
fn a_style_id_is_two_plain_segments_or_the_default() {
|
||||
std::env::set_var("MAPBOX_STYLE", "mapbox/outdoors-v12");
|
||||
assert_eq!(style_id(), "mapbox/outdoors-v12");
|
||||
for bad in ["../../secrets", "mapbox/streets-v12?x=1", "mapbox", "a/b/c"] {
|
||||
std::env::set_var("MAPBOX_STYLE", bad);
|
||||
assert_eq!(style_id(), "mapbox/streets-v12", "{bad}");
|
||||
}
|
||||
std::env::remove_var("MAPBOX_STYLE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Who belongs to which group, kept by portal itself.
|
||||
//!
|
||||
//! A membership is a case: one record per (group, person) in the
|
||||
//! built-in `portal_memberships` bucket, `invited` until the person is
|
||||
//! seen, `active` from then on, `removed` when a desk takes them out.
|
||||
//! A desk invite writes one, a state that `grants` a group writes one,
|
||||
//! the first person and the people the content names as responsible
|
||||
//! get theirs at start. Nothing here talks to an identity provider:
|
||||
//! the provider says who someone is, and this says what they belong
|
||||
//! to. At sign-in, the person's active memberships become their groups,
|
||||
//! and the access policy (Cedar, `access.rs`) sees them as the parents
|
||||
//! of the `User` entity, exactly as it saw a `groups` claim before.
|
||||
//!
|
||||
//! **A person is their phone number first, their email second.** A
|
||||
//! provider that verifies phone numbers (Vipps) makes the number the
|
||||
//! surest thing a record can hold; a form that collected an address
|
||||
//! still matches on it. A record's key is the group and whichever of
|
||||
//! the two it has, so a second invite of the same person into the same
|
||||
//! group finds the first.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::answers::Answer;
|
||||
|
||||
pub const BUCKET: &str = "portal_memberships";
|
||||
pub const INVITED: &str = "invited";
|
||||
pub const ACTIVE: &str = "active";
|
||||
pub const REMOVED: &str = "removed";
|
||||
/// Stamped on moves nobody at a desk made.
|
||||
pub const BY: &str = "portal";
|
||||
|
||||
/// The country a phone number without one is in, as a calling code.
|
||||
fn default_country() -> String {
|
||||
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string())
|
||||
}
|
||||
|
||||
/// Who a person is, as far as a record or a session knows.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Identity {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sub: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// A phone number as one string for everyone: digits with a leading
|
||||
/// `+` and a country code, `+4791234567`. Spaces, dots and dashes go;
|
||||
/// `00` becomes `+`; a bare national number gets the site's country.
|
||||
/// `None` for anything that is not a number.
|
||||
pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
|
||||
let mut digits: String = raw.trim().chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let trimmed = raw.trim();
|
||||
if digits.is_empty() || digits.len() < 5 || digits.len() > 15 {
|
||||
return None;
|
||||
}
|
||||
if trimmed.starts_with('+') {
|
||||
return Some(format!("+{digits}"));
|
||||
}
|
||||
if let Some(rest) = digits.strip_prefix("00") {
|
||||
return Some(format!("+{rest}"));
|
||||
}
|
||||
if trimmed.starts_with('(') && !trimmed.contains('+') {
|
||||
digits = digits.trim_start_matches('0').to_string();
|
||||
}
|
||||
Some(format!("{}{digits}", country.trim()))
|
||||
}
|
||||
|
||||
fn normalize_email(raw: &str) -> Option<String> {
|
||||
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// From what a form collected: the first phone-like field and the
|
||||
/// first email-like field, by the names forms use for them.
|
||||
pub fn from_responses(responses: &serde_json::Value) -> Self {
|
||||
let field = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|n| responses.get(*n).and_then(|v| v.as_str()))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
};
|
||||
let country = default_country();
|
||||
Identity {
|
||||
sub: None,
|
||||
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
|
||||
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_user(user: &crate::auth::User) -> Self {
|
||||
let country = default_country();
|
||||
Identity {
|
||||
sub: Some(user.sub.clone()),
|
||||
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
|
||||
email: user.email.as_deref().and_then(normalize_email),
|
||||
}
|
||||
}
|
||||
|
||||
/// One person given as one string: a phone number if it reads as
|
||||
/// one, else an address.
|
||||
pub fn from_contact(contact: &str) -> Self {
|
||||
let country = default_country();
|
||||
match normalize_email(contact) {
|
||||
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
|
||||
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// What a membership record is keyed by: the phone number, else the
|
||||
/// address, else the provider's subject. `None` when there is
|
||||
/// nothing to know the person by.
|
||||
pub fn key(&self) -> Option<String> {
|
||||
self.phone
|
||||
.as_deref()
|
||||
.or(self.email.as_deref())
|
||||
.or(self.sub.as_deref())
|
||||
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
|
||||
}
|
||||
|
||||
/// Whether a record about `other` is about this person: the same
|
||||
/// subject, phone number or address, any one of them.
|
||||
pub fn matches(&self, other: &Identity) -> bool {
|
||||
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
|
||||
same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email)
|
||||
}
|
||||
|
||||
/// What is worth writing on a record from a login: everything the
|
||||
/// provider verified that the record did not have.
|
||||
fn merged_from(&self, seen: &Identity) -> Identity {
|
||||
Identity {
|
||||
sub: self.sub.clone().or_else(|| seen.sub.clone()),
|
||||
phone: self.phone.clone().or_else(|| seen.phone.clone()),
|
||||
email: self.email.clone().or_else(|| seen.email.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One record's id: the group and the person's key.
|
||||
pub fn record_id(group: &str, key: &str) -> String {
|
||||
format!("{}.{key}", group.trim())
|
||||
}
|
||||
|
||||
/// The identity a record holds.
|
||||
pub fn identity_of(answer: &Answer) -> Identity {
|
||||
Identity {
|
||||
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_of(answer: &Answer) -> String {
|
||||
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// The groups `who` is an active member of, from every record.
|
||||
pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
|
||||
let mut groups: Vec<String> = records
|
||||
.iter()
|
||||
.filter(|a| a.state == ACTIVE && identity_of(a).matches(who))
|
||||
.map(group_of)
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
groups
|
||||
}
|
||||
|
||||
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
||||
match state.jetstream.get_key_value(BUCKET).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(_) => Ok(state
|
||||
.jetstream
|
||||
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
|
||||
.await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every membership record.
|
||||
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
||||
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() };
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for key in keys {
|
||||
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||
if let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) {
|
||||
out.push(answer);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Moves one record the way a desk button would: through the event
|
||||
/// log, onto the projection, out on the answers subject.
|
||||
async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result<Answer> {
|
||||
let aggregates = state.aggregates.load();
|
||||
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
|
||||
crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
answer.state = target.to_string();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(by.to_string());
|
||||
store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?;
|
||||
let parent_hashes = vec![answer.id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let event = crate::events::AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id: answer.question_id.clone(),
|
||||
alternative: target.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Puts a person in a group, or finds them there. A new record starts
|
||||
/// `invited`; a removed one is invited again; an invited or active one
|
||||
/// is left as it is, so a second grant costs nothing. `created` in
|
||||
/// what comes back says whether the person had a place already.
|
||||
pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result<crate::people::Invited> {
|
||||
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
|
||||
let store = store(state).await?;
|
||||
// The person's record in this group, however it was keyed: a desk
|
||||
// that invited by address and a decision that granted by phone
|
||||
// number are talking about one person once a sign-in has joined
|
||||
// the two, and must not make two.
|
||||
let existing: Option<Answer> = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
|
||||
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let invited = |created: bool| crate::people::Invited {
|
||||
username: key.clone(),
|
||||
display_name: name.to_string(),
|
||||
email: who.email.clone().unwrap_or_default(),
|
||||
group: group.to_string(),
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created,
|
||||
already_member: !created,
|
||||
};
|
||||
match existing {
|
||||
Some(answer) if answer.state == REMOVED => {
|
||||
move_to(state, &store, answer, INVITED, by).await?;
|
||||
Ok(invited(true))
|
||||
}
|
||||
Some(_) => Ok(invited(false)),
|
||||
None => {
|
||||
let responses = serde_json::json!({
|
||||
"name": name, "group": group, "by": by,
|
||||
"phone": who.phone, "email": who.email, "sub": who.sub,
|
||||
});
|
||||
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?;
|
||||
Ok(invited(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a person out of a group: the record moves to `removed` and
|
||||
/// stays, so the desk can see who was in and put them back.
|
||||
pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result<bool> {
|
||||
let Some(key) = who.key() else { return Ok(false) };
|
||||
let store = store(state).await?;
|
||||
let id = record_id(group, &key);
|
||||
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if answer.state == REMOVED {
|
||||
return Ok(false);
|
||||
}
|
||||
move_to(state, &store, answer, REMOVED, by).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Someone signed in: every invitation that was for them is now a
|
||||
/// membership, with what the provider verified written on the record,
|
||||
/// and their active groups come back to be their groups for the
|
||||
/// session. Their `sub` is remembered, so the next login matches on
|
||||
/// it before anything else.
|
||||
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
|
||||
let who = Identity::from_user(user);
|
||||
let records = all(state).await;
|
||||
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
|
||||
let mut settled = Vec::with_capacity(records.len());
|
||||
for answer in records {
|
||||
let held = identity_of(&answer);
|
||||
if !held.matches(&who) {
|
||||
settled.push(answer);
|
||||
continue;
|
||||
}
|
||||
let merged = held.merged_from(&who);
|
||||
let mut answer = answer;
|
||||
if merged != held {
|
||||
if let Some(map) = answer.responses.as_object_mut() {
|
||||
map.insert("sub".into(), serde_json::json!(merged.sub));
|
||||
map.insert("phone".into(), serde_json::json!(merged.phone));
|
||||
map.insert("email".into(), serde_json::json!(merged.email));
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&answer.id, bytes.into()).await;
|
||||
}
|
||||
}
|
||||
if answer.state == INVITED {
|
||||
match move_to(state, &store, answer.clone(), ACTIVE, BY).await {
|
||||
Ok(active) => {
|
||||
tracing::info!(group = %group_of(&active), user = %user.username, "an invitation became a membership");
|
||||
settled.push(active);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id = %answer.id, "an invitation could not become a membership: {e}");
|
||||
settled.push(answer);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settled.push(answer);
|
||||
}
|
||||
}
|
||||
groups_of(&settled, &who)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
|
||||
Answer {
|
||||
id: record_id(group, "k"),
|
||||
question_id: "/".into(),
|
||||
alternative: "Membership".into(),
|
||||
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
|
||||
state: state.into(),
|
||||
submitted_ms: 0,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_number_is_one_string_for_everyone() {
|
||||
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into()));
|
||||
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
|
||||
assert_eq!(normalize_phone("123", "+47"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_is_their_phone_first_and_their_address_second() {
|
||||
let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }));
|
||||
assert_eq!(both.key().as_deref(), Some("_4791234567"));
|
||||
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
||||
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
|
||||
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
|
||||
assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no"));
|
||||
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
|
||||
assert!(Identity::default().key().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
|
||||
let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
|
||||
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
|
||||
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
|
||||
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
|
||||
assert!(!Identity::default().matches(&kari));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_memberships_are_groups() {
|
||||
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||
let records = vec![
|
||||
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
||||
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
||||
record("trafikk", REMOVED, Some("+4791234567"), None, None),
|
||||
record("medlemmer", ACTIVE, None, Some("kari@vel.no"), None),
|
||||
record("styret", ACTIVE, Some("+4799999999"), None, None),
|
||||
];
|
||||
assert_eq!(groups_of(&records, &kari), vec!["medlemmer", "styret"]);
|
||||
}
|
||||
}
|
||||
+757
@@ -0,0 +1,757 @@
|
||||
//! Onboarding a person into Kanidm from a desk, without an admin
|
||||
//! session: create the account, record their email, add them to the
|
||||
//! group, and mint a one-time link where they set their own password
|
||||
//! or passkey. Kanidm has no self-registration by design; a member of
|
||||
//! the business vouching for each person is the intended shape, and
|
||||
//! this is that, as a form.
|
||||
//!
|
||||
//! Talks to Kanidm's REST API with a service-account token
|
||||
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
|
||||
//! service account belongs to `idm_people_on_boarding`, which may
|
||||
//! create persons and issue credential resets for ordinary people and
|
||||
//! nothing else, so a leaked token cannot touch an admin.
|
||||
//!
|
||||
//! **An invite is a case.** Each one is a record in `portal_invites`:
|
||||
//! `open` while the link waits, `done` once the person is in, `expired`
|
||||
//! when the link ran out unused. Nobody has to keep that current by
|
||||
//! hand: signing in to the site closes a person's invites, and a sweep
|
||||
//! moves the ones whose link has run out (`sweep_invites`). From
|
||||
//! `expired` a desk may send it again - the move back to `open` makes a
|
||||
//! fresh link and mails it (`on_reopened`).
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const INVITES_BUCKET: &str = "portal_invites";
|
||||
|
||||
/// Where people are kept. Kanidm makes accounts and holds groups; with
|
||||
/// memberships (`memberships.rs`) portal holds the groups itself and
|
||||
/// the identity provider only says who someone is. `PEOPLE_BACKEND`
|
||||
/// picks; unset, a `KANIDM_API_TOKEN` means Kanidm and its absence
|
||||
/// means memberships, so a site with no Kanidm token can still invite.
|
||||
pub enum People {
|
||||
Kanidm(Kanidm),
|
||||
Memberships,
|
||||
}
|
||||
|
||||
impl People {
|
||||
/// `None` only when Kanidm is asked for and not configured.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
|
||||
Some("memberships") => Some(People::Memberships),
|
||||
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
|
||||
Some(other) => {
|
||||
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
|
||||
Some(People::Memberships)
|
||||
}
|
||||
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is missing when `from_env` says no.
|
||||
pub const NOT_CONFIGURED: &'static str = "invites are not configured on this instance (PEOPLE_BACKEND=kanidm needs KANIDM_URL and KANIDM_API_TOKEN)";
|
||||
|
||||
/// Puts a person in a group, making them known if they were not:
|
||||
/// through Kanidm an account and a one-time link, through
|
||||
/// memberships a record that becomes theirs when they sign in.
|
||||
/// `by` is who asked, for the record.
|
||||
pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result<Invited> {
|
||||
match self {
|
||||
People::Kanidm(kanidm) => {
|
||||
let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?;
|
||||
kanidm.invite(name, email, group).await
|
||||
}
|
||||
People::Memberships => {
|
||||
let mut invited = crate::memberships::grant(state, group, name, who, by).await?;
|
||||
// A Kanidm beside memberships makes the person, not the
|
||||
// member: an account with no group, and the one-time
|
||||
// link, so someone invited by address can sign in
|
||||
// before the provider is one everyone already has.
|
||||
if let (Some(kanidm), Some(email)) = (Kanidm::from_env(), who.email.as_deref()) {
|
||||
match kanidm.account(name, email).await {
|
||||
Ok(account) => {
|
||||
invited.username = account.username;
|
||||
invited.reset_link = account.reset_link;
|
||||
invited.link_expires_ms = account.link_expires_ms;
|
||||
invited.created = account.created;
|
||||
}
|
||||
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(invited)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a person out of a group. Through Kanidm only what portal
|
||||
/// put there (see `Invited.already_member`); memberships remember.
|
||||
pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> {
|
||||
match self {
|
||||
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
|
||||
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_kanidm(&self) -> bool {
|
||||
matches!(self, People::Kanidm(_))
|
||||
}
|
||||
}
|
||||
pub const INVITED_SUBJECT: &str = "portal.people.invited";
|
||||
/// The states an invite moves between, besides the schema's initial
|
||||
/// `open` (see `BUILTIN_AGGREGATES_YAML`).
|
||||
pub const INVITE_EXPIRED: &str = "expired";
|
||||
pub const INVITE_DONE: &str = "done";
|
||||
/// What an invite made at start is recorded under, where a desk invite
|
||||
/// has the name of the alternative that made it.
|
||||
pub const SEED_ALTERNATIVE: &str = "Invited at start";
|
||||
/// Stamped on a move nobody pressed, in place of a username.
|
||||
pub const BY: &str = "portal";
|
||||
|
||||
/// How long a link lives: the longest Kanidm allows, since it travels
|
||||
/// by email and is read when the person gets to it.
|
||||
const LINK_SECONDS: i64 = 86_400;
|
||||
|
||||
/// What an invite produced: the account, and the link that sets it up.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Invited {
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
pub group: String,
|
||||
/// Absent when the person already had an account: they were added
|
||||
/// to the group and their credentials were left alone.
|
||||
pub reset_link: Option<String>,
|
||||
pub link_expires_ms: Option<i64>,
|
||||
pub created: bool,
|
||||
/// They were in the group before this invite put them there, so
|
||||
/// the membership is not portal's to take back.
|
||||
#[serde(default)]
|
||||
pub already_member: bool,
|
||||
}
|
||||
|
||||
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||
/// the epoch in 1.11, an RFC 3339 string before that.
|
||||
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's members as bare account names (`kari`, not
|
||||
/// `kari@id.example.no`). Kanidm answers an empty group with `null`.
|
||||
fn members_from(body: &str) -> Vec<String> {
|
||||
serde_json::from_str::<Option<Vec<String>>>(body)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| m.split('@').next().unwrap_or_default().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a `_credential/_status` answer shows a credential. Only a
|
||||
/// yes means anything: a person with nothing but a passkey is answered
|
||||
/// with an empty list too.
|
||||
fn has_credentials_from(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a `GET /v1/person/<name>` answer means the person exists.
|
||||
/// Kanidm 1.11 answers a missing person with 200 and the body `null`
|
||||
/// rather than 404, so the status alone would report everyone as
|
||||
/// existing and no account would ever get created. `None` for an
|
||||
/// answer that is neither.
|
||||
fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
|
||||
match status {
|
||||
200 => Some(body.trim() != "null" && !body.trim().is_empty()),
|
||||
404 => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first entry's `name` in a `/v1/raw/search` answer, which is
|
||||
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
|
||||
fn name_from_search(body: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
pub struct Kanidm {
|
||||
base: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Kanidm {
|
||||
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
|
||||
/// a clear message instead of a half-made account.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string();
|
||||
let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
|
||||
Some(Kanidm { base, token })
|
||||
}
|
||||
|
||||
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
|
||||
let mut req = crate::http::client()
|
||||
.request(method, format!("{}{path}", self.base))
|
||||
.bearer_auth(&self.token)
|
||||
.header("content-type", "application/json");
|
||||
if let Some(body) = body {
|
||||
req = req.body(serde_json::to_vec(&body)?);
|
||||
}
|
||||
let response = req.send().await?;
|
||||
let status = response.status().as_u16();
|
||||
let text = crate::http::text(response, crate::http::GITEA_CAP).await?;
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
||||
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
||||
}
|
||||
|
||||
/// The account that already carries this email, if the token may
|
||||
/// see it (the service account in `idm_people_pii_read`). None
|
||||
/// when nobody has it, or when the search is not permitted: then
|
||||
/// the username derived from the email decides, as before.
|
||||
async fn find_by_mail(&self, email: &str) -> anyhow::Result<Option<String>> {
|
||||
let (status, text) = self
|
||||
.call(
|
||||
openidconnect::reqwest::Method::POST,
|
||||
"/v1/raw/search",
|
||||
Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })),
|
||||
)
|
||||
.await?;
|
||||
// Refused is an answer, and the username decides. Anything else
|
||||
// that is not a yes is Kanidm not answering: guessing then could
|
||||
// give someone a second account.
|
||||
if (400..500).contains(&status) {
|
||||
tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
|
||||
return Ok(None);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: searching people by mail: http {status}");
|
||||
}
|
||||
Ok(name_from_search(&text))
|
||||
}
|
||||
|
||||
/// The whole onboarding, in the order that leaves the least behind
|
||||
/// if a step fails: find or create, then email, then group, then
|
||||
/// the link. A person who already has an account, under whatever
|
||||
/// username, is only added to the group; their credentials are
|
||||
/// left alone and no link is made.
|
||||
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
|
||||
self.onboard(name, email, Some(group)).await
|
||||
}
|
||||
|
||||
/// An account and nothing more: for a person the content names as
|
||||
/// responsible for a page, who needs to be able to sign in whether
|
||||
/// or not they sit at any desk. `group` is empty in what comes back.
|
||||
pub async fn account(&self, name: &str, email: &str) -> anyhow::Result<Invited> {
|
||||
self.onboard(name, email, None).await
|
||||
}
|
||||
|
||||
/// Whether Kanidm already holds a person with this address.
|
||||
pub async fn has_person_with_mail(&self, email: &str) -> anyhow::Result<bool> {
|
||||
Ok(self.find_by_mail(email).await?.is_some())
|
||||
}
|
||||
|
||||
/// Who is in a group, by account name.
|
||||
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{group}/_attr/member"), None).await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
|
||||
}
|
||||
Ok(members_from(&text))
|
||||
}
|
||||
|
||||
/// Takes a person out of a group. Theirs to do for a group the
|
||||
/// service account manages, like adding them was.
|
||||
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether Kanidm shows a credential for this person. `false` is
|
||||
/// "not that it can see", never "none": see `has_credentials_from`.
|
||||
pub async fn has_credentials(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: credential status of {username}: http {status}");
|
||||
}
|
||||
Ok(has_credentials_from(&text))
|
||||
}
|
||||
|
||||
/// A one-time link where the person sets their own password or
|
||||
/// passkey, and when it runs out.
|
||||
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Intent {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
expiry_time: Option<serde_json::Value>,
|
||||
}
|
||||
let intent: Intent = serde_json::from_str(&text)?;
|
||||
Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms)))
|
||||
}
|
||||
|
||||
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
|
||||
let existing = self.find_by_mail(email).await?;
|
||||
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||
let created = if existing.is_some() || self.person_exists(&username).await? {
|
||||
false
|
||||
} else {
|
||||
// The address goes in with the person, in one call: the
|
||||
// onboarding account may create a person but not modify one
|
||||
// (idm_people_on_boarding), so a separate PUT of `mail`
|
||||
// afterwards is refused with 403 and the invite fails.
|
||||
let (status, text) = self
|
||||
.call(
|
||||
openidconnect::reqwest::Method::POST,
|
||||
"/v1/person",
|
||||
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name], "mail": [email] } })),
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
|
||||
}
|
||||
true
|
||||
};
|
||||
let mut already_member = false;
|
||||
if let Some(group) = group {
|
||||
// Someone new is in no group. For anyone else, whether they
|
||||
// were here before is what says whose the membership is; a
|
||||
// failed read says "not before", which errs toward portal
|
||||
// tidying up after itself.
|
||||
already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
||||
}
|
||||
}
|
||||
let (reset_link, link_expires_ms) = if created {
|
||||
let (link, expires) = self.reset_link(&username).await?;
|
||||
(Some(link), expires)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Ok(Invited {
|
||||
username,
|
||||
display_name: name.to_string(),
|
||||
email: email.to_string(),
|
||||
group: group.unwrap_or_default().to_string(),
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// When an open invite's link runs out: what Kanidm said, or a link's
|
||||
/// lifetime after it was last sent for a record that does not say.
|
||||
fn link_runs_out(answer: &crate::answers::Answer) -> i64 {
|
||||
answer
|
||||
.responses
|
||||
.get("invited")
|
||||
.and_then(|i| i.get("link_expires_ms"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_else(|| answer.decided_ms.unwrap_or(answer.submitted_ms) + LINK_SECONDS * 1000)
|
||||
}
|
||||
|
||||
/// The account an invite record is about.
|
||||
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
||||
answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// Where a waiting invite goes now, if anywhere. Pure: what is known
|
||||
/// about the person comes in as `is_in`.
|
||||
///
|
||||
/// An invite that made no account has no link to wait for: the person
|
||||
/// had their way in already, and was only given a place.
|
||||
pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> {
|
||||
if answer.state != crate::answers::OPEN_STATE {
|
||||
return None;
|
||||
}
|
||||
let made_an_account = invited_of(answer).is_some_and(|i| i.created);
|
||||
if is_in || !made_an_account {
|
||||
Some(INVITE_DONE)
|
||||
} else if now_ms >= link_runs_out(answer) {
|
||||
Some(INVITE_EXPIRED)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An invite nobody at a desk made - the first person, someone the
|
||||
/// content names as responsible - on file like any other, so the desk
|
||||
/// that reads `portal_invites` sees it, and can send it again.
|
||||
pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mut responses = serde_json::json!({
|
||||
"name": invited.display_name,
|
||||
"email": invited.email,
|
||||
"invited": invited,
|
||||
});
|
||||
if !responsible_for.is_empty() {
|
||||
responses["responsible_for"] = serde_json::json!(responsible_for);
|
||||
}
|
||||
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
|
||||
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).await {
|
||||
tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an invite record, the way a desk button would: through the
|
||||
/// event log, onto the projection, and out on the answers subject.
|
||||
async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) {
|
||||
let aggregates = state.aggregates.load();
|
||||
let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else {
|
||||
// A content repo may declare the bucket itself, without this
|
||||
// state. Then the invite stays where its own graph has it.
|
||||
return;
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY });
|
||||
if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await {
|
||||
tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}");
|
||||
return;
|
||||
}
|
||||
answer.state = target.to_string();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(BY.to_string());
|
||||
// The link is a way in for as long as it is on file; one that is
|
||||
// used or run out is only something to leak.
|
||||
if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) {
|
||||
invited.insert("reset_link".into(), serde_json::Value::Null);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&answer.id, data.into()).await;
|
||||
}
|
||||
let parent_hashes = vec![answer.id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let event = crate::events::AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id: answer.question_id.clone(),
|
||||
alternative: target.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
storyline: answer.storyline,
|
||||
};
|
||||
if let Err(e) = crate::events::emit_answer_submitted(&state.nats, &event).await {
|
||||
tracing::warn!(id = %answer.id, target, "the invite was moved but not published: {e}");
|
||||
}
|
||||
tracing::info!(id = %answer.id, target, "an invite moved on its own");
|
||||
}
|
||||
|
||||
/// Every invite on file that is still waiting in one of `states`.
|
||||
async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec<crate::answers::Answer>)> {
|
||||
// Made on first write: a site that has invited nobody has no bucket.
|
||||
let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?;
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
store.keys().await.ok()?.try_collect().await.unwrap_or_default()
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
if states.contains(&answer.state.as_str()) {
|
||||
found.push(answer);
|
||||
}
|
||||
}
|
||||
Some((store, found))
|
||||
}
|
||||
|
||||
/// Runs for the life of the process, every ten minutes: a link lives a
|
||||
/// day, so an invite is never shown as waiting long after it stopped.
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(10 * 60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
sweep_invites(&state).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass over the waiting invites: done for whoever Kanidm shows a
|
||||
/// credential for, expired for a link that ran out.
|
||||
pub async fn sweep_invites(state: &crate::server::AppState) {
|
||||
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return };
|
||||
let kanidm = Kanidm::from_env();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
for answer in waiting {
|
||||
let is_in = match (&kanidm, invited_of(&answer)) {
|
||||
(Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(target) = invite_due(&answer, is_in, now_ms) {
|
||||
move_invite(state, &store, answer, target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an invite was this person's: the account it made, or the
|
||||
/// address it went to for someone whose account goes by another name.
|
||||
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
|
||||
let address = invited.email.trim().to_lowercase();
|
||||
invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address))
|
||||
}
|
||||
|
||||
/// Someone signed in: whatever invites were waiting for them, or had
|
||||
/// run out, are done. This is the one sure sign a link was used -
|
||||
/// they are here.
|
||||
pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) {
|
||||
let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return };
|
||||
for answer in waiting {
|
||||
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
|
||||
move_invite(&state, &store, answer, INVITE_DONE).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A desk sent an expired invite again (`expired` to `open`): a fresh
|
||||
/// link, on the record and in the mail. The same mail as the first
|
||||
/// time, which for someone the content names as responsible is the one
|
||||
/// that says so.
|
||||
pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
||||
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { return };
|
||||
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { return };
|
||||
let Some(mut invited) = invited_of(&answer) else {
|
||||
tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent");
|
||||
return;
|
||||
};
|
||||
let Some(kanidm) = Kanidm::from_env() else {
|
||||
tracing::warn!(id, "an invite was sent again, but invites are not configured on this instance (KANIDM_API_TOKEN) - nothing sent");
|
||||
return;
|
||||
};
|
||||
let (link, expires) = match kanidm.reset_link(&invited.username).await {
|
||||
Ok(made) => made,
|
||||
Err(e) => {
|
||||
tracing::error!(id, user = %invited.username, "a new link could not be made: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
invited.reset_link = Some(link);
|
||||
invited.link_expires_ms = expires;
|
||||
if let Some(map) = answer.responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(id, data.into()).await;
|
||||
}
|
||||
let responsible_for: Vec<String> = answer
|
||||
.responses
|
||||
.get("responsible_for")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
if responsible_for.is_empty() {
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
} else {
|
||||
crate::mail::on_responsible(state, &invited, &responsible_for).await;
|
||||
}
|
||||
tracing::info!(id, user = %invited.username, "an invite was sent again");
|
||||
}
|
||||
|
||||
/// A Kanidm account name from an email: the local part, lowercased,
|
||||
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
|
||||
pub fn username_for(email: &str) -> String {
|
||||
let local = email.split('@').next().unwrap_or(email).to_lowercase();
|
||||
let mut out: String = local
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' })
|
||||
.collect();
|
||||
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
|
||||
out.remove(0);
|
||||
}
|
||||
if out.is_empty() {
|
||||
"person".to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The person a record is about, for a state that grants a group: their
|
||||
/// name, and a phone number or an address to know them by. Required by
|
||||
/// the lint of any form that fills such a bucket (see
|
||||
/// `validate_questions`), so a record missing them is one that predates
|
||||
/// the rule, and is reported rather than guessed at.
|
||||
pub fn who_for(responses: &serde_json::Value) -> Option<(String, crate::memberships::Identity)> {
|
||||
let name = responses
|
||||
.get("name")
|
||||
.or_else(|| responses.get("navn"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)?;
|
||||
let who = crate::memberships::Identity::from_responses(responses);
|
||||
who.key().is_some().then_some((name, who))
|
||||
}
|
||||
|
||||
/// 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(people) = People::from_env() else {
|
||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED);
|
||||
return;
|
||||
};
|
||||
let Some((name, who)) = who_for(responses) else {
|
||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added");
|
||||
return;
|
||||
};
|
||||
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).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::*;
|
||||
|
||||
#[test]
|
||||
fn usernames_are_the_email_local_part_made_safe() {
|
||||
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
|
||||
assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site");
|
||||
assert_eq!(username_for("42@x.no"), "person");
|
||||
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_by_mail_yields_the_accounts_own_name() {
|
||||
assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into()));
|
||||
assert_eq!(name_from_search(r#"{"entries":[]}"#), None);
|
||||
assert_eq!(name_from_search("null"), None);
|
||||
assert_eq!(name_from_search("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_runs_out_when_kanidm_says_in_either_spelling() {
|
||||
assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!(null)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_are_account_names_and_an_empty_group_is_null() {
|
||||
assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]);
|
||||
assert!(members_from("null").is_empty());
|
||||
assert!(members_from("nonsense").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_listed_credential_counts() {
|
||||
assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#));
|
||||
assert!(!has_credentials_from(r#"{"creds":[]}"#));
|
||||
assert!(!has_credentials_from("null"));
|
||||
}
|
||||
|
||||
fn invite(state: &str, created: bool, expires: Option<i64>) -> crate::answers::Answer {
|
||||
crate::answers::Answer {
|
||||
id: "a".into(),
|
||||
question_id: "/styret".into(),
|
||||
alternative: "Inviter".into(),
|
||||
responses: serde_json::json!({ "name": "Kari", "email": "kari@vel.no", "invited": {
|
||||
"username": "kari", "display_name": "Kari", "email": "kari@vel.no", "group": "styret",
|
||||
"reset_link": "https://id/ui/reset?token=x", "link_expires_ms": expires, "created": created,
|
||||
}}),
|
||||
state: state.into(),
|
||||
submitted_ms: 1_000,
|
||||
storyline: 0,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
|
||||
let day = LINK_SECONDS * 1000;
|
||||
// Still within the link's life, and nobody has seen them: wait.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None);
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED));
|
||||
// In is in, however long the link had left.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE));
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE));
|
||||
// A record from before links carried their end: a day from when it was sent.
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None);
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED));
|
||||
// Someone who had an account was only given a place: nothing to wait for.
|
||||
assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE));
|
||||
// Only a waiting invite moves.
|
||||
assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None);
|
||||
assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invite_is_theirs_by_account_or_by_address() {
|
||||
let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap();
|
||||
let user = |username: &str, email: Option<&str>| crate::auth::User {
|
||||
sub: "s".into(),
|
||||
username: username.into(),
|
||||
display_name: String::new(),
|
||||
groups: vec![],
|
||||
phone: None,
|
||||
email: email.map(String::from),
|
||||
};
|
||||
assert!(is_for(&invited, &user("kari", None)));
|
||||
assert!(is_for(&invited, &user("kari.n", Some("Kari@Vel.no"))));
|
||||
assert!(!is_for(&invited, &user("ola", Some("ola@vel.no"))));
|
||||
assert!(!is_for(&invited, &user("ola", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_person_answer_means_nobody_there() {
|
||||
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
|
||||
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
|
||||
assert_eq!(person_lookup_found(404, ""), Some(false));
|
||||
assert_eq!(person_lookup_found(401, "unauthorized"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! What the storylines did, as numbers and nothing else.
|
||||
//!
|
||||
//! `GET /report` answers with a count per bucket, per storyline, per
|
||||
//! state: how many cases each storyline brought in and where they got
|
||||
//! to. That is the whole of what the storylines are compared on. No
|
||||
//! record, no field, no address, no time leaves the site: a storyline
|
||||
//! is assigned evenly, so the counts are the comparison, and the
|
||||
//! trainer (`portal-trainer report`) reads them to say which words
|
||||
//! worked. Public, since a count is what a public list already shows.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The report: the site's storylines, and per bucket the count of
|
||||
/// records by storyline and state.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub storylines: usize,
|
||||
/// bucket -> storyline (as a string, JSON's way) -> state -> count
|
||||
pub buckets: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
||||
}
|
||||
|
||||
pub async fn gather(state: &crate::server::AppState) -> Report {
|
||||
let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() };
|
||||
let aggregates = state.aggregates.load();
|
||||
for bucket in aggregates.keys() {
|
||||
// The runtime's own buckets are not the site's offers.
|
||||
if bucket.starts_with("portal_") {
|
||||
continue;
|
||||
}
|
||||
let Ok(store) = state.jetstream.get_key_value(bucket).await else { continue };
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
let counts = report.buckets.entry(bucket.clone()).or_default();
|
||||
for key in keys {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
*counts.entry(answer.storyline.to_string()).or_default().entry(answer.state).or_default() += 1;
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
pub async fn handler(axum::extract::State(state): axum::extract::State<crate::server::AppState>) -> axum::Json<Report> {
|
||||
axum::Json(gather(&state).await)
|
||||
}
|
||||
+154
-37
@@ -13,7 +13,7 @@ use leptos::prelude::*;
|
||||
/// `public: true` nor `requires_group` set is unreachable, not "open"
|
||||
/// by omission. `params` are the requesting form's current field
|
||||
/// values (if any) - forwarded as query parameters to a live external
|
||||
/// fetch (`GiteaStarred`/`GiteaOrgRepos`/`Url`), the mechanism behind
|
||||
/// fetch (`GiteaStarred`/`GiteaReleases`/`Url`), the mechanism behind
|
||||
/// "a resource parameterized by other form fields"; a `Kv` resource
|
||||
/// ignores them entirely, same as today.
|
||||
#[server(endpoint = "get_resource")]
|
||||
@@ -33,12 +33,12 @@ pub async fn get_resource(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let resource = feature
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||
authorize_resource(resource).await?;
|
||||
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
|
||||
fetch_resource_value(&state, resource, ¶ms).await
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ pub async fn get_requirement_options(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -82,7 +82,7 @@ pub async fn get_requirement_options(
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
||||
authorize_resource(resource).await?;
|
||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
|
||||
fetch_resource_value(&state, resource, ¶ms).await
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ pub async fn get_requirement_binding(
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -113,7 +113,7 @@ pub async fn get_requirement_binding(
|
||||
.bind
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
||||
authorize_resource(&bind.resource).await?;
|
||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
|
||||
fetch_resource_value(&state, &bind.resource, ¶ms).await
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ pub async fn get_requirement_binding(
|
||||
#[cfg(feature = "ssr")]
|
||||
fn find_feature(
|
||||
state: &crate::server::AppState,
|
||||
storyline: usize,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
feature_name: &str,
|
||||
@@ -137,7 +138,7 @@ fn find_feature(
|
||||
// also what substitutes the URL segment into the page's resource
|
||||
// keys - so this lookup is where a `/review/<record>` page's
|
||||
// feature acquires its record-specific key.
|
||||
let question = crate::content::resolve_question(&state.questions.load(), question_id)
|
||||
let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id)
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
question
|
||||
.alternatives
|
||||
@@ -152,26 +153,84 @@ fn find_feature(
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn authorize_resource(resource: &crate::content::ResourceSpec) -> Result<(), ServerFnError> {
|
||||
async fn authorize_resource(
|
||||
state: &crate::server::AppState,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
feature: &str,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use crate::access::{decide_and_publish, Action, Principal, Resource};
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
|
||||
if resource.public {
|
||||
return Ok(());
|
||||
}
|
||||
let group = resource
|
||||
.requires_group
|
||||
.as_deref()
|
||||
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session
|
||||
.get::<User>(SESSION_USER_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
||||
if !user.groups.iter().any(|g| g == group) {
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let resource = Resource::Feature {
|
||||
page: question_id.to_string(),
|
||||
alternative: alternative.to_string(),
|
||||
feature: feature.to_string(),
|
||||
};
|
||||
let decision = decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&Principal::from_user(user.as_ref()),
|
||||
Action::Read,
|
||||
&resource,
|
||||
serde_json::json!({}),
|
||||
);
|
||||
if decision.allow {
|
||||
Ok(())
|
||||
} else if user.is_none() {
|
||||
Err(ServerFnError::new("not signed in"))
|
||||
} else {
|
||||
Err(ServerFnError::new("not authorized"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
) -> 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,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -202,7 +261,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;
|
||||
@@ -217,7 +283,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::<serde_json::Value>(&bytes) {
|
||||
items.push(value);
|
||||
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
|
||||
items.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,9 +296,6 @@ async fn fetch_resource_value(
|
||||
ResourceSource::GiteaStarred { username } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
||||
}
|
||||
ResourceSource::GiteaOrgRepos { org } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
|
||||
}
|
||||
ResourceSource::GiteaReleases { owner, repo } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
|
||||
.await?
|
||||
@@ -260,21 +325,20 @@ async fn fetch_gitea_json(
|
||||
for (k, v) in params {
|
||||
url.query_pairs_mut().append_pair(k, v);
|
||||
}
|
||||
let client = openidconnect::reqwest::Client::new();
|
||||
let mut req = client.get(url.clone());
|
||||
let mut req = crate::http::client().get(url.clone());
|
||||
if let Ok(token) = std::env::var("GITEA_API_TOKEN") {
|
||||
if !token.is_empty() {
|
||||
req = req.header("Authorization", format!("token {token}"));
|
||||
}
|
||||
}
|
||||
let text = req
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?
|
||||
.text()
|
||||
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?;
|
||||
let text = crate::http::text(response, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
.map_err(|e| ServerFnError::new(format!("reading {url}: {e}")))?;
|
||||
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -341,16 +405,17 @@ async fn fetch_url_json(
|
||||
for (k, v) in &leftover {
|
||||
parsed.query_pairs_mut().append_pair(k, v);
|
||||
}
|
||||
let client = openidconnect::reqwest::Client::new();
|
||||
let text = client
|
||||
let response = crate::http::client()
|
||||
.get(parsed.clone())
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?
|
||||
.text()
|
||||
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?;
|
||||
// Content picks the URL; the cap is what keeps a runaway endpoint
|
||||
// from becoming a runaway allocation here.
|
||||
let text = crate::http::text(response, crate::http::URL_RESOURCE_CAP)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
.map_err(|e| ServerFnError::new(format!("reading {parsed}: {e}")))?;
|
||||
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -456,6 +521,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 ""
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
//! Accounts the content mandates, made at start.
|
||||
//!
|
||||
//! **The first person in.** A site with its own Kanidm starts with nobody
|
||||
//! on any desk, and every later person is invited from a desk - so the
|
||||
//! first one has to come from somewhere else. `SEED_ADMIN_EMAIL` names
|
||||
//! them: at start, portal invites that address into the site's most
|
||||
//! privileged group and mails them the one-time link, exactly as a desk
|
||||
//! invite would. From there they invite everyone else themselves.
|
||||
//!
|
||||
//! - `SEED_ADMIN_EMAIL` - who; unset, nothing happens.
|
||||
//! - `SEED_ADMIN_NAME` - their display name; defaults to the address's
|
||||
//! local part.
|
||||
//! - `SEED_GROUP` - the group; defaults to the one whose desk may invite
|
||||
//! into the most groups (the board, on a vel), read from the content.
|
||||
//!
|
||||
//! Once, not on every start: a done seed is remembered in the
|
||||
//! `portal_seed` bucket, keyed by address and group, so a restart or an
|
||||
//! upgrade never mails a second link. Changing `SEED_ADMIN_EMAIL` or
|
||||
//! `SEED_GROUP` seeds the new pair.
|
||||
//!
|
||||
//! **Everyone the content names as responsible.** A page's
|
||||
//! `responsible: { name, contact }` is a person answering for that
|
||||
//! question - unless `group` says it is a group's, in which case the
|
||||
//! address is an inbox and nobody is made for it. A person gets an
|
||||
//! account: at start, and again whenever the
|
||||
//! content changes, each responsible address Kanidm does not already
|
||||
//! hold is given one, and mailed that they are listed as responsible
|
||||
//! for asking those questions, with the link to sign in. Signed in,
|
||||
//! their own pages offer them a way to suggest changes.
|
||||
//! `RESPONSIBLE_GROUP` names a group they all join - on the vel,
|
||||
//! `redaktor`, which is also who may sign in to the project Gitea - and
|
||||
//! people who already had an account are added to it without a mail.
|
||||
//! A desk is still a desk invite. Each address is onboarded once per
|
||||
//! group (`portal_seed`).
|
||||
//!
|
||||
//! **And no longer.** Someone the content has stopped naming leaves
|
||||
//! `RESPONSIBLE_GROUP` in the same pass, if portal is who put them
|
||||
//! there. Their account is theirs and stays.
|
||||
//!
|
||||
//! Both run side by side and try again a minute apart, twenty times:
|
||||
//! Kanidm and gdo may still be coming up alongside portal. Every
|
||||
//! invite made here is put on file in `portal_invites` like a desk's,
|
||||
//! so a link that runs out unused shows up where someone can send it
|
||||
//! again.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use crate::content::Question;
|
||||
|
||||
pub const SEED_BUCKET: &str = "portal_seed";
|
||||
|
||||
/// The group whose members may invite into the most groups: a page
|
||||
/// gated to group G carrying an `invite: { group: X }` alternative
|
||||
/// means G may bring people into X. Ties go to the name that sorts
|
||||
/// first, so the choice is stable across starts. `None` when no page
|
||||
/// invites anyone.
|
||||
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
|
||||
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for q in questions.values() {
|
||||
let Some(group) = q.qualifies.as_deref() else { continue };
|
||||
for alt in &q.alternatives {
|
||||
if let Some(invite) = &alt.invite {
|
||||
reach.entry(group).or_default().insert(invite.group.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
reach
|
||||
.iter()
|
||||
.max_by(|(a, x), (b, y)| x.len().cmp(&y.len()).then_with(|| b.cmp(a)))
|
||||
.map(|(group, _)| group.to_string())
|
||||
}
|
||||
|
||||
/// The bucket key for one seed: stable, and within NATS key rules.
|
||||
pub fn seed_key(email: &str, group: &str) -> String {
|
||||
let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();
|
||||
format!("{}.{}", clean(group), clean(email))
|
||||
}
|
||||
|
||||
/// Everyone the content names as responsible, by address (lowercased,
|
||||
/// `mailto:` taken off): their name as the first page gives it, and the
|
||||
/// questions they answer for, in page order. A page a group answers
|
||||
/// for (`responsible.group`) names nobody: the board's inbox is not a
|
||||
/// person, and its members have their accounts through the desk.
|
||||
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
|
||||
let mut ids: Vec<&String> = questions.keys().collect();
|
||||
ids.sort();
|
||||
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
let q = &questions[id];
|
||||
let Some(r) = &q.responsible else { continue };
|
||||
if r.group.as_deref().is_some_and(|g| !g.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let email = contact_email(&r.contact);
|
||||
let Some(email) = email else { continue };
|
||||
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
||||
if !entry.1.contains(&q.name) {
|
||||
entry.1.push(q.name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A responsible `contact` as an address: `mailto:` off, lowercased;
|
||||
/// `None` for a phone number or anything else that is not one.
|
||||
pub fn contact_email(contact: &str) -> Option<String> {
|
||||
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
||||
}
|
||||
|
||||
/// A failed attempt is tried again this many times, this far apart:
|
||||
/// Kanidm and gdo may still be coming up beside portal.
|
||||
const ATTEMPTS: u32 = 20;
|
||||
const APART: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// One pass over the responsible people at a time: a reload may arrive
|
||||
/// while the pass from the start is still at it, and two at once would
|
||||
/// both find the same person missing and invite them twice.
|
||||
static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// The first person in and everyone the content names as responsible,
|
||||
/// side by side, so neither waits on the other's retries - and the
|
||||
/// responsible again whenever the content changes, for the life of the
|
||||
/// process.
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
// Subscribed before the first pass, so content that changes while
|
||||
// it runs is not missed.
|
||||
let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await;
|
||||
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
|
||||
let mut reloads = match reloads {
|
||||
Ok(reloads) => reloads,
|
||||
Err(e) => {
|
||||
tracing::error!("could not listen for changed content: {e} - a new responsible person waits for the next start");
|
||||
return;
|
||||
}
|
||||
};
|
||||
use futures::StreamExt;
|
||||
while reloads.next().await.is_some() {
|
||||
responsible_until_done(&state).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Passes over the responsible people until one leaves nobody behind.
|
||||
/// Each pass reads the content afresh, so what it retries is what the
|
||||
/// content says by then.
|
||||
async fn responsible_until_done(state: &crate::server::AppState) {
|
||||
for attempt in 1..=ATTEMPTS {
|
||||
let left = {
|
||||
let _one_at_a_time = PASS.lock().await;
|
||||
responsible(state).await
|
||||
};
|
||||
if left == 0 {
|
||||
return;
|
||||
}
|
||||
if attempt == ATTEMPTS {
|
||||
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
|
||||
return;
|
||||
}
|
||||
tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute");
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
||||
match state.jetstream.get_key_value(SEED_BUCKET).await {
|
||||
Ok(store) => Some(store),
|
||||
Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
|
||||
Ok(store) => Some(store),
|
||||
Err(e) => {
|
||||
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_admin_email() -> Option<String> {
|
||||
std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@'))
|
||||
}
|
||||
|
||||
/// Who portal put in the responsible group and the content no longer
|
||||
/// names: their key in the seed bucket, and the account. The first
|
||||
/// person is never among them - with `SEED_GROUP` the same group, their
|
||||
/// place there is from the seed, not from a page.
|
||||
pub fn no_longer_responsible(
|
||||
on_file: &[(String, crate::people::Invited)],
|
||||
people: &BTreeMap<String, (String, Vec<String>)>,
|
||||
seed_admin: Option<&str>,
|
||||
) -> Vec<(String, crate::people::Invited)> {
|
||||
on_file
|
||||
.iter()
|
||||
.filter(|(_, invited)| {
|
||||
let email = invited.email.trim().to_lowercase();
|
||||
!people.contains_key(&email) && seed_admin != Some(email.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One pass: an account for everyone the content names, and out of the
|
||||
/// group for everyone it has stopped naming. Returns how many people
|
||||
/// it could not finish with.
|
||||
async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
let people = responsible_people(&state.questions.load());
|
||||
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
|
||||
if people.is_empty() && group.is_none() {
|
||||
return 0;
|
||||
}
|
||||
let Some(people_backend) = crate::people::People::from_env() else {
|
||||
if !people.is_empty() {
|
||||
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
let Some(store) = seed_bucket(state).await else { return 0 };
|
||||
let mut left = 0;
|
||||
for (email, (name, questions)) in &people {
|
||||
let key = seed_key(email, group.as_deref().unwrap_or("responsible"));
|
||||
if let Ok(Some(_)) = store.get(&key).await {
|
||||
continue;
|
||||
}
|
||||
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
|
||||
let who = crate::memberships::Identity::from_contact(email);
|
||||
// With a group, one call makes the account if there is none and
|
||||
// adds them either way; without one, only a missing account is
|
||||
// made (Kanidm; a membership is nothing without a group). Either
|
||||
// way a person is mailed only when an account was made for
|
||||
// them: someone who had one already has their way in.
|
||||
let made = match (&group, &people_backend) {
|
||||
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
|
||||
(None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await {
|
||||
Ok(true) => {
|
||||
let _ = store.put(&key, "existing".into()).await;
|
||||
continue;
|
||||
}
|
||||
Ok(false) => kanidm.account(&name, email).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
(None, crate::people::People::Memberships) => continue,
|
||||
};
|
||||
match made {
|
||||
Ok(invited) => {
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||
let _ = store.put(&key, bytes.into()).await;
|
||||
}
|
||||
if invited.created {
|
||||
crate::people::record(state, &invited, questions).await;
|
||||
crate::mail::on_responsible(state, &invited, questions).await;
|
||||
}
|
||||
tracing::info!(user = %invited.username, created = invited.created, group = group.as_deref().unwrap_or(""), "a person the content names as responsible has their account");
|
||||
}
|
||||
Err(e) => {
|
||||
left += 1;
|
||||
tracing::warn!(%email, "could not set up a responsible person: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Being responsible ends when the content stops saying so. The
|
||||
// account stays - it is theirs - but the place in the group, and
|
||||
// whatever else that group opens, goes with the page. Only what
|
||||
// portal gave: someone who was in the group before is left there.
|
||||
let Some(group) = group else { return left };
|
||||
let prefix = seed_key("", &group);
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut on_file = Vec::new();
|
||||
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else { continue };
|
||||
on_file.push((key, invited));
|
||||
}
|
||||
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
|
||||
if !invited.already_member {
|
||||
let who = crate::memberships::Identity::from_contact(&invited.email);
|
||||
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await {
|
||||
left += 1;
|
||||
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Forgotten, so that being named again is being set up again.
|
||||
let _ = store.purge(&key).await;
|
||||
tracing::info!(user = %invited.username, group, removed = !invited.already_member, "the content no longer names this person as responsible");
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one -
|
||||
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
|
||||
/// their number.
|
||||
async fn seed_admin(state: &crate::server::AppState) {
|
||||
let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
|
||||
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
|
||||
let (Some(contact), who) = (
|
||||
phone.clone().or_else(|| email.clone()),
|
||||
crate::memberships::Identity {
|
||||
sub: None,
|
||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||
email: email.as_deref().map(|e| e.to_lowercase()),
|
||||
},
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let email = contact;
|
||||
let name = std::env::var("SEED_ADMIN_NAME")
|
||||
.ok()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
|
||||
let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) {
|
||||
Some(g) => g,
|
||||
None => match most_privileged_group(&state.questions.load()) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
tracing::warn!("SEED_ADMIN_EMAIL is set, but no page invites anyone and SEED_GROUP is unset - nobody to seed into");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let key = seed_key(&email, &group);
|
||||
let Some(store) = seed_bucket(state).await else { return };
|
||||
if let Ok(Some(_)) = store.get(&key).await {
|
||||
tracing::info!(group, "the first person was already invited into this group; not again");
|
||||
return;
|
||||
}
|
||||
let Some(people) = crate::people::People::from_env() else {
|
||||
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut attempt = 0;
|
||||
let invited = loop {
|
||||
attempt += 1;
|
||||
match people.invite(state, &name, &who, &group, "seed").await {
|
||||
Ok(invited) => break invited,
|
||||
Err(e) if attempt < ATTEMPTS => {
|
||||
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(group, "seeding the first person failed for good: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||
if let Err(e) = store.put(&key, bytes.into()).await {
|
||||
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
|
||||
}
|
||||
}
|
||||
if invited.created {
|
||||
crate::people::record(state, &invited, &[]).await;
|
||||
}
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
tracing::info!(group, user = %invited.username, created = invited.created, "the first person is invited");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn page(id: &str, qualifies: Option<&str>, invites: &[&str]) -> (String, Question) {
|
||||
let alternatives: String = invites
|
||||
.iter()
|
||||
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
|
||||
.collect();
|
||||
let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives });
|
||||
let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
|
||||
q.id = id.to_string();
|
||||
(id.to_string(), q)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
|
||||
let qs: HashMap<String, Question> = [
|
||||
page("/", None, &[]),
|
||||
page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
|
||||
page("/kasserer", Some("kasserer"), &["kasserer"]),
|
||||
page("/trafikk", Some("trafikk"), &["trafikk"]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
|
||||
// A tie goes to the name that sorts first, every time.
|
||||
let tie: HashMap<String, Question> = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect();
|
||||
assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
|
||||
// No invitations anywhere: nobody to seed into without SEED_GROUP.
|
||||
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
|
||||
assert!(most_privileged_group(&none).is_none());
|
||||
}
|
||||
|
||||
fn on_file(email: &str, already_member: bool) -> (String, crate::people::Invited) {
|
||||
(
|
||||
seed_key(email, "redaktor"),
|
||||
crate::people::Invited {
|
||||
username: email.split('@').next().unwrap().to_string(),
|
||||
display_name: String::new(),
|
||||
email: email.to_string(),
|
||||
group: "redaktor".to_string(),
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() {
|
||||
let people: BTreeMap<String, (String, Vec<String>)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect();
|
||||
let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)];
|
||||
let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no"));
|
||||
assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::<Vec<_>>(), vec!["Ola"]);
|
||||
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
|
||||
// Nobody named at all: everyone portal put there leaves.
|
||||
assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_named_responsible_is_found_once_by_their_address() {
|
||||
let mut qs: HashMap<String, Question> = HashMap::new();
|
||||
for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] {
|
||||
let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap();
|
||||
q.id = id.to_string();
|
||||
qs.insert(id.to_string(), q);
|
||||
}
|
||||
let people = responsible_people(&qs);
|
||||
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
|
||||
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
|
||||
assert_eq!(contact_email("+47 900 00 000"), None);
|
||||
// A group's inbox is nobody: its members have the desk.
|
||||
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
|
||||
held.id = "/d".into();
|
||||
qs.insert("/d".into(), held);
|
||||
assert!(!responsible_people(&qs).contains_key("post@vel.no"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_is_remembered_by_address_and_group() {
|
||||
assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
|
||||
assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -1,6 +1,7 @@
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
pub mod oidc;
|
||||
pub mod storyline;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::extract::FromRef;
|
||||
@@ -21,18 +22,22 @@ pub struct AppState {
|
||||
/// message (see `content::watch_for_reload`) - readers never hold
|
||||
/// a lock, just an atomic pointer load, so a reload never blocks or
|
||||
/// is blocked by an in-flight request.
|
||||
pub questions: Arc<ArcSwap<HashMap<String, Question>>>,
|
||||
pub questions: Arc<ArcSwap<crate::content::Storylines<HashMap<String, Question>>>>,
|
||||
/// Every bucket's event-sourced state graph, loaded from
|
||||
/// `aggregates.yaml` (see `content::load_aggregates_from_gitea`) and
|
||||
/// hot-swapped alongside `questions` on the same reload. A bucket
|
||||
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
||||
/// still works (see `answers.rs`).
|
||||
pub aggregates: Arc<ArcSwap<HashMap<String, crate::aggregates::AggregateSchema>>>,
|
||||
pub aggregates: Arc<ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>>,
|
||||
/// Site branding from the content repo's optional `site.yaml`
|
||||
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
||||
/// `aggregates` on the same reload. Default = the historical uhhm
|
||||
/// look.
|
||||
pub site: Arc<ArcSwap<crate::content::SiteConfig>>,
|
||||
pub site: Arc<ArcSwap<crate::content::Storylines<crate::content::SiteConfig>>>,
|
||||
/// The access policy compiled from the content above (see
|
||||
/// `access.rs`), swapped with it on every reload. Every server entry
|
||||
/// point decides through this and nothing else.
|
||||
pub access: Arc<ArcSwap<crate::access::Policy>>,
|
||||
/// `scheme://host` of the Gitea instance content is loaded from
|
||||
/// (see `content::gitea_api_base`) - kept alongside `questions`
|
||||
/// rather than re-derived per call, since `resolve_gitea_repo` needs
|
||||
|
||||
+44
-6
@@ -11,10 +11,10 @@ use axum::{
|
||||
response::Redirect,
|
||||
};
|
||||
use openidconnect::{
|
||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
|
||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||
TokenResponse,
|
||||
OAuth2TokenResponse, TokenResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
@@ -50,6 +50,12 @@ const NONCE_KEY: &str = "oidc_nonce";
|
||||
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
||||
|
||||
impl Oidc {
|
||||
/// Sign-in disabled: what a content-only instance has, and what a
|
||||
/// test builds an `AppState` with.
|
||||
pub fn disabled() -> Self {
|
||||
Self { inner: None }
|
||||
}
|
||||
|
||||
/// Discovers the provider and builds the client from environment:
|
||||
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
|
||||
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
|
||||
@@ -143,7 +149,7 @@ pub async fn login(
|
||||
let oidc = state.oidc.configured()?;
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (auth_url, csrf_state, nonce) = oidc
|
||||
let mut request = oidc
|
||||
.client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
@@ -153,8 +159,14 @@ pub async fn login(
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
.set_pkce_challenge(pkce_challenge);
|
||||
// What else to ask the provider for: `phoneNumber` for one that
|
||||
// verifies numbers (Vipps). Only when asked, because Kanidm denies
|
||||
// a sign-in that requests a scope the person does not hold.
|
||||
for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() {
|
||||
request = request.add_scope(Scope::new(scope.to_string()));
|
||||
}
|
||||
let (auth_url, csrf_state, nonce) = request.url();
|
||||
|
||||
session
|
||||
.insert(PKCE_KEY, pkce_verifier.secret())
|
||||
@@ -241,12 +253,34 @@ pub async fn callback(
|
||||
// raw compact JWT string "header.payload.signature".
|
||||
let groups = extract_groups_claim(&id_token);
|
||||
|
||||
let user = User {
|
||||
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
|
||||
// The verified phone number lives at userinfo, not in the ID token,
|
||||
// for providers that give one (Vipps). A provider that has none, or
|
||||
// no userinfo endpoint, leaves it empty; nothing depends on it.
|
||||
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
|
||||
Ok(request) => request
|
||||
.request_async(&oidc.http)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
|
||||
Err(_) => None,
|
||||
};
|
||||
let mut user = User {
|
||||
sub: claims.subject().as_str().to_string(),
|
||||
username,
|
||||
display_name,
|
||||
groups,
|
||||
phone,
|
||||
email,
|
||||
};
|
||||
// What they belong to, as portal keeps it: every invitation for
|
||||
// them becomes a membership now, and their active memberships join
|
||||
// whatever groups the provider itself asserted.
|
||||
for group in crate::memberships::on_signed_in(&state, &user).await {
|
||||
if !user.groups.contains(&group) {
|
||||
user.groups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate the session id on privilege change, then store the user.
|
||||
session.cycle_id().await.map_err(internal)?;
|
||||
@@ -258,6 +292,10 @@ pub async fn callback(
|
||||
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
|
||||
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
|
||||
|
||||
// Being here is what an invite was for (people.rs): off the
|
||||
// request, since nobody signing in should wait on a desk's list.
|
||||
tokio::spawn(crate::people::on_signed_in(state.clone(), user.clone()));
|
||||
|
||||
tracing::info!(user = %user.username, redirect = %target, "signed in");
|
||||
Ok(Redirect::to(&target))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Which storyline a visitor reads (see `content/variants.rs`): one
|
||||
//! index for the whole site, chosen at their first request and kept in
|
||||
//! a cookie for a year, so every page and every mail they get tells
|
||||
//! the same story. A site that tells one story sets no cookie.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, SET_COOKIE},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub const COOKIE_NAME: &str = "portal_storyline";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Storyline(pub usize);
|
||||
|
||||
/// The index in a request's cookies, if it names one this site tells.
|
||||
fn from_cookies(header: Option<&str>, arity: usize) -> Option<usize> {
|
||||
header?
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|kv| kv.strip_prefix(COOKIE_NAME).and_then(|rest| rest.strip_prefix('=')))
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.filter(|n| *n < arity)
|
||||
}
|
||||
|
||||
/// Evenly, from nothing about the person.
|
||||
fn pick(arity: usize) -> usize {
|
||||
(uuid::Uuid::new_v4().as_u128() % arity.max(1) as u128) as usize
|
||||
}
|
||||
|
||||
pub async fn assign(State(state): State<super::AppState>, mut req: Request, next: Next) -> Response {
|
||||
let arity = state.questions.load().arity();
|
||||
let held = from_cookies(req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), arity);
|
||||
let n = held.unwrap_or_else(|| pick(arity));
|
||||
req.extensions_mut().insert(Storyline(n));
|
||||
let mut res = next.run(req).await;
|
||||
if held.is_none() && arity > 1 {
|
||||
if let Ok(value) = format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() {
|
||||
res.headers_mut().append(SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// The storyline of the request a server function is answering, or
|
||||
/// the first when there is none to ask (a call outside a request).
|
||||
pub async fn current() -> usize {
|
||||
leptos_axum::extract::<axum::Extension<Storyline>>()
|
||||
.await
|
||||
.map(|axum::Extension(Storyline(n))| n)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cookie_names_a_storyline_the_site_tells() {
|
||||
assert_eq!(from_cookies(Some("a=b; portal_storyline=1; c=d"), 3), Some(1));
|
||||
assert_eq!(from_cookies(Some("portal_storyline=7"), 3), None);
|
||||
assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None);
|
||||
assert_eq!(from_cookies(None, 3), None);
|
||||
assert!(pick(1) == 0);
|
||||
assert!((0..50).all(|_| pick(3) < 3));
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
//! Sessions in NATS KV.
|
||||
//!
|
||||
//! A session used to live in a `MemoryStore`, which meant every
|
||||
//! restart signed everybody out: a deploy logged out every desk on
|
||||
//! every site, and the only reason that was bearable is that the desks
|
||||
//! were three people. This keeps them in the JetStream KV that is
|
||||
//! already under everything else here - no new service, no new
|
||||
//! dependency, and the same bucket a second instance of a site would
|
||||
//! read if there ever is one.
|
||||
//!
|
||||
//! Expiry is said twice on purpose. The bucket carries a `max_age`, so
|
||||
//! NATS collects abandoned sessions without anyone sweeping; and a
|
||||
//! record's own `expiry_date` is checked on the way out, because the
|
||||
//! bucket's age is a coarse fence and the record's date is the truth.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use tower_sessions::session::{Id, Record};
|
||||
use tower_sessions::session_store::{Error, Result};
|
||||
|
||||
/// How long an untouched session survives in the bucket. Comfortably
|
||||
/// longer than any expiry `SessionManagerLayer` will set, so the
|
||||
/// record's own date is always what decides; this is only here so a
|
||||
/// session nobody ever comes back for does not sit in NATS forever.
|
||||
const MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60 * 24 * 40);
|
||||
|
||||
pub const BUCKET: &str = "portal_sessions";
|
||||
|
||||
/// A new id is drawn this many times before a collision is reported as
|
||||
/// a backend error. Two random `i128`s colliding is not a thing that
|
||||
/// happens; this exists so a bucket misbehaving cannot spin forever.
|
||||
const ID_ATTEMPTS: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NatsStore {
|
||||
store: async_nats::jetstream::kv::Store,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NatsStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NatsStore").field("bucket", &BUCKET).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl NatsStore {
|
||||
/// Opens the bucket, creating it on first use the way every other
|
||||
/// bucket here is created.
|
||||
pub async fn open(js: &async_nats::jetstream::Context) -> anyhow::Result<Self> {
|
||||
let store = match js.get_key_value(BUCKET).await {
|
||||
Ok(store) => store,
|
||||
Err(_) => {
|
||||
js.create_key_value(async_nats::jetstream::kv::Config {
|
||||
bucket: BUCKET.to_string(),
|
||||
max_age: MAX_AGE,
|
||||
history: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(Self { store })
|
||||
}
|
||||
|
||||
fn encode(record: &Record) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(record).map_err(|e| Error::Encode(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tower_sessions::SessionStore for NatsStore {
|
||||
/// `create` draws a fresh id until the bucket accepts one as new.
|
||||
/// `put` would overwrite a stranger's session on a collision;
|
||||
/// `create` refuses, which is the whole difference.
|
||||
async fn create(&self, record: &mut Record) -> Result<()> {
|
||||
for _ in 0..ID_ATTEMPTS {
|
||||
let bytes = Self::encode(record)?;
|
||||
match self.store.create(&record.id.to_string(), bytes.into()).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => record.id = Id::default(),
|
||||
}
|
||||
}
|
||||
Err(Error::Backend(format!(
|
||||
"could not find an unused session id in {ID_ATTEMPTS} attempts"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn save(&self, record: &Record) -> Result<()> {
|
||||
let bytes = Self::encode(record)?;
|
||||
self.store
|
||||
.put(&record.id.to_string(), bytes.into())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| Error::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
async fn load(&self, id: &Id) -> Result<Option<Record>> {
|
||||
let Some(bytes) = self
|
||||
.store
|
||||
.get(&id.to_string())
|
||||
.await
|
||||
.map_err(|e| Error::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let record: Record =
|
||||
serde_json::from_slice(&bytes).map_err(|e| Error::Decode(e.to_string()))?;
|
||||
// An expired record is gone as far as anyone asking is
|
||||
// concerned, whether or not the bucket has collected it yet.
|
||||
if record.expiry_date <= tower_sessions::cookie::time::OffsetDateTime::now_utc() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &Id) -> Result<()> {
|
||||
// Purge, not delete: a signed-out session should leave no
|
||||
// history in the bucket to read back.
|
||||
self.store
|
||||
.purge(&id.to_string())
|
||||
.await
|
||||
.map_err(|e| Error::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
//! An alternative as the visitor meets it, in text.
|
||||
//!
|
||||
//! Portal renders a card: the heading, the description, a button, one
|
||||
//! or two encouragements, and for each section an icon, a title, a
|
||||
//! line of guidance and the fields with their labels and placeholders.
|
||||
//! A visitor is oriented by all of it, not the heading alone. The
|
||||
//! answering visitor and `rate` both read this rendering, so the
|
||||
//! scored page is the served page; the gutted twin keeps the card's
|
||||
//! shape and none of its words.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One option on a page. `full` is the card as rendered; otherwise the
|
||||
/// heading alone, which is what a visitor who reads nothing else sees.
|
||||
pub fn alternative(alt: &Value, full: bool) -> String {
|
||||
let mut out = format!("- {}", s(&alt["name"]));
|
||||
if !full {
|
||||
out.push('\n');
|
||||
return out;
|
||||
}
|
||||
let d = s(&alt["description"]).trim();
|
||||
if !d.is_empty() {
|
||||
out.push_str(&format!(": {d}"));
|
||||
}
|
||||
out.push('\n');
|
||||
if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) {
|
||||
out.push_str(&format!(" Button: {button}\n"));
|
||||
}
|
||||
for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
out.push_str(&format!(" {e}\n"));
|
||||
}
|
||||
for f in alt["features"].as_array().into_iter().flatten() {
|
||||
let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim());
|
||||
let mut line = String::from(" ");
|
||||
if !icon.is_empty() {
|
||||
line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon)));
|
||||
}
|
||||
if !name.is_empty() {
|
||||
line.push_str(name);
|
||||
}
|
||||
if !desc.is_empty() {
|
||||
line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " }));
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
out.push_str(line.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
let fields: Vec<String> = f["requirements"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
|
||||
let hint = s(&r["placeholder"]).trim();
|
||||
let opt = if r["optional"] == true { " (optional)" } else { "" };
|
||||
if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") }
|
||||
})
|
||||
.collect();
|
||||
if !fields.is_empty() {
|
||||
out.push_str(&format!(" Asks for: {}\n", fields.join("; ")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The page around its options: what it asks and says of itself.
|
||||
pub fn page_head(page: &Value) -> String {
|
||||
let mut out = format!("{}\n", s(&page["name"]));
|
||||
let d = s(&page["description"]).trim();
|
||||
if !d.is_empty() {
|
||||
out.push_str(&format!("{d}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() {
|
||||
let alt = json!({
|
||||
"name": "Get a price", "description": "For owners.",
|
||||
"consequence": ["Send it"], "encouragements": ["We answer within two days."],
|
||||
"features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.",
|
||||
"requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"},
|
||||
{"name": "address", "label": "Address", "optional": true}]}]
|
||||
});
|
||||
let full = alternative(&alt, true);
|
||||
assert!(full.starts_with("- Get a price: For owners.\n"));
|
||||
assert!(full.contains("Button: Send it") && full.contains("We answer within two days."));
|
||||
assert!(full.contains("[icon file-text] Your brief: A few lines are enough."));
|
||||
assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)"));
|
||||
assert_eq!(alternative(&alt, false), "- Get a price\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Generates a portal content repo from a business intake.
|
||||
//!
|
||||
//! Three phases, each held to portal's `question_lint`:
|
||||
//!
|
||||
//! 1. **Needs.** The intake (what the business does, who comes to it,
|
||||
//! what it keeps track of and through which stages, who handles
|
||||
//! what) becomes `needs.yaml`. After this phase the file is frozen:
|
||||
//! it is the spec, and the generator may not weaken it to pass.
|
||||
//! 2. **Structure.** Pages, `aggregates.yaml` and desks are generated
|
||||
//! and revised against the lint's findings until it exits zero.
|
||||
//! 3. **Wording.** The personas are answered blind (headings only,
|
||||
//! then with descriptions) and misroutes are fed back as wording
|
||||
//! defects. Only pages may change.
|
||||
//!
|
||||
//! The LLM is an external command: prompt on stdin, text on stdout.
|
||||
//! `claude -p` works; so does anything else with that shape.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
|
||||
pub mod build;
|
||||
mod card;
|
||||
pub mod plan;
|
||||
mod rate;
|
||||
mod report;
|
||||
mod score;
|
||||
mod simulate;
|
||||
|
||||
pub const REFERENCE: &str = include_str!("reference.md");
|
||||
|
||||
struct Opts {
|
||||
intake: PathBuf,
|
||||
out: PathBuf,
|
||||
lint: String,
|
||||
llm: String,
|
||||
rounds: usize,
|
||||
wording_rounds: usize,
|
||||
/// The model that plays the visitor. A generator grading its own
|
||||
/// wording agrees with itself, so this should be a different one.
|
||||
visitor: Option<String>,
|
||||
}
|
||||
|
||||
/// The trainer, as `portal <command> ...`: `argv` is everything after
|
||||
/// `portal`. `answer --tasks <file> [--llm <cmd>]` is the blind
|
||||
/// answerer alone, for grading an existing site with any backend;
|
||||
/// answers go to stdout, ready for `portal lint --needs-score`.
|
||||
pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
match argv.first().map(String::as_str) {
|
||||
Some("simulate") => return simulate::run(&argv),
|
||||
Some("compare") => return simulate::compare(&argv),
|
||||
Some("plan") => return plan::run(&argv),
|
||||
Some("build") => return build::run(&argv),
|
||||
Some("rate") => return rate::rate(&argv),
|
||||
Some("report") => return report::run(&argv),
|
||||
Some("prefer") => return rate::prefer(&argv),
|
||||
Some("score") => return score::run(&argv),
|
||||
_ => {}
|
||||
}
|
||||
if argv.first().map(String::as_str) == Some("answer") {
|
||||
let value = |flag: &str| {
|
||||
argv.iter()
|
||||
.position(|a| a == flag)
|
||||
.and_then(|i| argv.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let tasks = value("--tasks").ok_or_else(|| anyhow!("usage: portal-trainer answer --tasks <tasks.jsonl> [--repo <content dir>] [--llm 'cmd'] [--seed <n>]"))?;
|
||||
let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string());
|
||||
let repo = value("--repo").map(PathBuf::from);
|
||||
// --seed: the same tasks answered again with other shuffles and
|
||||
// sampling, so a score comes with its spread. 0 is the run
|
||||
// every earlier score used.
|
||||
let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref(), seed)?);
|
||||
return Ok(());
|
||||
}
|
||||
let opts = parse_args()?;
|
||||
let intake = std::fs::read_to_string(&opts.intake)
|
||||
.with_context(|| format!("reading {}", opts.intake.display()))?;
|
||||
std::fs::create_dir_all(opts.out.join("questions"))?;
|
||||
std::fs::create_dir_all(opts.out.join("llm"))?;
|
||||
LLM_LOG.set(opts.out.join("llm")).ok();
|
||||
let mut log = Vec::new();
|
||||
|
||||
// Phase 1: the spec.
|
||||
let mut feedback = String::new();
|
||||
for round in 1..=opts.rounds {
|
||||
let prompt = format!(
|
||||
"{REFERENCE}\n\n# Task\n\nA business filled in this intake. Write ONLY `needs.yaml` for it: every need the intake implies, for the public and for each role inside the business, with states taken from how the business says things progress, and personas.\n\n## Intake\n\n{intake}\n{feedback}\n{FORMAT}"
|
||||
);
|
||||
let Ok(files) = parse_files(&llm(&opts.llm, &prompt)?) else {
|
||||
eprintln!("needs round {round}: the reply had no file blocks, asking again");
|
||||
feedback = "\n## Your last attempt\n\nIt contained no <<<FILE ...>>> blocks at all. Return needs.yaml in exactly that format.\n".to_string();
|
||||
continue;
|
||||
};
|
||||
let Some(needs) = files.get("needs.yaml") else {
|
||||
feedback = "\n## Your last attempt\n\nIt contained no needs.yaml.\n".to_string();
|
||||
continue;
|
||||
};
|
||||
std::fs::write(opts.out.join("needs.yaml"), needs)?;
|
||||
// A placeholder front door, so the lint gets as far as parsing
|
||||
// the spec; every need fails, a parse error is what we look for.
|
||||
std::fs::write(opts.out.join("questions/index.yaml"), "name: Placeholder?\n")?;
|
||||
let (_, report) = lint(&opts, &[])?;
|
||||
if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) {
|
||||
eprintln!("needs round {round}: {line}");
|
||||
feedback = format!("\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n");
|
||||
continue;
|
||||
}
|
||||
eprintln!("needs round {round}: needs.yaml parses, frozen");
|
||||
log.push(format!("needs: parsed in round {round}"));
|
||||
feedback.clear();
|
||||
break;
|
||||
}
|
||||
if !feedback.is_empty() {
|
||||
bail!("needs.yaml never parsed in {} round(s); the replies are in {}/llm/", opts.rounds, opts.out.display());
|
||||
}
|
||||
let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?;
|
||||
|
||||
// Phase 2: structure. The page-writing model sees the needs and
|
||||
// never the personas: pages written with the test in view route
|
||||
// their own personas perfectly and strangers badly (measured:
|
||||
// 10/10 on its own personas, 8/10 headings-only on held-out ones).
|
||||
let needs_full = needs;
|
||||
let needs = needs_full
|
||||
.split("\npersonas:")
|
||||
.next()
|
||||
.unwrap_or(&needs_full)
|
||||
.to_string();
|
||||
let mut green = false;
|
||||
let mut report = String::new();
|
||||
for round in 1..=opts.rounds {
|
||||
let prompt = if round == 1 {
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
|
||||
dump(&opts.out)?
|
||||
)
|
||||
};
|
||||
match parse_files(&llm(&opts.llm, &prompt)?) {
|
||||
Ok(files) => apply(&opts.out, files)?,
|
||||
Err(_) => eprintln!("structure round {round}: the reply had no file blocks"),
|
||||
}
|
||||
let (ok, r) = lint(&opts, &[])?;
|
||||
report = r;
|
||||
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
||||
eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") });
|
||||
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
||||
if ok {
|
||||
green = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !green {
|
||||
std::fs::write(opts.out.join("run.log"), format!("{}\n\n{report}", log.join("\n")))?;
|
||||
bail!("structure never went green in {} round(s); last verifier output is in run.log", opts.rounds);
|
||||
}
|
||||
|
||||
// Phase 3: wording.
|
||||
for round in 0..=opts.wording_rounds {
|
||||
let mut defects = String::new();
|
||||
let mut summary = Vec::new();
|
||||
for skim in [true, false] {
|
||||
let tasks_path = opts.out.join(if skim { "tasks-skim.jsonl" } else { "tasks.jsonl" });
|
||||
let mut args = vec!["--needs-tasks".to_string(), tasks_path.display().to_string()];
|
||||
if skim {
|
||||
args.push("--skim".to_string());
|
||||
}
|
||||
lint(&opts, &args)?;
|
||||
let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out), 0)?;
|
||||
let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" });
|
||||
std::fs::write(&answers_path, answers)?;
|
||||
let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()];
|
||||
if skim {
|
||||
args.push("--skim".to_string());
|
||||
}
|
||||
let (_, scored) = lint(&opts, &args)?;
|
||||
let mode = if skim { "headings only" } else { "headings and descriptions" };
|
||||
for line in scored.lines().filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) {
|
||||
defects.push_str(&format!("[{mode}] {line}\n"));
|
||||
}
|
||||
if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) {
|
||||
summary.push(format!("{mode}: {line}"));
|
||||
}
|
||||
}
|
||||
eprintln!("wording round {round}: {}", summary.join(" | "));
|
||||
log.push(format!("wording round {round}: {}", summary.join(" | ")));
|
||||
if defects.is_empty() || round == opts.wording_rounds {
|
||||
break;
|
||||
}
|
||||
let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default();
|
||||
let prompt = format!(
|
||||
"{REFERENCE}\n\n# Task\n\nThe structure is correct. Visitors are picking the wrong alternative. Each MISROUTE below is a persona who, reading the page, chose an alternative that does not meet their need; HESITANT means they chose right and nearly did not. Rewrite alternative headings first (a heading that needs its description has failed), descriptions second. Keep every `record_as`, `action`, field and state as it is. If you rename an alternative that needs.yaml names in `via`, you may not: keep that one. Return only the pages you change, each complete.\n\n## Defects\n\n```\n{defects}```\n\n## The personas\n\n```yaml\npersonas:{personas}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
|
||||
dump(&opts.out)?
|
||||
);
|
||||
let before = snapshot(&opts.out)?;
|
||||
apply(&opts.out, parse_files(&llm(&opts.llm, &prompt)?)?)?;
|
||||
let (ok, r) = lint(&opts, &[])?;
|
||||
if !ok {
|
||||
eprintln!("wording round {round}: the rewrite broke structure, reverted");
|
||||
log.push(format!("wording round {round}: rewrite reverted\n{r}"));
|
||||
restore(&opts.out, before)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::fs::write(opts.out.join("run.log"), log.join("\n") + "\n")?;
|
||||
println!("content repo written to {}", opts.out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const FORMAT: &str = "# Output format\n\nReturn files and nothing else, each as:\n\n<<<FILE relative/path.yaml>>>\n...complete file content...\n<<<END>>>\n\nNo prose, no code fences.";
|
||||
|
||||
fn parse_args() -> anyhow::Result<Opts> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
if args.next().as_deref() != Some("generate") {
|
||||
bail!("usage: portal-trainer generate --intake <file> --out <dir> [--lint question_lint] [--llm 'claude -p'] [--rounds 4] [--wording-rounds 2]");
|
||||
}
|
||||
let mut opts = Opts {
|
||||
intake: PathBuf::new(),
|
||||
out: PathBuf::new(),
|
||||
lint: "question_lint".to_string(),
|
||||
llm: "claude -p".to_string(),
|
||||
rounds: 4,
|
||||
wording_rounds: 2,
|
||||
visitor: None,
|
||||
};
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = || args.next().ok_or_else(|| anyhow!("{arg} needs a value"));
|
||||
match arg.as_str() {
|
||||
"--intake" => opts.intake = value()?.into(),
|
||||
"--out" => opts.out = value()?.into(),
|
||||
"--lint" => opts.lint = value()?,
|
||||
"--llm" => opts.llm = value()?,
|
||||
"--rounds" => opts.rounds = value()?.parse()?,
|
||||
"--wording-rounds" => opts.wording_rounds = value()?.parse()?,
|
||||
"--visitor-llm" => opts.visitor = Some(value()?),
|
||||
other => bail!("unknown argument {other}"),
|
||||
}
|
||||
}
|
||||
if opts.intake.as_os_str().is_empty() || opts.out.as_os_str().is_empty() {
|
||||
bail!("--intake and --out are required");
|
||||
}
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
/// Prompt on stdin, text on stdout.
|
||||
/// Where `generate` keeps every prompt and reply (`<out>/llm/`). A
|
||||
/// model that returns nothing usable has to be read, not guessed at.
|
||||
static LLM_LOG: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
static LLM_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
fn llm(cmd: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
llm_env(cmd, prompt, &[])
|
||||
}
|
||||
|
||||
/// As `llm`, with hints for the backend in its environment.
|
||||
fn llm_env(cmd: &str, prompt: &str, env: &[(&str, &str)]) -> anyhow::Result<String> {
|
||||
let reply = llm_with(cmd, prompt, env);
|
||||
if let Some(dir) = LLM_LOG.get() {
|
||||
let n = LLM_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
std::fs::write(dir.join(format!("{n:02}-prompt.md")), prompt).ok();
|
||||
let text = match &reply {
|
||||
Ok(text) => text.clone(),
|
||||
Err(e) => format!("ERROR: {e}"),
|
||||
};
|
||||
std::fs::write(dir.join(format!("{n:02}-reply.md")), text).ok();
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
/// As `llm`, with hints for the backend in its environment (`SEED`,
|
||||
/// `FORMAT=json`, `TEMPERATURE`). A backend is free to ignore them.
|
||||
pub(crate) fn llm_with(cmd: &str, prompt: &str, env: &[(&str, &str)]) -> anyhow::Result<String> {
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.with_context(|| format!("starting {cmd:?}"))?;
|
||||
let mut stdin = child.stdin.take().expect("piped");
|
||||
let prompt = prompt.to_string();
|
||||
// Written from a thread: a large prompt would otherwise deadlock
|
||||
// against a child that starts writing before it finishes reading.
|
||||
let writer = std::thread::spawn(move || stdin.write_all(prompt.as_bytes()));
|
||||
let output = child.wait_with_output()?;
|
||||
writer.join().ok();
|
||||
if !output.status.success() {
|
||||
bail!("{cmd:?} exited with {}", output.status);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn lint(opts: &Opts, extra: &[String]) -> anyhow::Result<(bool, String)> {
|
||||
let output = Command::new(&opts.lint)
|
||||
.arg("--path")
|
||||
.arg(opts.out.join("questions"))
|
||||
.args(extra)
|
||||
.output()
|
||||
.with_context(|| format!("running {}", opts.lint))?;
|
||||
let mut report = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
report.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
Ok((output.status.success(), report))
|
||||
}
|
||||
|
||||
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
|
||||
/// ignored, so a model that adds a sentence does no harm.
|
||||
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
||||
// Small models mangle the markers: `<<<END>>` with a bracket short,
|
||||
// a stray `</think>`, the next `<<<FILE` glued onto the same line.
|
||||
// So markers are found anywhere in a line, an END needs only its
|
||||
// opening, and a new FILE closes whatever was open.
|
||||
let mut files = BTreeMap::new();
|
||||
let mut current: Option<(String, String)> = None;
|
||||
let close = |current: &mut Option<(String, String)>, files: &mut BTreeMap<String, String>| {
|
||||
if let Some((path, body)) = current.take() {
|
||||
files.insert(path, body);
|
||||
}
|
||||
};
|
||||
for line in text.lines() {
|
||||
let mut rest = line;
|
||||
loop {
|
||||
let (file_at, end_at) = (rest.find("<<<FILE "), rest.find("<<<END"));
|
||||
match (file_at, end_at) {
|
||||
(Some(f), e) if e.is_none_or(|e| f < e) => {
|
||||
close(&mut current, &mut files);
|
||||
let after = &rest[f + "<<<FILE ".len()..];
|
||||
let stop = after.find(">>>").unwrap_or(after.len());
|
||||
current = Some((after[..stop].trim().to_string(), String::new()));
|
||||
rest = after.get(stop + 3..).unwrap_or("");
|
||||
}
|
||||
(_, Some(e)) => {
|
||||
close(&mut current, &mut files);
|
||||
rest = &rest[e + "<<<END".len()..];
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
// A line that carried a marker carries no content.
|
||||
if std::ptr::eq(rest, line) {
|
||||
if let Some((_, body)) = current.as_mut() {
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut current, &mut files);
|
||||
if files.is_empty() {
|
||||
bail!("the model returned no <<<FILE ...>>> blocks");
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Model output is written to disk, so the path is checked, not
|
||||
/// trusted: repo-relative, yaml, and only where content lives.
|
||||
fn allowed(path: &str) -> bool {
|
||||
let p = Path::new(path);
|
||||
let plain = p.components().all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||
plain
|
||||
&& p.extension().and_then(|e| e.to_str()) == Some("yaml")
|
||||
&& (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/"))
|
||||
}
|
||||
|
||||
fn apply(out: &Path, files: BTreeMap<String, String>) -> anyhow::Result<()> {
|
||||
for (path, body) in files {
|
||||
if !allowed(&path) {
|
||||
eprintln!("ignored {path:?}: outside what the generator may write");
|
||||
continue;
|
||||
}
|
||||
let target = out.join(&path);
|
||||
if body.trim() == "DELETE" {
|
||||
std::fs::remove_file(&target).ok();
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(target, body)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn content_files(out: &Path) -> anyhow::Result<Vec<PathBuf>> {
|
||||
fn walk(dir: &Path, found: &mut Vec<PathBuf>) -> anyhow::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, found)?;
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for name in ["aggregates.yaml", "site.yaml"] {
|
||||
if out.join(name).exists() {
|
||||
found.push(out.join(name));
|
||||
}
|
||||
}
|
||||
walk(&out.join("questions"), &mut found)?;
|
||||
found.sort();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn snapshot(out: &Path) -> anyhow::Result<Vec<(PathBuf, String)>> {
|
||||
content_files(out)?
|
||||
.into_iter()
|
||||
.map(|p| Ok((p.clone(), std::fs::read_to_string(p)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn restore(out: &Path, before: Vec<(PathBuf, String)>) -> anyhow::Result<()> {
|
||||
for path in content_files(out)? {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
for (path, body) in before {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, body)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dump(out: &Path) -> anyhow::Result<String> {
|
||||
let mut text = String::new();
|
||||
for path in content_files(out)? {
|
||||
let rel = path.strip_prefix(out).unwrap_or(&path).display().to_string();
|
||||
text.push_str(&format!("<<<FILE {rel}>>>\n{}<<<END>>>\n\n", std::fs::read_to_string(&path)?));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The answering side never sees `expect` or which need a persona has.
|
||||
///
|
||||
/// One call per task, so no visitor sees another visitor's situation
|
||||
/// (a batch of eight personas over four headings is nearly a matching
|
||||
/// puzzle, and a model solves it as one). The options are shuffled per
|
||||
/// task, so a heading's place on the page cannot carry the answer, and
|
||||
/// the call is seeded from the task id, so a rerun on the same site is
|
||||
/// the same experiment.
|
||||
///
|
||||
/// With a content repo to read, the visitor sees each option as the
|
||||
/// card portal renders: button, encouragement, sections with icon and
|
||||
/// guidance, fields with placeholders. Headings-only tasks (`--skim`,
|
||||
/// where the task's text is the heading itself) stay headings only.
|
||||
pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_offset: u64) -> anyhow::Result<String> {
|
||||
let mut answers = String::new();
|
||||
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let task: serde_json::Value = serde_json::from_str(line)?;
|
||||
let id = task["id"].as_str().unwrap_or_default();
|
||||
let body = task["state"]["body"].as_str().unwrap_or_default();
|
||||
let pick = &task["questions"]["pick"];
|
||||
let instructions = pick["instructions"].as_str().unwrap_or_default();
|
||||
let mut options: Vec<(&str, &str)> = pick["criteria"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
|
||||
.collect();
|
||||
let skim = options.iter().all(|(name, text)| text.is_empty() || text == name);
|
||||
let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) };
|
||||
let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _);
|
||||
shuffle(&mut options, seed);
|
||||
// Options are numbered and the answer is the number: with a
|
||||
// whole card in view, a small model asked to copy a heading
|
||||
// copied the page's description instead (measured: six of
|
||||
// fourteen misroutes on one run were that), and no heading
|
||||
// matched. A number cannot be a description.
|
||||
let listed: Vec<String> = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, text))| {
|
||||
let card = page
|
||||
.as_ref()
|
||||
.and_then(|p| p["alternatives"].as_array())
|
||||
.and_then(|alts| alts.iter().find(|a| a["name"] == *name))
|
||||
.map(|a| card::alternative(a, true));
|
||||
let body = match card {
|
||||
Some(card) => card.trim_end().trim_start_matches("- ").to_string(),
|
||||
None if skim => name.to_string(),
|
||||
None => format!("{name}: {text}"),
|
||||
};
|
||||
format!("{}. {body}", i + 1)
|
||||
})
|
||||
.collect();
|
||||
let mut head = page.as_ref().map(card::page_head).unwrap_or_default();
|
||||
// What the page says before it offers anything (the opener, the
|
||||
// look around): read, not picked.
|
||||
if let Some(context) = task["state"]["page"].as_str().filter(|c| !c.trim().is_empty()) {
|
||||
head.push_str(&format!("The page first says:\n{context}\n\n"));
|
||||
}
|
||||
let prompt = format!(
|
||||
"You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": <the option's number>, \"confidence\": 0.00}}",
|
||||
listed.join("\n\n")
|
||||
);
|
||||
let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?;
|
||||
let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null);
|
||||
let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
|
||||
let raw = match &parsed["choice"] {
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
other => other.as_str().unwrap_or_default().to_string(),
|
||||
};
|
||||
// A number, or a heading for a model that wrote one anyway.
|
||||
let choice = match raw.trim().trim_end_matches('.').parse::<usize>() {
|
||||
Ok(n) if (1..=names.len()).contains(&n) => names[n - 1].to_string(),
|
||||
_ => simulate::resolve_choice(&names, &raw),
|
||||
};
|
||||
let confidence = parsed["confidence"].as_f64().unwrap_or(0.0);
|
||||
answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?);
|
||||
answers.push('\n');
|
||||
}
|
||||
Ok(answers)
|
||||
}
|
||||
|
||||
/// FNV-1a over the task id: a seed that is the same on every run.
|
||||
fn fnv(text: &str) -> u32 {
|
||||
text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193))
|
||||
}
|
||||
|
||||
/// Fisher-Yates on a small xorshift, so the order is fixed by the seed.
|
||||
fn shuffle<T>(items: &mut [T], seed: u32) {
|
||||
let mut x = seed.max(1);
|
||||
for i in (1..items.len()).rev() {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
items.swap(i, (x as usize) % (i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_are_parsed_from_blocks_and_prose_is_ignored() {
|
||||
let files = parse_files("Sure!\n<<<FILE site.yaml>>>\ntitle: A\n<<<END>>>\nnoise\n<<<FILE questions/index.yaml>>>\nname: Q?\n<<<END>>>\n").unwrap();
|
||||
assert_eq!(files["site.yaml"], "title: A\n");
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(parse_files("no blocks here").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mangled_markers_from_a_small_model_still_separate_the_files() {
|
||||
// Verbatim shape of a nemotron reply: a short END, a stray
|
||||
// think tag, the next FILE glued on, and no END at the very end.
|
||||
let files = parse_files(
|
||||
"<<<FILE questions/desk/index.yaml>>>\nname: Desk?\n<<<END>></think><<<FILE questions/index.yaml>>>\nname: Hva trenger du?\n<<<FILE site.yaml>>>\ntitle: A\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(files["questions/desk/index.yaml"], "name: Desk?\n");
|
||||
assert_eq!(files["questions/index.yaml"], "name: Hva trenger du?\n");
|
||||
assert_eq!(files["site.yaml"], "title: A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_is_shuffled_the_same_way_every_time_and_differently_from_the_next() {
|
||||
let mut a = ["w", "x", "y", "z"];
|
||||
let mut b = a;
|
||||
shuffle(&mut a, fnv("scaffolder@/"));
|
||||
shuffle(&mut b, fnv("scaffolder@/"));
|
||||
assert_eq!(a, b);
|
||||
let mut c = ["w", "x", "y", "z"];
|
||||
shuffle(&mut c, fnv("apprentice@/"));
|
||||
assert_ne!(a, c);
|
||||
let mut sorted = a;
|
||||
sorted.sort();
|
||||
assert_eq!(sorted, ["w", "x", "y", "z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_content_paths_may_be_written() {
|
||||
assert!(allowed("site.yaml"));
|
||||
assert!(allowed("aggregates.yaml"));
|
||||
assert!(allowed("questions/desk/_section.yaml"));
|
||||
// The spec is frozen after phase one.
|
||||
assert!(!allowed("needs.yaml"));
|
||||
assert!(!allowed("../outside.yaml"));
|
||||
assert!(!allowed("/etc/passwd.yaml"));
|
||||
assert!(!allowed("questions/run.sh"));
|
||||
assert!(!allowed(".gitea/workflows/deploy.yaml"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//! From an intake, in plain words, to a `plan.yaml` a person can check.
|
||||
//!
|
||||
//! This is the one step where a model decides structure: which needs
|
||||
//! the intake implies, the stages each goes through, who handles it,
|
||||
//! what the form collects and how much that person will put up with.
|
||||
//! Everything after it is deterministic (`build`), so this is where a
|
||||
//! misreading of the business has to be caught, and it is caught by a
|
||||
//! person reading the plan, not by a verifier. The model's YAML is
|
||||
//! parsed and validated exactly as `build` would, and the errors go
|
||||
//! back to it for another round; what passes is written verbatim, so
|
||||
//! what the person reads is what the model wrote.
|
||||
//!
|
||||
//! Personas are never asked of this model: a persona written by the
|
||||
//! same model that phrased the needs tests nothing. They come from a
|
||||
//! person or a sector file, attached with `--personas`.
|
||||
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
|
||||
use crate::trainer::build::Plan;
|
||||
use crate::trainer::simulate::first_object;
|
||||
|
||||
const REFERENCE: &str = r#"# plan.yaml
|
||||
|
||||
A plan is a needs spec that also says what each form collects. A
|
||||
builder turns it into a working site with no further decisions, so
|
||||
every structural fact about the business has to be in it.
|
||||
|
||||
```yaml
|
||||
business: <name>
|
||||
language: en # the intake's language code
|
||||
contact: { name: <who answers for the site>, email: <their email> }
|
||||
about: >
|
||||
What the business does, in its own words.
|
||||
mail: { from: "<name> <post@their-domain>", reply_to: <their inbox> }
|
||||
# who every mail the site sends comes from; required as soon
|
||||
# as any need has `mails`
|
||||
|
||||
does: # the front door's first card: what the business does, one
|
||||
- id: kebab-case # or two features, one role each, no button. Say it for the
|
||||
for: <need id> # role the business exists for and the next one it serves.
|
||||
text: what it does for them, in the intake's own words
|
||||
|
||||
shows: # what the site shows as it is: a live figure, the latest
|
||||
- id: kebab-case # releases, what has been built; `resource` is portal's block
|
||||
text: one line on what it is
|
||||
resource: { public: true, source: { kind: url, url: https://... } }
|
||||
|
||||
doors: # a first question before the forms, only when the offers
|
||||
- id: kebab-case # are more than one page holds (measured: four doors beat
|
||||
who: who comes through it # ten offers on one page; six offers on one page beat doors)
|
||||
|
||||
groups: # groups with a label the public may see: the committees
|
||||
- { id: traffic, label: Traffic, invited_by: [board] } # a person can ask to join (optional; label defaults to the id)
|
||||
invites_by: [board] # groups whose desk may invite people into every desk
|
||||
|
||||
needs:
|
||||
- id: kebab-case # ASCII only
|
||||
who: the kind of person, in plain words, in the intake's language
|
||||
wants: what they want from the business
|
||||
door: <door id> # the door this public need sits behind; omit to put it on the front door
|
||||
enters_as: <group> # ONLY for a role inside the business; omit for the public
|
||||
lands_in: <bucket> # snake_case, ASCII
|
||||
handled_by: <group> # the role that must see and move the record
|
||||
stages: [open, <stage>, ..., <end>, <end>]
|
||||
# EVERY stage the business said a case goes through, first to
|
||||
# last, read off their own sentences one verb at a time: "they
|
||||
# come in, we go and look, we send a quote, we win or lose it"
|
||||
# is [open, surveyed, quoted, won, lost]. A pipeline is not an
|
||||
# inbox: "answered" is rarely the real outcome.
|
||||
done_when: [<end>, <end>] # every stage that counts as finished, good or bad
|
||||
success: [<end>] # the finished stages that are a good outcome for the business
|
||||
skippable: [<stage>] # stages a case may pass over (papers already complete)
|
||||
requires: <one sentence> # what the person must ALREADY have or be for this to work:
|
||||
# a membership, a reference number, the link from a mail, an
|
||||
# account. Stated on the card above the form. Omit when there
|
||||
# is none; never invent one
|
||||
turned_down_from: [open, <stage>]
|
||||
# the unfinished stages a case can still be turned down from;
|
||||
# omit for every one. An applicant is declined before they
|
||||
# start, not after - after, they depart
|
||||
reached_from: { <stage>: [<stage>, ...] }
|
||||
# where a stage is entered from, when the line does not say it.
|
||||
# Two things need it and nothing else does: a case SENT BACK to
|
||||
# a stage it was already past ("papers missing" again, after the
|
||||
# hearing turned something up), and a GOOD ENDING partway along,
|
||||
# where a case finishes well without walking to the end ("we
|
||||
# changed our own decision", instead of passing it upwards).
|
||||
# A stage named here is entered from exactly those stages and
|
||||
# nowhere else, so it also says where a refusal may NOT come
|
||||
# from. Omit unless the business described one of those two
|
||||
moves_by: { <stage>: [<group>, ...] }
|
||||
# only where someone other than handled_by moves a case INTO a
|
||||
# stage: another group, or `submitter` (the person who sent the
|
||||
# form; the form must then collect a required `email`)
|
||||
watched_by: [<group>] # groups that see these cases and move nothing; `public` opens a
|
||||
# read-only page of the bucket to everyone (contact details hidden)
|
||||
mails: { open: submitter, <end>: submitter, <stage>: <address> }
|
||||
# who hears when a case enters a stage: `submitter` (the form
|
||||
# must then collect a required `email`) or one address. Tell the
|
||||
# person on arrival and on every decision that is about them.
|
||||
grants: { <stage>: <group> }
|
||||
# a stage that makes the person a member of a group when their
|
||||
# case arrives (an approval that IS membership); the form must
|
||||
# then collect a required `name` and a required `email`
|
||||
deadlines: { <stage>: { days: 30, to: <stage> } }
|
||||
# a stage a clock moves on from when a case sits in it that long
|
||||
# (a notice that expires, a hearing with no answer); only along
|
||||
# a move the stages already allow
|
||||
public_states: [<stage>] # with `public` in watched_by: the stages the public list shows
|
||||
# (a noticeboard shows what is up, not what was taken down)
|
||||
stages_text: { open: <words>, <stage>: <words> }
|
||||
# each stage in the intake's language, where the ASCII id
|
||||
# would read wrong to a person: "ikke_lost" is "ikke løst"
|
||||
events: { open: applied } # ONLY when rebuilding a live site: the event names its
|
||||
# aggregates.yaml already has, so its records keep replaying
|
||||
attended_by: > # ONLY for a stage no desk button moves: the automation that does
|
||||
what moves it, in a sentence
|
||||
max_fields: 4 # decide this BEFORE writing collects, from what the intake
|
||||
# says about the person; a budget that equals the form's size
|
||||
# is counted, not decided, and is reported as such
|
||||
# how many required fields THIS person will tolerate, from what
|
||||
# the intake says about them: someone on a phone with gloves
|
||||
# on gets 3, a first enquiry 4 to 6, a firm's paperwork 5 to 6
|
||||
collects: # the form; name is snake_case ASCII, type one of
|
||||
# text (default), email, tel, textarea, date, select, location
|
||||
# (a pin on a map, stored as lat/lon/zoom; optional: true,
|
||||
# since a map needs a browser that can draw one)
|
||||
- { name: name }
|
||||
- { name: phone, type: tel }
|
||||
- { name: problem, type: textarea }
|
||||
- { name: urgency, type: select, options: [This week, This month, No rush] }
|
||||
- { name: can_do, type: select, multiple: true, options: [Drive, Cook, Carry] } # any number
|
||||
- { name: committee, type: select, options_from: groups } # the labelled groups above
|
||||
- { name: address, optional: true, private: true } # never on a public page
|
||||
```
|
||||
|
||||
What a portal site can do - use what serves this business, leave the
|
||||
rest. Each is one line in the plan, and the builder does the wiring:
|
||||
|
||||
- **Show, not only ask.** A bucket with `watched_by: [public]` becomes
|
||||
a public list: its newest few records sit inline on the front door,
|
||||
linked to the whole list, contact details pruned. What neighbours
|
||||
reported and where it stands, which changes were approved, what is
|
||||
on the noticeboard: a visitor who sees the place working trusts it,
|
||||
and a person who reported something sees it being dealt with.
|
||||
`public_states` shows only the stages worth showing.
|
||||
- **Stay in dialogue.** `mails` tells the person at the stages that
|
||||
are about them, and a reply to that mail comes back to the case as
|
||||
a note. `moves_by: { <stage>: [submitter] }` gives them a move of
|
||||
their own through their link: confirm, withdraw, say it is solved.
|
||||
- **Let a decision do the work.** `grants` makes an approval into
|
||||
membership: the person gets an account and a place in the group the
|
||||
moment the case gets there; `deadlines` lets a stage end on its own
|
||||
(a notice that expires, a request nobody took up); `invites_by` lets
|
||||
a desk bring a person in without anyone touching an admin tool.
|
||||
- **Let nothing wait unseen.** `escalate: { open: { days: 7, to: styret } }`
|
||||
sends a case nobody has moved in a week up to another group's desk,
|
||||
where it sits with the same buttons until someone finishes it; the
|
||||
desk that had it keeps it too, and may send it up itself. A desk job,
|
||||
not a mail: the group sees it when they sit down.
|
||||
- **Let one record answer another.** A need `about` another need is
|
||||
one answer per record of it, from that record's own row on the
|
||||
public list: a neighbour saying "I can lend you that" to a notice, a
|
||||
bid to a request. `mails: { open: owner }` sends the answer straight
|
||||
to whoever posted the record, and the site keeps no queue - the
|
||||
poster's inbox is the queue. The site never becomes a go-between.
|
||||
- **Keep some things for members.** A need with `door` and `enters_as`
|
||||
is offered to everyone on the door and taken on the group's page
|
||||
behind sign-in, with name and address filled from the account: a
|
||||
seat on a committee for members, a discount for customers. The
|
||||
group is one a decision `grants`, so nobody has to be waved in.
|
||||
- **Tell it two ways and find out.** `tones` lists tones of voice, one
|
||||
storyline each: every word on the site is written once per tone, a
|
||||
visitor reads one storyline throughout, and the site counts what
|
||||
each brought in. Two is plenty; name them as a person would ("plain,
|
||||
like a good notice on the door"; "warm, as a neighbour would put
|
||||
it"). Leave it out to tell one story.
|
||||
- **Carry a text word for word.** `documents` puts bylaws or terms on
|
||||
a page of their own, one card per section, from a markdown file
|
||||
beside the plan. Nothing rewords them.
|
||||
- **Keep the people in portal.** `people: memberships` keeps every
|
||||
group in portal itself: a desk invite is a place held until the
|
||||
person signs in, a person is their phone number first, and any
|
||||
sign-in provider that says who someone is will do (Vipps, for a
|
||||
Norwegian association). Invite forms then ask for a phone number.
|
||||
Without it, Kanidm makes an account per invite.
|
||||
- **Answer for the pages as a group.** `contact: { name, email, group }`
|
||||
makes the contact a group's inbox rather than a person: no account
|
||||
is made for it, and every member of the group may suggest changes
|
||||
to the pages from the page itself.
|
||||
- **Make the choices real.** `groups` with labels, and a select with
|
||||
`options_from: groups`, so a form offers exactly the committees or
|
||||
teams that exist; `multiple: true` where a person can pick several.
|
||||
- **Place things.** A `location` field puts a pin on a map (where the
|
||||
hazard is, where the job is), when the site has maps.
|
||||
- **Open with the business itself.** `does` says what it does for the
|
||||
roles it exists for; `shows` puts its live content beside that.
|
||||
|
||||
Rules:
|
||||
- One need per (kind of person, thing they want). Work inside the
|
||||
business counts: a role recording or posting something is a need
|
||||
with `enters_as`. The intake's "what slows us down" usually names one.
|
||||
- Groups are the roles the intake names, as snake_case identifiers.
|
||||
- The front door opens with `does` (at most two features, one role
|
||||
each) and `shows`, then holds at most seven offers: doors plus needs
|
||||
with no door. A door with no need behind it is refused; a door on a
|
||||
need with `enters_as` is the way to the members' page. A role `does`
|
||||
speaks for must have a move
|
||||
of its own after sending (`moves_by` with `submitter`) or a public
|
||||
view, or it is a spectator.
|
||||
- Every need whose form collects an email should tell the person on
|
||||
arrival and on the decisions about them (`mails`); the plan then
|
||||
needs `mail: { from }`.
|
||||
- Every feature on a card explains itself: a list says what one row is
|
||||
and what its buttons do, a form says what to have ready. If an
|
||||
alternative only works for someone who already has something, say so
|
||||
in `requires` - a condition the reader meets only after filling the
|
||||
form in has already cost them the filling in.
|
||||
- Needs that land in the same bucket must have the same stages,
|
||||
done_when, skippable, moves_by, watched_by and handled_by.
|
||||
- Every required field is one the business will use to act on the
|
||||
record. If the intake gives no reason to need it, make it optional or
|
||||
leave it out. The required fields must not exceed max_fields.
|
||||
- Only `select` lists `options`, and it must list them.
|
||||
- Everything a person reads (who, wants, about, select options) is in
|
||||
the intake's language. Everything a machine reads (ids, buckets,
|
||||
groups, stages, field names) is plain ASCII: no æ, ø, å.
|
||||
- Write no personas.
|
||||
"#;
|
||||
|
||||
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
let value = |flag: &str| {
|
||||
argv.iter()
|
||||
.position(|a| a == flag)
|
||||
.and_then(|i| argv.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let usage = "usage: portal-trainer plan --intake <file> --out <dir> [--llm 'cmd'] [--personas <yaml with personas:>] [--rounds 4]";
|
||||
let intake_path = value("--intake").ok_or_else(|| anyhow!(usage))?;
|
||||
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
|
||||
let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string());
|
||||
let rounds: usize = value("--rounds")
|
||||
.map(|v| v.parse())
|
||||
.transpose()?
|
||||
.unwrap_or(4);
|
||||
let intake =
|
||||
std::fs::read_to_string(&intake_path).with_context(|| format!("reading {intake_path}"))?;
|
||||
let mut personas: Option<serde_yaml::Value> = match value("--personas") {
|
||||
Some(path) => {
|
||||
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
|
||||
let doc: serde_yaml::Value =
|
||||
serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?;
|
||||
Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
std::fs::create_dir_all(out.join("llm"))?;
|
||||
|
||||
let mut feedback = String::new();
|
||||
// A structural finding the builder would only warn about (a role
|
||||
// the opener speaks for with nothing to play) goes back to the
|
||||
// model once; a second time it is the reader's, not another round.
|
||||
let mut spectators_sent = false;
|
||||
for round in 1..=rounds {
|
||||
let prompt = format!(
|
||||
"{REFERENCE}\n\n# Task\n\nA business filled in this intake. Write its plan.yaml: every need the intake implies, for the public and for each role inside the business, with the stages read off how the business says things progress, the form each need collects, and how much each person will tolerate.\n\n## Intake\n\n{intake}\n{feedback}\nReply with the YAML only, no prose, no code fences."
|
||||
);
|
||||
let reply = crate::trainer::llm_with(&cmd, &prompt, &[("TEMPERATURE", "0.2")])?;
|
||||
std::fs::write(out.join(format!("llm/{round:02}-prompt.md")), &prompt)?;
|
||||
std::fs::write(out.join(format!("llm/{round:02}-reply.md")), &reply)?;
|
||||
let yaml = extract_yaml(&reply);
|
||||
match check(&yaml) {
|
||||
Ok(plan) if !plan.spectators().is_empty() && !spectators_sent && round < rounds => {
|
||||
spectators_sent = true;
|
||||
let findings = plan.spectators().join("\n- ");
|
||||
eprintln!("plan round {round}: the plan parses, and a role the opener speaks for has no move after sending; asking once more");
|
||||
feedback = format!("\n## Your last attempt parsed, with one thing to fix\n\n- {findings}\n\nGive each such need a move the person makes themselves (`moves_by: {{<stage>: [submitter, <group>]}}`, with a required `email` in its form) or `watched_by: [public]`, and return the whole plan again.\n\n```yaml\n{yaml}\n```\n");
|
||||
continue;
|
||||
}
|
||||
Ok(plan) => {
|
||||
let mut text = yaml.trim_end().to_string();
|
||||
text.push('\n');
|
||||
// The attached personas were written against someone
|
||||
// else's need ids. The plan is final by now, so a model
|
||||
// may map them to this plan's ids without its words
|
||||
// reaching any page: it returns ids and nothing else,
|
||||
// and an id not in the plan is ignored. What it leaves
|
||||
// unmapped is listed for the reader.
|
||||
if let Some(list) = personas.as_mut() {
|
||||
let mapped = map_personas(&cmd, &plan, list)?;
|
||||
if mapped > 0 {
|
||||
eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs");
|
||||
}
|
||||
text.push_str("\n# Held out from every model that writes words.\n");
|
||||
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([(
|
||||
serde_yaml::Value::from("personas"),
|
||||
list.clone(),
|
||||
)]))?);
|
||||
}
|
||||
let whole: Plan =
|
||||
serde_yaml::from_str(&text).context("the plan with its personas attached")?;
|
||||
let strangers: Vec<String> = whole
|
||||
.personas
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
!whole
|
||||
.needs
|
||||
.iter()
|
||||
.any(|n| n.id == p["need"].as_str().unwrap_or_default())
|
||||
})
|
||||
.map(|p| {
|
||||
format!(
|
||||
"{} (need: {})",
|
||||
p["id"].as_str().unwrap_or_default(),
|
||||
p["need"].as_str().unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(out.join("plan.yaml"), &text)?;
|
||||
let groups: std::collections::BTreeSet<&str> = plan
|
||||
.needs
|
||||
.iter()
|
||||
.flat_map(|n| [Some(n.handled_by.as_str()), n.enters_as.as_deref()])
|
||||
.flatten()
|
||||
.collect();
|
||||
let buckets: std::collections::BTreeSet<&str> =
|
||||
plan.needs.iter().map(|n| n.lands_in.as_str()).collect();
|
||||
eprintln!("plan round {round}: {} need(s), {} group(s), {} bucket(s), {} persona(s) attached", plan.needs.len(), groups.len(), buckets.len(), whole.personas.len());
|
||||
for n in &plan.needs {
|
||||
eprintln!(
|
||||
" {}: {} -> {} ({}), {} of {} field(s) required",
|
||||
n.id,
|
||||
n.stages.join(" -> "),
|
||||
n.lands_in,
|
||||
n.handled_by,
|
||||
n.collects.iter().filter(|f| !f.optional).count(),
|
||||
n.max_fields
|
||||
);
|
||||
}
|
||||
if !strangers.is_empty() {
|
||||
eprintln!(" {} persona(s) name a need this plan does not have; set their `need:` to one of the ids above before building:\n {}", strangers.len(), strangers.join("\n "));
|
||||
}
|
||||
// A budget that equals the form is the tell of a model
|
||||
// that counted the form and called it the person's
|
||||
// tolerance. One such need is a form that uses its
|
||||
// room; most of them is the number being invented.
|
||||
let tight: Vec<&str> = plan
|
||||
.needs
|
||||
.iter()
|
||||
.filter(|n| n.collects.iter().filter(|f| !f.optional).count() == n.max_fields)
|
||||
.map(|n| n.id.as_str())
|
||||
.collect();
|
||||
if tight.len() * 2 > plan.needs.len() && tight.len() > 1 {
|
||||
eprintln!(" {} of {} need(s) have max_fields equal to their required fields ({}): check each against what the intake says about that person, not the form", tight.len(), plan.needs.len(), tight.join(", "));
|
||||
}
|
||||
println!(
|
||||
"plan written to {}; read it before building from it",
|
||||
out.join("plan.yaml").display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("plan round {round}: {e}");
|
||||
feedback = format!("\n## Your last attempt was refused\n\n{e}\n\nFix that and return the whole plan again.\n\n```yaml\n{yaml}\n```\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!(
|
||||
"no plan passed in {rounds} round(s); the replies are in {}/llm/",
|
||||
out.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Personas whose `need:` is not one of the plan's ids get one mapped
|
||||
/// by a model that sees the plan's needs and the persona texts and
|
||||
/// returns ids only. Returns how many were mapped; an id the plan does
|
||||
/// not have, or a persona the model leaves out, stays as it was.
|
||||
fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result<usize> {
|
||||
let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect();
|
||||
let Some(list) = personas.as_sequence_mut() else { return Ok(0) };
|
||||
let strangers: Vec<(String, String)> = list
|
||||
.iter()
|
||||
.filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default()))
|
||||
.map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string()))
|
||||
.filter(|(id, _)| !id.is_empty())
|
||||
.collect();
|
||||
if strangers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let needs: Vec<String> = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect();
|
||||
let people: Vec<String> = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect();
|
||||
let prompt = format!(
|
||||
"A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.",
|
||||
needs.join("\n"),
|
||||
people.join("\n")
|
||||
);
|
||||
let reply = crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
|
||||
let Some(mapping) = first_object(&reply) else { return Ok(0) };
|
||||
let mut mapped = 0;
|
||||
for persona in list.iter_mut() {
|
||||
let id = persona["id"].as_str().unwrap_or_default().to_string();
|
||||
if let Some(need) = mapping.get(&id).and_then(|v| v.as_str()) {
|
||||
if ids.contains(&need) && !ids.contains(&persona["need"].as_str().unwrap_or_default()) {
|
||||
persona["need"] = serde_yaml::Value::from(need);
|
||||
mapped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
/// Parsed and validated exactly as `build` will.
|
||||
fn check(yaml: &str) -> anyhow::Result<Plan> {
|
||||
let plan: Plan =
|
||||
serde_yaml::from_str(yaml).map_err(|e| anyhow!("does not parse as a plan: {e}"))?;
|
||||
if !plan.personas.is_empty() {
|
||||
bail!("contains personas; write none");
|
||||
}
|
||||
if plan.needs.is_empty() {
|
||||
bail!("has no needs");
|
||||
}
|
||||
plan.validate()?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// The YAML in a reply: inside code fences if the model used them,
|
||||
/// otherwise from the first top-level key on. A stray JSON object
|
||||
/// (some models answer `{"business": ...}`) is turned into YAML.
|
||||
fn extract_yaml(reply: &str) -> String {
|
||||
let text = reply.trim();
|
||||
if let Some(start) = text.find("```") {
|
||||
let after = &text[start + 3..];
|
||||
let after = after.find('\n').map_or(after, |i| &after[i + 1..]);
|
||||
let body = after.find("```").map_or(after, |i| &after[..i]);
|
||||
return body.to_string();
|
||||
}
|
||||
if text.starts_with('{') {
|
||||
if let Some(v) = first_object(text) {
|
||||
return serde_yaml::to_string(&v).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
let start = text
|
||||
.lines()
|
||||
.position(|l| {
|
||||
l.starts_with("business:") || l.starts_with("needs:") || l.starts_with("contact:")
|
||||
})
|
||||
.unwrap_or(0);
|
||||
text.lines().skip(start).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: enquiry\n who: an owner\n wants: a look\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 3\n collects:\n - { name: name }\n - { name: phone, type: tel }\n";
|
||||
|
||||
#[test]
|
||||
fn yaml_is_taken_from_fences_prose_or_json() {
|
||||
assert_eq!(
|
||||
extract_yaml(&format!("Here you go:\n```yaml\n{GOOD}```\nDone.")),
|
||||
GOOD
|
||||
);
|
||||
assert_eq!(
|
||||
extract_yaml(&format!("Sure, the plan:\n\n{GOOD}")),
|
||||
GOOD.trim_end()
|
||||
);
|
||||
let from_json = extract_yaml("{\"business\": \"B\", \"needs\": []}");
|
||||
assert!(from_json.contains("business: B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plan_is_held_to_the_same_checks_as_build() {
|
||||
assert!(check(GOOD).is_ok());
|
||||
assert!(
|
||||
check("business: B\ncontact: { name: I, email: i@b.example }\nneeds: []\n")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no needs")
|
||||
);
|
||||
assert!(check(&GOOD.replace("max_fields: 3", "max_fields: 1"))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("tolerate 1"));
|
||||
assert!(check(&format!(
|
||||
"{GOOD}personas:\n - {{ id: p, need: enquiry, text: hi }}\n"
|
||||
))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("personas"));
|
||||
assert!(check(&GOOD.replace(
|
||||
"lands_in: enquiries",
|
||||
"lands_in: henvendelser\n extra: 1"
|
||||
))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("does not parse"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
//! Understanding, readability, friendliness: three scores to iterate
|
||||
//! wording against, each measured the way that keeps it from sticking
|
||||
//! at its maximum.
|
||||
//!
|
||||
//! - **Readability** is counted, not judged: LIX, sentence length plus
|
||||
//! the share of words over six letters, over the page's prose only -
|
||||
//! the question, the descriptions and the followup - never headings
|
||||
//! or field labels, which are not sentences and would drag the score
|
||||
//! down to "very easy" whatever the prose says. Below 30 reads very
|
||||
//! easily, 30-40 easily, 40-50 takes effort, above 50 is official
|
||||
//! prose.
|
||||
//! - **Understanding** is three yes-or-no questions put to each persona
|
||||
//! about the page in front of them: which option is theirs, what the
|
||||
//! form will ask, and what happens after sending. The last needs the
|
||||
//! followup page, which `rate` reads from the content repo (`--repo`)
|
||||
//! because the sim export stops at the form. `rate` also puts all
|
||||
//! three to a deliberately gutted twin of the page (headings replaced
|
||||
//! by "Option 1", descriptions, the form line and the followup
|
||||
//! removed). An instrument that still says yes to the twin is not
|
||||
//! measuring the page, and `rate` says so, per question. Every page
|
||||
//! on a need's path is rated: behind a door, "which" is decided on
|
||||
//! the door page and the form's page carries "asked" and "next".
|
||||
//! - **Friendliness** is a preference between two versions, never an
|
||||
//! absolute mark: `prefer` shows each persona both versions in both
|
||||
//! orders; a pick that flips with the order counts as no preference.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::trainer::card;
|
||||
use crate::trainer::simulate::ask;
|
||||
|
||||
fn flag(argv: &[String], name: &str) -> Option<String> {
|
||||
argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned()
|
||||
}
|
||||
|
||||
fn load(path: &str) -> anyhow::Result<Value> {
|
||||
Ok(serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)?)
|
||||
}
|
||||
|
||||
/// LIX = words per sentence + 100 * long words / words.
|
||||
pub fn lix(text: &str) -> f64 {
|
||||
let words: Vec<&str> = text
|
||||
.split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '-'))
|
||||
.filter(|w| w.chars().any(char::is_alphabetic))
|
||||
.collect();
|
||||
if words.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sentences = text.split(['.', '!', '?', ':', '\n']).filter(|s| s.chars().any(char::is_alphabetic)).count().max(1);
|
||||
let long = words.iter().filter(|w| w.chars().count() > 6).count();
|
||||
words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64
|
||||
}
|
||||
|
||||
pub fn lix_band(score: f64) -> &'static str {
|
||||
match score {
|
||||
s if s < 30.0 => "very easy",
|
||||
s if s < 40.0 => "easy",
|
||||
s if s < 50.0 => "takes effort",
|
||||
_ => "official prose",
|
||||
}
|
||||
}
|
||||
|
||||
/// The page as the visitor meets it: the question, every alternative,
|
||||
/// the form behind the one that serves them, and the page they land on
|
||||
/// after sending, when the repo is there to read it from.
|
||||
fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutted: bool) -> String {
|
||||
let mut text = match (gutted, page) {
|
||||
(false, Some(p)) => card::page_head(p),
|
||||
_ => format!("{}\n", step["question"].as_str().unwrap_or_default()),
|
||||
};
|
||||
// The cards that lead nowhere (the opener, the look around) are
|
||||
// read before the options; the gutted twin loses them too.
|
||||
if let Some(context) = step["context"].as_str().filter(|c| !c.trim().is_empty() && !gutted) {
|
||||
text.push_str(&format!("\n{context}\n"));
|
||||
}
|
||||
for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() {
|
||||
if gutted {
|
||||
text.push_str(&format!("\n- Option {}\n", i + 1));
|
||||
continue;
|
||||
}
|
||||
// The whole card when the repo is there to read it from;
|
||||
// heading and description otherwise.
|
||||
let card = page
|
||||
.and_then(|p| p["alternatives"].as_array())
|
||||
.and_then(|alts| alts.iter().find(|a| a["name"] == option["name"]))
|
||||
.map(|a| card::alternative(a, true));
|
||||
match card {
|
||||
Some(c) => text.push_str(&format!("\n{c}")),
|
||||
None => text.push_str(&format!(
|
||||
"\n- {}\n {}\n",
|
||||
option["name"].as_str().unwrap_or_default(),
|
||||
option["description"].as_str().unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
}
|
||||
if page.is_some() && !gutted {
|
||||
// The cards carry their own "Asks for" lines.
|
||||
return finish_page_text(text, followup);
|
||||
}
|
||||
// The twin has no form line at all: "Field 1, Field 2" still
|
||||
// tells a reader how many things they will be asked, and a model
|
||||
// answers "can you tell what you will be asked" with yes to that
|
||||
// (measured: 2 of 2 on every need).
|
||||
let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect();
|
||||
if !gutted && !fields.is_empty() {
|
||||
text.push_str(&format!(
|
||||
"\nThe form under \"{}\" asks for: {}.\n",
|
||||
step["alternative"].as_str().unwrap_or_default(),
|
||||
fields.join(", ")
|
||||
));
|
||||
}
|
||||
if gutted {
|
||||
return text;
|
||||
}
|
||||
finish_page_text(text, followup)
|
||||
}
|
||||
|
||||
fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
|
||||
if let Some(next) = followup {
|
||||
text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default()));
|
||||
if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) {
|
||||
text.push_str(&format!("{}\n", d.trim()));
|
||||
}
|
||||
for alt in next["alternatives"].as_array().into_iter().flatten() {
|
||||
let d = alt["description"].as_str().unwrap_or_default().trim();
|
||||
text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d));
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The running text a reader has to parse: the question, each
|
||||
/// description, and the followup. Headings and labels are left out.
|
||||
fn prose_text(step: &Value, followup: Option<&Value>) -> String {
|
||||
let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()];
|
||||
prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string));
|
||||
if let Some(next) = followup {
|
||||
prose.extend(next["description"].as_str().map(str::to_string));
|
||||
prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string));
|
||||
}
|
||||
prose.into_iter().filter(|p| !p.trim().is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The page a visitor lands on after the step's alternative: read from
|
||||
/// the content repo, since the sim export stops at the form. `None`
|
||||
/// when there is no repo, no `action`, or the page is not there.
|
||||
fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
|
||||
let repo = repo?;
|
||||
let page = load_page(repo, step["page"].as_str()?)?;
|
||||
let alternative = step["alternative"].as_str()?;
|
||||
let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?;
|
||||
load_page(repo, action)
|
||||
}
|
||||
|
||||
/// Every desk's buttons per bucket, from the simulation model:
|
||||
/// (page, group, bucket, [(from, to, label)]). A desk with one button
|
||||
/// for a bucket has nothing to confuse and is left out.
|
||||
fn desk_buttons(model: &Value) -> Vec<(String, String, String, Vec<(String, String, String)>)> {
|
||||
let mut out = Vec::new();
|
||||
for (bucket, spec) in model["buckets"].as_object().into_iter().flatten() {
|
||||
for desk in spec["desks"].as_array().into_iter().flatten() {
|
||||
let moves: Vec<(String, String, String)> = desk["moves"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|m| (s(&m["from"]), s(&m["to"]), s(&m["label"])))
|
||||
.filter(|(_, _, l)| !l.is_empty())
|
||||
.collect();
|
||||
if moves.len() >= 2 {
|
||||
out.push((s(&desk["page"]), s(&desk["group"]), bucket.clone(), moves));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> String {
|
||||
v.as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A worker on the desk, told what just happened to a case, picks
|
||||
/// the button for it. The outcome is put in plain words from the
|
||||
/// state's own name, never from the label, and the buttons are listed
|
||||
/// in a seeded shuffle.
|
||||
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result<String> {
|
||||
// The state in the plan's own words when the spec carries them,
|
||||
// else the id made readable.
|
||||
let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " "));
|
||||
let mut order: Vec<&str> = labels.to_vec();
|
||||
// A fixed rotation per move: the right answer is not always first.
|
||||
let by = seed % order.len().max(1);
|
||||
order.rotate_left(by);
|
||||
let listed: Vec<String> = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect();
|
||||
let prompt = format!(
|
||||
"You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": <the button's number>}}",
|
||||
plain(group),
|
||||
plain(bucket),
|
||||
plain(from),
|
||||
plain(to),
|
||||
listed.join("\n")
|
||||
);
|
||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||
let n = reply["choice"].as_u64().unwrap_or(0) as usize;
|
||||
Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Every `mail:` on a state in the repo's aggregates.yaml, as
|
||||
/// (`bucket:state`, subject, body) with the `{placeholders}` taken
|
||||
/// out, so they read as the person will read them.
|
||||
pub(crate) fn mail_texts(repo: &Path) -> Option<Vec<(String, String, String)>> {
|
||||
let raw = std::fs::read_to_string(repo.join("aggregates.yaml")).ok()?;
|
||||
let doc: Value = serde_yaml::from_str(&raw).ok()?;
|
||||
let strip = |s: &str| {
|
||||
let mut out = String::new();
|
||||
let mut skip = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'{' => skip = true,
|
||||
'}' => skip = false,
|
||||
_ if !skip => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect())
|
||||
}
|
||||
|
||||
/// Every mail the site sends, as written: the state mails in
|
||||
/// aggregates.yaml, then the invite mail in site.yaml - onboarding is
|
||||
/// a mail too, and the first one a new member reads.
|
||||
fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for aggregate in aggregates["aggregates"].as_array().into_iter().flatten() {
|
||||
let bucket = aggregate["bucket"].as_str().unwrap_or_default();
|
||||
for (state, spec) in aggregate["states"].as_object().into_iter().flatten() {
|
||||
if let Some(mail) = spec.get("mail") {
|
||||
out.push((
|
||||
format!("{bucket}:{state}"),
|
||||
mail["subject"].as_str().unwrap_or_default().to_string(),
|
||||
mail["body"].as_str().unwrap_or_default().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let invite = &site["mail"]["invite"];
|
||||
if invite.is_object() {
|
||||
out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A mail as it arrives: the placeholders filled with plausible values,
|
||||
/// so a reader meets a link where the link will be, not a blank.
|
||||
fn as_received(template: &str, site_name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
out.push_str(&rest[..i]);
|
||||
let after = &rest[i + 1..];
|
||||
match after.find('}') {
|
||||
Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => {
|
||||
out.push_str(&match &after[..j] {
|
||||
"site" => "https://example.site".to_string(),
|
||||
"chain" => "8f3a1c".to_string(),
|
||||
"link" => "https://id.example.site/reset/8f3a1c".to_string(),
|
||||
"site_name" => site_name.to_string(),
|
||||
"expires" => "2026-10-05 12:00 UTC".to_string(),
|
||||
key => format!("[{}]", key.replace('_', " ")),
|
||||
});
|
||||
rest = &after[j + 1..];
|
||||
}
|
||||
_ => {
|
||||
out.push('{');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
const MAIL_QUESTIONS: [(&str, &str); 3] = [
|
||||
("happened", "Do you know, from this mail alone, what has happened and to what?"),
|
||||
("now", "Do you know what, if anything, you can or must do now, and how?"),
|
||||
("who", "Do you know who sent it, and how you would answer them?"),
|
||||
];
|
||||
|
||||
/// Three yes-or-no questions on one mail, from the person it is for.
|
||||
fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> {
|
||||
let prompt = format!(
|
||||
"You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}",
|
||||
MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||
Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true])
|
||||
}
|
||||
|
||||
/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or
|
||||
/// `questions/a/b/index.yaml`.
|
||||
pub(crate) fn load_page(repo: &Path, route: &str) -> Option<Value> {
|
||||
let rel = route.trim_matches('/');
|
||||
let candidates = if rel.is_empty() {
|
||||
vec![repo.join("questions/index.yaml")]
|
||||
} else {
|
||||
vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))]
|
||||
};
|
||||
let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?;
|
||||
serde_yaml::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// What one page may ask of one person before it should be split into
|
||||
/// a further question, or the fields moved to a later stage of the
|
||||
/// case where they are actually needed. Complexity belongs in the
|
||||
/// number of steps and states, not in the weight of any one page.
|
||||
const MAX_OPTIONS: usize = 5;
|
||||
const MAX_REQUIRED: usize = 5;
|
||||
|
||||
/// (alternatives offered, required fields in the form that serves them)
|
||||
fn page_load(step: &Value) -> (usize, usize) {
|
||||
(
|
||||
step["options"].as_array().map_or(0, Vec::len),
|
||||
step["fields"].as_array().into_iter().flatten().filter(|f| f["optional"] != true).count(),
|
||||
)
|
||||
}
|
||||
|
||||
const QUESTIONS: [(&str, &str); 4] = [
|
||||
("which", "Is it clear from the page which option is meant for someone in your situation?"),
|
||||
("where", "Do you know where you are on this site, who this option is for, and what pressing its button will do?"),
|
||||
("asked", "Can you tell from the page what you will be asked to provide?"),
|
||||
("next", "Does the page tell you what happens after you send it, or who will read it?"),
|
||||
];
|
||||
const N: usize = QUESTIONS.len();
|
||||
|
||||
fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Result<[bool; N]> {
|
||||
let prompt = format!(
|
||||
"You are this person: {who}\n\nThis is the whole of a web page you have just opened:\n---\n{page}---\n\nAnswer four questions about it as yourself. Say yes only when the page text itself gives you that; if you would be guessing, say no.\n{}\nReply as JSON: {{\"which\": true or false, \"where\": true or false, \"asked\": true or false, \"next\": true or false}}",
|
||||
QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||
Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true])
|
||||
}
|
||||
|
||||
pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
let usage = "usage: portal-trainer rate --model <model.json> --out <dir> [--repo <content dir>] [--llm 'cmd']";
|
||||
let model = load(&flag(argv, "--model").ok_or_else(|| anyhow!(usage))?)?;
|
||||
let out = std::path::PathBuf::from(flag(argv, "--out").ok_or_else(|| anyhow!(usage))?);
|
||||
let repo = flag(argv, "--repo").map(std::path::PathBuf::from);
|
||||
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
|
||||
std::fs::create_dir_all(&out)?;
|
||||
if repo.is_none() {
|
||||
eprintln!("rate: no --repo, so no followup page is shown and \"next\" is not measured");
|
||||
}
|
||||
|
||||
let mut report = BTreeMap::new();
|
||||
// The desks: the inside of the business reads button labels and
|
||||
// nothing else, so each desk is scored on whether a worker told
|
||||
// what has just happened to a case picks the right button among
|
||||
// that desk's buttons for the bucket.
|
||||
let desks = desk_buttons(&model);
|
||||
if !desks.is_empty() {
|
||||
println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for");
|
||||
for (page, group, bucket, moves) in &desks {
|
||||
// A desk row shows only the buttons that start from the
|
||||
// row's state, so those are the ones the worker chooses
|
||||
// among; a state with one button has nothing to confuse.
|
||||
let mut right = 0;
|
||||
let mut asked = 0;
|
||||
let mut mistaken = Vec::new();
|
||||
for (i, (from, to, label)) in moves.iter().enumerate() {
|
||||
let labels: Vec<&str> = moves.iter().filter(|(f, _, _)| f == from).map(|(_, _, l)| l.as_str()).collect();
|
||||
if labels.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
asked += 1;
|
||||
let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?;
|
||||
if picked == *label {
|
||||
right += 1;
|
||||
} else {
|
||||
mistaken.push(format!("{from}>{to} as {picked:?}"));
|
||||
}
|
||||
}
|
||||
if asked == 0 {
|
||||
continue;
|
||||
}
|
||||
println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, asked, mistaken.join("; "));
|
||||
report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": asked, "mistaken": mistaken }));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
// The mails first, when the repo carries any: a person reads a
|
||||
// mail alone, with no card around it, so its prose is rated on
|
||||
// its own - LIX and length.
|
||||
// Then the three questions a person asks of any mail - what
|
||||
// happened, what now, who is this - each also put to a gutted twin
|
||||
// that says only that there is news. A yes the twin also gets is
|
||||
// the reader agreeing with anything.
|
||||
if let Some(mails) = repo.as_deref().and_then(mail_texts) {
|
||||
if !mails.is_empty() {
|
||||
let dir = repo.as_deref().unwrap_or(Path::new("."));
|
||||
let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let site_name = site["title"].as_str().unwrap_or("the site").to_string();
|
||||
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let raw = raw_mails(dir, &aggregates);
|
||||
println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says");
|
||||
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() {
|
||||
let text = format!("{subject}. {body}");
|
||||
let score = lix(&text);
|
||||
let reader = if name == "invite" {
|
||||
format!("someone a member of {site_name} has just brought in to help run it; you did not ask for this mail")
|
||||
} else {
|
||||
format!("someone who sent a form to {site_name} some time ago")
|
||||
};
|
||||
let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name));
|
||||
let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?;
|
||||
let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?;
|
||||
let yn = |b: bool| if b { "yes" } else { "no" };
|
||||
let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect();
|
||||
println!(
|
||||
"{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
|
||||
name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]),
|
||||
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
|
||||
);
|
||||
report.insert(format!("mail {name}"), json!({
|
||||
"lix": score, "words": text.split_whitespace().count(),
|
||||
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
|
||||
"control_ok": failed.is_empty(),
|
||||
}));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load");
|
||||
for need in model["needs"].as_array().into_iter().flatten() {
|
||||
let id = need["id"].as_str().unwrap_or_default();
|
||||
let steps = need["steps"].as_array().cloned().unwrap_or_default();
|
||||
let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect();
|
||||
if personas.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Every page on the path is rated. The followup follows only
|
||||
// the last page's alternative: on a door page, what the
|
||||
// alternative leads to is the next page, not what happens
|
||||
// after sending.
|
||||
let mut pages = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None };
|
||||
let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
|
||||
let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() };
|
||||
let readability = lix(&prose_text(step, followup.as_ref()));
|
||||
let (mut yes, mut control) = ([0usize; N], [0usize; N]);
|
||||
for (i, persona) in personas.iter().enumerate() {
|
||||
let who = persona["text"].as_str().unwrap_or_default();
|
||||
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
|
||||
let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?;
|
||||
for (slot, answer) in tally.iter_mut().zip(answers) {
|
||||
*slot += answer as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = personas.len();
|
||||
let measured = |k: usize| k != 3 || followup.is_some();
|
||||
let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() };
|
||||
// The twin has no headings, no descriptions, no cards and no
|
||||
// followup: every question must collapse on it, or that
|
||||
// question is agreeing with anything.
|
||||
let control_ok: Vec<bool> = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect();
|
||||
let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect();
|
||||
let (options, required) = page_load(step);
|
||||
println!(
|
||||
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
|
||||
row,
|
||||
readability,
|
||||
lix_band(readability),
|
||||
share(0),
|
||||
share(1),
|
||||
share(2),
|
||||
share(3),
|
||||
format!(
|
||||
"{}/{}/{}/{} {}",
|
||||
control[0],
|
||||
control[1],
|
||||
control[2],
|
||||
if measured(3) { control[3].to_string() } else { "-".to_string() },
|
||||
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
|
||||
),
|
||||
format!(
|
||||
"{options} options, {required} required{}",
|
||||
if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" }
|
||||
),
|
||||
);
|
||||
pages.push(json!({
|
||||
"page": step["page"], "lix": readability, "personas": n,
|
||||
"options": options, "required_fields": required,
|
||||
"overloaded": options > MAX_OPTIONS || required > MAX_REQUIRED,
|
||||
"followup_shown": followup.is_some(),
|
||||
"understanding": {"which": yes[0], "where": yes[1], "asked": yes[2], "next": if measured(3) { json!(yes[3]) } else { Value::Null }},
|
||||
"control": {"which": control[0], "where": control[1], "asked": control[2], "next": if measured(3) { json!(control[3]) } else { Value::Null }},
|
||||
"control_ok": failed.is_empty(),
|
||||
}));
|
||||
}
|
||||
report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages }));
|
||||
}
|
||||
std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
|
||||
let usage = "usage: portal-trainer prefer --a <model-a.json> --b <model-b.json> [--llm 'cmd']";
|
||||
let a = load(&flag(argv, "--a").ok_or_else(|| anyhow!(usage))?)?;
|
||||
let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?;
|
||||
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
|
||||
|
||||
println!("{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B");
|
||||
println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer");
|
||||
for need_a in a["needs"].as_array().into_iter().flatten() {
|
||||
let id = need_a["id"].as_str().unwrap_or_default();
|
||||
let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue };
|
||||
let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue };
|
||||
let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false));
|
||||
if text_a == text_b {
|
||||
continue;
|
||||
}
|
||||
// [friendlier, clearer] x [A, B, none]
|
||||
let mut wins = [[0usize; 3]; 2];
|
||||
for (i, persona) in a["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).enumerate() {
|
||||
let who = persona["text"].as_str().unwrap_or_default();
|
||||
let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]];
|
||||
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)].into_iter().enumerate() {
|
||||
let prompt = format!(
|
||||
"You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}"
|
||||
);
|
||||
let reply = ask(&cmd, &prompt, &(i * 2 + order).to_string())?;
|
||||
for (k, key) in ["friendlier", "clearer"].iter().enumerate() {
|
||||
let first_won = reply[key] == 1;
|
||||
// In the second order, version 1 is B.
|
||||
picks[k][order] = if first_won == (order == 0) { "A" } else { "B" };
|
||||
}
|
||||
}
|
||||
for k in 0..2 {
|
||||
let slot = match picks[k] {
|
||||
["A", "A"] => 0,
|
||||
["B", "B"] => 1,
|
||||
_ => 2,
|
||||
};
|
||||
wins[k][slot] += 1;
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
|
||||
id, wins[0][0], wins[0][1], wins[0][2], wins[1][0], wins[1][1], wins[1][2],
|
||||
format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b))
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lix_separates_plain_words_from_official_prose() {
|
||||
let plain = "Get someone to look at a building. We come and see it. Then we tell you what it takes.";
|
||||
let official = "Submission of documentation regarding rehabilitation requirements necessitates comprehensive structural evaluation procedures.";
|
||||
assert!(lix(plain) < 30.0, "{}", lix(plain));
|
||||
assert!(lix(official) > 60.0, "{}", lix(official));
|
||||
assert_eq!(lix_band(lix(plain)), "very easy");
|
||||
assert_eq!(lix(""), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gutted_twin_keeps_the_question_and_loses_the_wording() {
|
||||
let step = json!({
|
||||
"question": "What brings you here?",
|
||||
"options": [{"name": "Get a price", "description": "For owners."}, {"name": "Work with us", "description": "For crews."}],
|
||||
"alternative": "Get a price",
|
||||
"fields": [{"label": "Email"}, {"label": "The build"}]
|
||||
});
|
||||
let next = json!({"name": "What happens now?", "description": "A project manager reads it.", "alternatives": [{"name": "We call you within two days", "description": ""}]});
|
||||
let page = page_text(&step, None, Some(&next), false);
|
||||
assert!(page.contains("Get a price") && page.contains("asks for: Email, The build"));
|
||||
assert!(page.contains("After you send it") && page.contains("A project manager reads it."));
|
||||
// With the repo's page in hand, each option is its whole card.
|
||||
let repo_page = json!({"name": "What brings you here?", "description": "", "alternatives": [
|
||||
{"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]},
|
||||
{"name": "Work with us", "description": "For crews."}]});
|
||||
let rich = page_text(&step, Some(&repo_page), Some(&next), false);
|
||||
assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email"));
|
||||
assert!(!rich.contains("asks for: Email, The build"));
|
||||
let twin = page_text(&step, Some(&repo_page), Some(&next), true);
|
||||
assert!(!twin.contains("Send it") && !twin.contains("Your brief"));
|
||||
let twin = page_text(&step, None, Some(&next), true);
|
||||
assert!(twin.contains("What brings you here?") && twin.contains("Option 2"));
|
||||
assert!(!twin.contains("asks for"));
|
||||
assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager"));
|
||||
// Readability is measured on the sentences, not the headings.
|
||||
let prose = prose_text(&step, Some(&next));
|
||||
assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() {
|
||||
let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel");
|
||||
assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}");
|
||||
let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap();
|
||||
std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap();
|
||||
let mails = mail_texts(&dir).unwrap();
|
||||
assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(), ["b:open", "invite"]);
|
||||
assert_eq!(mails[0].2, "Vi har det, .");
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_followup_page_is_read_from_the_repo_by_following_the_action() {
|
||||
let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("questions/received")).unwrap();
|
||||
std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap();
|
||||
std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap();
|
||||
let step = json!({"page": "/", "alternative": "Get a price"});
|
||||
let next = followup_page(Some(&dir), &step).unwrap();
|
||||
assert_eq!(next["name"], "What happens now?");
|
||||
assert!(followup_page(None, &step).is_none());
|
||||
assert!(followup_page(Some(&dir), &json!({"page": "/", "alternative": "Nope"})).is_none());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
# Portal content reference (for the generator)
|
||||
|
||||
Portal serves a site that is one question per page. Each page offers a
|
||||
few alternatives. An alternative may carry a form; submitting it
|
||||
records the answer into a *bucket* whose records move through a state
|
||||
machine, worked from a *desk*: a page gated to a group, listing the
|
||||
bucket with one button per allowed move. There is no other mechanism.
|
||||
Everything a business needs is expressed as: who answers which
|
||||
question, where the answer lands, who moves it, through which states.
|
||||
|
||||
A content repo is these files and nothing else:
|
||||
|
||||
```
|
||||
needs.yaml what the business needs (the spec you are held to)
|
||||
aggregates.yaml one state machine per bucket
|
||||
site.yaml title and language
|
||||
questions/index.yaml the front door, "/"
|
||||
questions/<name>.yaml "/<name>"
|
||||
questions/<dir>/index.yaml "/<dir>"
|
||||
questions/<dir>/_section.yaml applies `qualifies` to the whole directory
|
||||
questions/<dir>/<name>.yaml "/<dir>/<name>" - a followup page by default
|
||||
```
|
||||
|
||||
## needs.yaml
|
||||
|
||||
```yaml
|
||||
needs:
|
||||
- id: kebab-case
|
||||
who: the kind of person, in plain words
|
||||
wants: what they want from the business
|
||||
enters: / # page they arrive on; default /
|
||||
enters_as: <group> # only when they are logged-in members of a group; omit for the public
|
||||
via: <alternative name> # only when several alternatives on the entry page record into the same bucket
|
||||
lands_in: <bucket>
|
||||
handled_by: <group> # the role inside the business that must see and move the record
|
||||
done_when: [<state>, <state>] # every state that counts as finished, good or bad
|
||||
max_steps: 1 # submissions before the record exists (1 or 2)
|
||||
max_fields: 4 # required fields the visitor will tolerate across the path
|
||||
success: [<state>] # the done_when states that are a good outcome for the business
|
||||
stages: [open, <stage>, <stage>, <end>, <end>]
|
||||
# EVERY stage the business said a case goes through, first to last,
|
||||
# read off their own sentences one verb at a time ("they come in, we
|
||||
# go and look, we send a quote, we win or lose it" is five stages).
|
||||
# The state machine must have a state for each, or the verifier fails.
|
||||
personas:
|
||||
- id: kebab-case
|
||||
need: <need id>
|
||||
text: >
|
||||
Two or three sentences in the visitor's own voice and vocabulary.
|
||||
```
|
||||
|
||||
Rules:
|
||||
- One need per (kind of person, thing they want). Internal work counts:
|
||||
a role inside the business recording or posting something is a need
|
||||
with `enters_as`.
|
||||
- States come from the business's own description of how a thing
|
||||
progresses. A pipeline is not an inbox: "answered" is rarely the real
|
||||
outcome.
|
||||
- `max_fields` is small on purpose. Reporting something from a phone
|
||||
gets 3. A first enquiry gets 4 to 6.
|
||||
- Two or three personas per public need. At least one must avoid the
|
||||
trade's own vocabulary. A persona must never quote a page heading.
|
||||
|
||||
## aggregates.yaml
|
||||
|
||||
```yaml
|
||||
aggregates:
|
||||
- bucket: leads
|
||||
initial: open
|
||||
states: # state: { event: past-tense verb }
|
||||
open: { event: received }
|
||||
quoted: { event: quoted }
|
||||
won: { event: won }
|
||||
lost: { event: lost }
|
||||
transitions: # only moves the business described
|
||||
open: [quoted, lost]
|
||||
quoted: [won, lost]
|
||||
```
|
||||
|
||||
The initial state should be `open` (a desk transition with no `from`
|
||||
means from `open`).
|
||||
|
||||
## A page
|
||||
|
||||
```yaml
|
||||
name: What brings you here? # always a question the visitor could ask themselves
|
||||
description: >
|
||||
One or two sentences.
|
||||
responsible: { name: <person or team>, contact: <email> } # required on every entry page
|
||||
alternatives:
|
||||
- name: <heading> # what the visitor picks. Must work WITHOUT the description.
|
||||
description: >
|
||||
One or two sentences.
|
||||
action: /received # absolute path of an existing page
|
||||
consequence: [Send] # the button label
|
||||
record_as: leads # omit for a pure navigation alternative
|
||||
features:
|
||||
- name: About the building
|
||||
description: Optional line of guidance.
|
||||
requirements:
|
||||
- { name: email, label: Email, type: email }
|
||||
- { name: problem, label: What is wrong, type: textarea }
|
||||
- { name: urgency, label: How soon, type: select, options: [This week, This month, No rush] }
|
||||
- { name: address, label: Address, optional: true }
|
||||
```
|
||||
|
||||
- Field types: `text` (default), `email`, `tel`, `textarea`, `date`,
|
||||
`select` (must list `options`). Use no other types.
|
||||
- Every `action` must name a page that exists. After a recording
|
||||
alternative, send the visitor to a followup page that says who reads
|
||||
it, when they answer, and what happens next. A followup page is a
|
||||
top-level file with `followup: true`, or any non-index file in a
|
||||
subdirectory. Give it one alternative with `action: /` to go back.
|
||||
- A page for members of a group sets `qualifies: <group>` (or sits in a
|
||||
directory whose `_section.yaml` does). Only `enters_as: <group>`
|
||||
needs can use it.
|
||||
- Every bucket that is recorded into must be read by a desk.
|
||||
|
||||
## A desk
|
||||
|
||||
```yaml
|
||||
# questions/desk/_section.yaml
|
||||
qualifies: project_managers
|
||||
```
|
||||
```yaml
|
||||
# questions/desk/index.yaml
|
||||
name: What came in?
|
||||
responsible: { name: ..., contact: ... }
|
||||
alternatives:
|
||||
- name: Leads
|
||||
description: One line on what to do with them.
|
||||
action: /desk
|
||||
consequence: [Confirm]
|
||||
features:
|
||||
- name: ""
|
||||
description: ""
|
||||
resource:
|
||||
source: { kind: kv, bucket: leads }
|
||||
requires_group: project_managers
|
||||
transitions:
|
||||
- { to: quoted, label: Quote sent }
|
||||
- { to: lost, label: Not for us }
|
||||
- { from: quoted, to: won, label: Won }
|
||||
- { from: quoted, to: lost, label: Lost }
|
||||
```
|
||||
|
||||
- One desk directory per handling group; one alternative per bucket
|
||||
that group handles.
|
||||
- Offer every move the state machine allows and no other. A state with
|
||||
no move out of it, on the way to a finished state, strands records.
|
||||
- A group may read a bucket it does not handle: the same `resource`
|
||||
block with its own `requires_group` and no `transitions`.
|
||||
|
||||
## site.yaml
|
||||
|
||||
```yaml
|
||||
title: <business name>
|
||||
lang: en
|
||||
```
|
||||
|
||||
## Language
|
||||
|
||||
Everything a person reads is written in the intake's `language`: page
|
||||
names, headings, descriptions, field labels, button labels, the
|
||||
`who`/`wants` of each need, and the personas. Write it the way people
|
||||
in that trade and country actually speak, not as a translation.
|
||||
Everything a machine reads stays plain ASCII snake_case or kebab-case:
|
||||
need ids, persona ids, bucket names, state names, field `name`s, file
|
||||
names and paths. Letters such as æ, ø and å belong in the copy and
|
||||
never in an identifier. Set `lang` in site.yaml to the intake's code.
|
||||
|
||||
## Copy rules
|
||||
|
||||
- Headings say what the visitor gets, in the visitor's words.
|
||||
- Lead with what a thing enables. Never define something by what it
|
||||
is not. No negations in headings.
|
||||
- Two alternatives must never be separable only by a distinction the
|
||||
business makes and the visitor does not.
|
||||
- Every required field is one the business will use to answer.
|
||||
|
||||
## The verifier
|
||||
|
||||
`question_lint` runs after every attempt. Non-zero exit means you are
|
||||
not done. Its messages are exact:
|
||||
|
||||
- `no path from ... records into ...`: no chain of alternatives from
|
||||
the entry page reaches an alternative with that `record_as`.
|
||||
- `the path asks for N required field(s), over the budget`: mark fields
|
||||
`optional: true` or drop them.
|
||||
- `no desk gated on "g" reads "b"`: add the desk.
|
||||
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
||||
is missing a transition the state machine allows, or the state
|
||||
machine is missing the edge.
|
||||
- `action ... does not match any declared question id`: the page does
|
||||
not exist.
|
||||
- `entry page ... names no one responsible`: add `responsible`.
|
||||
@@ -0,0 +1,127 @@
|
||||
//! What a live site's storylines did, read back as counts.
|
||||
//!
|
||||
//! `portal-trainer report --site https://vel.example [--needs needs.yaml]`
|
||||
//! fetches the site's `/report` (portal docs, Storylines): per bucket,
|
||||
//! per storyline, per state, a count and nothing else. With the site's
|
||||
//! `needs.yaml` the states that end well are known, and each storyline
|
||||
//! is scored on how many cases it brought in and how many of those
|
||||
//! ended well. Storylines are assigned evenly, so the counts compare
|
||||
//! directly. This is the live half of what the scorer measures offline
|
||||
//! with personas: the same tones, judged by what people did.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Report {
|
||||
storylines: usize,
|
||||
/// bucket -> storyline -> state -> count
|
||||
buckets: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Needs {
|
||||
#[serde(default)]
|
||||
needs: Vec<Need>,
|
||||
#[serde(default)]
|
||||
tones: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Need {
|
||||
lands_in: String,
|
||||
#[serde(default)]
|
||||
done_when: Vec<String>,
|
||||
#[serde(default)]
|
||||
success: Vec<String>,
|
||||
}
|
||||
|
||||
/// One storyline's showing in one bucket.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Showing {
|
||||
arrived: u64,
|
||||
ended_well: u64,
|
||||
}
|
||||
|
||||
/// Per bucket, per storyline index, how many arrived and how many
|
||||
/// ended in a state the need calls success (or, without `success`,
|
||||
/// any finished state).
|
||||
fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>, needs: &[Need]) -> BTreeMap<String, Vec<Showing>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (bucket, per_story) in counts {
|
||||
let well: Vec<&str> = needs
|
||||
.iter()
|
||||
.filter(|n| &n.lands_in == bucket)
|
||||
.flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() })
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let arity = per_story.keys().filter_map(|k| k.parse::<usize>().ok()).max().map(|m| m + 1).unwrap_or(1);
|
||||
let mut rows: Vec<Showing> = (0..arity).map(|_| Showing::default()).collect();
|
||||
for (story, states) in per_story {
|
||||
let Ok(n) = story.parse::<usize>() else { continue };
|
||||
for (state, count) in states {
|
||||
rows[n].arrived += count;
|
||||
if well.contains(&state.as_str()) {
|
||||
rows[n].ended_well += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.insert(bucket.clone(), rows);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
|
||||
let site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]"))?;
|
||||
let url = format!("{}/report", site.trim_end_matches('/'));
|
||||
let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?;
|
||||
if !fetched.status.success() {
|
||||
bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim());
|
||||
}
|
||||
let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
|
||||
let needs: Needs = match value("--needs") {
|
||||
Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?,
|
||||
None => Needs::default(),
|
||||
};
|
||||
println!("{site}: {} storyline(s)", report.storylines);
|
||||
for (i, tone) in needs.tones.iter().enumerate() {
|
||||
println!(" [{i}] {tone}");
|
||||
}
|
||||
if report.buckets.is_empty() {
|
||||
println!("nothing sent in yet");
|
||||
return Ok(());
|
||||
}
|
||||
let rows = showings(&report.buckets, &needs.needs);
|
||||
for (bucket, per_story) in &rows {
|
||||
println!("\n{bucket}");
|
||||
for (n, showing) in per_story.iter().enumerate() {
|
||||
let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() };
|
||||
println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well);
|
||||
}
|
||||
if per_story.len() > 1 {
|
||||
let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0);
|
||||
println!(" most so far: storyline {best}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() {
|
||||
let counts: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>> =
|
||||
serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap();
|
||||
let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }];
|
||||
let rows = showings(&counts, &needs);
|
||||
assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 });
|
||||
assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 });
|
||||
// Without a success list, any finished state counts.
|
||||
let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }];
|
||||
assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user