Compare commits

..
266 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 48ae5e07c2 Release 0.6.18
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m0s
Publish release / publish (push) Successful in 1m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 b619926a11 generate follows its findings through, and stops when it stalls
Each structure round is logged as fixed, still open, new and reopened;
a fix round sees the open findings with their fixes and only the files
they name; a change that reopens a closed finding is undone; a round
that closes nothing ends the phase with what is left in run.log. A
hesitant visitor alone no longer triggers a wording rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:09 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 706c8108df iris check --findings: every finding as JSON with a stable id
The id hashes scope, need, code and subject, nothing of the wording, so
the same gap keeps its id across runs. Every message that knows its fix
says it after "fix:". Codes listed in docs/lint.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:09 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 2feeea0810 Release 0.6.17
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 2m12s
Publish release / publish (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:26:13 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 6b91258ebe One set of mail variables for every mail, and {link} in state mails
{site} and {site_name} everywhere; a state mail adds {self_link} (the
submitter's own page, when their record's state allows a move there),
{desk_link} and {link}, which picks by who the mail is to. gullberg's
{link} mails went out blank until now. Documented in docs/mail.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:25:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 ae119af987 A desk shows the three newest finished records, and older on request
Every record still moving stays; of those in a final state only the
three most recently decided, plus one so the page knows to offer
"Show older", three more at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:25:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 92dd9bfc79 The lint walks every state of every bucket a need lands in
Clocks count as moves and a visitor's link only from its own states.
Per bucket: a state nothing reaches fails, a state a record can reach
and never leave fails (after done too), a declared move nobody offers
warns, a desk gated twice warns, and a mail from an unreachable state
or naming a field no form collects is caught. Working stages must
follow each other. Every finding ends in its fix, and a schema error no
longer hides the needs findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 11:25:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 2cc74e3354 CI builds and lints every plan with this portal
Test / test (push) Successful in 2m35s
The check portal-trainer's CI ran with the standalone trainer, which
kept failing on a builder bug fixed here long ago. Now it runs where the
trainer lives, with the same binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
2026-10-02 09:27:44 +02:00
Bendik Aagaard Lynghaug 7b54c4c6d7 Release 0.6.16
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m56s
2026-10-01 21:46:45 +02:00
bl 951fac9aec Merge pull request 'Test fixtures get the action the lint now requires' (#28) from fixture-action into main
Test / test (push) Successful in 1m10s
2026-10-01 19:46:29 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 738dbac7fe Test fixtures: every form has the action the lint now requires
Test / test (pull_request) Successful in 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:46:24 +02:00
bl 98f93f5ad5 Merge pull request 'A form with no action is refused; portal iris' (#27) from form-needs-action into main
docs / deploy (push) Successful in 3s
Test / test (push) Failing after 1m7s
2026-10-01 19:42:03 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4049809aa4 The lint refuses a form with no action, which would have no button; portal iris names the lint
Test / test (pull_request) Failing after 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:41:56 +02:00
Bendik Aagaard Lynghaug b43ff57eab Release 0.6.15
Test / test (push) Successful in 1m14s
Publish release / publish (push) Successful in 2m5s
2026-10-01 18:06:16 +02:00
bl ed235cd134 Merge pull request 'An invite may carry its own mail' (#26) from invite-mail into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m52s
2026-10-01 16:06:14 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 cc9f1c5d87 An invite may carry its own mail, with the form's fields and a spoken date and time
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 18:06:12 +02:00
bl 01abf79005 Merge pull request 'Klingenberg Bygg plan: Fredrik's caption corrections' (#25) from kb-fredrik-captions into main
Test / test (push) Successful in 1m9s
2026-10-01 15:57:10 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 506bb712fe Klingenberg Bygg plan: Fredrik's two caption corrections from the live site
Test / test (pull_request) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 17:57:08 +02:00
Bendik Aagaard Lynghaug 74fd2d1750 Release 0.6.14
Test / test (push) Successful in 1m11s
Publish release / publish (push) Successful in 1m57s
2026-10-01 17:47:51 +02:00
bl ae521c0fa2 Merge pull request 'Less room above the wordmark' (#24) from hero-top-space into main
docs / deploy (push) Successful in 11s
Test / test (push) Successful in 1m13s
2026-10-01 15:47:47 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d0ee06f48c Less room above the wordmark, now that who is here sits on its line
Test / test (pull_request) Successful in 1m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 17:47:45 +02:00
Bendik Aagaard Lynghaug 6a9be3bef4 Release 0.6.13
Test / test (push) Successful in 1m5s
Publish release / publish (push) Successful in 1m48s
2026-10-01 11:40:32 +02:00
bl 49be010e8e Merge pull request 'Who is here on the wordmark's line; signed-in people aren't asked for their name' (#23) from prefill-everywhere into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m5s
2026-10-01 09:40:29 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 78f42e6f8a Who is here on the wordmark's line; every form carries a signed-in person's name and address
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 11:40:27 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9b50c9cab8 Gullberg: i Ås, ikke på Ås
Test / test (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 11:09:08 +02:00
bl 858a66d84e Merge pull request 'GULLBERG sanatorium: plan, words, wordmark; a board may publish its own list' (#20) from gullberg-plan into main
Test / test (push) Successful in 1m5s
2026-10-01 08:44:09 +00:00
Bendik Aagaard Lynghaug bf92666b3e Merge branch 'main' into gullberg-plan
Test / test (pull_request) Successful in 1m4s
2026-10-01 10:42:35 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 b070d77acd Gullberg: the house behind Ås kirke, rooms for everyone with students first, mails written to the reader who opens them
Test / test (pull_request) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 10:41:41 +02:00
Bendik Aagaard Lynghaug cc07cd321d Release 0.6.12
Test / test (push) Successful in 1m5s
Publish release / publish (push) Successful in 1m44s
2026-10-01 10:41:31 +02:00
bl 7a2e45c6c4 Merge pull request 'Trainer: the words model knows who opens each mail' (#22) from mail-recipients into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m43s
2026-10-01 08:41:29 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 8f24e5483c Trainer: tell the words model who opens each mail
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 10:41:27 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 b970c64c6a Gullberg wordmark: sanatorium set in a little from the edges
Test / test (pull_request) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 10:19:43 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 8a1fbd92ba Gullberg wordmark: sanatorium spread to GULLBERG's width
Test / test (pull_request) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 10:16:04 +02:00
Bendik Aagaard Lynghaug 1929d2f44e Release 0.6.11
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m50s
2026-10-01 09:33:31 +02:00
bl 26c1d447f3 Merge pull request 'wordmark_ink: the wordmark in the page's own ink' (#21) from wordmark-ink into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-10-01 07:33:28 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 8376c68f8f wordmark_ink: an SVG wordmark drawn in the page's own ink, following light and dark everywhere
Test / test (pull_request) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 09:33:27 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 b9f69a5e7a Gullberg wordmark as Futura outlines, cropped to the letters
Test / test (pull_request) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 09:28:47 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 37624e8e2b Gullberg: reviewed Norwegian words (nine misreadings fixed, invite mail written), tenants follow their own fault reports
Test / test (pull_request) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:36:45 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 902dc36831 Gullberg: lighter on the bank, the co-owner offer held back, and the points the shareholder agreement must cover
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:28:28 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 54efedef56 Gullberg draft: the site as the bank's running proof that the company operates; the ownership transfer step by step; a read-only bank group
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:20:35 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 742be200a9 Gullberg draft: two of the four quarters are for sale; finding the co-owners comes first; the bank parts only if the company borrows
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:18:25 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 2d695f990b Gullberg draft: four equal shareholders; faults go to Klingenberg Bygg, its estimates to the board and the reports
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:15:35 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 b016dfd964 Gullberg draft: the whole house in Gullberg AS, two shareholders, a bank lender; what the bank requires
Test / test (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:11:44 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 35e9586e77 Gullberg draft: the repayment plan all shareholders approve, and the monthly rent ledger
Test / test (pull_request) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 07:07:26 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4e211ddbff Draft: GULLBERG sanatorium's plan, business-plan outline and wordmark; a board's published list may be public
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 22:34:33 +02:00
Bendik Aagaard Lynghaug c8901558b2 Release 0.6.10
Test / test (push) Successful in 1m18s
Publish release / publish (push) Successful in 2m1s
2026-09-30 21:52:28 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 02c94ae5dc Changelog: 0.6.10
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:52:25 +02:00
bl 5fc29fe5a6 Merge pull request 'Who we are, on its own page; inside forms ask real questions' (#19) from present-yourself into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 19:52:24 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 e6c4f67db7 Who a business is gets a page of its own; inside forms ask real questions; build names untried needs
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:52:23 +02:00
Bendik Aagaard Lynghaug 19b7750292 Release 0.6.9
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m54s
2026-09-30 21:38:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 1a1723f8bb Changelog: 0.6.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:56 +02:00
bl 0d67675456 Merge pull request 'A bio is markdown, on stricter rules' (#18) from bio-markdown into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 19:38:55 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 eb982ec6e6 A bio is markdown, on stricter rules than the site's own
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:54 +02:00
Bendik Aagaard Lynghaug c6dffc384c Release 0.6.8
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 2m0s
2026-09-30 21:08:33 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db0972974f Changelog: 0.6.8
docs / deploy (push) Successful in 4s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:30 +02:00
bl 72faf44559 Merge pull request 'An upload that did not arrive says why' (#17) from upload-messages into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 19:08:30 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db89ea6400 An upload that did not arrive says why
Test / test (pull_request) Successful in 1m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:28 +02:00
Bendik Aagaard Lynghaug 95bffc221b Release 0.6.7
Test / test (push) Successful in 1m19s
Publish release / publish (push) Successful in 2m8s
2026-09-30 20:50:44 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f5266cb347 Changelog: 0.6.7
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:42 +02:00
bl 248561e805 Merge pull request 'leaves_after: finished records leave their lists' (#16) from leaves-after into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 18:50:41 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0dc25b4023 A record may leave its lists some time after reaching a state: leaves_after
Test / test (pull_request) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:28 +02:00
Bendik Aagaard Lynghaug 96c513d912 Release 0.6.6
Test / test (push) Successful in 1m16s
Publish release / publish (push) Successful in 2m7s
2026-09-30 20:39:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0cb1dda202 Changelog: 0.6.6
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:10 +02:00
bl ec9bf10b72 Merge pull request 'A card of one's own; no double sends; refusals shown' (#15) from own-card into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
2026-09-30 18:39:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 809a6bdfe8 A card of one's own: one_per_person, prefilled, replaced on send; no double sends; refusals are shown
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:01 +02:00
Bendik Aagaard Lynghaug cae116b50c Release 0.6.5
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m54s
2026-09-30 20:10:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d8528ff1f9 Changelog: 0.6.5
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:10:56 +02:00
bl 6c2e0cc299 Merge pull request 'A need may be paused' (#14) from paused-needs into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 18:10:56 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9368086d81 A need may be paused: its card stays, with no form and a button that does not press
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:09:14 +02:00
Bendik Aagaard Lynghaug e2497941c8 Release 0.6.4
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:52:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 55fc61c514 Changelog: 0.6.4
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:52:10 +02:00
bl 3b17e6440e Merge pull request 'Rate limit: 100 at once, 10 a second; the page's own files uncounted' (#13) from rate-limit into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:52:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 87d4dda0eb Rate limit: a hundred at once, ten a second, and the page's own files are not counted
Test / test (pull_request) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:51:48 +02:00
Bendik Aagaard Lynghaug 3604683df3 Release 0.6.3
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:33:11 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 1193894b02 Changelog: 0.6.3
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:33:08 +02:00
bl 574d0bfe71 Merge pull request 'Pages move the way their addresses do; picture cards; who we are' (#12) from people-and-motion into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:33:07 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 309e30f36d Pages move the way their addresses do; a record with a picture is a card; the trainer says who a business is
Test / test (pull_request) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:32:49 +02:00
bl 748241709a Merge pull request 'Klingenberg Bygg's plan; a plan carries a slideshow' (#11) from klingenberg-plan into main
Test / test (push) Successful in 1m15s
2026-09-30 15:56:50 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a70690a736 Klingenberg Bygg's plan and words; a plan carries a slideshow; the words model is told what a follower's desk and an inside sent page are
Test / test (pull_request) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:56:24 +02:00
Bendik Aagaard Lynghaug 827a75be3d Release 0.6.2
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
Publish release / publish (push) Successful in 2m3s
2026-09-30 17:39:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4e0a56be13 Changelog: 0.6.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:39:55 +02:00
bl 04204bef23 Merge pull request 'The header knows who is here; my cases; controls that scale' (#10) from header into main
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m14s
2026-09-30 15:39:54 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c766dedc0e A stored file on a record shows as its name and opens from the row
Test / test (pull_request) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:38:32 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a822f2175f Trainer: followed_by - a group whose members each see their own cases; file fields
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:36:08 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f2b131ebbb KANIDM_GROUP_PREFIX: plain group names in content on a shared Kanidm
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:34:21 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 6b72ae568a The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s
A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:32:41 +02:00
Bendik Aagaard Lynghaug 9a96386444 Release 0.6.1
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 1m46s
2026-09-30 17:19:36 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 5c95b6d8b3 Changelog: 0.6.1; the bare binary sorts after the tarball
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:19:34 +02:00
bl d3a9e79351 Merge pull request 'A slideshow across a page' (#9) from slideshow into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 15:19:33 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c1a8433640 Slideshow: brace the closure
Test / test (pull_request) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:17:55 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 cfb050d3ef A slideshow across a page: pictures the full width of the screen, captions over them
Test / test (pull_request) Failing after 1m5s
slideshow on a page: max_height in svh, slides with an image (a path
in the content repo or https), a caption laid over the picture's foot,
and optionally the picture's size so its place is held before it
loads. Swiper upgrades it; before that it is a row that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:16:50 +02:00
bl fc708c3546 Merge pull request 'Trainer: documents - a text word for word, one card per section' (#8) from documents-as-cards into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s
2026-09-30 15:06:28 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c7e0ee146f Trainer: documents - a text word for word, one card per section
Test / test (pull_request) Successful in 1m8s
Bylaws and terms are not the model's to write. documents in a plan
names a markdown file beside it; every heading with text under it is
one alternative on a page of its own, its paragraphs the card's words.
Tomter Vel's bylaws of 23.04.2018 are the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:05:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3bb517be0f Docs: vel.klingenbergbygg.no runs v0.6.0
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 8s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:40:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4eb1d03583 Changelog: 0.5.11 was tagged and never published
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 35s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:38:53 +02:00
Bendik Aagaard Lynghaug 3cb0733955 Release 0.6.0
docs / deploy (push) Successful in 3s
Publish release / publish (push) Successful in 1m53s
Test / test (push) Successful in 1m5s
2026-09-30 15:33:17 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 969c22b432 Changelog: 0.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:33:15 +02:00
bl a17da3198c Merge pull request 'One binary: the lint and the trainer are commands of portal' (#7) from one-binary into main
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m6s
2026-09-30 13:33:14 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 48cba5cd8e Docs: the examples in English
Test / test (pull_request) Successful in 1m5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:31:59 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 96b3746643 One binary: the lint and the trainer are commands of portal
Test / test (pull_request) Successful in 1m7s
portal serve runs a site; portal lint is what iris was; portal plan,
build, report and the rest are what portal-trainer was. One source at
one version, so the three never disagree about what content means.
Both histories are merged in (subtree). The release attaches the bare
binary for a content repo's CI. The trainer's plans, intakes, engines
and reference prompt come along; its runs stay in the old repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:29:30 +02:00
Bendik Aagaard Lynghaug d0d1cbf23d Bring the lint in (uhhm/iris main, history kept)
git-subtree-dir: vendor/lint
git-subtree-mainline: bedd306854
git-subtree-split: e3c68f9441
2026-09-30 15:23:16 +02:00
Bendik Aagaard Lynghaug bedd306854 Bring the trainer in (uhhm/portal-trainer main, history kept)
git-subtree-dir: vendor/trainer
git-subtree-mainline: c7c2956e15
git-subtree-split: a2a9f4edcf
2026-09-30 15:23:15 +02:00
Bendik Aagaard Lynghaug c7c2956e15 Release 0.5.11
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 39s
Publish release / publish (push) Failing after 2m13s
2026-09-30 15:17:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 267f7f0787 Changelog: 0.5.11
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:17:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e3c68f9441 needs.yaml may name the tones
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:17:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 a2a9f4edcf Tones and storylines: every word once per tone, kept through a lift, and a report of what each brought in
tones in the plan lists tones of voice, one storyline each. The words
pass writes each page once per tone and folds the tones into lists
where they differ; the render emits the lists portal reads as
storylines; the lift carries them back. needs.yaml names the tones.
portal-trainer report --site reads the site's counts per storyline
and scores each on what arrived and what ended well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:16:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f395ace556 Storylines: the same site told several ways, one per visitor, counted
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m1s
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 and reads it on every page and in every mail about their case.
Records and events carry the storyline; GET /report gives counts per
bucket, storyline and state, and nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:11:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 cc3107e502 Docs: vel.klingenbergbygg.no runs v0.5.10 with memberships
docs / deploy (push) Successful in 3s
Test / test (push) Failing after 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:22:11 +02:00
portal release 050fb34fb0 Match portal v0.5.10 2026-09-30 14:13:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ca73ef7e9f plans: Tomter Vel keeps its people in portal (people: memberships)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:12:22 +02:00
Bendik Aagaard Lynghaug cb31995299 Release 0.5.10
docs / deploy (push) Successful in 4s
Test / test (push) Failing after 45s
Publish release / publish (push) Successful in 3m35s
2026-09-30 14:10:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 579ad6f50d The lint lets an invite form ask for a phone number instead of an address
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:09:49 +02:00
portal release b86a12e5df Match portal v0.5.9 2026-09-30 14:09:37 +02:00
Bendik Aagaard Lynghaug 89d419ff7b Release 0.5.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m36s
2026-09-30 14:06:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 00571f65bd Changelog: 0.5.9
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:06:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c4e2de5efa Memberships beside Kanidm: the invite makes the account too
Kanidm makes people, portal makes members. A site can switch to
memberships before its sign-in provider is one everyone already has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:06:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 5bc021de77 Docs: vel.klingenbergbygg.no runs v0.5.8
docs / deploy (push) Failing after 2s
Test / test (push) Failing after 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:55:49 +02:00
portal release 2f04c82c89 Match portal v0.5.8 2026-09-30 13:44:15 +02:00
Bendik Aagaard Lynghaug f4ed98322f Release 0.5.8
docs / deploy (push) Failing after 3s
Test / test (push) Failing after 59s
Publish release / publish (push) Successful in 3m22s
2026-09-30 13:40:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 bce9ad13e4 Changelog: 0.5.8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:40:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6c86dc47a2 The phone scope is asked for only when told to: Kanidm denies a scope the person lacks
0.5.7 asked every provider for phoneNumber, so a Kanidm site could
not sign anyone in. OIDC_EXTRA_SCOPES=phoneNumber asks for it (Vipps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:40:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b0787f7c5a build: people: memberships - invite by phone number, list who is in
With portal keeping the groups itself (portal v0.5.7), a desk's invite
form asks for a phone number first, the list of invites is the list
of memberships, and a grant may know the person by number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:37:02 +02:00
portal release 00449916a3 Match portal v0.5.7 2026-09-30 13:36:50 +02:00
Bendik Aagaard Lynghaug cc26a232ec Release 0.5.7
docs / deploy (push) Failing after 3s
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m21s
2026-09-30 13:33:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 77f2880e83 Changelog: 0.5.7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:33:12 +02:00
bl 23f98add51 Merge pull request 'Memberships: groups kept by portal itself, a person by phone number first' (#6) from memberships into main
docs / deploy (push) Failing after 3s
Test / test (push) Successful in 58s
2026-09-30 11:33:12 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 a9b5a81ebd Docs as a site: portal.uhhm.no from docs/ with mdBook, put to git-pages
Test / test (pull_request) Successful in 58s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:31:23 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f047e21c4a Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s
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 the record; removed by a desk, and invited
again after that. One record per group and person however it was
keyed, and at sign-in every record that matched learns what the
provider verified. The active memberships join the groups claim, so
Cedar sees the same parents as before.

PEOPLE_BACKEND picks Kanidm or memberships; unset, a KANIDM_API_TOKEN
means Kanidm. The NATS access service looks a person's groups up when
a question names them by sub, phone or email and lists none. The
sign-in asks for the phoneNumber scope and reads it from userinfo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:28:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1025dd7718 build: escalate - a case left too long goes up to another desk with the same buttons
escalate: { <stage>: { days, to: <group> } } on a need adds a stage
<stage>_escalated the clock enters, with the same moves out of it; the
group's desk lists exactly those cases with those buttons, and the
desk that had it keeps it and may send it up itself. Not a mail: a
desk job. The vel's help requests go up to styret after a week.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 11:58:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3dabcf7c58 build: one page holds every need that answers a record; runs: the kommune planned again by Opus
Two needs about the same record wrote the same page, and the last won.
The record's page now holds one form per answering need, under
words["forms"][need].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:58:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 41db6093e5 The effort budget counts what a person fills in, not carrier fields
A type: record field carries the case an answer is about on the link,
and a hidden preset carries a value; neither is asked. An objection
form that carried the application's id counted one field over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:57:03 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9776dc1808 plan: the guidance names records that answer records, members-only needs and a group contact
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:38:44 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 28ef235906 build: a door may point to a members-only need; signed-in people are not asked who they are; a granted group does not self-invite
The vel: paid membership grants the medlemmer group, and a committee
seat is offered on the bidra door and taken on the members' page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:38:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b9761cec63 Docs: vel.klingenbergbygg.no runs v0.5.6
Test / test (push) Failing after 40s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:28:05 +02:00
portal release 572dfc200c Match portal v0.5.6 2026-09-30 08:22:10 +02:00
Bendik Aagaard Lynghaug b9327a170f Release 0.5.6
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m9s
2026-09-30 08:18:41 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 789cbf7977 Changelog: 0.5.6
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:18:39 +02:00
bl 6acb7e6a30 Merge pull request 'A group may hold a page's responsibility' (#5) from group-responsible into main
Test / test (push) Successful in 55s
2026-09-30 06:18:38 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 67250af445 build: the plan's contact may be a group, which then answers for every page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:18:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 014594283a A group may hold a page's responsibility
Test / test (pull_request) Successful in 56s
responsible: { name, contact, group } says the address is a group's
inbox, not a person: no account or mail is made for it, and any
signed-in member of the group gets the suggest-a-change card. A
board's shared address was onboarded as a person before this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:17:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 de65392917 Docs: vel.klingenbergbygg.no runs v0.5.5
Test / test (push) Failing after 56s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:04:35 +02:00
portal release 955a33609a Match portal v0.5.5 2026-09-30 07:54:29 +02:00
Bendik Aagaard Lynghaug 77867090ed Release 0.5.5
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m29s
2026-09-30 07:51:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c24e117f67 Changelog: 0.5.5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 07:51:04 +02:00
bl 95928f7265 Merge pull request 'One record answers another, from a list' (#4) from answer-a-record into main
Test / test (push) Successful in 57s
2026-09-30 05:51:04 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 894f228b8f build: the answer page's guidance says what it is not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 07:02:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 42b6ce7c70 build: a desk card is its list; runs: Tomter Vel's lending board answered from the board
The lift matched cards on a desk by action, which every card there
shares, so a card that left the desk left its words on the next one.
A card with a strong identity - a bucket, a group it invites into, a
page of its own - is matched on that alone, and a desk card's identity
is the bucket its feature lists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 07:02:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4e1bc2027d build: a need about another, answered from the board's own rows
`about: <need>` on a need puts its form on a page per record of the
other need (/status/<bucket>/[key]), links every row of the public
list to it, carries the record's id in a type: record field, takes a
signed-in person's address without asking, and lets `mails: { open:
owner }` reach whoever sent the record. The vel's plan uses it for
the lending board: notices stand from the moment they are sent, the
board hears, an answer goes straight to the poster, the queue is the
poster's inbox, and the redaktør or the clock takes down what stands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:55:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f0e50477c6 aggregates.yaml accepts a mail to a field of the record this one belongs to
Test / test (pull_request) Successful in 55s
The parser refused {notice.email} before the mailer could resolve it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:55:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fc998c8592 One record answers another, from a list
Test / test (pull_request) Successful in 56s
Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:47:36 +02:00
bl 470338b969 Merge pull request 'The way down the tree is the link at the foot, not an arrow that follows' (#3) from foot-only into main
Test / test (push) Successful in 55s
2026-09-29 19:24:01 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 02235a7ef1 build: the way down the tree is the link at the foot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:23:05 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 86ec51012e The way down the tree is the link at the foot, not an arrow that follows
Test / test (pull_request) Successful in 56s
0.5.3 put the same link in two places: in words at the foot of the
page, and as an arrow fixed at the left edge half-way up the screen.
The arrow stayed with the reader the whole way down a page to offer
what the foot offers where they end up anyway. It is removed; the foot
link and where it leads are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:22:54 +02:00
portal release 9b96111916 Match portal v0.5.4 2026-09-29 21:14:02 +02:00
Bendik Aagaard Lynghaug 48a1a58d01 Release 0.5.4
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m10s
2026-09-29 21:11:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 72b7945ab4 Changelog: 0.5.4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:11:11 +02:00
bl a4c39e5ebc Merge pull request 'Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible' (#2) from seed-admin into main
Test / test (push) Successful in 55s
2026-09-29 19:10:40 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 5e53485de5 build: invites can be sent again, and suggested changes have a desk
Portal 0.5.4 moves an invite whose link ran out to expired and files
what responsible people suggest as cases in portal_edit_suggestions.
Every desk that lists invites gets the move back to open (a fresh link,
mailed), and the desk that invites into the most groups - where portal
seeds the first person - gets the list of suggestions with its moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:01:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef4789f9b2 Invites and suggestions are cases; the responsible follow the content
Test / test (pull_request) Successful in 56s
Five things the first cut of accounts-from-content left open.

The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.

The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.

Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.

An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.

A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.

Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:00:10 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 7a50ce77b2 Docs: getting started says what is retried and what waits for the next start
Test / test (pull_request) Successful in 51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 18:03:51 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 641c5f1bd3 Docs: getting started, bringing your people in
Test / test (pull_request) Successful in 51s
From an empty Kanidm to everyone at their own desk: what in the content
decides who needs an account, the four things Kanidm needs for portal,
the first person, what happens at start, how everyone after comes in
from the desks, what the people who ask the questions can do, and what
each failure looks like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 18:02:02 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0886869607 Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible
Test / test (pull_request) Successful in 51s
Every person a page names as responsible gets a Kanidm account at start
if they have none, and a mail saying they are listed as responsible for
asking that question, with the link to sign in; RESPONSIBLE_GROUP puts
them in one group. SEED_ADMIN_EMAIL invites the first person into the
most privileged group - the one whose desk may invite into the most -
once. Signed in, a responsible person may suggest a change to their
own pages; checked on the server, kept, published, and mailed to the
site's inbox.

Fixes an invite of someone new on a Kanidm with only the onboarding
account: the person is created with their address in one call, since
idm_people_on_boarding may create but not modify (it answered 403).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 15:44:20 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 6a746543df runs: Tomter Vel without back alternatives, on portal v0.5.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:38:33 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 bd174224fe Docs: vel.klingenbergbygg.no runs v0.5.3, from its own podman stack
Test / test (push) Failing after 56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:38:32 +02:00
portal release f2fc5097f7 Match portal v0.5.3 2026-09-29 13:33:07 +02:00
Bendik Aagaard Lynghaug 46d19e6d9d Release 0.5.3
Test / test (push) Successful in 51s
Publish release / publish (push) Successful in 2m59s
2026-09-29 13:30:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9229fc1674 Changelog: 0.5.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:30:12 +02:00
bl c9d52c6005 Merge pull request 'A way down the tree from every page' (#1) from down-the-tree into main
Test / test (push) Successful in 54s
2026-09-29 11:29:57 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c2658ae2c4 build: no 'back' alternatives; portal's way down the tree replaces them
The page after sending and every public list ended with a Back/Tilbake
alternative. Portal now puts a way down the tree on every page (an
arrow at the left edge and a link at the foot, uhhm/portal PR 1), to
the nearest page below that the visitor may open: the front door after
a public form, the group's desk after an internal one. Sites built from
here need a portal that carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 07:05:48 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 e2596bc3cf A way down the tree from every page
Test / test (pull_request) Successful in 53s
An arrow fixed at the left edge, half-way up the screen, and the same
link in words at the foot of the page. It goes one level down: the
nearest page below the current path that the visitor may open, found by
taking the path apart a segment at a time, and the front door when none
of those is a page (/status/oppslag -> /, /review/abc -> /review). The
front door itself has none. Labelled with where it goes: 'Tilbake til
starten', or the target page's question in quotation marks.

Content stops needing a 'back' alternative on every page, and a visitor
who went up a branch whose context did not apply is never stranded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 07:05:39 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 cda3b0685a build: new generic lines fall back in the plan's language; Tomter Vel's front door deployed
List notes, empty lists and the invite form's guidance are lines no
model is asked about before a page ships; on a Norwegian site they
showed in English until reworded. They now fall back in Norwegian for
no/nb/nn plans, like the mail fallbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:50:24 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 5e736668e4 runs: uhhm on portal 0.5.1 (scrapped after preview), its mail rounds, and Tomter Vel's front door
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:34:53 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d4d4c7cba3 Portal 0.5 in the plan, mails scored as read, and a front door that shows
Portal 0.5's content, from the plan: `grants` (an approval that is
membership; refused without a mail sender, since the sign-in link goes
by mail), `deadlines` (portal's `after:`, only along a move the
machine has), and `public_states` with an `empty` line on public lists,
whose words now say which stages they show.

Onboarding is a mail too: a plan that invites or grants gets
`mail.invite` in site.yaml in its own language, and a reword that loses
{link} gets it back. `rate --repo` rates every mail, the invite
included, read as it arrives: LIX, and what happened / what now / who
wrote, each against a gutted twin. The mail words are guided by what
that found on uhhm (name the case by a short required field, sign with
the business, invite a reply rather than promise a reason).

The front door shows public lists instead of pointing at them: each
public bucket is a feature with its newest three records inline and a
link to the whole list. Its card is named for what it shows. The plan
reference gains 'What a portal site can do': the schema's options and
when each pays off, as guidance rather than rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:34:53 +02:00
Bendik Aagaard Lynghaug f0c43c5c0f Docs: both hosts deploy from CI; the site table reads v0.5.2
Test / test (push) Failing after 1m7s
2026-09-28 18:26:31 +02:00
portal release f2ca00b309 Match portal v0.5.2 2026-09-28 18:11:49 +02:00
Bendik Aagaard Lynghaug 45240b7cab Release 0.5.2
Test / test (push) Successful in 57s
Publish release / publish (push) Successful in 3m1s
2026-09-28 18:09:28 +02:00
portal release ac4af4395a Match portal v0.5.1 2026-09-28 17:51:11 +02:00
Bendik Aagaard Lynghaug 6ee6551400 Release 0.5.1
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m2s
2026-09-28 17:48:37 +02:00
portal release 1e7245b062 Match portal v0.5.0 2026-09-28 16:50:47 +02:00
Bendik Aagaard Lynghaug 68471969b5 Release 0.5.0
Test / test (push) Successful in 1m0s
Publish release / publish (push) Successful in 6m47s
2026-09-28 16:46:50 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 214d4eb85c Sessions outlive the deploy that interrupted them
Test / test (push) Successful in 58s
A MemoryStore meant every restart signed out every desk on the site
being deployed. Bearable while a desk is three people, and not the
kind of thing that gets better on its own.

They live in NATS KV now: the JetStream already under the events, the
projections, the content reload and the access questions. No new
service, no new store, and the same bucket a second instance of a site
would read if there ever is one. `create` draws a fresh id rather than
writing over a session already at that key, the bucket's `max_age`
collects what nobody comes back for, and a record's own expiry is what
answers a load, whatever the bucket still holds.

Proved the only way it can be: a store opened twice against one
JetStream, which is what two runs of the binary are.

Also fixed, while there: the integration test asserted a record's
projected state was one that `aggregates::transition` never writes -
it appends events, the desk handler updates the projection - so it
failed whenever it was actually run. It now asserts what it says it
does, that a mailed reply leaves the state alone. test.yml has been
red since 0.3.47 while publish.yml stayed green, which is how v0.4.0
went out over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:38:05 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 d5de25e8c6 A file can come back out, and a script cannot come back forever
Two halves of the same evening.

Uploads were one-way. A `type: file` field put an object in the bucket
and wrote its key into the record, and nothing ever read it back - a
desk showed `0aba5734-...-drawing.pdf` as a string with no link, so a
builder could receive drawings and never open one.
`GET /attachment/{bucket}/{record}/{field}` streams it. The key is not
the credential and is not taken from the caller: it is read out of the
record named, so a file is reachable only through a record somebody is
allowed to see, and a key that leaks off a desk is a link to nothing.

And there was no rate limiting anywhere. A burst of 30 per caller IP,
refilled one every two seconds, outside the session layer so anyone
over it is turned away before a handler is entered. Well above a
person filling in a form - a page load fetches several resources at
once - and well below being a nuisance. PORTAL_RATE_BURST and
PORTAL_RATE_SECONDS retune it; either 0 turns it off for an instance
behind something that already does this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:43 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 8d6f29011c An upload asks the policy, like every other way in
/upload asked nothing. It took a file, capped it at 20 MB, and wrote
it to the bucket for whoever sent it - no session, no page, no policy.
It was the one server entry point that did not ask the question every
other one asks, and on a site whose forms are public that makes the
route public too: a free disk for anyone who finds the host.

Harmless while no instance had storage configured, which is why it sat
there. kasse now has a garage wired to klingenberg's portal, so it is
not harmless any more.

An upload now carries the question and the alternative the file is
being attached to. The page has to exist, the alternative has to be
open and to actually declare a `type: file` field, and the policy has
to allow Submit on that page for whoever is asking - checked after the
bytes are read and before any of them are written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:08:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 9891c462c6 A page says nothing until it has something to say
The Suspense fallback on a question is the whole served page until
the question resolves - no header, no wordmark - so the word
"loading…" was the first and only thing a visitor or a crawler read
about the site. True, and not about them. It holds its space and
stays quiet now; `aria-busy` says what is happening to a reader that
would otherwise find an empty document. Same for the two resource
slots inside a page, which keep a line's worth of room.

Also gone: the `gitea_org_repos` resource kind, which no site ever
named. `gitea_starred` and `gitea_releases` stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 13:25:11 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 040a95ee07 A submitter's own moves say where they may be made from
portal compiles one access rule per state a self_transition names, and
without them assumed the day the case arrived. The builder already
knew the answer - `moves_of(need, SUBMITTER)` carries the from-state
of every move - and now writes it down, so the applicant answering a
request for papers from `incomplete` gets a rule for it, and withdrawal
is offered from wherever the case has got to.

Measured on the building authority: the submitter had two rules, both
out of `open`. It now has ten, across applications and appeals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 21:35:32 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 f18d1a5181 A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.

The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.

Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.

Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.

95 tests, up from 82.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 21:35:25 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 3e1c299490 A door costs the person who was going to pick correctly
Three seeds over the building authority's front door, then three more
over a variant where the two bare needs became doors of their own.

The diagnosis held: six of the eight front-door misroutes went to the
appeal, a concrete sentence standing among abstract categories, which
collected everyone with any sort of grievance whatever it was about.
Behind a door of its own it collected none.

The site got no better for it - 77.3% against 80.0%, inside the spread.
Two offers went from findable to hidden: the firm whose insurance
certificate had lapsed found "to be on the list, and then to file
without asking again" as a sentence on the front door, and fails every
seed once it is behind a door called "Work as a firm".

So the leak was not costing what it looked like it was costing, and the
rewrite is rejected on measurement. The mixed front door stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 20:25:25 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 d2aae383b2 A case can be sent back, and can end well partway along
A municipal building authority is the first sector that the line a
case walks could not describe. An application is returned for the
drawing it was missing and walks the same stretch again; an appeal
ends the moment the authority changes its own decision, without ever
reaching the board; an applicant may withdraw from anywhere, while a
refusal may only come once the papers are in.

`reached_from: { <stage>: [<stage>, ...] }` says where a stage is
entered from when the line does not. 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 - which `turned_down_from`
could not, being one list for every bad ending at once.

Found by replaying fifteen real building cases through portal's own
engine against a throwaway JetStream: it refused three legal paths and
accepted two illegal ones. Portal needed no change - hand-writing the
correct graph into aggregates.yaml, cycle and all, was accepted and
enforced exactly. The rebuilt site now produces that same graph from
the plan, and the access policy follows it.

Also: a public view is of a bucket, not of a need. A permit register
holds the owner's small application and the approved firm's large one
and is one page either way; the rule refused to publish a bucket the
moment any role filed into it. A bucket only a role ever writes to is
still refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 20:17:06 +02:00
portal release a847d0defa Match portal v0.4.0 2026-09-24 21:37:28 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 443ddcca68 A plan may ask for a place
`type: location` on a field is portal 0.4.0's map pin, stored as
lat/lon/zoom. The reference tells the model it needs `optional: true`,
since a map needs a browser that can draw one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:35:45 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 26c1762dfa A reply to a case mail becomes a note on its record
Test / test (push) Failing after 53s
Publish release / publish (push) Successful in 3m14s
Whatever reads the mailbox publishes on portal.mail.received; portal
appends the note after checking the record exists in a bucket this
site has and the sender is the address the record itself holds. A
note never moves a case: a mailbox is the one door anyone may knock
on, so it cannot drive a state machine even if someone forges their
way past the checks. The desks show the conversation beside the case.

Tested: the address parser, every refusal (a stranger, no address to
compare, an empty reply, the same reply twice, an unknown contract),
and the whole path through a real JetStream - the note lands, the
state does not move, and no event is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:26:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 4fd60293fa A field can be a place, and a question can be about one
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.

Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:17:28 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 242a1e0ad6 The keys come out in the order they were written
A page is read by the people who own it, and serde_json sorts keys, so
every file put `name` at the bottom under `action` and `consequence`.
With preserve_order the file reads the way it was built: name, then
description, then what the card does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 20:44:37 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 1ed33b6160 build: a card states what the reader must already have
`requires:` on a need is one sentence naming what the person must
have or be for the alternative to work - a membership, a reference
number, the link from a mail. It renders as the card's first feature,
above the road, who-reads-it and the form, because a condition a
reader meets only after filling the form in has already cost them the
filling in. Whether the section exists is read from the plan, never
from its wording: under a lift every slot holds a token and a token is
never empty, which would conjure a section the live page lacks and
shift every feature after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 20:42:41 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 743f4a8563 build: every feature says something; a lift carries words, not absences
Five features were emitted empty, as layout: a door's strip, a desk's
list, an invited list, a public list, an invite form. Each now has a
title and a line that says what a row is and what the buttons under it
do, or what the list leaves out - true from the plan before a model
writes a word, and a slot the model can improve. Desk cards, their
links and the desk page itself get real fallbacks too. The lift no
longer copies an empty live string over a fallback, which would have
emptied every new slot on the next rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 20:40:03 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 c6d0701639 build: the mail cleanup runs on the final words, not only a model's reply
A stray placeholder or a lost link was only fixed when a model wrote
the mail, so a words file that already carried one kept it through
every later build. The pass now runs once on the finished
aggregates.yaml words, covering --words, --words-from and the model
alike; the football club's three mails lose the `{chain}` they were
shipped with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 20:28:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 0a02b4db1a build: a bare {chain} in a mail body goes, on its own line or inline
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:33:21 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 88e54ed17f runs: the club's short mails, as actually written
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:01:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f516e26926 build: a mail is at most three short sentences and the link line
The mail prompt said "short"; it now says how short. The football
club's enrolment mails halved in length and read easier, and the
vel's evened out, with every link kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:00:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6f2f7e271c runs: the football club with a sender, stage words and mails
The second Norwegian case: level with its earlier build on routing
inside the spread, desks 13 of 14 with outcomes in words, Norwegian
mails from Sonnet in one pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 08:28:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1df710f32f runs: the friendliness judge passes a positive control
Live vel front door against a cold, bureaucratic twin: friendlier 9
of 10 to the live page, none to the twin, both orders. So the earlier
no-preference between the live doors and the rejected rewrite was a
verdict, not blindness; and judged clarity and measured routing are
different things.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 07:50:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fd785288a4 runs: the rejected vel doors read clearer and route worse
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 07:15:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4d22f4cb44 plan: a spectator role goes back to the model once; bare-word movers and unlabelled groups parse
Re-planning the contractor showed three wasted rounds: `moves_by: {
checking: submitter }` written as a bare word, a group without a
label, and the two featured owner roles left with nothing to play,
which the builder only warned about after the fact. The bare word and
the missing label now parse (a label defaults to the id), and a plan
that parses with spectator roles is sent back once with the finding;
on the contractor, round three fixed both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 06:42:05 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6e49694211 build: turned_down_from, and uhhm's stages in words
A need may say which unfinished stages a case can still be turned
down from; the machine no longer offers the bad end from every stage.
uhhm's collaborators are declined before they start and depart after,
as the live site has it. The uhhm plan gets stages_text and three
desk buttons reworded: every uhhm desk scores full marks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 06:01:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 aeff4c8cbe CI: lint with iris v0.3.47
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:23:48 +02:00
portal release 57b733e606 Match portal v0.3.47 2026-09-24 05:20:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9ed8194484 Release 0.3.47
Test / test (push) Failing after 55s
Publish release / publish (push) Successful in 3m48s
iris learns stage_words; portal unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 be14e33603 build: stages in the plan's own words
`stages_text` on a need gives each stage plain words in the plan's
language. The fallback mails, the road line and the desk buttons use
them, and needs.yaml carries them as `stage_words` so the desk score
describes an outcome the way the site would. The vel's plan has them;
its desks score 26 of 26 where the id-based outcome scored 24.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:18 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3923d4d0dc needs: stage_words, each state in the site's own words, carried into the simulation model
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:11 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 8e6a93ebb7 rate: a desk row offers only the buttons that start from its state
The first desk score offered every button of the bucket, where portal
shows a row only the moves from its own state; that scored the live
vel 17 of 26 and sent a rewrite of the neighbour-help desk chasing an
instrument fault. With the row's buttons only: 24 of 26, and the
rewrite loses on the fair test too. Noted: an accent-stripped state id
(ikke_lost) reads as English even to a strong picker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 04:39:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9089503524 rate: the desks are scored on their buttons
The inside of the business reads button labels and nothing else. For
every desk and bucket with two or more buttons, a worker is told what
just happened to a case, in the state's own words, and picks the
button; scored right of total with what each miss was taken for. The
vel's neighbour-help desk scores 2 of 4 and the notice board 1 of 2:
the first numbers a desk has ever had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 03:44:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 5e313b7d40 runs: the planned contractor rated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 03:05:00 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 2622fdac88 plan: a budget that equals the form is reported as counted, not decided
Four of the planned contractor's five needs had max_fields equal to
their required fields: the model counted the form and called it the
person's tolerance. `plan` now says so when most needs do, and the
reference tells the model to decide the number before writing the
form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 03:04:22 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 d132f1e4df runs: the contractor planned end to end, 7 of 8 across seeds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 02:32:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 209e9d1258 plan: held-out personas are mapped to the plan's needs by a model, ids only
The one manual step left after `plan` was mapping the attached
personas' `need:` to whatever ids the model chose. A second call now
does it: the model sees the plan's needs and the persona texts and
returns ids only, after the plan is final, so no persona wording can
reach a page. An id the plan lacks is ignored and the reader is told.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 02:27:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b1f0210691 build: personas naming undeclared needs is the reader's mapping, not a builder bug
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 01:55:39 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 20e5e16641 plan: the model that writes a plan learns this week's keys
`does`, `shows`, `mail`, `mails`, `invites_by`, `events`,
`attended_by`, and the measured guidance on doors: the plan reference
the model reads lagged the builder by a week, so a planned site had no
opener, no mails and no onboarding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 01:54:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 42059da48f CI: the tests, and every plan built and linted
The trainer had no CI. Every push now runs the tests and builds every
plan under plans/ against the iris release the live sites run, and
lints the onboarding site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 01:16:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 07da3b40f7 Docs: the skill file says what the trainer does now
iris instead of question_lint everywhere; the skill's procedure is
plan, build, score: the opener and the look-around, mails and the
sender, moves for the person, doors only when measured, the lift
through a rebuild, the language and link guards, the descriptions
score over the headings score, and the spread a change has to beat.
README documents --llm-for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 00:45:16 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 deee27d280 runs: the vel doors reworded by a model, worse across three seeds, rejected
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 00:12:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 8c7fb870bf build: a reply in the wrong language keeps the words it had; the vel's mails
A model asked for Norwegian sometimes answers in English. The commonest
function words of each language are counted across a reply, and one
that reads as the other language is refused with a line saying so.
Tomter Vel's six mails, worded by Sonnet in Norwegian, are round 2 of
runs/tomtervel-opener-2026-09-23 and live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 23:35:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 72a081ed59 build: a model may not lose the submitter's link; uhhm round 7, the mails
Sonnet wrote the ten uhhm mails and replaced the decide link with a
bare {chain}. keep_links puts the fallback's link line back into any
mail that lost it. The mails read easier than the fallbacks (LIX 9 to
33 against 18 to 28) and say something.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 23:02:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b5f5c46ff2 build --llm-for <prefix>, and rate reads the mails
The words model can be pointed at a subset of pages (`aggregates.yaml`
for the mails alone) while everything else keeps the words it has.
`rate --repo` rates each mail's prose on its own, LIX and length,
since a person reads a mail with no card around it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 23:00:37 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1191551f28 README: the noise floor, measured over four seeds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:59:10 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c240d3a646 spread.sh: the engine path is absolute
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:46:44 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 cc50249d7f answer --seed, and runs/spread.sh: a score comes with its spread
Every score so far was one run of a small visitor. `answer --seed n`
answers the same tasks with other shuffles and sampling, and
runs/spread.sh prints a run's score per seed, so a change can be told
from the visitor's mood.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:46:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ec2f07da77 runs: Tomter Vel keeps its doors and gains the opener; merged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:43:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 00db014eae A card with no button is read, never picked
The opener that says what the business does and the look-around card
lead nowhere: they are what the page says before its options, so a
task and a simulation step carry them as `page` context and offer
only the cards that lead somewhere. Five personas per round had been
"choosing" the opener and counted as misroutes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:36:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 09aea7061c The visitor and the rater read what the page says first
iris now hands a task the cards that lead nowhere as `page` context;
the answerer shows it before the options and the rater before the
cards, and the gutted twin loses it like everything else. The Tomter
Vel plan keeps its four doors and gains the opener, the mails and the
member's own move: flat scored worse and the doors stay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:36:13 +02:00
portal release d2450fcb75 Match portal v0.3.46 2026-09-23 22:21:14 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 2ac34b6167 Release 0.3.46
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 3m45s
An invite finds an existing account by its email and only adds it to
the group.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:18:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e3dae063d4 English in the code: fixtures, identifiers, and one phrase table
The fallback mail phrases move into a `Phrases` table, the one place a
language other than English appears in this code; the `navn` special
case goes (a form names the person's field `name`). Test fixtures
speak English: traffic, board, hazards, report, fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:09:04 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 713f8ff3a0 build: words follow a card across pages when a site is restructured
`--words-from` matched page by page, so flattening Tomter Vel lost
every heading its door pages carried. Now a second pass pools every
alternative in the repo and hands a rendered card the pooled card
with the same strong identity (bucket, own page, invite group; never
a bare action like `/`). Two things stood in the way and are fixed:
needs.yaml is no longer a source of words (its `via` named the door),
and a live feature's `name` no longer blocks a positional match, so
placeholders and guidance lines come along too. Rebuilding the vel
flat from the live repo now lifts all ten headings and every hint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:01:55 +02:00
portal release 0841ed561a Match portal v0.3.45 2026-09-23 21:26:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 a8e9d38a6a runs: uhhm round 6, the mails
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:19:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 8341089898 build: a plan says who hears when a case moves, and the words write the mail
`mails: { <stage>: submitter | <address> }` on a need becomes `mail:`
on that state in aggregates.yaml (portal 0.3.43+), and `mail: { from,
reply_to }` on the plan becomes site.yaml's sender. The words map
gains `aggregates.yaml`, one subject and body per mailing stage, so
the model writes them like any other page; the fallback, in the
plan's language, says what happened and where the submitter's own
move is. A submitter mail needs a required email on the form; any
mail needs a sender. The uhhm and Tomter Vel plans tell the submitter
at every stage that matters to them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:18:14 +02:00
portal release 79f85132c5 Match portal v0.3.44 2026-09-23 21:18:10 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f5f5132056 build: the lift matches cards by identity, not position
Sequences were walked side by side, so a rebuilt page whose cards sit
in a different order, or with a new card in front, gave every card
its neighbour's words: uhhm.no's front door got "Attention is earned"
on the wrong card that way. Items now match on the bucket they record
into, the page they lead to, a field's name, the group they invite
into or the bucket they read; information features with no identity
still match by position. Lifting live uhhm.no into the current plan
puts each of its three headings on the card with its bucket.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 20:48:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 bf60879de9 score: the whole grading of a built site as one command
iris writes the tasks and the model, the blind visitor answers with
descriptions and with headings only, iris scores, rate reads the
cards; the routing numbers land in summary.json. Replaces the
score.sh every run carried. Proven on runs/uhhm-2026-09-23/site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 20:25:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9ff9924374 build: --words repeats and layers; the desk's confirm slot says what it is
Two things the uhhm rounds ran into. Only the first --words was read,
so a round of hand changes had to be merged into the previous words
by hand; now each file layers over the last. And the desk's one
confirm slot was described like any button, so a model wrote "Send
invite" under the cases list; it now says it serves every list.
README: the lift is positional, and where that bit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 20:02:30 +02:00
portal release 58d68c3bff Match portal v0.3.43 2026-09-23 19:48:01 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9e7f16c489 build: the front door opens modestly, and a featured role must have a move
`does:` on a plan, at most two features, each `for:` a need: the first
card of the front door says what the business does, one role each,
headed by what that role gets, and asks for nothing. `shows:` is the
second card, portal resource blocks passed through verbatim plus every
public view. Only then the offers, as more alternatives on the same
page; the front-door warning moves to seven and stops recommending
doors. `Plan::spectators()` warns when a featured need gives the
submitter neither a move nor a view. uhhm.no rounds 4 and 5 (flat,
at least as good per persona) and Tomter Vel flat (worse: ten offers
on one page, the close pairs collide) are in runs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 18:24:23 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9e511b5b57 runs: uhhm.no from a plan, three rounds of wording
Sonnet writes, qwen3:8b routes: 16 then 19 of 27 with descriptions,
19 then 21 of 27 on headings alone. One card took seven personas
until it named redoal; what remains is on the front door and mostly
a judgment the plan could make differently, not the words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:09:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 04a6eeea85 build: a plan keeps a live site's event names, and names its automations
`events:` on a need overrides the event a stage is entered by. Portal
replays records by event name, so rebuilding a site that already holds
records has to keep what its aggregates.yaml says, or the desks empty.
`attended_by:` writes portal's annotation for a bucket an automation
moves. plans/uhhm.yaml: uhhm.no from a plan - the five live buckets
with their names, plus trials (the trainer tried on a client's
business, approved by hand) and custody_requests (UHHM as redoal's
custodian, a desk of its own).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:49:25 +02:00
portal release 0cf70502fc Match portal v0.3.42 2026-09-23 14:35:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 93a6fc0e10 build: invites from the plan, and rebuilding a live site without losing its words
`invites_by` on a plan (and `invited_by` on a group) put an invite form
per fillable group on the inviting group's desk, plus a list of the
invites sent - portal's `invite:` does the onboarding. Every desk
group invites into itself by default; an inviter without a desk is
refused.

`--words-from <repo>` lifts the words an existing repo carries back
into the words map by structure: the plan is rendered with a token in
every slot and walked alongside the repo's files. Hand edits and
model-chosen icons survive a rebuild; only new slots get fallbacks,
which `--words <json>` can override. The words as used are written to
words.json. First use: Tomter Vel, additions only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 12:42:23 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 255aeb4f4b plan: intake to plan by a model; doors, labelled groups, the whole card
Work from another session, committed as found (tests green):

- `plan` reads an intake into a plan.yaml (a model decides needs,
  stages, forms and budgets; a person reads the result before build).
- Plans gain `doors` (a first question before the forms, when a front
  door would carry more than five needs), `groups` with labels that a
  select may draw its options from (`options_from: groups`), `private`
  fields kept off public pages, `multiple` selects, and `max_fields`
  as what the intake said the person will tolerate.
- `card.rs`: an alternative as the visitor meets it, in text, so the
  answering visitor and `rate` read the served card, not the heading.
- `rate` reads followup pages from the repo and reports "what happens
  next" as not measured rather than zero when it cannot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 12:31:51 +02:00
portal release fb66694b85 Match portal v0.3.41 2026-09-23 11:55:10 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 959e5f6ef0 check: compile and validate the access policy; --access prints the matrix
Every check now compiles the content into portal's Cedar policy and
fails when it does not validate against the schema - a rule the
schema cannot express would grant nothing at runtime. `--access`
prints the matrix: action, resource, who, and the state move where it
applies. Pinned to portal v0.3.40, where the policy lives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:48:33 +02:00
portal release 9cd5dff4c2 Match portal v0.3.39 2026-09-23 06:03:18 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 bd8a5e0c6c Versioned as portal: released by portal's publish job
iris reports its own version as the portal it matches; the tag, the
pin and the changelog line are written by portal's release step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 22:05:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e543b41cb0 CI: a writable CARGO_HOME, so the portal git dependency can be fetched
The shared /var/local/cargo is read-only for the runner; 0.1.1 built
only because this machine had already cloned the same portal tag into
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:50:53 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 5c5a5e9990 Release 0.1.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:42:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b093ebf0de check: fetch needs.yaml itself, so portal can drop that loader
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:28:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 73906cdf35 iris: the lint over a portal site, as its own repo
question_lint becomes `iris check`, needs_replay becomes `iris
replay`, and the needs module and local-checkout loaders come with
them. Portal is a library dependency pinned to the release iris
matches (v0.3.36), so every type iris reads is the site's own and the
two never disagree about what a page is. `iris --path questions`
still works, so a content repo's one-line CI needs only a new path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:25:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 baf36319b7 Overlapping roles on one aggregate, relay routes, and a dry run
plan.yaml: `moves_by` says who presses the button into a stage when it
is not `handled_by` - other groups, several groups, or `submitter`,
the person who sent the form, through their own link (the form must
then collect a required email). `watched_by` puts a bucket on a
group's desk with no buttons. Each group's desk carries its own buttons
only; a submitter's moves become a followup page of self_transitions;
needs.yaml gains `also_moved_by` for portal's verifier.

simulate: a route is now a relay across every role that holds a legal
button, the submitter included, and each step is judged as the role
that presses it. `--dry` asks no model anything and walks every simple
route of every aggregate, for needs_replay to put through the real
engine.

plans/contractor-full.yaml: 13 needs, 6 roles, 10 aggregates. Verifier
green by construction; 40 routes, 78 transitions accepted by the real
engine, none refused. What it exposed is in its run README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 21:46:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 aef0fe1396 build: slots instead of placeholders, and the first worded run
Fallback words read like finished copy, and nemotron handed the front
door straight back. The prompt now shows every string as a slot saying
what belongs there; a slot echoed back counts as no answer and keeps
the fallback.

runs/contractor-build-2026-09-21: nemotron-3.5-lightning:30b-a3b, the
model that produced no loadable site free-form, words a complete
contractor site in 130 s. Verifier green by construction; held-out
personas route 8/8 with descriptions and 8/8 on headings alone
(qwen3:8b as the visitor). "What happens next" is still 0/2 on the
front door: no heading or description says who reads a submission.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 21:08:08 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 8c286a8483 build: structure from the plan, words from the model
A plan.yaml is a needs spec that also says what each form collects
(and which stages a case may pass over). `portal-trainer build`
derives everything structural from it with no model involved:
needs.yaml, aggregates.yaml with the state machine read off `stages`
(walk the line, be turned down anywhere, succeed only from the end),
the front door, a followup page per need, a gated directory per group
holding its desk and the pages only that group may open, and every
desk button the machine allows.

The model is asked for words only, one page at a time, as a JSON object
whose shape is fixed by built-in fallbacks. Its strings are merged over
the fallbacks key by key, so a missing, malformed or partial reply
costs wording, never a working site, and it can neither add nor drop
structure. `via` in needs.yaml is filled mechanically from the heading
the words gave.

plans/contractor.yaml builds with no model into a site portal's
question_lint accepts: 6 needs met, 12 pages, 5 aggregates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 21:01:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e016698dc0 generate: drop a needless mut
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 20:22:05 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b53e7e6aca generate: a Norwegian run on a local model, and what it took to read it
Every prompt and reply is now kept under <out>/llm/. An empty reply is
a retry, not a fatal error; a spec that never parses stops the run
before the structure phase; --visitor-llm lets a different model play
the visitor. The file-block parser finds markers anywhere in a line,
accepts a short END and lets a new FILE close an open one, after a
reply glued two files together with `<<<END>></think>`. The reference
gains a language section: copy in the intake's language, identifiers
in ASCII.

runs/rehab-entreprenor-no-2026-09-21: nemotron-3.5-lightning:30b-a3b
did not produce a loadable site. Written up with the reason and the
way forward: structure from needs.yaml deterministically, words from
the model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 20:21:46 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b9f2acb7b8 rate and prefer: understanding, readability, friendliness, page load
Readability is counted (LIX), not judged. Understanding is three
yes-or-no questions per persona, with a gutted twin of every page as
the control: an instrument that still says yes to "Option 1, Option 2"
is not measuring the page. Friendliness is a preference between two
versions shown in both orders; a pick that flips with the order counts
as no preference. Page load (options offered, required fields in the
step) flags a page that should be split into a further question or
have fields moved to a later stage.

First run on the generated contractor site, qwen3:8b as the visitor:
control passed on every need; "what happens next" was 0/2 everywhere
and 2/2 everywhere once the page said who reads it and when (LIX 28 to
30); an official-voice control reads LIX 63 against 27. Friendliness
mostly came back "no preference" at two personas per need: directionally
right (never preferred the cold version), too thin to rely on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:48:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 612745bc66 runs: simulating the generated contractor site, with what held up and what did not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:35:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 02672547ee simulate: play every case through every state, judge only what a model can
`portal-trainer simulate` reads the model `question_lint --needs-sim`
exports. A visitor model picks an alternative and fills the form in a
manner that varies by run; the case is then walked along the handling
group's own desk buttons to a finished state, a different one each
run, so every state and button is exercised. At each button the
handler model is asked one thing: with only what the form collected,
could this role really do what the button claims? Its question goes
back to the visitor, who may not know.

Who wins is deliberately not simulated. The first version let the
handler decide: qwen3:8b sent every case to "won", and after one
sentence of prompt change, every case to "lost". Outcomes belong to
the live bucket; `success` in needs.yaml is what a report over real
records counts.

`compare` sets two score files side by side. engines/ollama.sh honours
SEED, FORMAT=json and TEMPERATURE so the same case replays the same
way against two versions of a site. generator/reference.md documents
`success` and `stages`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 19:16:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e163c22792 First generated site, a held-out test, and the leak it exposed
runs/contractor-2026-09-21: the generator's first end-to-end run, on
intakes/contractor.yaml. Needs parsed first round, structure green
first round, 10/10 on its own personas - and 8/10 headings-only on
ten held-out personas with qwen3:8b as the visitor, because the pages
had been written with the personas in view. Phase 2 now shows the
model the needs without the personas.

`portal-trainer answer` exposes the blind answerer on its own. README
rewritten around what this is: an intake in, a verified content repo
out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 18:18:08 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b6bdac6f62 Generator, LLM backends, and the onboarding site
`portal-trainer generate` turns a plain-words business intake into a
content repo in three phases, each held to portal's question_lint:
needs.yaml (then frozen, so the generator cannot weaken the spec to
pass), structure (revised against the lint's findings until green),
and wording (personas answered blind, misroutes fed back; a rewrite
that breaks structure is reverted). Model output is written to disk
only under aggregates.yaml, site.yaml and questions/**.yaml.
`portal-trainer answer` is the blind answerer alone.

The LLM is an external command, prompt on stdin, text on stdout:
engines/ollama.sh (local Ollama), engines/anthropic.sh (API key),
engines/mailbox.sh (hand each prompt to an agent or a person).

portal-portal/ is the onboarding site as a portal content repo: the
front door is the intake interview, records land in `onboardings`
(open -> drafted <-> revising -> live | declined), and it passes its
own needs spec.

runs/uhhm-2026-09-21/ollama: qwen3:8b and granite4.1:8b as blind
answerers on the same eight tasks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 18:14:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7caaa27ba7 portal-trainer: the loop around portal's needs verifier
The trainer procedure as an agent skill, the engine contract, two
sector specs (software-studio: UHHM's own, green; construction: a
target with no pages yet), and the first wording run against uhhm.no.
The verifier itself is portal's question_lint (uhhm/portal 188f9bf).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 16:31:37 +02:00
111 changed files with 24516 additions and 495 deletions
+45
View File
@@ -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"
+9 -64
View File
@@ -56,75 +56,12 @@ jobs:
- name: Build
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
# The lint (uhhm/iris) is versioned as portal: this step re-pins
# iris to the tag being released, builds and tests it against it,
# and pushes the matching iris tag, which iris's own CI publishes.
# A portal change to something the lint reads fails the release
# right here. Needs IRIS_PUSH_USER (a variable) and IRIS_PUSH_TOKEN
# (a secret) that may push to uhhm/iris; the workflow's own token
# is scoped to this repo. A re-run of a tag whose iris tag already
# exists just builds that.
- name: Release iris to match
env:
# The shared cargo home is read-only for the runner, and a git
# dependency has to be cloned into one (same as uhhm/iris's CI).
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
RUSTC_WRAPPER: /usr/bin/sccache
IRIS_PUSH_USER: ${{ vars.IRIS_PUSH_USER }}
IRIS_PUSH_TOKEN: ${{ secrets.IRIS_PUSH_TOKEN }}
run: |
set -euo pipefail
tag="${{ github.ref_name }}"
ver="${tag#v}"
host="${{ github.server_url }}"
remote="https://${IRIS_PUSH_USER}:${IRIS_PUSH_TOKEN}@${host#https://}/uhhm/iris.git"
# Re-pin, version, changelog: idempotent, so a retry on a fresh
# clone redoes exactly the same edits.
match() {
sed -i -E "s|^(portal = \{ git = \"[^\"]+\", tag = \")[^\"]+(\")|\1$tag\2|" Cargo.toml
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"$ver\"/" Cargo.toml
cargo update -q -p portal
awk -v h="## $ver ($(date -u +%F)) - portal $tag" -v l="- Matches portal $tag (released by portal's publish job)." \
'done==0 && /^## /{print h; print ""; print l; print ""; done=1} {print}' CHANGELOG.md > CHANGELOG.new
mv CHANGELOG.new CHANGELOG.md
git config user.name "portal release"
git config user.email "portal@uhhm.no"
git commit -qam "Match portal $tag"
git tag "$tag"
}
# Someone may push to iris main while this job builds; a push
# then fails as non-fast-forward, and the answer is to start
# over on a fresh clone, once.
for attempt in 1 2; do
cd "$GITHUB_WORKSPACE"
rm -rf iris-src
git clone -q "$host/uhhm/iris.git" iris-src
cd iris-src
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "iris $tag already exists, building it"
git checkout -q "$tag"
cargo build --release
cargo test
break
fi
match
cargo build --release
cargo test
if git push -q "$remote" main "$tag"; then
echo "pushed iris $tag"
break
fi
echo "push rejected on attempt $attempt (iris main moved), retrying on a fresh clone"
[ "$attempt" = 2 ] && exit 1
done
- 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/iris" "$stage/iris"
# 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"
@@ -132,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 iris 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 -
@@ -159,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"
+19
View File
@@ -38,6 +38,25 @@ jobs:
- name: Test
run: cargo test --features ssr
# Every plan under plans/ built deterministically and held to the
# lint, both by this very portal: a builder change that renders a
# site the lint rejects fails here, not at the next training run.
# (portal-trainer's CI did this until the trainer moved in.)
- name: Build and lint every plan
run: |
set -euo pipefail
cargo build --features ssr --bin portal
bin="$CARGO_TARGET_DIR/debug/portal"
tmp=$(mktemp -d)
lint="$tmp/portal-lint"
printf '#!/bin/sh\nexec %s lint "$@"\n' "$bin" > "$lint" && chmod +x "$lint"
for plan in plans/*.yaml; do
name=$(basename "$plan" .yaml)
echo "== $plan"
rm -rf "$tmp/plan-$name"
"$bin" build --plan "$plan" --out "$tmp/plan-$name" --lint "$lint"
done
- name: Stop the throwaway JetStream
if: always()
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
+1
View File
@@ -1,2 +1,3 @@
/target
.claude/
docs/changelog.md
+194 -9
View File
@@ -4,6 +4,191 @@ 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.18 (2026-10-03)
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
- `portal generate` follows its findings through: each structure round is logged as fixed, still open, new and reopened; a fix round is shown the open findings with their fixes and only the files they name; a change that reopens a closed finding is undone; and the phase stops when a round closes nothing, writing what is left to run.log. A hesitant visitor no longer triggers a wording rewrite by itself, only a misroute does. A lint without `--findings` is still driven the old way.
## 0.6.17 (2026-10-02)
- A desk shows every record still moving and only the three most recently finished (a final state: no move out of it), with "Show older" for three more at a time. A desk that has run for a year is still about the work in hand.
- Mail variables are one set for every kind of mail (docs: Mail). Every mail has `{site}` and `{site_name}`; a state mail also has `{link}`, `{self_link}` and `{desk_link}`: the reader's own page for the record (a self-service page with `?chain=&email=`, when the record's state allows a move there), the desk that moves it, and `{link}` choosing between them by who the mail is to. `{link}` in a state mail used to render as nothing.
- The lint follows every state of every bucket a need lands in: a state no desk, link or clock reaches fails; a state a record can reach and never leave fails, after "done" too; a declared move nobody offers warns; a desk gated to one group on the page and another on its list warns; working stages must follow each other in the business's order; a mail from an unreachable state warns, and one naming a field no form collects fails. Clocks (`after:`) count as moves, and a visitor's own link only from the states it names. Each finding ends in the fix: the `{ from, to, label }` and the desk page it goes on, or the group that already has the button. A schema error no longer hides the needs findings.
## 0.6.16 (2026-10-01)
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working.
## 0.6.15 (2026-10-01)
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
## 0.6.14 (2026-10-01)
- Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space.
## 0.6.13 (2026-10-01)
- Who is here (the name and "sign out", or "sign in") sits on the wordmark's own line instead of being pinned to the corner of the page, where it read as a row of its own. With a picture strip it is in the wordmark's bar; without, it is level with the mark at the right edge. On a narrow screen it drops under the mark.
- Trainer: every form, public ones included, carries a signed-in person's name and address from the login instead of asking for them; a visitor still sees the fields. Only the inside forms did before, so someone signed in filled in their own name and address again on a public form.
## 0.6.12 (2026-10-01)
- Trainer: the words model is told who opens each mail: the person who sent the case, the person who posted the record an answer is about, or someone at the business. Mails to the business about another person's case used to be written as if to that person ("you will hear from us"); on Gullberg, three of nine were.
## 0.6.11 (2026-10-01)
- `wordmark_ink: true` in `site.yaml`: an SVG wordmark is drawn in the page's own ink, as a mask over the text colour, so it follows the page's light and dark in every browser. A wordmark shown as an `<img>` only knows the system's setting, and some browsers don't pass even that on. Opacity in the file still shows, so a lighter line stays lighter. Opt-in, for one-colour marks; a mark with a colour of its own keeps its colours.
## 0.6.10 (2026-09-30)
- Trainer: who a business is gets a page of its own ("who are we?", `/hvem` in Norwegian), with nothing to do on it. It has one card per need that `presents` (the people, the work, each shown as picture cards) and one for the plan's `values`. The front door leads there instead of carrying it all.
- Trainer: the words model is told that a form page from the inside asks a real question and says where the answer ends up ("How do you present yourself?", not "Your card on the front page"), and that a `one_per_person` form is the colleague's own.
- Trainer: `portal build` names every need no persona tries. Klingenberg Bygg's plan had personas only for customers, so nobody ever played the office filling in its own forms.
- Klingenberg Bygg: "Hvem består Klingenberg Bygg av?" with Menneskene, Erfaringer (projects with pictures, added from the office) and Verdier (a first draft for Fredrik to replace).
## 0.6.9 (2026-09-30)
- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
## 0.6.8 (2026-09-30)
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
## 0.6.7 (2026-09-30)
- `leaves_after: { minutes: 10 }` on a state: a record that has been there that long leaves its bucket's lists (days, hours and minutes add up; swept every minute; the event log keeps it). Trainer: `leaves_after` per stage on a need. A Klingenberg Bygg card taken off the front page leaves the office's list ten minutes later instead of standing there greyed out.
## 0.6.6 (2026-09-30)
- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page.
- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice.
- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not.
- A refused send says why, under its button. It used to say nothing.
## 0.6.5 (2026-09-30)
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
## 0.6.4 (2026-09-30)
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
## 0.6.3 (2026-09-30)
- Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before.
- A record that carries a picture, in a list with no buttons, is shown as a card: the picture, the first field as the heading, the rest under it, with no field names, state or time. Cards are shown in the order they were put up. On a desk, the same picture is a thumbnail.
- Trainer: `values` in a plan and `presents: true` on a need make a "who we are" card on the front door. The business's own statements are carried word for word, beside every record the need presents: the people who work there, entered from inside, each with a `picture` field (a file that only takes JPEG, PNG or WebP) that shows as their portrait. A public list keeps the form's field order, so the first field heads each row.
## 0.6.2 (2026-09-30)
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
- "My cases": `where: { field: email, equals: "{user.email}" }` lists the records that carry the signed-in person's own address, filled from the session and never from the caller; nobody signed in matches nothing. An attachment is fetched only through a list that shows its record, so a list of my cases opens my files and nobody else's.
- A stored file on a listed record shows as the name it was sent with and opens from the row, for whoever the list shows the record to. It used to show as its storage key, a string nobody could open.
- Form controls scale with the text around them (em, not rem): fields, pills, buttons and labels follow a site's body size. The date picker's calendar icon is the size of the text beside it, and the file picker's button takes the field's colours instead of the browser's.
- `KANIDM_GROUP_PREFIX`: a site on a Kanidm it shares with others keeps plain group names in its content; the prefix is put on when portal adds or removes a member, and the client's claim map takes it off.
- Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration.
## 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.
@@ -282,7 +467,7 @@ content repos pin the tag they run (see the end of this file).
- 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 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
@@ -309,14 +494,14 @@ content repos pin the tag they run (see the end of this file).
## What each site runs
Read from the hosts on 2026-09-23; a rollout is a pin bump in the
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`), or a manual
tarball deploy on kasse.
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.3.36 |
| redoal.com | ergo | `app@redoal-portal` | v0.3.36 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.41 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.41 |
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.3.41 |
| 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
+213 -4
View File
@@ -132,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",
@@ -794,7 +794,7 @@ dependencies = [
"linked-hash-map",
"linked_hash_set",
"miette",
"nonempty",
"nonempty 0.12.0",
"ref-cast",
"regex",
"rustc-literal-escaper",
@@ -1060,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"
@@ -1161,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"
@@ -1517,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"
@@ -1594,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"
@@ -1642,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]]
@@ -1695,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"
@@ -1756,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"
@@ -1993,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"
@@ -2887,6 +2967,12 @@ 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"
@@ -2896,6 +2982,12 @@ 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"
@@ -3265,11 +3357,12 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.45"
version = "0.6.18"
dependencies = [
"anyhow",
"arc-swap",
"async-nats",
"async-trait",
"aws-sdk-s3",
"axum",
"base64 0.22.1",
@@ -3297,6 +3390,7 @@ dependencies = [
"tower",
"tower-http",
"tower-sessions",
"tower_governor",
"tracing",
"tracing-subscriber",
"url",
@@ -3468,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"
@@ -3667,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"
@@ -4585,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"
@@ -4906,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"
@@ -4984,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"
@@ -4992,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",
@@ -5106,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"
@@ -5528,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"
@@ -5537,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"
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.45"
version = "0.6.18"
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"] }
@@ -63,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 = []
@@ -81,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",
+31 -170
View File
@@ -1,190 +1,51 @@
# portal
A content-driven question engine: one Rust binary that turns a Git
repo of YAML into a live site — pages, forms, review desks, and state
repo of YAML into a live site — pages, forms, review desks and state
machines, with a durable event-sourced record of every answer
underneath. The engine special-cases nothing: everything a site
serves is declared in its content repo, and the same build serves any
number of sites.
underneath.
Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, branding, and even the landing hero
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
hero.js}` — a JavaScript module the content repo ships, served
same-origin at `/site/<path>`, that portal mounts at parse time and
stops on navigation; the YES canvas and redoal's sine swings are both
content, not engine). Content
pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
[redoal.com](https://redoal.com)
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
two faces of the same binary, deployed from the same release
artifact.
The engine special-cases nothing. Everything a site serves is declared
in its content repo, content pushes hot-reload every running instance,
and the same build serves any number of sites: instances differ only
by environment variables. [uhhm.no](https://uhhm.no) and
[redoal.com](https://redoal.com) are two faces of the same binary from
the same release artifact.
It composes with the surrounding stack rather than bundling it: Gitea
hosts and serves the content, NATS JetStream stores events and
projections, Kanidm provides identity, and anything downstream
(automations, newsletters, onboarding) subscribes to the answer
stream.
It composes with the surrounding stack rather than bundling it. Gitea
hosts the content, NATS JetStream stores the events and projections,
Kanidm provides identity, and anything downstream — automations,
newsletters, onboarding — subscribes to the answer stream.
## What the engine provides
## Documentation
- **Content-driven pages** (`src/content/`, `src/app.rs`): YAML
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
a bad push keeps the last-good content serving. The `questions/`
tree IS the router — file paths become URLs, `_section.yaml`
applies criteria to a whole directory, `[name].yaml` pages serve
any `/dir/<value>` with the segment fed into resource keys, and
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **Gesture and voice fields** (`gesture.js`, `voice.js`): a stroke
becomes a redoal gesture key through a relay (ADR-0013/0015 in the
redoal repo) - the input shows the key's own decode under the
stroke, who is at a similar shape now, and *places* with recordings
the visitor can pick to make that key theirs; a voice field records
in the browser and leaves the audio at a key. Actions may be
templated from the answer (`/shape/{curve.key}`) onto dynamic
pages, whose URL segment also fills `value: "{key}"` presets and
`url` resource sources; resource items with an https `audio` field
render a player.
- **Announced pages** (`src/announce.rs`): a question with an
`event: {starts, duration, place}` window is announced in the
header (not the footer nav) while the window is open, becomes a
followup when it closes, and its record in the runtime-owned
`portal_events` bucket moves to `awaiting_summary` - a "post what
happened" task any review desk can read, published on NATS like a
decision. A one-minute sweeper keeps it all idempotent.
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
declares each bucket's states and legal transitions; the engine
replays a record's event history and refuses undeclared moves, with
optimistic concurrency (CAS on the event log's sequence) against
racing decisions. An empty history reseeds from the KV projection,
so wiping the event stream strands nothing.
- **Durable events + projections** (`src/events/`, `src/answers.rs`):
every submission and decision appends to a JetStream event log and
projects into a NATS KV bucket pages read back; every one also
publishes on `portal.answers.submitted` for automations (n8n) to
react to.
- **Answer chains** (`src/chain.rs`): each submission hashes its
parent(s), so a visitor's path through the questions is a verifiable
lineage; `?chain=` links carry it, and self-service transitions
(unsubscribe) authorize by holding one. The question nav reads it
too: a `followup` page is offered only once the visitor has earned
it - the chain verifiably answered the alternative that leads there,
or it hangs off an earned page by a link that records nothing. A
page behind an answer they have not given is never offered.
- **One access policy** (`src/access.rs`): who may do what to what is
compiled from content on every load into a Cedar policy set - `qualifies`
on a page, `requires_group`/`public` on a resource, a desk's transitions,
a `self_transition` by link and email, an `invite` - and every server
entry point asks that one policy. Decisions carry the rule ids that
granted them; refusals and every mutation are published on
`portal.access.decided`, and other apps ask the same question over
request-reply on `portal.access.<site>.may`. Identity stays Kanidm's.
- **Onboarding as a form** (`src/people.rs`): an alternative with
`invite: {group}` on a gated page creates the person in Kanidm, records
their email, adds them to the group and mints a one-time link to set
their own credentials, through a service account in
`idm_people_on_boarding` (`KANIDM_API_TOKEN`). The result lands in the
`portal_invites` bucket for the inviter's desk and on
`portal.people.invited` for whatever sends the mail.
- **Business needs as a checkable spec** (in
[uhhm/iris](https://project.uhhm.no/uhhm/iris)): a content repo may
ship a `needs.yaml` saying who wants what, which bucket the answer
must land in, and which group has to carry it to a finished state.
`iris check` proves the site meets it. The runtime never reads the
file; portal only provides the content types iris reads.
- **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos/releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
- **Input kinds as content**: a requirement's `type:` picks the
widget — plain fields, a rich-text editor, or a `gesture` drawing
canvas that can connect to a redoal relay so similar strokes echo
between visitors live. Submitted values are arbitrary JSON; the
engine records what the widget produced.
- **Review desks**: any Kv resource with `transitions` renders rows
with per-state action buttons and one shared confirm per
alternative — owners walk records through their graphs without
bespoke UI per bucket.
Everything is under [`docs/`](docs/README.md):
## Binaries
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
- `iris` — the lint, from [uhhm/iris](https://project.uhhm.no/uhhm/iris),
which depends on this crate as a library pinned to a release tag and
carries the same version: the publish job here re-pins, tests and tags
iris for every portal release.
Every release tarball ships that `iris` binary
too, so each content repo's CI can
lint with the exact portal version its instance runs:
`iris check --path <dir>`.
- [Architecture](docs/architecture.md) — what the engine is made of.
- [Content reference](docs/content-reference.md) — every key a content
repo may write.
- [State machines](docs/state-machines.md) — `aggregates.yaml`.
- [Access](docs/access.md) — the policy compiled from content.
- [Operations](docs/operations.md) — releasing, deploying, adding a site.
## Development
```sh
cargo leptos build # full app (server + wasm)
cargo test --features ssr # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL
cargo leptos build # server + wasm
cargo test --features ssr # tests/jetstream.rs needs PORTAL_TEST_NATS_URL
```
Runtime configuration is env vars (see `src/main.rs`, and each
content repo's `.gitea/workflows/deploy.yml` for the values in
production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
(`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
`GITEA_API_TOKEN` for authenticated resource pulls,
`AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
## Release and deploy
Portal doesn't deploy itself — it publishes versions, and each site
decides when to take one. The whole day-to-day surface is two
commands:
**Cut a release** (here):
Content is linted by [iris](https://project.uhhm.no/uhhm/iris), which
depends on this crate and carries the same version, so a content repo
can check itself against the exact portal its instance runs:
```sh
cargo release patch # or minor / major
iris check --path questions
```
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `iris` +
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
## Releasing
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
heading for the new version. That section becomes the Gitea release
notes, and the table at the end of the file records which version each
site runs; update it when a pin moves.
**Roll it out** (in a content repo): edit one line in that repo's
`.gitea/workflows/deploy.yml` —
```yaml
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
commit, so reverting a bad version is `git revert` + push. Sites
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
:3020) can pin different versions.
Content changes never come through any of this — they hot-reload
live from the content repos over NATS.
**Add a site**: new content repo with a copy of an existing
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
content repo handles all branding; no portal changes needed.
`cargo release patch` tags and pushes; CI builds the tarball and
attaches it to the Gitea release. A site takes it by bumping
`PORTAL_RELEASE` in its own content repo. See
[operations](docs/operations.md).
+23
View File
@@ -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
+28
View File
@@ -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.
+29
View File
@@ -0,0 +1,29 @@
# Summary
[portal](README.md)
# Using it
- [Getting started](getting-started.md)
- [Content reference](content-reference.md)
- [State machines](state-machines.md)
- [Mail](mail.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)
+68
View File
@@ -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.
+92
View File
@@ -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.
+308
View File
@@ -0,0 +1,308 @@
# 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_ink: true # a one-colour SVG mark drawn in the page's own ink
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
# or with words of its own, using any field of the form:
# invite:
# group: guests
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
```
`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.
`where: { field: email, equals: "{user.email}" }` is "my cases": the
records carrying the signed-in person's own address, read from the
session. A file on such a record is theirs alone to open.
A record that carries a picture (a `type: file` field holding a JPEG,
PNG, WebP, AVIF or GIF) is shown rather than read when the list has no
`transitions`: a card with the picture on top, the first field as its
heading, and the other fields under it, with no field names, state or
time. The cards appear in the order the records were put up. That is
how a site shows the people who work there, or a gallery of what it
has built. On a desk, the same picture is a thumbnail that opens.
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
send a JPEG whatever it keeps its photos as. What the person wrote is shown as
markdown on stricter rules than the site's own: paragraphs, line
breaks as typed, lists, emphasis and links to https, mailto or tel
(marked `nofollow ugc`); no images, no raw HTML.
A desk (a list with `transitions`) shows every record still moving,
and of those in a final state - one the state machine has no move out
of - only the three most recently decided, newest first. "Show older"
under the list brings three more each time it is pressed. The desk
stays about the work in hand however long the site has run; nothing
is hidden from a read-only list, and `leaves_after` still takes a
record out of every list altogether.
`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.
+205
View File
@@ -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 |
+104
View File
@@ -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.
+94
View File
@@ -0,0 +1,94 @@
# The lint
The iris over a site. A content push is an incoming wormhole; nothing
comes through until it checks out.
```
portal iris --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
```
`portal iris` is the lint's name, after the iris that keeps bad content
out of the portal; `portal lint` runs the same thing, so workflows that
already call it keep working.
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, and each
working stage can follow the one before it;
- across each bucket, whichever need brought a record in: every state
is somewhere a record can get to, no state a record gets to holds it
with no way on (before done or after), every declared move is a
button, a visitor's own link or a clock (a move nobody offers warns),
no desk is gated to one group on the page and another on its list,
and every mail goes out from a state records reach and names only
fields the forms collect.
Every finding says what to change and where: the missing
`{ from, to, label }` and the desk page it goes on, the group that
already has the button, or the edge aggregates.yaml lacks. A schema
error no longer hides the needs findings - both are printed, so one
round can fix them together.
`--findings <out.jsonl>` writes the same findings one JSON object per
line: `id`, `level` (`fail`/`warn`), `code`, `scope` (`need`,
`bucket`, or `repo` for a schema error), `need` (the need's id, or the
bucket's name), `subject` (the state, the move as `from>to`, or the
page), `message` and `fix`. The file is written empty when the content
is clean. `id` hashes scope, need, code and subject and nothing of the
wording, so the same gap keeps its id from one run to the next: a
trainer can tell what a round closed, what is still open and what it
broke. The codes:
| code | |
|---|---|
| `schema` | content the site would refuse to load |
| `need-duplicate`, `persona-unknown-need` | needs.yaml itself |
| `entry-missing`, `entry-gated`, `entry-no-responsible` | the page a visitor arrives on |
| `path-missing`, `path-closed`, `path-over-budget`, `path-no-action` | getting the answer recorded |
| `machine-missing`, `desk-missing`, `desk-gated-twice` | who works the bucket |
| `done-unknown`, `done-unreachable`, `success-not-done` | finishing a case |
| `stage-unknown`, `stage-unreachable`, `stage-order` | the business's own stages |
| `state-unreached`, `strand`, `edge-unoffered` | every state and move of the bucket |
| `mail-unreachable`, `mail-unknown-field` | what a state mails |
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.
+59
View File
@@ -0,0 +1,59 @@
# Mail
Portal writes every mail itself and hands it to gdo to deliver
(`portal.mail.send`); a reply comes back as a note beside the record
(`portal.mail.received`). A mail is a subject and a markdown body with
`{placeholders}`, sent as text with an HTML alternative. The kinds of
mail are the ones content declares on a state (see
[State machines](state-machines.md#mail)), the invite and its
rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the
note to a person named responsible for a question, and the answers to
a suggested change.
## Variables
A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that
names nothing renders as nothing; a brace that opens no name stays a
brace. The variables come in layers, so a template written for one
kind of mail reads the same in another.
**On every mail**
| | |
|---|---|
| `{site}` | the instance's public URL, no trailing slash |
| `{site_name}` | site.yaml's `title` |
**On a mail about a record** (every state mail)
| | |
|---|---|
| `{field}` | any field of the record, as the form sent it |
| `{chain}` | the record's id |
| `{email}` | the record's `email` field, trimmed |
| `{bucket}`, `{state}` | where it is and the state it just entered |
| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` |
| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address |
| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket |
| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty |
`{link}` is the one to reach for. "Se meldingen og avtal befaring:
{link}" sent to the caretaker opens the desk the report is on; "Feilen
du meldte om er utbedret. {link}" sent to the tenant opens their own
case where they can still act on it, or the site when there is
nothing left for them to do.
**On an invite and the note to someone responsible**, beside every
field of the invite's form: `{name}`, `{username}`, `{email}`,
`{group}` (invite), `{questions}` (responsible), `{expires}`, and
`{link}`, which here is the one-time sign-in link.
## What the lint holds a mail to
A state mail that sends from a state no record reaches warns; one that
names a field no form recording into the bucket collects fails, since
it would go out with a blank where the field should be. The lint
knows every variable above, so `{link}` and the rest are never
flagged. `to: submitter` requires a required `email` on every form
filling the bucket, and `to: "{notice.email}"` requires the `notice`
field.
+108
View File
@@ -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.
+181
View File
@@ -0,0 +1,181 @@
# 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 |
| `KANIDM_GROUP_PREFIX` | for a Kanidm shared with other sites: the content's `kunder` is the group `<prefix>kunder` there, and the OAuth2 client's claim map gives it back to portal as `kunder` |
| `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_PER_SECOND` | 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 that makes portal do something passes a per-caller-IP
limit before a session is loaded or a handler is entered: a burst of
100, refilled at ten a second. More than a hundred requests in ten
seconds is a script; someone reloading a page again and again stays
well inside it.
The files a page is made of, served from portal's own disk, are not
counted: the stylesheet and scripts under `/pkg`, the fonts, the
favicons, the vendored libraries. Refusing one of those is what turns
a reloaded page into an unstyled mess, and serving one costs next to
nothing. Anything that makes portal ask someone else is counted: a
content picture under `/site/` is fetched from Gitea, a map tile from
the map provider.
| | |
|---|---|
| `PORTAL_RATE_BURST` | how many requests at once, default 100 |
| `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
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.
+179
View File
@@ -0,0 +1,179 @@
# 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}`, `{state}` and `{link}` - the page where the
reader acts on the record, their own or the desk's (every variable is
in [Mail](mail.md#variables)). 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.
### leaves_after
```yaml
taken_down:
event: taken_down
leaves_after: { minutes: 10 }
```
How long a record stays in its bucket's lists once it has entered the
state, when that is not for ever. A card taken off the front page
leaves the desk's list ten minutes later instead of standing there
greyed out, and the ten minutes are the time to see what was just
done. Days, hours and minutes add up, and a sweep takes records out
once a minute. Nothing is decided and no mail goes. The record leaves
the lists, and the event log keeps it.
## 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.
+60
View File
@@ -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.
+501
View File
@@ -0,0 +1,501 @@
# 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 state
of every bucket reachable and none stranding a record. The
page-writing model sees the needs and never the personas. Each
round the lint writes its findings with stable ids (`--findings`,
kept in `<out>/findings/round-N.jsonl`), and the round is logged as
what it fixed, what is still open, what is new and what it
reopened. A fix round is shown only the open findings, each with
the change that closes it, and the files they name (the whole repo
when a finding is about getting around the site). A change that
reopens a closed finding is undone and the model told what it
broke. A round that closes nothing, or leaves as many open as it
found, ends the phase: what is left is written to `run.log` rather
than polished at again.
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 visitor who chose right
but hesitated is reported and rewrites nothing by itself. 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.
+11
View File
@@ -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("")'
+22
View File
@@ -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 ""
+12
View File
@@ -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"
+18
View File
@@ -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'
+28
View File
@@ -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?" }
+91
View File
@@ -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.
+44
View File
@@ -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.
+38
View File
@@ -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.
+38
View File
@@ -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.
+42
View File
@@ -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.
+48
View File
@@ -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.
+62
View File
@@ -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.
+128
View File
@@ -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);
}
+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 409.87222 77.398003" role="img" aria-label="GULLBERG sanatorium">
<title>GULLBERG sanatorium</title>
<!-- Futura, drawn as outlines so every machine shows the same mark.
"sanatorium" is spaced out to run the width of GULLBERG, set in
a little from each edge. As an image it colours itself for light
and dark; with wordmark_ink in site.yaml, portal colours it with
the page's own ink instead, and "sanatorium" stays lighter by its
opacity. -->
<style>
.mark { fill: #1d1b18; }
.sub { fill: #1d1b18; fill-opacity: 0.6; }
@media (prefers-color-scheme: dark) {
.mark, .sub { fill: #f1ece3; }
}
</style>
<path class="mark"
d="M 0,25.216 Q 0,19.84 1.984,15.232 3.968,10.624 7.36,7.232 10.816,3.84 15.424,1.92 20.032,0 25.216,0 q 2.88,0 5.44,0.576 2.624,0.512 4.992,1.728 2.432,1.216 4.736,3.072 2.304,1.856 4.608,4.48 l -5.184,4.928 Q 33.856,6.912 25.344,6.912 q -3.84,0 -7.168,1.408 -3.264,1.408 -5.696,3.904 -2.432,2.432 -3.84,5.76 -1.344,3.328 -1.344,7.232 0,3.968 1.472,7.296 1.536,3.328 3.968,5.76 2.496,2.368 5.632,3.712 3.136,1.344 6.4,1.344 3.008,0 5.568,-0.96 2.56,-1.024 4.416,-2.688 1.92,-1.664 3.072,-3.904 1.152,-2.304 1.408,-4.8 V 30.912 H 27.2 v -6.848 h 19.968 v 1.6 q 0,5.504 -1.6,10.048 -1.6,4.48 -4.544,7.68 -2.88,3.2 -7.104,4.992 -4.16,1.728 -9.344,1.728 -5.12,0 -9.6,-1.92 Q 10.496,46.208 7.168,42.816 3.84,39.424 1.92,34.88 0,30.336 0,25.216 Z M 69.360026,0.96 h 7.296004 v 30.208 q 0,3.328 0.832,5.696 0.832,2.304 2.24,3.712 1.472,1.408 3.392,2.048 1.92,0.64 4.096,0.64 2.176,0 4.096,-0.64 1.92,-0.64 3.328,-2.048 1.472,-1.472 2.304,-3.84 0.896,-2.368 0.896,-5.824 V 0.96 h 7.232 V 32 q 0,4.352 -1.472,7.744 -1.408,3.392 -3.84,5.696 -2.432,2.304 -5.696,3.52 -3.2,1.152 -6.848,1.152 -3.648,0 -6.912,-1.152 -3.2,-1.216 -5.632004,-3.52 -2.432,-2.304 -3.904,-5.696 -1.408,-3.392 -1.408,-7.744 z M 129.18404,49.216 V 0.96 h 7.296 v 41.408 h 14.208 v 6.848 z m 41.90405,0 V 0.96 h 7.296 v 41.408 h 14.208 v 6.848 z m 41.90405,0 V 0.96 h 8.32 q 8.064,0 12.48,3.264 4.48,3.2 4.48,9.792 0,3.2 -1.536,5.76 -1.472,2.496 -3.904,3.84 v 0.064 q 4.416,1.28 6.912,4.416 2.56,3.136 2.56,7.296 0,3.52 -1.28,6.144 -1.28,2.56 -3.584,4.288 -2.304,1.728 -5.568,2.56 -3.2,0.832 -7.168,0.832 z m 12.416,-6.848 q 2.496,0 4.288,-0.576 1.792,-0.576 2.944,-1.536 1.152,-0.96 1.664,-2.24 0.512,-1.344 0.512,-2.88 0,-1.536 -0.512,-2.816 -0.512,-1.344 -1.728,-2.368 -1.152,-1.024 -3.136,-1.6 -1.92,-0.576 -4.8,-0.576 h -4.352 v 14.592 z m -2.688,-20.672 q 4.352,0 6.336,-1.984 1.984,-1.984 1.984,-5.504 0,-3.456 -2.048,-4.992 -1.984,-1.536 -6.272,-1.536 h -2.432 V 21.696 Z M 291.31219,0.96 v 6.848 h -19.328 v 11.584 h 18.816 v 6.848 h -18.816 v 16.128 h 19.328 v 6.848 h -26.624 V 0.96 Z m 32.56003,0 q 4.16,0 7.552,0.832 3.392,0.832 5.824,2.56 2.432,1.728 3.776,4.416 1.344,2.624 1.344,6.336 0,4.736 -2.688,8.32 -2.624,3.52 -7.808,5.376 l 14.72,20.416 h -8.896 l -13.568,-19.776 h -1.536 v 19.776 h -7.296 V 0.96 Z m -1.28,22.144 h 2.304 q 5.12,0 7.744,-1.984 2.624,-1.984 2.624,-5.888 0,-7.424 -10.048,-7.424 h -2.624 z m 40.112,2.112 q 0,-5.376 1.984,-9.984 1.984,-4.608 5.376,-8 3.456,-3.392 8.064,-5.312 4.608,-1.92 9.792,-1.92 2.88,0 5.44,0.576 2.624,0.512 4.992,1.728 2.432,1.216 4.736,3.072 2.304,1.856 4.608,4.48 l -5.184,4.928 q -5.952,-7.872 -14.464,-7.872 -3.84,0 -7.168,1.408 -3.264,1.408 -5.696,3.904 -2.432,2.432 -3.84,5.76 -1.344,3.328 -1.344,7.232 0,3.968 1.472,7.296 1.536,3.328 3.968,5.76 2.496,2.368 5.632,3.712 3.136,1.344 6.4,1.344 3.008,0 5.568,-0.96 2.56,-1.024 4.416,-2.688 1.92,-1.664 3.072,-3.904 1.152,-2.304 1.408,-4.8 v -0.064 h -12.032 v -6.848 h 19.968 v 1.6 q 0,5.504 -1.6,10.048 -1.6,4.48 -4.544,7.68 -2.88,3.2 -7.104,4.992 -4.16,1.728 -9.344,1.728 -5.12,0 -9.6,-1.92 -4.48,-1.984 -7.808,-5.376 -3.328,-3.392 -5.248,-7.936 -1.92,-4.544 -1.92,-9.664 z"
id="g"
aria-label="GULLBERG" />
<path class="sub"
d="m 11.999999,76.176 1.079,-0.819 q 0.195,0.338 0.481,0.546 0.286,0.195 0.676,0.195 0.351,0 0.546,-0.143 0.195,-0.156 0.195,-0.442 0,-0.143 -0.065,-0.26 -0.052,-0.117 -0.195,-0.234 -0.143,-0.117 -0.39,-0.247 -0.247,-0.13 -0.624,-0.299 -0.793,-0.364 -1.118,-0.793 -0.325,-0.442 -0.325,-1.066 0,-0.403 0.156,-0.728 0.156,-0.325 0.416,-0.546 0.273,-0.234 0.624,-0.351 0.364,-0.13 0.78,-0.13 0.676,0 1.17,0.247 0.507,0.247 0.793,0.663 l -1.053,0.845 q -0.143,-0.182 -0.403,-0.325 -0.247,-0.143 -0.52,-0.143 -0.273,0 -0.429,0.117 -0.156,0.117 -0.156,0.312 0,0.104 0.052,0.195 0.052,0.091 0.195,0.195 0.143,0.104 0.39,0.234 0.26,0.13 0.663,0.312 0.819,0.377 1.118,0.832 0.299,0.442 0.299,1.066 0,0.455 -0.156,0.832 -0.156,0.364 -0.442,0.624 -0.286,0.26 -0.676,0.39 -0.39,0.143 -0.845,0.143 -0.884,0 -1.443,-0.377 -0.559,-0.377 -0.793,-0.845 z m 40.993985,-2.067 q 0,-0.689 0.221,-1.274 0.234,-0.598 0.637,-1.027 0.416,-0.442 0.962,-0.689 0.559,-0.247 1.209,-0.247 0.585,0 1.079,0.195 0.494,0.195 0.962,0.65 h 0.013 v -0.689 h 1.43 v 6.188 h -1.43 v -0.689 h -0.013 q -0.468,0.455 -0.962,0.663 -0.481,0.208 -1.04,0.208 -0.663,0 -1.235,-0.247 -0.559,-0.26 -0.975,-0.702 -0.403,-0.442 -0.637,-1.04 -0.221,-0.598 -0.221,-1.3 z m 1.456,0 q 0,0.429 0.117,0.806 0.13,0.364 0.364,0.637 0.234,0.273 0.572,0.429 0.351,0.143 0.78,0.143 0.429,0 0.767,-0.143 0.351,-0.156 0.598,-0.416 0.247,-0.273 0.377,-0.637 0.143,-0.364 0.143,-0.793 0,-0.416 -0.143,-0.78 -0.13,-0.364 -0.377,-0.624 -0.247,-0.273 -0.598,-0.429 -0.338,-0.156 -0.754,-0.156 -0.416,0 -0.767,0.156 -0.338,0.156 -0.585,0.416 -0.234,0.26 -0.364,0.624 -0.13,0.351 -0.13,0.767 z M 98.512,71.028 v 0.663 h 0.026 q 0.338,-0.39 0.78,-0.611 0.455,-0.221 1.092,-0.221 1.079,0 1.599,0.676 0.533,0.663 0.533,1.885 v 3.796 h -1.43 v -3.458 q 0,-0.897 -0.286,-1.248 -0.286,-0.364 -0.884,-0.364 -0.663,0 -1.053,0.442 -0.377,0.442 -0.377,1.521 v 3.107 h -1.43 v -6.188 z m 41.05899,3.081 q 0,-0.689 0.221,-1.274 0.234,-0.598 0.637,-1.027 0.416,-0.442 0.962,-0.689 0.559,-0.247 1.209,-0.247 0.585,0 1.079,0.195 0.494,0.195 0.962,0.65 h 0.013 v -0.689 h 1.43 v 6.188 h -1.43 v -0.689 h -0.013 q -0.468,0.455 -0.962,0.663 -0.481,0.208 -1.04,0.208 -0.663,0 -1.235,-0.247 -0.559,-0.26 -0.975,-0.702 -0.403,-0.442 -0.637,-1.04 -0.221,-0.598 -0.221,-1.3 z m 1.456,0 q 0,0.429 0.117,0.806 0.13,0.364 0.364,0.637 0.234,0.273 0.572,0.429 0.351,0.143 0.78,0.143 0.429,0 0.767,-0.143 0.351,-0.156 0.598,-0.416 0.247,-0.273 0.377,-0.637 0.143,-0.364 0.143,-0.793 0,-0.416 -0.143,-0.78 -0.13,-0.364 -0.377,-0.624 -0.247,-0.273 -0.598,-0.429 -0.338,-0.156 -0.754,-0.156 -0.416,0 -0.767,0.156 -0.338,0.156 -0.585,0.416 -0.234,0.26 -0.364,0.624 -0.13,0.351 -0.13,0.767 z M 183.958,71.028 v -2.262 h 1.43 v 2.262 h 1.105 v 1.339 h -1.105 v 4.849 h -1.43 v -4.849 h -1.001 v -1.339 z m 38.849,3.055 q 0,-0.663 0.234,-1.248 0.234,-0.585 0.663,-1.014 0.442,-0.442 1.04,-0.702 0.611,-0.26 1.352,-0.26 0.741,0 1.339,0.26 0.611,0.247 1.04,0.689 0.442,0.442 0.676,1.04 0.247,0.598 0.247,1.287 0,0.689 -0.247,1.287 -0.234,0.598 -0.676,1.04 -0.429,0.429 -1.04,0.689 -0.611,0.247 -1.365,0.247 -0.728,0 -1.339,-0.247 -0.598,-0.26 -1.027,-0.702 -0.429,-0.442 -0.663,-1.053 -0.234,-0.611 -0.234,-1.313 z m 1.456,0.026 q 0,0.923 0.494,1.469 0.494,0.546 1.339,0.546 0.832,0 1.339,-0.546 0.507,-0.546 0.507,-1.443 0,-0.884 -0.507,-1.43 -0.494,-0.559 -1.339,-0.559 -0.403,0 -0.741,0.143 -0.338,0.143 -0.585,0.416 -0.247,0.26 -0.377,0.624 -0.13,0.351 -0.13,0.78 z m 42.28101,-3.081 h 1.43 v 0.559 h 0.026 q 0.351,-0.377 0.65,-0.546 0.312,-0.182 0.767,-0.182 0.559,0 1.17,0.377 l -0.65,1.3 q -0.403,-0.286 -0.793,-0.286 -1.17,0 -1.17,1.755 v 3.211 h -1.43 z m 42.15098,0 v 6.188 h -1.43 v -6.188 z m -1.651,-2.691 q 0,-0.377 0.273,-0.65 0.273,-0.273 0.65,-0.273 0.39,0 0.663,0.273 0.286,0.273 0.286,0.65 0,0.377 -0.273,0.663 -0.273,0.273 -0.663,0.273 -0.403,0 -0.676,-0.273 -0.26,-0.286 -0.26,-0.663 z m 39.122,2.691 h 1.43 v 3.549 q 0,1.547 1.209,1.547 1.222,0 1.222,-1.547 v -3.549 h 1.43 v 3.588 q 0,1.313 -0.676,2.054 -0.676,0.728 -1.976,0.728 -0.598,0 -1.092,-0.169 -0.481,-0.169 -0.832,-0.507 -0.338,-0.351 -0.533,-0.871 -0.182,-0.52 -0.182,-1.235 z m 42.74902,0 h 1.43 V 71.6 h 0.013 q 0.286,-0.338 0.65,-0.533 0.377,-0.208 0.962,-0.208 0.624,0 1.027,0.234 0.403,0.221 0.702,0.689 h 0.013 q 0.377,-0.455 0.845,-0.689 0.481,-0.234 1.118,-0.234 1.105,0 1.651,0.637 0.546,0.637 0.546,1.898 v 3.822 h -1.43 v -3.432 q 0,-0.897 -0.26,-1.261 -0.26,-0.377 -0.845,-0.377 -0.65,0 -0.949,0.429 -0.286,0.416 -0.286,1.313 v 3.328 h -1.43 v -3.523 q 0,-0.806 -0.26,-1.17 -0.26,-0.377 -0.819,-0.377 -0.637,0 -0.949,0.429 -0.299,0.416 -0.299,1.313 v 3.328 h -1.43 z"
id="s"
aria-label="sanatorium" />
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

+487
View File
@@ -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?" }
+294
View File
@@ -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." }
+122
View File
@@ -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,61 @@
# Aksjonæravtalen for Gullberg AS: punkter å dekke
Ikke en avtale, og ikke på siden. En liste over det aksjonæravtalen bør
svare på, til samtale mellom de fire og med advokat eller regnskapsfører.
Lånet går over lang tid (rundt 100 000 kr i året mot 1,6 millioner), så
avtalen må tåle at livene til de fire endrer seg underveis.
## Eierskapet
- Hvem de fire aksjonærene er, med 25 % hver, og hva hver har skutt inn.
- Hvordan huset går fra dødsboet til selskapet: skjøte, takst som legges
til grunn, og hvem som betaler dokumentavgiften.
- Hva søsknene som løses ut får, når, og at de ikke har noe krav etterpå.
## Lånet
- Hvem som står for lånet overfor banken, og om noen har kausjonert
personlig. Hvis bare noen kausjonerer: hvordan de holdes skadesløse av
de andre.
- Hva som skjer hvis husleia ikke dekker avdraget en måned, eller et
halvår: hvem skyter inn, i hvilket forhold, og som lån eller kapital.
- Hva som skjer hvis renta stiger mye.
## Driften
- Hva styret avgjør alene, og hva som krever alle fire (nye lån, salg av
huset, store utbedringer over et beløp, endring av husleia, ny eier).
- Hva som skjer ved stemmelikhet, to mot to: hvem eller hva som bryter.
- Klingenberg Bygg som forvalter, og som den som gir overslag og gjør
arbeidet: hvordan overslag godkjennes, og hvordan en mulig
interessekonflikt håndteres.
- Et vedlikeholdsfond: hvor mye av overskuddet som settes av før noe
deles ut.
## Bruken av huset
- Hvordan sommerukene fordeles mellom de fire, hvem som velger først, og
hva som skjer med uker ingen bruker.
- Om uker kan leies ut til andre, og hvem som får inntekten.
- Hva aksjonærene betaler for egne uker, om noe.
## Når noe endrer seg
- En aksjonær vil selge: forkjøpsrett for de andre, hvordan prisen
fastsettes (takst, formel), og hvor lang tid de andre har.
- En aksjonær dør eller skiller seg: om aksjene går til arvinger eller
ektefelle, eller om de andre kan løse dem ut.
- En aksjonær klarer ikke sin del: hva som skjer med andelen.
- Huset skal selges: hvordan, og hvordan pengene fordeles etter lånet.
- Brann, stor skade, eller at huset ikke kan leies ut: forsikring, og
hva som skjer med lånet.
## Utbytte og innsyn
- Når det deles ut utbytte, og i hvilken rekkefølge (lån, fond, utbytte).
- Hva hver aksjonær har rett til å se (rapportene på siden, regnskapet).
## Tvister og varighet
- Hvordan uenighet løses før noen går til retten (mekling).
- Når avtalen tas opp igjen, for eksempel hvert femte år.
@@ -0,0 +1,27 @@
# Huset
Utkast, skrives av eierne: hva huset er, hvor det ligger, hvor mange rom det har og i hvilken stand.
# Forretningen
Utkast, skrives av eierne: de to inntektene, romleie til studenter og timeshare-uker, og hva hver av dem gir i året.
# Eierne
Utkast, skrives av eierne: Gullberg AS eies av fire aksjonærer med 25 % hver. Hvem de er, hvordan styret er satt sammen, og hva som krever alle fire.
# Lånet
Utkast, skrives av eierne: 1,6 millioner til å løse ut de to søsknene som ikke blir med (800 000 kr hver), og at selskapet overtar huset ved skjøte tinglyst på selskapet. Dokumentavgiften (normalt 2,5 % av verdien), rente, løpetid og sikkerhet slik banken tilbyr, og hvordan avdraget betjenes av husleia.
# Budsjettet
Utkast, skrives av eierne: inntekter, drift, renter og avdrag for et normalår, og hvor rapportene står.
# Det som mangler
Utkast, skrives av eierne: de største manglene ved huset, hva de koster å rette, og planen for det. Lista på siden holdes oppdatert.
# Forvaltningen
Utkast, skrives av eierne: at Klingenberg Bygg forvalter huset, og hva det innebærer.
+88
View File
@@ -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.
+119
View File
@@ -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." }
+327
View File
@@ -0,0 +1,327 @@
# GULLBERG sanatorium - et Klingenberg Bygg-prosjekt på
# gullberg.klingenbergbygg.no. UTKAST 2026-09-30, til samtale; ikke
# bygget eller satt ut.
#
# Oppsettet (samtale 2026-10-01): hele huset legges i Gullberg AS, som
# eies av fire aksjonærer med 25 % hver. Selskapet låner 1,6 millioner i
# banken for å løse ut de to søsknene som ikke blir med (800 000 kr hver),
# og overtar huset (skjøte tinglyst på selskapet). Dokumentavgiften,
# normalt 2,5 % av verdien, må med i lånet eller budsjettet. Rundt
# 100 000 kr i året til banken er en lang nedbetaling: alt som kan gå
# galt underveis hører hjemme i aksjonæravtalen (se
# documents/gullberg-aksjonaeravtale-punkter.md, ikke på siden).
#
# Huset ligger bak Ås kirke, i Syverudveien (17?), et kort stykke fra
# NMBU. Rommene er for alle, men studenter går foran.
#
# Siden er huset i drift: rom til leie, feil meldt til Klingenberg
# Bygg som forvalter, styrets liste over det som er bra, mangler og er
# planlagt, husleia måned for måned, kvartalsrapporter, nedbetalingsplanen
# alle fire står bak, og eierskiftet steg for steg. Banken er ikke en egen
# gruppe på siden ennå; styret sender rapportene.
#
# Pengene: 3 soverom à 8 000 kr er 24 000 kr i måneden, rundt 264 000 i
# året over 11 måneder; sommeren er timeshare-uker. Husleia går som fast
# trekk eller AvtaleGiro til selskapets konto; Vipps eller Stripe bare for
# engangsbeløp. Depositum på egen depositumskonto (husleieloven).
#
# Åpne spørsmål:
# - hvem de fire aksjonærene er, og om nye medeiere søkes ved siden av
# lånet (medeier-tilbudet er tatt ut av forsiden til det er avgjort)
# - bankens tilbud: rente, løpetid, sikkerhet, kausjon
# - takst; hvem som sitter i styret
# - antall måneder studentene leier; timeshare bare for aksjonærene?
# - regnskapsfører/advokat: skiftet, dokumentavgift, skatt på husleie
# - kontakt og avsender (under er plassholdere)
business: GULLBERG sanatorium
language: no
# Plassholder til styret har en adresse.
contact: { name: Styret i Gullberg, email: bl@klingenbergbygg.no, group: styret }
people: memberships
mail: { from: "Gullberg <gullberg@klingenbergbygg.no>", reply_to: bl@klingenbergbygg.no }
about: >
Et gammelt hus bak Ås kirke, i Syverudveien, med rimelige rom til leie;
studenter ved NMBU går foran, men alle kan søke,
og uker om sommeren for dem som eier det. Drevet av eierne, forvaltet
av Klingenberg Bygg; det som mangler, står på lista med planen for
det, og tallene ligger åpent.
groups:
- { id: styret, label: Styret }
- { id: aksjonaerer, label: Aksjonærer }
- { id: leietakere, label: Leietakere }
- { id: klingenberg, label: Klingenberg Bygg }
invites_by: [styret]
does:
- id: rom
for: leie-rom
text: >
Rimelige rom bak Ås kirke, et kort stykke fra NMBU. Studenter går
foran, men alle kan søke. Du søker her, kommer på visning og sier
selv ja til tilbudet.
# Forretningsplanen, ord for ord: ett kort per del. Skal skrives av
# eierne; filen har bare delene og hva som hører hjemme i dem.
documents:
- id: forretningsplan
question: Hvordan går Gullberg rundt?
description: Forretningsplanen for Gullberg, slik eierne har lagt den fram for banken.
file: documents/gullberg-forretningsplan.md
needs:
# Offentlig: noen søker rom. Studenter går foran; styret ser hva den
# som søker gjør til daglig, og velger etter det.
- id: leie-rom
who: en som trenger et rimelig rom i Ås, oftest en student ved NMBU
wants: et rom å bo på til en leie som går opp, med studenter først i køen
lands_in: romsoknader
handled_by: styret
stages: [open, visning, tilbud, akseptert, signert, avslatt]
stages_text: { open: "mottatt", visning: "visning avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", signert: "kontrakt signert", avslatt: "avsluttet uten leie" }
done_when: [signert, avslatt]
success: [signert]
turned_down_from: [open, visning, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, styret] }
mails: { open: submitter, visning: submitter, tilbud: submitter, akseptert: bl@klingenbergbygg.no, signert: submitter }
grants: { signert: leietakere }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel }
- { name: situasjon, type: select, options: [Student ved NMBU, Student et annet sted, Jobber eller annet] }
- { name: innflytting, type: date, optional: true }
- { name: om, type: textarea, optional: true }
# Rommene som er ledige, som bildekort styret legger inn og tar ned.
- id: ledig-rom
who: styret, når et rom blir ledig
wants: at de som søker ser rommet før de søker
enters_as: styret
lands_in: rom
handled_by: styret
watched_by: [public]
presents: true
public_states: [open]
stages: [open, utleid]
stages_text: { open: "ledig", utleid: "leid ut" }
done_when: [utleid]
success: [utleid]
leaves_after: { utleid: { minutes: 10 } }
max_fields: 3
collects:
- { name: tittel }
- { name: bilde, type: picture }
- { name: om, type: textarea }
# Styrets liste: det som er bra, det som mangler, det som er planlagt.
# Aksjonærene ser alt; allmennheten bare planene styret har publisert,
# og de står som kunngjøringer.
- id: liste
who: styret, om huset og driften
wants: én liste over det som er bra, det som mangler og det som er planlagt, med pris og tid
enters_as: styret
lands_in: liste
handled_by: styret
watched_by: [aksjonaerer, public]
public_states: [publisert]
stages: [open, vedtatt, publisert, forkastet]
stages_text: { open: "utkast", vedtatt: "vedtatt", publisert: "publisert", forkastet: "forkastet" }
done_when: [publisert, forkastet]
success: [publisert]
max_fields: 3
collects:
- { name: tittel }
- { name: slag, type: select, options: [Kvalitet, Mangel, Plan] }
- { name: beskrivelse, type: textarea }
- { name: kostnad, optional: true }
- { name: naar, optional: true }
# Budsjettrapporten: tallene i felt, ikke bare en PDF. Aksjonærene ser
# den her; styret sender den til banken og fører inn at den er sendt.
- id: rapport
who: styret, hvert kvartal
wants: at aksjonærene ser hvordan det går, i tall, og at banken får det den skal ha
enters_as: styret
lands_in: rapporter
handled_by: styret
watched_by: [aksjonaerer]
stages: [open, sendt]
stages_text: { open: "lagt fram for aksjonærene", sendt: "sendt til banken" }
done_when: [sendt]
success: [sendt]
max_fields: 7
collects:
- { name: periode }
- { name: leieinntekter }
- { name: timeshare }
- { name: driftskostnader }
- { name: renter-og-avdrag }
- { name: saldo }
- { name: kommentar, type: textarea }
- { name: dokument, type: file, optional: true }
# Nedbetalingsplanen: hvordan pengene går og hva banken får. Styret
# legger den fram, BEGGE aksjonærene må godkjenne før den går til
# banken, og styret fører inn bankens svar. "Alle i gruppa" finnes ikke
# i portal ennå: i dag holder det at én trykker. Ny portal-funksjon før
# dette brukes.
- id: nedbetalingsplan
who: styret, når veien videre og pengestrømmen skal avtales
wants: en nedbetalingsplan alle fire aksjonærene står bak, som banken sier ja til
enters_as: styret
lands_in: nedbetalingsplaner
handled_by: styret
watched_by: [aksjonaerer]
stages: [open, godkjent, sendt, innvilget, endringer]
stages_text: { open: "til godkjenning hos aksjonærene", godkjent: "godkjent av aksjonærene", sendt: "sendt til banken", innvilget: "innvilget av banken", endringer: "banken ber om endringer" }
reached_from: { godkjent: [open], sendt: [godkjent], innvilget: [sendt], endringer: [sendt] }
done_when: [innvilget, endringer]
success: [innvilget]
moves_by: { godkjent: [aksjonaerer], sendt: [styret], innvilget: [styret], endringer: [styret] }
max_fields: 6
collects:
- { name: tittel }
- { name: laanebelop }
- { name: maanedlig-avdrag }
- { name: forste-trekk, type: date }
- { name: pengestrom, type: textarea }
- { name: dokument, type: file, optional: true }
# Husleia, måned for måned: forventet mot betalt, fra kontoutskriften.
# Aksjonærene og långiveren ser hvordan det går mellom rapportene.
- id: husleie
who: styret, når husleia for en måned er kommet inn
wants: at aksjonærene ser hvilke rom som har betalt, og hva som gikk til banken
enters_as: styret
lands_in: husleie
handled_by: styret
watched_by: [aksjonaerer]
stages: [open, betalt, purret]
stages_text: { open: "forventet", betalt: "betalt", purret: "purret" }
reached_from: { purret: [open], betalt: [open, purret] }
done_when: [betalt]
success: [betalt]
max_fields: 4
collects:
- { name: rom }
- { name: maaned }
- { name: belop }
- { name: betalt-dato, type: date, optional: true }
# Feil i huset: en leietaker melder fra, Klingenberg Bygg ser på det og
# gir et overslag, styret godkjenner kostnaden før arbeidet gjøres.
- id: feil
who: en leietaker som har funnet noe som er galt i huset
wants: at det blir sett på og rettet
enters_as: leietakere
lands_in: feil
handled_by: klingenberg
# Leietakeren ser sine egne meldinger og hvor langt de har kommet.
followed_by: [leietakere]
watched_by: [styret]
stages: [open, befaring, overslag, godkjent, utbedret, avvist]
stages_text: { open: "meldt", befaring: "befaring avtalt", overslag: "overslag gitt", godkjent: "godkjent av styret", utbedret: "utbedret", avvist: "avvist" }
reached_from: { avvist: [open, befaring, overslag] }
done_when: [utbedret, avvist]
success: [utbedret]
moves_by: { godkjent: [styret], avvist: [styret, klingenberg] }
mails: { open: fredrik@klingenbergbygg.no, overslag: bl@klingenbergbygg.no, utbedret: submitter }
max_fields: 3
collects:
- { name: hva }
- { name: hvor, optional: true }
- { name: beskrivelse, type: textarea }
- { name: email, type: email }
- { name: bilde, type: picture, optional: true }
# Overslaget: Klingenberg Bygg gir et beløp for å rette en feil. Styret
# og aksjonærene ser det; godkjente overslag hører med i rapporten.
- id: overslag
who: Klingenberg Bygg, når en feil er sett på
wants: at styret får et beløp å ta stilling til, og at det havner i regnskapet
enters_as: klingenberg
lands_in: overslag
handled_by: styret
watched_by: [aksjonaerer, klingenberg]
stages: [open, godkjent, avvist, fakturert]
stages_text: { open: "til styret", godkjent: "godkjent", avvist: "avvist", fakturert: "fakturert" }
reached_from: { avvist: [open], fakturert: [godkjent] }
done_when: [fakturert, avvist]
success: [fakturert]
moves_by: { fakturert: [klingenberg] }
mails: { open: bl@klingenbergbygg.no }
max_fields: 3
collects:
- { name: gjelder }
- { name: belop }
- { name: hva-det-dekker, type: textarea }
- { name: dokument, type: file, optional: true }
# Eierskiftet, steg for steg: fra selskapet stiftes til søsknene er
# utbetalt. Aksjonærene og banken ser hvor langt det har kommet.
- id: eierskifte
who: styret, mens huset går over til selskapet
wants: at aksjonærene og banken ser hvor langt eierskiftet har kommet, med dokumentene
enters_as: styret
lands_in: eierskifte
handled_by: styret
watched_by: [aksjonaerer]
stages: [open, arbeid, ferdig]
stages_text: { open: "ikke startet", arbeid: "i gang", ferdig: "ferdig" }
done_when: [ferdig]
success: [ferdig]
max_fields: 2
collects:
- { name: steg, type: select, options: [Selskapet stiftet, Takst, Lånet innvilget, Aksjonæravtale signert, Skjøte signert, Skjøte tinglyst, Dokumentavgift betalt, Søsknene utbetalt] }
- { name: merknad, type: textarea, optional: true }
- { name: dokument, type: file, optional: true }
# Aksjonærene: uker i huset, og saker til styret.
- id: uke
who: en aksjonær som vil bruke huset en periode
wants: en uke i huset, bekreftet av styret
enters_as: aksjonaerer
lands_in: uker
handled_by: styret
stages: [open, bekreftet, avslatt]
stages_text: { open: "ønsket", bekreftet: "bekreftet", avslatt: "ikke ledig" }
done_when: [bekreftet, avslatt]
success: [bekreftet]
max_fields: 3
collects:
- { name: fra, type: date }
- { name: til, type: date }
- { name: merknad, type: textarea, optional: true }
- id: sak
who: en aksjonær som vil ha noe avgjort
wants: at styret tar saken opp og avgjør den
enters_as: aksjonaerer
lands_in: saker
handled_by: styret
watched_by: [aksjonaerer]
stages: [open, sakslisten, vedtatt, avvist]
stages_text: { open: "meldt", sakslisten: "på sakslisten", vedtatt: "vedtatt", avvist: "avvist" }
done_when: [vedtatt, avvist]
success: [vedtatt]
max_fields: 2
collects:
- { name: tittel }
- { name: sak, type: textarea }
personas:
- { id: student-nmbu, need: leie-rom, text: "Jeg begynner på master på NMBU til høsten og har ikke råd til hybel i Ås sentrum. Et rimelig rom i et stort hus høres perfekt ut." }
- { id: ung-arbeider, need: leie-rom, text: "Jeg har fått jobb i Ås og trenger et rimelig sted å bo. Jeg er ikke student; kan jeg søke likevel?" }
- { id: student-kort, need: leie-rom, text: "Jeg skal på utveksling og trenger et rom bare for ett semester. Går det an?" }
- { id: styret-rom, need: ledig-rom, text: "Rommet i andre etasje blir ledig til jul, og jeg har et fint bilde av det. Det vil jeg legge ut." }
- { id: styret-mangel, need: liste, text: "Taket på uthuset lekker. Det må stå på lista, og vi må ha en plan og en pris for det." }
- { id: styret-kvartal, need: rapport, text: "Tredje kvartal er gjort opp. Leia kom inn, men strømmen ble dyrere enn vi trodde. Rapporten skal til banken." }
- { id: leietaker-vann, need: feil, text: "Det drypper fra taket på badet i andre etasje når noen dusjer over. Hvem sier jeg fra til?" }
- { id: klingenberg-overslag, need: overslag, text: "Vi har sett på lekkasjen på badet. Det er en pakning og litt flis, rundt 6 500 kroner. Styret må si ja før vi setter i gang." }
- { id: styret-skjote, need: eierskifte, text: "Skjøtet er tinglyst på selskapet i dag. Banken skal se det, og kopien skal ligge ved." }
- { id: aksjonaer-sommer, need: uke, text: "Vi vil gjerne være i huset første uka i juli med barna." }
- { id: styret-plan, need: nedbetalingsplan, text: "Vi har regnet på det: 24 000 i måneden fra rommene, og vi kan sette av en fast sum til långiveren hver måned. Alle fire må stå bak det før vi går til banken." }
- { id: styret-husleie, need: husleie, text: "Oktober er inne for to av rommene, det tredje mangler. Det må stå et sted alle ser." }
- { id: aksjonaer-sak, need: sak, text: "Jeg mener vi bør se på solceller før vi tar opp mer lån. Kan styret ta det opp?" }
+94
View File
@@ -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." }
+312
View File
@@ -0,0 +1,312 @@
# Klingenberg Bygg: tilstand, prosjektering og søknad for bygg, for
# kommuner, borettslag, bedrifter og private. Bygget på det gamle
# spørreskjemaet (klingenbergbygg/kundeportal, questions.ts) og et
# intervju 2026-09-30: alle fire kundegrupper, én pult (Fredrik), og
# hele oppdraget på siden - fra henvendelse over befaring og tilbud til
# levert, med kunden innlogget for å følge saken og hente dokumentene.
# Færre felt enn det gamle skjemaet: det som må til for å ta kontakt og
# komme på befaring, resten tar vi der.
business: Klingenberg Bygg
language: "no"
contact: { name: Fredrik Borch Lynghaug, email: fredrik@klingenbergbygg.no }
mail: { from: "Klingenberg Bygg <portal@klingenbergbygg.no>", reply_to: fredrik@klingenbergbygg.no }
about: >
Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt**
og godkjent **søknad** for ditt bygg.
# Kontoret er pulten; kundene får konto i det de takker ja til et
# tilbud, og ser da sine egne oppdrag og dokumentene vi leverer.
groups:
- { id: kontor, label: Kontoret }
- { id: kunder, label: Kunder }
invites_by: [kontor]
# Forsiden åpner med det vi gjør, uten knapp: to trekk.
does:
- id: tilstand
for: tilstand
text: >
Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med
befaring, materialprøver og termofotografering. Skal bygget rives,
leverer vi miljøsaneringsrapport; energibruk og besparelser hører
med i vurderingen. Du får et tilbud og sier selv ja eller nei.
- id: soknad
for: soknad
text: >
Vi prosjekterer og søker: arbeidsbeskrivelse opp mot NS 3420,
fremdriftsplan, og byggesøknad med ansvarsrett. Vi har sentral
godkjenning innen flere fagområder. Når tilbudet er godtatt,
følger du oppdraget og henter dokumentene innlogget.
# Hvem vi er: en egen side med menneskene, erfaringene (oppdrag med
# bilder, lagt inn fra kontoret) og verdiene under. Verdiene er et
# utkast i husets tone; Fredrik bytter dem ut når han vil.
values:
- id: mal-to-ganger
text: Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.
- id: bygget-forst
text: Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.
- id: ingen-overraskelser
text: Det vi finner, får du vite. Også sprekkene.
- id: vannet
text: Vannet finner alltid en vei. Derfor leter vi etter den først.
needs:
- id: tilstand
who: en eier eller forvalter som må vite hvilken forfatning bygget er i, for en kommune, et borettslag, en bedrift eller seg selv
wants: en tilstandsrapport å planlegge og beslutte ut fra
lands_in: tilstandsrapporter
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "i arbeid", levert: "levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
# Kunden sier selv ja eller nei til tilbudet, gjennom sin egen lenke.
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
# Et godtatt tilbud er kundeforholdet: konto og plass blant kundene.
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: eiendom, type: select, options: [Skole eller annet offentlig bygg, Borettslag eller sameie, Næringsbygg, Enebolig, Leilighet, Fritidsbolig, Annet] }
- { name: adresse }
- { name: mangler, type: textarea, optional: true }
- { name: tegninger, type: file, optional: true }
- id: prosjekt
who: en byggherre som har planer for bygget og trenger dem prosjektert før noen kan bygge
wants: en arbeidsbeskrivelse og fremdriftsplan som gir det beste grunnlaget for utførelse
lands_in: prosjekteringer
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "i arbeid", levert: "levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: adresse }
- { name: planer, type: textarea }
- { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true }
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
- id: soknad
paused: true
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
wants: en godkjent byggesøknad, med ansvarsrett der det kreves
lands_in: soknader
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "forhåndskonferanse eller befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "søknad under arbeid", levert: "søknad levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: adresse }
- { name: tiltak, type: textarea }
- { name: tegninger, type: file, optional: true }
# Det kontoret leverer: rapporten, tegningene, vedtaket. Kunden får
# e-post og henter dokumentet innlogget, fra sin egen liste.
- id: leveranse
who: kontoret, når en rapport, tegninger eller et vedtak er klart til kunden
wants: at kunden får dokumentet og vet at det ligger der
enters_as: kontor
lands_in: leveranser
handled_by: kontor
followed_by: [kunder]
stages: [open, arkivert]
stages_text: { open: "levert", arkivert: "arkivert" }
done_when: [arkivert]
success: [arkivert]
mails: { open: submitter }
max_fields: 3
collects:
- { name: email, type: email }
- { name: hva }
- { name: dokument, type: file }
# Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle,
# erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned.
- id: ansatt
who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan
wants: at den som vurderer oss ser hvem som gjør jobben
enters_as: kontor
one_per_person: true
# Et kort som er tatt ned, forsvinner fra kontorets liste etter ti minutter.
leaves_after: { sluttet: { minutes: 10 } }
lands_in: ansatte
handled_by: kontor
watched_by: [public]
presents: true
public_states: [open]
stages: [open, sluttet]
stages_text: { open: "vises på forsiden", sluttet: "sluttet" }
done_when: [sluttet]
success: [sluttet]
max_fields: 4
collects:
- { name: name }
- { name: rolle }
- { name: erfaring, type: textarea }
- { name: portrett, type: picture }
# Erfaringene: oppdrag vi vil vise fram, med bilde og noen løse ord.
- id: erfaring
who: en av oss på kontoret, når et oppdrag er verdt å vise fram
wants: at den som vurderer oss ser hva vi har gjort
enters_as: kontor
lands_in: erfaringer
handled_by: kontor
watched_by: [public]
presents: true
public_states: [open]
stages: [open, fjernet]
stages_text: { open: "vises", fjernet: "fjernet" }
done_when: [fjernet]
success: [fjernet]
leaves_after: { fjernet: { minutes: 10 } }
max_fields: 3
collects:
- { name: tittel }
- { name: bilde, type: picture }
- { name: om, type: textarea }
personas:
- { id: kommune-skole, need: tilstand, text: "Vi i kommunen skal legge fram en sak om rehabilitering av en barneskole fra 1972 og trenger en tilstandsanalyse politikerne kan beslutte ut fra." }
- { id: borettslag, need: tilstand, text: "Styret i borettslaget lurer på hva som haster mest av tak, fasade og rør. Vi trenger noen som kan gå over byggene og si hva det står til." }
- { id: sprekk, need: tilstand, text: "Det har kommet en sprekk midt på stueveggen og jeg vet ikke om det er farlig. Kan noen komme og se på det?" }
- { id: tilbygg, need: prosjekt, text: "Vi vil bygge på et tilbygg på lageret vårt og trenger noen som kan beskrive arbeidet så vi kan hente inn priser fra entreprenører." }
- { id: skole-i-drift, need: prosjekt, text: "Skolen skal utvides mens den er i drift. Vi trenger en beskrivelse og en fremdriftsplan med byggetrinn." }
- { id: soke-garasje, need: soknad, text: "Tegningene til garasjen er ferdige, men jeg aner ikke hvordan jeg søker kommunen. Kan dere ta søknaden?" }
- { id: ansvarsrett, need: soknad, text: "Vi har prosjektert ombyggingen selv, men mangler noen med ansvarsrett som kan stå for søknaden." }
# Innenfra: det kontoret selv fyller ut.
- { id: rapport-klar, need: leveranse, text: "Tilstandsrapporten til borettslaget er ferdig. Jeg vil at styreleder skal få den og vite at den ligger der." }
- { id: ny-hos-oss, need: ansatt, text: "Jeg har nettopp begynt hos Klingenberg Bygg og vil at kundene skal se hvem jeg er og hva jeg kan." }
- { id: nytt-portrett, need: ansatt, text: "Portrettet mitt på forsiden er fra da jeg begynte. Jeg vil bytte det uten å skrive alt det andre på nytt." }
- { id: brannstasjonen, need: erfaring, text: "Brannstasjonen i Ytre Enebakk står ferdig, og vi har gode bilder fra fundamenteringen. Den vil jeg vise fram." }
slideshow:
max_height: 60
slides:
- image: images/bildeserie/01.webp
width: 1867
height: 1400
caption: Tilstandsanalyse for politisk vurdering.
- image: images/bildeserie/02.webp
width: 2365
height: 1400
caption: Tilleggsisolert fasade uten fasadeendring. Tanum skole.
- image: images/bildeserie/03.webp
width: 1867
height: 1400
caption: Fundamenteringen av brannstasjonen i Ytre Enebakk
- image: images/bildeserie/04.webp
width: 1867
height: 1400
caption: Gammel stjernehimmel skal fornyes
- image: images/bildeserie/05.webp
width: 1050
height: 1400
caption: Sprekk midt på stuevegg
- image: images/bildeserie/06.webp
width: 1867
height: 1400
caption: Snart klart for overlevering! Løvenstad omsorgsboliger.
- image: images/bildeserie/07.webp
width: 1867
height: 1400
caption: Omlegging av vannforsyning før etablering av nye boliger
- image: images/bildeserie/08.webp
width: 1777
height: 1400
caption: Utbedring parkett i gymsal, sliping og lakking.
- image: images/bildeserie/09.webp
width: 3629
height: 1400
caption: Tilbygg på skole i drift, i Ås
- image: images/bildeserie/10.webp
width: 3207
height: 1400
caption: Fornying kantine Det Norske Teatret
- image: images/bildeserie/11.webp
width: 1050
height: 1400
caption: Solid veggprofil tilpasset lyd- og brannkrav
- image: images/bildeserie/12.webp
width: 1867
height: 1400
caption: Nye vinduer med tradisjonelt utseende «Restover»-glass som avslører utsikten fra Munkeramme på Hvitsten
- image: images/bildeserie/13.webp
width: 2571
height: 1400
caption: Kjerneved til verandaen og inngangspartiet til Muncheramme
- image: images/bildeserie/14.webp
width: 1050
height: 1400
caption: Drenering og riktige rammer før kunstgressbanen legges mesterfullt ned helt uten gummikuler
- image: images/bildeserie/15.webp
width: 2098
height: 1400
caption: Navnebror sprenger plass til ny brannstasjon
- image: images/bildeserie/16.webp
width: 1283
height: 1400
caption: Kontroll av betong før støping
- image: images/bildeserie/17.webp
width: 1050
height: 1400
caption: Etablering av ny byggekran
- image: images/bildeserie/18.webp
width: 1050
height: 1400
caption: Armeringskontroll før støping
- image: images/bildeserie/19.webp
width: 1867
height: 1400
caption: Brannstasjonens tårn under oppføring blir også klatrevegg
- image: images/bildeserie/20.webp
width: 1867
height: 1400
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i minst 10 år.
- image: images/bildeserie/21.webp
width: 1867
height: 1400
caption: Nyetablering av vei.
- image: images/bildeserie/22.webp
width: 838
height: 1400
caption: Støpefeil med frosset treverk i søyle fra 1960, avdekket og utbedret
- image: images/bildeserie/23.webp
width: 2095
height: 1400
caption: Nytt kunstgress med varme på Fredrikstad stasjon.
- image: images/bildeserie/24.webp
width: 1050
height: 1400
caption: Sydvendte fasader er utsatt for slagregn. Dette bygget er på byantikvarens gule liste.
- image: images/bildeserie/25.webp
width: 1487
height: 1400
caption: pH-test av borprøver i betong fra forskjellige dybder
+113
View File
@@ -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." }
+339
View File
@@ -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
View File
@@ -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?" }
+985
View File
@@ -0,0 +1,985 @@
{
"aggregates.yaml": {
"feil": {
"open": {
"subject": "Ny feil meldt på Gullberg: {hva}",
"body": "En leietaker på Gullberg har meldt fra om {hva}. Se meldingen og avtal befaring: {link}"
},
"overslag": {
"subject": "Overslag til styret: {hva}",
"body": "Klingenberg Bygg har sett på {hva} og gitt et overslag. Styret godkjenner eller avviser det før arbeidet settes i gang: {link}"
},
"utbedret": {
"subject": "{hva} er rettet",
"body": "Feilen du meldte om, {hva}, er nå utbedret. Si gjerne til hvis noe fortsatt ikke er som det skal. {link}"
}
},
"overslag": {
"open": {
"subject": "Nytt overslag fra Klingenberg Bygg: {gjelder}",
"body": "Klingenberg Bygg har sendt et overslag for {gjelder}. Det ligger hos styret til godkjenning: {link}"
}
},
"romsoknader": {
"akseptert": {
"subject": "{name} har takket ja til rommet",
"body": "{name} har takket ja til tilbudet om rom på Gullberg. Neste steg er kontrakten og depositumet. {link}"
},
"open": {
"subject": "Søknaden din om rom på Gullberg er mottatt",
"body": "Hei {name},\n\nsøknaden din om rom på Gullberg er mottatt. Styret går gjennom søknadene fortløpende; studenter går foran, men alle blir sett på. Du hører fra oss om visning. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}\n\nHilsen styret i Gullberg"
},
"signert": {
"subject": "Kontrakten er signert",
"body": "Kontrakten for rommet du søkte om er nå signert av begge parter. Rommet er ditt - velkommen inn. {link}"
},
"tilbud": {
"subject": "Du har fått tilbud om rom",
"body": "Styret har sett på søknaden din og kan tilby deg rommet. Svar her så snart du kan, så går vi videre med kontrakten. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}"
},
"visning": {
"subject": "Visning av rommet er satt opp",
"body": "Søknaden din har kommet til visning - styret vil vise deg rommet før det avgjøres. Du hører fra oss om tid og sted. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}"
}
}
},
"questions/aksjonaerer/index.yaml": {
"question": "Hvordan ligger huset og driften an?",
"description": "Her ser aksjonærene hvordan huset og driften står: eierskiftet, husleia, lista, overslagene og sakene.",
"confirm": "Bekreft",
"buckets": {
"eierskifte": {
"heading": "Se hvor langt eierskiftet har kommet",
"description": "Hvert steg i overgangen til Gullberg AS, med dokumentene som hører til.",
"moves": {},
"list_title": "Steg i eierskiftet",
"list_note": "Hver rad er et steg med merknad og dokument; flytt det videre når det er i arbeid eller ferdig."
},
"husleie": {
"heading": "Se hvilke rom som har betalt",
"description": "Husleia for hvert rom, måned for måned, og hva som er purret på.",
"moves": {},
"list_title": "Husleie",
"list_note": "Hver rad er ett rom for én måned, med beløp og betalingsdato; merk den betalt eller purret."
},
"liste": {
"heading": "Se det som er bra, mangler og planlagt",
"description": "Én liste over tilstanden i huset, med pris og tid på det som står igjen.",
"moves": {},
"list_title": "Lista",
"list_note": "Hver rad er ett punkt med kostnad og tidspunkt; vedta den, publiser den eller forkast den."
},
"nedbetalingsplaner": {
"heading": "Godkjenn nedbetalingsplanen",
"description": "Planen for lånet, lagt fram for alle fire aksjonærene og banken.",
"moves": {
"open->godkjent": "Godkjenn planen"
},
"list_title": "Nedbetalingsplaner",
"list_note": "Hver rad er en plan med lånebeløp og pengestrøm; følg den fra godkjent til innvilget hos banken."
},
"overslag": {
"heading": "Se overslagene fra Klingenberg Bygg",
"description": "Beløpet Klingenberg Bygg har satt på en feil, klart til vedtak.",
"moves": {},
"list_title": "Overslag",
"list_note": "Hver rad er ett overslag med beløp og hva det dekker; godkjenn, avvis eller fakturer det."
},
"rapporter": {
"heading": "Se kvartalsrapportene",
"description": "Tallene aksjonærene og banken skal ha, kvartal for kvartal.",
"moves": {},
"list_title": "Rapporter",
"list_note": "Hver rad er én periode med inntekter, kostnader og saldo; send den når den er klar."
},
"saker": {
"heading": "Se sakene som er meldt til styret",
"description": "Det en aksjonær vil ha styrets ord på, samlet på ett sted.",
"moves": {},
"list_title": "Saksliste",
"list_note": "Hver rad er én sak; sett den på sakslisten, vedta den eller avvis den."
}
},
"links": {
"uke": {
"heading": "Be om en uke i huset",
"description": "Reserver en periode selv, bekreftet av styret.",
"button": "Søk om uke"
},
"sak": {
"heading": "Få noe avgjort av styret",
"description": "Meld en sak du vil ha styrets ord på.",
"button": "Meld sak"
}
},
"invites": {
"aksjonaerer": {
"heading": "Inviter en ny aksjonær inn",
"description": "De får en konto og en lenke som gjelder én gang.",
"button": "Send invitasjon",
"form_title": "Ny aksjonær",
"form_description": "Ha navn, e-post og telefon klart.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Se hvem som er invitert",
"description": "Status på invitasjonene som er sendt ut.",
"done": "Aktiv",
"withdraw": "Trekk tilbake",
"again": "Send på nytt",
"list_title": "Invitasjoner",
"list_note": "Hver rad er én invitasjon; send den på nytt om lenken har gått ut, eller trekk den tilbake."
},
"suggestions": null
},
"questions/aksjonaerer/sak-sent.yaml": {
"question": "Hva skjer med saken jeg nettopp sendte inn?",
"description": "Saken er sendt til styret. Den havner på sakslisten og blir tatt opp der.",
"next_heading": "Følg saken videre",
"next_description": "Styret vurderer saken og vedtar eller avviser den. Du finner status på sakslisten."
},
"questions/aksjonaerer/sak.yaml": {
"heading": "Få en sak opp i styret",
"description": "Noe som bør avgjøres, men ikke er ditt å avgjøre alene? Legg den fram, så tar styret den opp.",
"button": "Send saken",
"encouragement": "Styret ser den og setter den på sakslisten.",
"icon": "vote",
"paused": "Pause mottak av saker",
"requires_title": "Krev tittel",
"requires_description": "Krev beskrivelse av saken",
"requires_icon": "Krev ikon",
"road_title": "Fra forslag til vedtak",
"road_description": "Saken kommer inn og settes på sakslisten, blir tatt opp av styret og ender med et vedtak eller en avvisning.",
"road_icon": "list",
"who_title": "Hvem som leser den",
"who_description": "Styret får saken og tar stilling til den; haster det, nå dem på bl@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Ny sak",
"form_description": "Skriv kort hva saken går ut på og hvorfor den bør opp.",
"labels": {
"tittel": "Tittel på saken",
"sak": "Hva gjelder saken"
},
"hints": {
"tittel": "Fast eller flytende leie for sommeren",
"sak": "Vi bør avklare om timeshare-ukene skal prises likt hele året, eller om sommeren skal koste mer. Foreslår at styret tar stilling til dette før neste sesong."
},
"question": "Er det noe som bør avgjøres av styret?",
"page_description": "Her melder du inn en sak du vil at styret skal ta opp og avgjøre."
},
"questions/aksjonaerer/uke-sent.yaml": {
"question": "Hva skjer nå med ukesøknaden din?",
"description": "Den er sendt og ligger hos styret, som avgjør om uken blir bekreftet.",
"next_heading": "Styret ser på søknaden din",
"next_description": "De bekrefter eller avslår uken. Du finner svaret i oversikten når det er klart."
},
"questions/aksjonaerer/uke.yaml": {
"heading": "Reserver en uke i huset",
"description": "Si fra når du vil bruke huset, så ser styret om datoene passer.",
"button": "Send ønske",
"encouragement": "Du hører fra styret så snart datoene er sjekket.",
"icon": "calendar",
"paused": "Tar vi ikke imot ukeønsker nå",
"requires_title": "Krever tittel",
"requires_description": "Krever beskrivelse",
"requires_icon": "Krever ikon",
"road_title": "Veien videre",
"road_description": "Styret ser på ønsket ditt og bekrefter eller avslår uken.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Styret ser ønsket ditt; skriv til bl@klingenbergbygg.no om det haster.",
"who_icon": "users",
"form_title": "Ukeønske",
"form_description": "Fyll inn datoene du ønsker huset.",
"labels": {
"fra": "Fra dato",
"til": "Til dato",
"merknad": "Merknad"
},
"hints": {
"fra": "f.eks. 6. juli 2027",
"til": "f.eks. 13. juli 2027",
"merknad": "f.eks. kommer med familie, trenger ekstra seng"
},
"question": "Hvilken uke vil du bruke huset?",
"page_description": "Meld fra om uken du ønsker, så bekrefter styret den."
},
"questions/decide/leie-rom.yaml": {
"question": "Vil du ta imot tilbudet om rom?",
"description": "Styret har sett på søknaden din og tilbyr deg et rom. Si ja og det er ditt, si nei og det går videre til neste på lista.",
"moves": {
"akseptert": {
"heading": "Ta imot rommet",
"description": "Du sier ja til rommet du har fått tilbud om. Styret tar kontakt om innflytting og signering.",
"button": "Ta imot"
},
"avslatt": {
"heading": "Takk nei til rommet",
"description": "Du sier nei til tilbudet, og rommet går videre til neste søker.",
"button": "Takk nei"
}
}
},
"questions/hvem.yaml": {
"question": "Hva er Gullberg?",
"description": "Et gammelt hus bak Ås kirke, eid av fire aksjonærer og drevet av styret, med Klingenberg Bygg som forvalter. Her er rommene som er ledige nå.",
"presents": {
"rom": {
"heading": "Rommene som er ledige",
"description": "Rommene som står ledige for dem som søker et sted å bo.",
"empty": "Ingen rom er ledige akkurat nå, men nye kommer når noen flytter ut."
}
},
"values": {
"heading": "Det styret har vedtatt",
"description": "Det som gjelder for huset og driften, slik styret har satt det.",
"items": {}
}
},
"questions/index.yaml": {
"question": "Trenger du et rimelig rom i Ås?",
"description": "Gullberg er et gammelt hus bak Ås kirke, et kort stykke fra NMBU. Eierne driver det selv, og Klingenberg Bygg forvalter huset og vedlikeholdet.",
"opener": {
"heading": "Et rimelig sted å bo, nær NMBU",
"description": "Rommene leies ut for et år av gangen, til en pris som går opp med studielånet. Studenter går foran, men alle kan søke.",
"features": {
"rom": {
"heading": "Rom til leie, studenter først",
"description": "Du leier et rom i huset for et år av gangen. Studenter ved NMBU får rommene først; er det ledig, får andre også.",
"icon": "house"
}
}
},
"who": {
"heading": "Hva er Gullberg?",
"description": "Fire aksjonærer eier huset sammen og driver det selv, med Klingenberg Bygg som forvalter.",
"button": "Se huset og rommene"
},
"shopping": {
"heading": "Hva som er gjort, og hva som mangler",
"description": "Det styret har vedtatt for huset: planene, med pris og tid der det er kjent.",
"items": {
"status-liste": {
"heading": "Siste fra lista",
"description": "Punktene styret har tatt stilling til, ferskest øverst.",
"link": "Se hele lista",
"empty": "Snart kommer de første punktene på lista.",
"icon": "clipboard-list"
}
}
},
"doors": {},
"needs": {
"leie-rom": {
"heading": "Søk om et rom",
"description": "Fortell litt om deg og hva du gjør til daglig. Studenter går foran, men alle kan søke.",
"button": "Søk rom",
"encouragement": "Det tar bare et par minutter, og du hører fra styret så snart et rom er ledig.",
"icon": "key",
"paused": "Søknader er satt på pause",
"requires_title": "Dette trenger vi",
"requires_description": "Navn, e-post og hvilket lærested du går på.",
"requires_icon": "clipboard-list",
"road_title": "Veien videre",
"road_description": "Søknaden havner hos styret, som inviterer deg på visning av et ledig rom. Sier du ja til tilbudet, signerer du leieavtalen og flytter inn.",
"road_icon": "route",
"who_title": "Hvem leser søknaden",
"who_description": "Styret i Gullberg leser søknaden din, og du når dem i mellomtiden på bl@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Søknadsskjema",
"form_description": "Fyll inn det du vet nå - resten kan vente til visningen.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"innflytting": "Ønsket innflytting",
"om": "Litt om deg",
"situasjon": "Hva gjør du til daglig?"
},
"hints": {
"name": "Kari Nordmann",
"email": "kari@student.no",
"telefon": "123 45 678",
"innflytting": "August 2026",
"om": "Jeg studerer medisin og trenger et rolig sted å bo nær campus.",
"situasjon": ""
}
}
}
},
"questions/klingenberg/index.yaml": {
"question": "Hva trenger huset nå?",
"description": "Her samler Klingenberg Bygg det leietakerne melder inn, og styret ser overslagene før de godkjennes.",
"confirm": "Bekreft",
"buckets": {
"feil": {
"heading": "Se hva som er meldt inn av feil",
"description": "Her ser du det leietakerne har meldt, fra ny melding til den er utbedret.",
"moves": {
"befaring->overslag": "Lag overslag",
"befaring->avvist": "Avvis saken",
"godkjent->utbedret": "Meld utbedret",
"open->befaring": "Sett opp befaring",
"open->avvist": "Avvis saken",
"overslag->avvist": "Avvis overslaget"
},
"list_title": "Meldte feil",
"list_note": "Hver rad er en feil som er meldt inn; knappene under flytter den videre til befaring, overslag eller utbedret."
},
"overslag": {
"heading": "Følg opp overslagene dine",
"description": "Her ser du overslagene du har levert, og om styret har godkjent dem.",
"moves": {
"godkjent->fakturert": "Merk fakturert"
},
"list_title": "Leverte overslag",
"list_note": "Hver rad er et overslag du har levert; marker det fakturert når regningen er sendt."
}
},
"links": {
"overslag": {
"heading": "Se alle overslag i ett regnskap",
"description": "Her finner du overslagene samlet, uansett hvem som har meldt feilen.",
"button": "Åpne overslag"
}
},
"invites": {
"klingenberg": {
"heading": "Gi noen hos Klingenberg Bygg tilgang",
"description": "Personen får en konto og en lenke som gjelder én gang, så de kan logge inn og se feilene selv.",
"button": "Send invitasjon",
"form_title": "Inviter ny person",
"form_description": "Ha navn, telefon og e-post klart.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Hold oversikt over inviterte",
"description": "Her ser du hvem som er invitert, og om de har tatt lenken i bruk.",
"done": "Tatt i bruk",
"withdraw": "Trekk tilbake",
"again": "Send på nytt",
"list_title": "Sendte invitasjoner",
"list_note": "Hver rad er en invitasjon; send den på nytt hvis lenken har gått ut, eller trekk den tilbake."
},
"suggestions": null
},
"questions/klingenberg/overslag-sent.yaml": {
"question": "Hva skjer med overslaget du nettopp sendte inn?",
"description": "Det ligger nå hos styret, med beløp og hva det dekker. De tar stilling til det og fører det videre i regnskapet.",
"next_heading": "Styret vurderer overslaget",
"next_description": "Du hører fra dem hvis de trenger mer, ellers venter du på svar om det blir godkjent, avvist eller fakturert."
},
"questions/klingenberg/overslag.yaml": {
"heading": "Overslaget går til styret",
"description": "Når du har sett på en feil og satt en pris, sender du overslaget hit, så styret kan ta stilling til det.",
"button": "Send overslag",
"encouragement": "Styret ser det i løpet av kort tid, og det følger saken videre til regnskapet.",
"icon": "receipt",
"paused": "Ta ikke mot nye overslag nå",
"requires_title": "Krev tittel",
"requires_description": "Krev beskrivelse",
"requires_icon": "Krev ikon",
"road_title": "Veien videre",
"road_description": "Styret godkjenner eller avviser overslaget. Blir det godkjent og arbeidet utført, blir det fakturert.",
"road_icon": "route",
"who_title": "Hvem leser det",
"who_description": "Styret i Gullberg leser overslaget og tar stilling til det, du når dem på bl@klingenbergbygg.no i mellomtiden.",
"who_icon": "users",
"form_title": "Overslag på feil",
"form_description": "Ha prisen og hva den dekker klart før du sender.",
"labels": {
"gjelder": "Hva overslaget gjelder",
"belop": "Beløp",
"hva-det-dekker": "Hva beløpet dekker",
"dokument": "Dokument"
},
"hints": {
"gjelder": "Lekkasje på badet i rom 4",
"belop": "12 500 kr",
"hva-det-dekker": "Ny membran, fliser og arbeidstid, to dagers arbeid",
"dokument": "overslag-rom4.pdf"
},
"question": "Hva vil det koste å rette feilen?",
"page_description": "Overslaget går til styret, som godkjenner eller avviser det før det føres i regnskapet."
},
"questions/leietakere/feil-sent.yaml": {
"question": "Hva skjer nå med feilen jeg meldte?",
"description": "Meldingen er sendt til Klingenberg Bygg, som drar på befaring og vurderer hva den koster å rette.",
"next_heading": "Klingenberg Bygg ser på saken",
"next_description": "De befarer feilen og lager et overslag som styret tar stilling til."
},
"questions/leietakere/feil.yaml": {
"heading": "Meld fra om noe som er galt i huset",
"description": "Fant du noe som ikke virker eller er ødelagt? Si fra, så ser styret på det.",
"button": "Send feilen",
"encouragement": "Tar et par minutter, og styret får beskjed med én gang.",
"icon": "wrench",
"paused": "Ta imot nye feilmeldinger",
"requires_title": "Overskrift",
"requires_description": "Forklaring",
"requires_icon": "Ikon",
"road_title": "Hva skjer videre",
"road_description": "Styret ser på feilen og avtaler en befaring. Deretter henter de inn et overslag, godkjenner det, og feilen rettes opp – eller avvises, med beskjed om hvorfor.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Styret i Gullberg får meldingen og følger den opp; haster det, send en e-post til bl@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Meld en feil",
"form_description": "Fortell hva som er galt og hvor, gjerne med bilde.",
"labels": {
"hva": "Hva er galt",
"hvor": "Hvor i huset",
"beskrivelse": "Beskriv feilen",
"email": "E-post",
"bilde": "Bilde"
},
"hints": {
"hva": "Vannlekkasje under kjøkkenvasken",
"hvor": "Kjøkkenet i 2. etasje",
"beskrivelse": "Det drypper fra røret under vasken, har blitt verre siste uken",
"email": "navn@student.no",
"bilde": "Last opp et bilde av feilen"
},
"question": "Har du funnet noe i huset som bør rettes opp?",
"page_description": "Meldingen går rett til Klingenberg Bygg, som forvalter huset. De ser på det, og styret godkjenner det som koster penger."
},
"questions/leietakere/index.yaml": {
"question": "Hva kan vi gjøre for deg i huset?",
"description": "Feil leietakerne melder inn, og overslagene Klingenberg Bygg sender over, samlet på ett sted.",
"confirm": "Bekreft",
"buckets": {},
"links": {
"feil": {
"heading": "Har du funnet noe som er galt i huset?",
"description": "Meld fra, så går det rett til Klingenberg Bygg.",
"button": "Meld fra"
}
},
"invites": {},
"invited": null,
"suggestions": null
},
"questions/received/leie-rom.yaml": {
"question": "Hva skjer med søknaden min nå?",
"description": "Styret går gjennom romsøknader fortløpende og svarer så snart det er en visning å tilby.",
"next_heading": "Du hører fra styret om en visning",
"next_description": "Styret tar kontakt på e-post eller telefon for å avtale tid. Får du tilbud om rommet, får du beskjed rett etter."
},
"questions/status/liste/index.yaml": {
"question": "Hva er vedtatt og gjort for huset?",
"description": "Her ligger det som styret har vedtatt og satt i verk, med pris og tid der det er kjent.",
"heading": "Det som er vedtatt og gjort",
"list_description": "Åpen liste over det styret har vedtatt for huset.",
"list_title": "Vedtatt og gjort",
"list_note": "Hver rad er én sak, med hva den gjelder, kostnad og tidspunkt der det er satt.",
"empty": "Ingen saker er publisert ennå.",
"answer": "Svar"
},
"questions/status/rom/index.yaml": {
"question": "Hvilke rom er ledige nå?",
"description": "Her finner du rommene som er ledige for innflytting, med bilde og en kort beskrivelse av hvert rom.",
"heading": "Ledige rom i huset",
"list_description": "Rommene som er ledige akkurat nå.",
"list_title": "Ledige rom",
"list_note": "Hver rad er et rom du kan søke på.",
"empty": "Ingen rom er ledige akkurat nå, men se gjerne innom igjen senere.",
"answer": "Svar"
},
"questions/styret/eierskifte-sent.yaml": {
"question": "Hva skjer nå med eierskiftesaken du sendte inn?",
"description": "Steget er registrert og legger seg på listen over eierskiftet. Aksjonærene ser det neste gang de ser på fremdriften.",
"next_heading": "Følg eierskiftet videre",
"next_description": "Du finner status og alle steg i oversikten over eierskiftet, og kan legge inn mer når noe nytt skjer."
},
"questions/styret/eierskifte.yaml": {
"heading": "Se hvor eierskiftet står",
"description": "Meld fra om neste steg i overgangen til selskapet, så ser aksjonærene og banken fremgangen.",
"button": "Legg inn steg",
"encouragement": "Tar et minutt, og listen oppdateres med én gang.",
"icon": "file-text",
"paused": "Satt på vent",
"requires_title": "Krever tittel",
"requires_description": "Krever beskrivelse",
"requires_icon": "Krever ikon",
"road_title": "Fra melding til ferdig",
"road_description": "Steget legges inn, så arbeides det med, og til slutt merkes det som ferdig.",
"road_icon": "route",
"who_title": "Hvem leser det",
"who_description": "Styret følger eierskiftet her; ring styret i mellomtiden hvis noe ikke kan vente.",
"who_icon": "users",
"form_title": "Nytt steg i eierskiftet",
"form_description": "Skriv hva som er gjort, og legg ved dokumentet hvis det finnes et.",
"labels": {
"steg": "Hva er gjort",
"merknad": "Merknad",
"dokument": "Dokument"
},
"hints": {
"steg": "Skjøtet er sendt til tinglysing",
"merknad": "Megler venter på signatur fra den siste selgeren",
"dokument": "skjote-gullberg.pdf"
},
"question": "Hvor langt har eierskiftet kommet?",
"page_description": "Her legger styret inn stegene i overgangen til selskapet, så aksjonærene kan følge med. Banken får det den skal ha fra styret."
},
"questions/styret/husleie-sent.yaml": {
"question": "Hva skjer nå med husleia jeg registrerte?",
"description": "Betalingen er lagt inn og synlig for resten av styret, sammen med de andre rommenes status denne måneden.",
"next_heading": "Følg med i husleielista",
"next_description": "Der ser dere hvilke rom som har betalt og hva som er purret på."
},
"questions/styret/husleie.yaml": {
"heading": "Husleia for måneden, samlet ett sted",
"description": "Legg inn hva som er betalt per rom, så aksjonærene ser det og banken får sitt.",
"button": "Registrer husleie",
"encouragement": "Det tar et minutt per rom.",
"icon": "banknote",
"paused": "Tas ikke imot nå",
"requires_title": "Krever tittel",
"requires_description": "Krever beskrivelse",
"requires_icon": "Krever ikon",
"road_title": "Hva som skjer videre",
"road_description": "Du registrerer at husleia er betalt. Hvis et rom ikke har betalt, markeres det som purret til det kommer inn.",
"road_icon": "refresh-cw",
"who_title": "Hvem som ser dette",
"who_description": "Styret ser oversikten; har du spørsmål underveis, ta kontakt med styret på bl@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Registrer husleie",
"form_description": "Ha rom, måned og beløp klart.",
"labels": {
"rom": "Hvilket rom",
"maaned": "Hvilken måned",
"belop": "Beløp",
"betalt-dato": "Betalt dato"
},
"hints": {
"rom": "Rom 2",
"maaned": "Oktober 2026",
"belop": "4500",
"betalt-dato": "2026-10-05"
},
"question": "Hvilke rom har betalt husleie denne måneden?",
"page_description": "Her ser styret hvilke rom som har betalt husleia og hva som har gått til banken."
},
"questions/styret/index.yaml": {
"question": "Hva trenger styret å gjøre nå?",
"description": "Her samler styret alt som skjer i huset: søknader, feil, økonomi og eierskiftet, med knappene som flytter hver sak videre.",
"confirm": "Bekreft",
"buckets": {
"eierskifte": {
"heading": "Se hvor langt eierskiftet har kommet",
"description": "Følg stegene når huset går over til selskapet, med dokumentene på plass for aksjonærer og bank.",
"moves": {
"arbeid->ferdig": "Merk steget ferdig",
"open->arbeid": "Start arbeidet"
},
"list_title": "Steg i eierskiftet",
"list_note": "Hver rad er ett steg i overgangen, med merknad og dokument; knappen under flytter steget videre."
},
"feil": {
"heading": "Følg opp feil som er meldt inn",
"description": "Se hva leietakerne har funnet galt i huset, og send det videre til Klingenberg Bygg for befaring og overslag.",
"moves": {
"befaring->avvist": "Avvis feilen",
"open->avvist": "Avvis feilen",
"overslag->godkjent": "Godkjenn overslaget",
"overslag->avvist": "Avvis overslaget"
},
"list_title": "Meldte feil",
"list_note": "Hver rad er en feil meldt av en leietaker, med bilde og beskrivelse; knappene under sender den videre eller avviser den."
},
"husleie": {
"heading": "Hold oversikt over husleia hver måned",
"description": "Se hvilke rom som har betalt, og purr dem som ikke har gjort det.",
"moves": {
"open->betalt": "Merk som betalt",
"open->purret": "Send purring",
"purret->betalt": "Merk som betalt"
},
"list_title": "Husleie per rom",
"list_note": "Hver rad er husleia for ett rom én måned, med beløp og betalingsdato; knappene under merker betalt eller sender purring."
},
"liste": {
"heading": "Styr lista over det som er bra, mangler og er planlagt",
"description": "Vedta punktene, sett pris og tid, og publiser dem som leietakerne skal se.",
"moves": {
"open->vedtatt": "Vedta punktet",
"open->forkastet": "Forkast punktet",
"vedtatt->publisert": "Publiser punktet",
"vedtatt->forkastet": "Forkast punktet"
},
"list_title": "Punkter på lista",
"list_note": "Hver rad er ett punkt med slag, beskrivelse, kostnad og tid; knappene under vedtar, publiserer eller forkaster det."
},
"nedbetalingsplaner": {
"heading": "Følg nedbetalingsplanen fra aksjonærene til banken",
"description": "Følg planen fra forslag til bankens svar, med pengestrømmen alle fire aksjonærene står bak.",
"moves": {
"godkjent->sendt": "Send til banken",
"sendt->endringer": "Be om endringer",
"sendt->innvilget": "Merk som innvilget"
},
"list_title": "Nedbetalingsplaner",
"list_note": "Hver rad er en plan med lånebeløp, avdrag og pengestrøm; knappene under sender den til banken eller merker svaret."
},
"overslag": {
"heading": "Ta stilling til overslag fra Klingenberg Bygg",
"description": "Se beløpet for hver feil som er sett på, og godkjenn det som skal inn i regnskapet.",
"moves": {
"open->godkjent": "Godkjenn overslaget",
"open->avvist": "Avvis overslaget"
},
"list_title": "Overslag",
"list_note": "Hver rad er et overslag med beløp og hva det dekker; knappene under godkjenner eller avviser det."
},
"rapporter": {
"heading": "Send kvartalsrapporten til aksjonærene og banken",
"description": "Samle tallene for kvartalet, og send rapporten videre når den er klar.",
"moves": {
"open->sendt": "Send rapporten"
},
"list_title": "Kvartalsrapporter",
"list_note": "Hver rad er rapporten for ett kvartal, med leieinntekter, kostnader og saldo; knappen under sender den."
},
"rom": {
"heading": "Vis ledige rom frem til studentene",
"description": "Legg ut rommet som er blitt ledig, og merk det utleid når noen har flyttet inn.",
"moves": {
"open->utleid": "Merk som utleid"
},
"list_title": "Rom",
"list_note": "Hver rad er et rom med bilde og beskrivelse; knappen under merker det utleid."
},
"romsoknader": {
"heading": "Følg opp søknader om rom",
"description": "Se hvem som har søkt og hva de gjør til daglig (studenter går foran), sett opp visning, og før søknaden frem til signert leieavtale.",
"moves": {
"akseptert->signert": "Merk som signert",
"open->visning": "Sett opp visning",
"open->avslatt": "Avslå søknaden",
"tilbud->avslatt": "Avslå søknaden",
"visning->tilbud": "Gi tilbud om rom",
"visning->avslatt": "Avslå søknaden"
},
"list_title": "Søknader om rom",
"list_note": "Hver rad er en søknad, med hva søkeren gjør til daglig og ønsket innflytting. Studenter går foran. Knappene fører søknaden videre eller avslår den."
},
"saker": {
"heading": "Saker aksjonærene har meldt",
"description": "Ta saker aksjonærene melder inn, sett dem på sakslisten, og vedta eller avvis dem.",
"moves": {
"open->sakslisten": "Sett på sakslisten",
"open->avvist": "Avvis saken",
"sakslisten->vedtatt": "Vedta saken",
"sakslisten->avvist": "Avvis saken"
},
"list_title": "Saker",
"list_note": "Hver rad er en sak meldt inn av en aksjonær, med tittel og beskrivelse; knappene under setter den på sakslisten, vedtar eller avviser den."
},
"uker": {
"heading": "Bekreft ukene aksjonærene vil bruke huset",
"description": "Se hvem som har meldt seg på en uke, og bekreft eller avslå den.",
"moves": {
"open->bekreftet": "Bekreft uken",
"open->avslatt": "Avslå uken"
},
"list_title": "Ønskede uker",
"list_note": "Hver rad er en uke en aksjonær har meldt seg på, med datoer og merknad; knappene under bekrefter eller avslår den."
}
},
"links": {
"ledig-rom": {
"heading": "Legg ut et rom som er blitt ledig",
"description": "Skriv tittel, legg ved bilde og beskriv rommet, så studentene kan søke.",
"button": "Legg ut rom"
},
"liste": {
"heading": "Legg til et nytt punkt på lista",
"description": "Skriv hva som er bra, mangler eller planlagt, med pris og tid om du har det.",
"button": "Legg til punkt"
},
"rapport": {
"heading": "Lag kvartalets rapport",
"description": "Fyll inn tallene for kvartalet, så aksjonærene og banken får det de skal ha.",
"button": "Lag rapport"
},
"nedbetalingsplan": {
"heading": "Lag en ny nedbetalingsplan",
"description": "Skriv lånebeløp, avdrag og pengestrøm, så banken har noe å ta stilling til.",
"button": "Lag plan"
},
"husleie": {
"heading": "Registrer husleie for et rom",
"description": "Før inn beløp og måned når husleia for et rom er kommet inn.",
"button": "Registrer husleie"
},
"eierskifte": {
"heading": "Legg til et steg i eierskiftet",
"description": "Beskriv steget og legg ved dokument, så aksjonærene ser fremgangen.",
"button": "Legg til steg"
}
},
"invites": {
"aksjonaerer": {
"heading": "Gi en aksjonær tilgang til sin side",
"description": "Send en invitasjon så aksjonæren får konto og engangslenke.",
"button": "Send invitasjon",
"form_title": "Inviter aksjonær",
"form_description": "Ha navn, e-post og telefon klart.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"klingenberg": {
"heading": "Gi Klingenberg Bygg tilgang til feilmeldinger",
"description": "Send en invitasjon så de får konto og engangslenke.",
"button": "Send invitasjon",
"form_title": "Inviter Klingenberg Bygg",
"form_description": "Ha navn, e-post og telefon klart.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"leietakere": {
"heading": "Gi en leietaker tilgang til sin side",
"description": "Send en invitasjon så leietakeren får konto og engangslenke.",
"button": "Send invitasjon",
"form_title": "Inviter leietaker",
"form_description": "Ha navn, e-post og telefon klart.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"styret": {
"heading": "Gi et styremedlem tilgang til desken",
"description": "Send en invitasjon så styremedlemmet får konto og engangslenke.",
"button": "Send invitasjon",
"form_title": "Inviter styremedlem",
"form_description": "Ha navn, e-post og telefon klart.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Se hvem som er invitert og hvor langt de har kommet",
"description": "Følg med på invitasjoner som er sendt, og send en ny lenke om den gamle har løpt ut.",
"done": "Tatt i bruk",
"withdraw": "Trekk tilbake",
"again": "Send på nytt",
"list_title": "Sendte invitasjoner",
"list_note": "Hver rad er en invitasjon sendt til en person; knappene under trekker den tilbake eller sender den på nytt."
},
"suggestions": {
"heading": "Ta stilling til foreslåtte endringer",
"description": "Se hva andre har foreslått å endre på sidene de har ansvar for.",
"list_title": "Foreslåtte endringer",
"list_note": "Hver rad er en foreslått endring; knappene under tar den til vurdering, godtar eller avslår den.",
"looking": "Tar en titt",
"changed": "Endret",
"declined": "Avslått"
}
},
"questions/styret/ledig-rom-sent.yaml": {
"question": "Hva skjer med rommet du nettopp la ut?",
"description": "Rommet er lagt inn og synlig for dem som søker. Dere ser det under ledige rom til det er leid ut.",
"next_heading": "Studenter kan søke på rommet nå",
"next_description": "Søknader kommer inn etter hvert, og dere i styret ser dem sammen med de andre søknadene.",
"see_all": {
"heading": "Se alle rommene og status",
"description": "Full oversikt over hvilke rom som er ledige og hvilke som er leid ut.",
"button": "Se rommene"
}
},
"questions/styret/ledig-rom.yaml": {
"heading": "Meld inn et rom som er ledig",
"description": "Når et rom står tomt, legger du det ut her, så ser de som søker det først.",
"button": "Legg ut rommet",
"encouragement": "Så snart det er lagt ut, kan folk søke på det.",
"icon": "key",
"paused": "Ta rommet ned fra listen for nå",
"requires_title": "Krever tittel",
"requires_description": "Krever beskrivelse",
"requires_icon": "Krever bilde",
"road_title": "Fra ledig til utleid",
"road_description": "Rommet ligger ute til det er utleid.",
"road_icon": "key",
"who_title": "Hvem ser dette",
"who_description": "Studenter som søker rom ser dette, og styret kan nås på bl@klingenbergbygg.no i mellomtiden.",
"who_icon": "users",
"form_title": "Om rommet",
"form_description": "Et bilde og en kort beskrivelse er nok til at de som søker kan se seg om.",
"labels": {
"tittel": "Tittel på rommet",
"bilde": "Bilde av rommet",
"om": "Om rommet"
},
"hints": {
"tittel": "Rom i 2. etasje, mot gata",
"bilde": "",
"om": "Lyst rom med eget bad, nær kjøkken og felles stue."
},
"question": "Hvilket rom er ledig nå?",
"page_description": "Legg ut et rom som har blitt ledig, så de som søker kan se det."
},
"questions/styret/liste-sent.yaml": {
"question": "Hva skjer med punktet nå?",
"description": "Forslaget ditt er lagret. Styret tar det opp til behandling.",
"next_heading": "Styret vedtar eller forkaster punktet",
"next_description": "Det blir stående til styret har tatt stilling, med pris og tid synlig for alle.",
"see_all": {
"heading": "Se hele lista",
"description": "Alt som er meldt inn, vedtatt eller publisert ligger samlet.",
"button": "Til lista"
}
},
"questions/styret/liste.yaml": {
"heading": "Listen over huset og planene",
"description": "Legg til det som er bra, det som mangler og det som er planlagt, med pris og tid der dere vet det.",
"button": "Legg til punkt",
"encouragement": "Det havner på listen med en gang, og dere kan vedta det senere.",
"icon": "list",
"paused": "Listen er stengt for nye punkter",
"requires_title": "Krev tittel",
"requires_description": "Krev beskrivelse",
"requires_icon": "Krev ikon",
"road_title": "Fra forslag til vedtak",
"road_description": "Punktet legges inn, styret tar stilling til det og vedtar eller forkaster, og det som vedtas blir publisert på listen alle ser.",
"road_icon": "check-circle",
"who_title": "Hvem ser listen",
"who_description": "Styret fører og vedtar listen; ta kontakt på bl@klingenbergbygg.no om noe haster.",
"who_icon": "users",
"form_title": "Nytt punkt på listen",
"form_description": "Skriv kort hva det gjelder, og ta med pris og tid hvis dere har tall.",
"labels": {
"tittel": "Tittel",
"slag": "Hva slags punkt",
"beskrivelse": "Beskrivelse",
"kostnad": "Kostnad",
"naar": "Når"
},
"hints": {
"tittel": "Nytt kjøkken i andre etasje",
"slag": "Mangler",
"beskrivelse": "Kjøkkenet er slitt og trenger nye hvitevarer og benkeplate.",
"kostnad": "ca. 45 000 kr",
"naar": "Våren 2027"
},
"question": "Hva bør med på listen over huset og driften?",
"page_description": "Punktene går inn på listen styret fører over det som er bra, det som mangler og det som er planlagt."
},
"questions/styret/nedbetalingsplan-sent.yaml": {
"question": "Hvor er nedbetalingsplanen nå?",
"description": "Den ligger nå hos aksjonærene. Når alle fire har godkjent den, sender styret den til banken og fører inn svaret.",
"next_heading": "Følg svaret fra banken",
"next_description": "Når banken svarer, oppdaterer dere planen med godkjent, innvilget eller endringer – så de andre aksjonærene ser hvor den står."
},
"questions/styret/nedbetalingsplan.yaml": {
"heading": "Nedbetalingsplanen alle fire står bak",
"description": "Legg fram planen for lånet, så banken og aksjonærene vet hvordan det skal betales ned.",
"button": "Send plan",
"encouragement": "Styret tar den videre til banken så snart alle fire har sagt ja.",
"icon": "banknote",
"paused": "Satt på pause",
"requires_title": "Krever tittel",
"requires_description": "Krever beskrivelse",
"requires_icon": "Krever ikon",
"road_title": "Veien videre",
"road_description": "Planen går til godkjenning hos aksjonærene, så sendes den til banken. Sier banken ja, er den innvilget; ber den om endringer, kommer det en ny runde.",
"road_icon": "route",
"who_title": "Hvem ser den",
"who_description": "Styret og aksjonærene ser planen; spørsmål før den er sendt går til styret på bl@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Nedbetalingsplan",
"form_description": "Ha lånebeløp, avdrag og når det første trekket kommer, klart.",
"labels": {
"tittel": "Tittel",
"laanebelop": "Lånebeløp",
"maanedlig-avdrag": "Månedlig avdrag",
"forste-trekk": "Første trekk",
"pengestrom": "Hvordan pengene flyter",
"dokument": "Dokument"
},
"hints": {
"tittel": "Nedbetalingsplan 2026",
"laanebelop": "2 400 000 kr",
"maanedlig-avdrag": "18 000 kr",
"forste-trekk": "1. desember 2026",
"pengestrom": "Husleie dekker avdraget, timeshare går til vedlikehold, overskudd settes av til buffer",
"dokument": "Last opp plan eller avtaleutkast"
},
"question": "Hvordan skal lånet betales ned, og står alle fire bak det?",
"page_description": "Planen går til aksjonærene og banken, og viser hvordan lånet skal betales ned."
},
"questions/styret/rapport-sent.yaml": {
"question": "Hvor går rapporten nå?",
"description": "Den er sendt. Aksjonærene får tallene, og det som skal til banken går dit.",
"next_heading": "Neste rapport venter til kvartalet er omme",
"next_description": "Neste rapport legger styret inn når kvartalet er omme."
},
"questions/styret/rapport.yaml": {
"heading": "Rapporten som viser hvordan det går",
"description": "Hvert kvartal legger styret inn tallene, så aksjonærene ser status og banken får det den skal ha.",
"button": "Send rapport",
"encouragement": "Så snart den er sendt, ligger den klar for aksjonærene og banken.",
"icon": "file-text",
"paused": "Sett rapportering på pause",
"requires_title": "Krev tittel",
"requires_description": "Krev beskrivelse",
"requires_icon": "Krev ikon",
"road_title": "Hva skjer videre",
"road_description": "Rapporten sendes til aksjonærene og banken. Når den er ute, merkes den som sendt.",
"road_icon": "send",
"who_title": "Hvem leser den",
"who_description": "Styret står for rapportene; har du spørsmål underveis, ta kontakt på bl@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Kvartalets tall",
"form_description": "Ha regnskapstallene for kvartalet klare før du fyller ut.",
"labels": {
"periode": "Hvilket kvartal gjelder dette",
"leieinntekter": "Leieinntekter",
"timeshare": "Inntekter fra ukene",
"driftskostnader": "Driftskostnader",
"renter-og-avdrag": "Renter og avdrag til banken",
"saldo": "Saldo ved kvartalets slutt",
"kommentar": "Kommentar til tallene",
"dokument": "Regnskapsdokument"
},
"hints": {
"periode": "3. kvartal 2026",
"leieinntekter": "54 000",
"timeshare": "12 000",
"driftskostnader": "18 500",
"renter-og-avdrag": "22 000",
"saldo": "145 000",
"kommentar": "Ett rom stod tomt i juli, ellers som normalt",
"dokument": "kvartalsregnskap-q3-2026.pdf"
},
"question": "Hvordan gikk kvartalet, i tall?",
"page_description": "Rapporten går til aksjonærene og banken, så de ser inntekter, kostnader og saldo for kvartalet."
},
"site.yaml": {
"invite": {
"subject": "Du er invitert til Gullberg",
"body": "Hei {name},\n\nDu er invitert inn på Gullberg sin side, som {group}.\n\nVelg passord her før {expires}:\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.\n\nHilsen styret i Gullberg"
}
}
}
+622
View File
@@ -0,0 +1,622 @@
{
"questions/index.yaml": {
"question": "Hvordan inkluderer vi ditt bygg i framtiden?",
"description": "Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt** og godkjent **søknad** for ditt bygg.",
"opener": {
"heading": "Dette gjør vi",
"description": "For kommuner, borettslag, bedrifter og private.",
"features": {
"tilstand": {
"heading": "Tilstand, målt og dokumentert",
"description": "Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med befaring, materialprøver og termofotografering. Skal bygget rives, leverer vi miljøsaneringsrapport; energibruk og besparelser hører med i vurderingen. Vi har samarbeidet med [Drammen Kommune](https://drammen.kommune.no), [Nordre Follo Kommune](https://nordrefollo.kommune.no) og [Bølerskogens Borettslag](https://bolerskogenborettslag.no/).",
"icon": "clipboard-check"
},
"soknad": {
"heading": "Prosjekt og søknad, med ansvarsrett",
"description": "Vi lager arbeidsbeskrivelse opp mot [NS 3420](https://standard.no/fagomrader/ns-3420-/) og fremdriftsplan, og utarbeider byggesøknaden. Vi har sentral [godkjenning](https://sgregister.dibk.no/enterprises/976015711) innen flere fagområder og kan stå med det offentligrettslige ansvaret. Samarbeidspartner: [Westra Engineering AS](https://www.westra-engineering.no/).",
"icon": "file-check"
}
}
},
"needs": {
"tilstand": {
"heading": "Få kartlagt byggets tilstand",
"description": "Vi utarbeider tilstandsrapporter som representerer bygget med hensyn til fremtidige mål.",
"button": "Kom i gang",
"encouragement": "Mindre stillstand etter dette.",
"icon": "clipboard-list",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om bygget",
"form_description": "Det vi trenger for å komme på befaring. Har du tegninger, bilder eller video, tar vi gjerne imot dem.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"eiendom": "Hva slags eiendom er det?",
"mangler": "Hvilke mangler ønskes adressert?",
"tegninger": "Bilder, tegninger, skisser eller video"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"mangler": "Forfall, skader eller annet…"
}
},
"prosjekt": {
"heading": "Prosjektér dine planer med oss",
"description": "Ivareta eksisterende ressurser for et velfungerende og helhetlig bygg.",
"button": "Start samarbeidet",
"encouragement": "Gi det beste grunnlaget for utførelse.",
"icon": "ruler",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om planene",
"form_description": "Beskriv hva du ønsker for bygget. Har du noe digitalt å vise oss, blir det lettere å vite hvor vi skal starte.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"planer": "Hva er dine planer for bygget?",
"oppstart": "Ønsket start",
"skisser": "Bilder, CAD-filer og skisser"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"planer": "Ønsker for bygget…"
}
},
"soknad": {
"heading": "Ta det første spadetaket med en godkjent søknad",
"description": "Er bygget ferdig prosjektert, kan vi utarbeide en byggesøknad og utførelsesbeskrivelse for offentlig godkjenning.",
"button": "Send henvendelse",
"encouragement": "Nesten som å ha gravemaskin!",
"icon": "file-text",
"road_title": "Slik går det videre",
"road_description": "Vi går gjennom det du har, avtaler befaring eller forhåndskonferanse, og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger søknaden og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om tiltaket",
"form_description": "Hva skal bygges, og hvor. Avhengig av størrelsen kan vi holde forhåndskonferanse med kommunen først.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"tiltak": "Hva går tiltaket ut på?",
"tegninger": "Tegninger og prosjektering"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
},
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
}
},
"who": {
"heading": "Hvem består Klingenberg Bygg av?",
"description": "Mennesker, erfaringer og verdier.",
"button": "Bli kjent med oss"
}
},
"questions/received/tilstand.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om tilstandsrapport er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/prosjekt.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om prosjektering er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/soknad.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om byggesøknad er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/decide/tilstand.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på tilstandsrapporten har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/prosjekt.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på prosjekteringen har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/soknad.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på byggesøknaden har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/kontor/index.yaml": {
"question": "Hva ligger på pulten nå?",
"description": "Alle henvendelser og oppdrag, fra første melding til levert dokument.",
"confirm": "Bekreft",
"buckets": {
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Henvendelser om tilstandsrapport, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Henvendelser om prosjektering, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Henvendelser om byggesøknad, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Byggesøknader",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"leveranser": {
"heading": "Leverte dokumenter",
"description": "Rapporter, tegninger og vedtak som er sendt til kunder.",
"moves": {
"open->arkivert": "Arkiver"
},
"list_title": "Leveranser",
"list_note": "Hver rad er ett dokument en kunde har fått. Arkiver når saken er avsluttet."
},
"ansatte": {
"heading": "Ansatte på forsiden",
"description": "Menneskene slik besøkende ser dem. Hver retter sitt eget kort; herfra kan et kort tas ned.",
"moves": {
"open->sluttet": "Tatt av forsiden"
},
"list_title": "Ansatte",
"list_note": "Hver rad er én person på forsiden."
},
"erfaringer": {
"heading": "Erfaringer",
"description": "Oppdragene slik besøkende ser dem.",
"moves": {
"open->fjernet": "Fjern"
},
"list_title": "Erfaringer",
"list_note": "Hver rad er ett oppdrag som vises nå."
}
},
"links": {
"leveranse": {
"heading": "Lever et dokument til en kunde",
"description": "Rapporten, tegningene eller vedtaket. Kunden får e-post og henter det innlogget.",
"button": "Ny leveranse"
},
"ansatt": {
"heading": "Hvordan presenterer du deg?",
"description": "Kortet ditt på forsiden: rolle, erfaring og portrett.",
"button": "Åpne"
},
"erfaring": {
"heading": "Hvilket oppdrag vil du vise fram?",
"description": "Et bilde og noen løse ord, blant erfaringene.",
"button": "Åpne"
}
},
"invites": {
"kontor": {
"heading": "Gi en kollega tilgang",
"description": "De får en konto og plass på kontoret, og ser de samme sakene som deg.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"kunder": {
"heading": "Gi en kunde tilgang",
"description": "For en kunde som ikke kom inn gjennom et tilbud. De får en konto og ser sine egne oppdrag.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Sendte invitasjoner",
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"done": "Er inne",
"again": "Send på nytt",
"list_title": "Hvem som er invitert",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"suggestions": {
"heading": "Forslag til endringer på siden",
"description": "Det de som står som ansvarlige for et spørsmål, har foreslått å endre.",
"list_title": "Forslag",
"list_note": "Hver rad er ett forslag, med siden det gjelder.",
"looking": "Vi ser på det",
"changed": "Endringen er gjort",
"declined": "Gjøres ikke"
}
},
"questions/kunder/index.yaml": {
"question": "Hvor står oppdraget ditt?",
"description": "Her følger du dine egne oppdrag hos Klingenberg Bygg og henter dokumentene vi har levert.",
"confirm": "Bekreft",
"buckets": {
"leveranser": {
"heading": "Dokumenter til deg",
"description": "Rapporter, tegninger og vedtak vi har levert. Trykk på filnavnet for å hente dokumentet.",
"moves": {},
"list_title": "Dokumenter",
"list_note": "Hver rad er ett dokument vi har levert til deg."
},
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Dine oppdrag om tilstandsrapport, og hvor langt de har kommet.",
"moves": {},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Dine oppdrag om prosjektering, og hvor langt de har kommet.",
"moves": {},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Dine oppdrag om byggesøknad, og hvor langt de har kommet.",
"moves": {},
"list_title": "Byggesøknader",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
}
}
},
"questions/kontor/leveranse.yaml": {
"heading": "Lever et dokument til en kunde",
"description": "Last opp rapporten, tegningene eller vedtaket. Kunden får e-post og henter dokumentet innlogget.",
"button": "Lever",
"encouragement": "Kunden får beskjed med en gang.",
"icon": "send",
"road_title": "Hva skjer",
"road_description": "Dokumentet legges på kundens side, og kunden får e-post om at det ligger der.",
"road_icon": "route",
"who_title": "Hvem ser dette",
"who_description": "Kunden med den e-postadressen du oppgir, og kontoret.",
"who_icon": "users",
"form_title": "Leveransen",
"form_description": "Bruk den e-postadressen kunden logger inn med.",
"labels": {
"email": "Kundens e-post",
"hva": "Hva er levert",
"dokument": "Dokument"
},
"hints": {
"email": "kunde@eksempel.no",
"hva": "Tilstandsrapport for Storgata 12",
"dokument": ""
},
"question": "Hvilket dokument er klart til kunden?",
"page_description": "Ferdige rapporter, tegninger og vedtak leveres her."
},
"questions/kontor/leveranse-sent.yaml": {
"question": "Hva skjer nå?",
"description": "Dokumentet er levert.",
"next_heading": "Kunden har fått beskjed",
"next_description": "Kunden har fått e-post og finner dokumentet på sin side. Leveransen ligger i listen på pulten til du arkiverer den."
},
"aggregates.yaml": {
"tilstandsrapporter": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om tilstandsrapport for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på tilstandsrapport for {adresse}",
"body": "Hei {name},\n\nTilbudet på tilstandsrapport for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på tilstandsrapport for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Tilstandsrapport for {adresse} er levert",
"body": "Hei {name},\n\nTilstandsrapport for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"prosjekteringer": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om prosjektering for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på prosjektering for {adresse}",
"body": "Hei {name},\n\nTilbudet på prosjektering for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på prosjektering for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Prosjektering for {adresse} er levert",
"body": "Hei {name},\n\nProsjektering for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"soknader": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om byggesøknad for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på byggesøknad for {adresse}",
"body": "Hei {name},\n\nTilbudet på byggesøknad for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på byggesøknad for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Byggesøknad for {adresse} er levert",
"body": "Hei {name},\n\nByggesøknad for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"leveranser": {
"open": {
"subject": "{hva} ligger klar til deg",
"body": "Hei,\n\n{hva} er levert fra Klingenberg Bygg. Logg inn og hent dokumentet her:\n{site}/kunder\n\nSvar på denne e-posten om noe mangler.\n\nKlingenberg Bygg"
}
}
},
"site.yaml": {
"invite": {
"subject": "Din innlogging hos {site_name}",
"body": "Hei {name},\n\nDu har fått en konto hos {site_name}, med plass blant {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}."
}
},
"questions/kontor/ansatt-sent.yaml": {
"question": "Kortet ditt er lagret",
"description": "Slik står du nå på forsiden.",
"next_heading": "Vil du endre noe?",
"next_description": "Åpne «Hvordan presenterer du deg?» igjen: kortet ditt er fylt ut med det du har, og det du lagrer erstatter det.",
"see_all": {
"heading": "Se alle ansatte",
"description": "",
"button": "Åpne"
}
},
"questions/kontor/ansatt.yaml": {
"heading": "Kortet ditt",
"description": "Rett det du vil og lagre. Du har ett kort, og det du lagrer erstatter det som står der nå.",
"button": "Lagre kortet",
"encouragement": "",
"icon": "id-card",
"requires_title": "",
"requires_description": "",
"requires_icon": "",
"road_title": "Når du lagrer",
"road_description": "Kortet ditt på forsiden endres med en gang.",
"road_icon": "route",
"who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Navnet ditt er det du er logget inn med.",
"who_icon": "users",
"form_title": "Kortet ditt",
"form_description": "",
"labels": {
"name": "Navn",
"rolle": "Rolle",
"erfaring": "Erfaring",
"portrett": "Portrett"
},
"hints": {
"name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
"erfaring": "Utdanning, fagområder, bygg du er stolt av. Du kan skrive **fet**, *kursiv*, lister med - og [lenker](https://…).",
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
},
"question": "Hvordan presenterer du deg?",
"page_description": "Det du skriver her, står på kortet ditt blant menneskene på forsiden."
},
"questions/status/ansatte/index.yaml": {
"question": "Hvem består Klingenberg Bygg av?",
"description": "Menneskene som går befaringene, skriver rapportene og står for søknadene.",
"heading": "Menneskene",
"list_description": "",
"list_title": "Menneskene",
"list_note": "",
"empty": "Snart presenterer vi oss her.",
"answer": ""
},
"questions/hvem.yaml": {
"question": "Hvem består Klingenberg Bygg av?",
"description": "Menneskene som gjør jobben, oppdragene vi har gjort, og det vi står for.",
"presents": {
"ansatte": {
"heading": "Menneskene",
"description": "De som går befaringene, skriver rapportene og står for søknadene.",
"empty": "Snart presenterer vi oss her."
},
"erfaringer": {
"heading": "Erfaringer",
"description": "Et utvalg oppdrag, med bilder.",
"empty": "Snart viser vi fram oppdrag her."
}
},
"values": {
"heading": "Verdier",
"description": "Det vi står for.",
"items": {
"mal-to-ganger": {
"heading": "Mål to ganger",
"description": "Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.",
"icon": "ruler"
},
"bygget-forst": {
"heading": "Bygget først",
"description": "Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.",
"icon": "building"
},
"ingen-overraskelser": {
"heading": "Ingen overraskelser i veggen",
"description": "Det vi finner, får du vite. Også sprekkene.",
"icon": "search"
},
"vannet": {
"heading": "Vannet finner en vei",
"description": "Vannet finner alltid en vei. Derfor leter vi etter den først.",
"icon": "droplet"
}
}
}
},
"questions/kontor/erfaring.yaml": {
"heading": "Et oppdrag med bilde",
"description": "Et bilde og noen løse ord om hva vi gjorde. Det vises med en gang.",
"button": "Vis fram",
"encouragement": "",
"icon": "image-plus",
"paused": "",
"requires_title": "",
"requires_description": "",
"requires_icon": "",
"road_title": "Når du sender",
"road_description": "Oppdraget vises blant erfaringene med en gang, til noen på kontoret fjerner det.",
"road_icon": "route",
"who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Skriv ikke noe om kunden de ikke ville lest selv.",
"who_icon": "users",
"form_title": "Oppdraget",
"form_description": "",
"labels": {
"tittel": "Hva var det?",
"bilde": "Bilde",
"om": "Noen ord om det"
},
"hints": {
"tittel": "For eksempel: Brannstasjonen i Ytre Enebakk",
"bilde": "Et bilde fra oppdraget, gjerne liggende",
"om": "Hva vi gjorde, og hva som var verdt å gjøre skikkelig. **Fet**, *kursiv*, lister og lenker går an."
},
"question": "Hvilket oppdrag vil du vise fram?",
"page_description": "Det du legger inn her, står blant erfaringene på «Hvem består Klingenberg Bygg av?»."
},
"questions/kontor/erfaring-sent.yaml": {
"question": "Oppdraget vises nå",
"description": "Det står blant erfaringene på «Hvem består Klingenberg Bygg av?».",
"next_heading": "Skal det ned igjen?",
"next_description": "Trykk «Fjern» på raden i listen over erfaringer her på kontoret. Det forsvinner fra listen ti minutter etter.",
"see_all": {
"heading": "Se alle erfaringer",
"description": "",
"button": "Åpne"
}
},
"questions/status/erfaringer/index.yaml": {
"question": "Hva har Klingenberg Bygg gjort?",
"description": "Et utvalg oppdrag, med bilder.",
"heading": "Erfaringer",
"list_description": "",
"list_title": "Erfaringer",
"list_note": "",
"empty": "Snart viser vi fram oppdrag her.",
"answer": ""
}
}
+17
View File
@@ -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]
+41
View File
@@ -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
+25
View File
@@ -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 }
+21
View File
@@ -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]
+49
View File
@@ -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]
+15
View File
@@ -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]
+2
View File
@@ -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
+144
View File
@@ -0,0 +1,144 @@
// Pages move the way their addresses do.
//
// Deeper in the address comes in from the right and going back up
// comes in from the left: /kontor/ansatt sits to the right of /kontor.
// A page at the same depth comes from below, or from above when it
// sorts first, so the index is on top. The cards on a page leave as
// islands of their own and the next page's cards arrive one after
// another.
//
// The router swaps the page after its data has arrived, and in between
// shows nothing at all. So the click is held here, before the router
// sees it: the browser takes its picture of the page as it is, the
// click is handed on, and the transition waits until the new page has
// replaced the old one. Only transforms and opacity are animated, on
// layers the browser composites itself. Without View Transitions, or
// with reduced motion asked for, nothing here does anything and the
// router navigates as it always did.
(() => {
if (!document.startViewTransition) return;
const still = matchMedia("(prefers-reduced-motion: reduce)");
const MAX_CARDS = 8;
const WAIT_MS = 2500;
const handed = new WeakSet();
const segments = (path) => path.split("/").filter(Boolean);
// forward, back, down or up: which way the new page comes from.
function direction(from, to) {
const a = segments(from);
const b = segments(to);
if (b.length > a.length) return "forward";
if (b.length < a.length) return "back";
return to < from ? "up" : "down";
}
// Only the cards on screen become islands: each is a picture the
// browser has to take and hold, and one nobody sees costs the same.
function inView(card) {
const r = card.getBoundingClientRect();
return r.bottom > 0 && r.top < innerHeight;
}
function name(cards, prefix) {
cards.filter(inView).slice(0, MAX_CARDS).forEach((card, i) => {
card.style.viewTransitionName = `${prefix}-${i + 1}`;
});
}
function unname() {
document.querySelectorAll(".alt-card").forEach((card) => {
card.style.viewTransitionName = "";
});
}
// Resolves once the page that was showing has been replaced by the
// one at `path`, or after WAIT_MS whatever happened.
function replaced(old, path) {
return new Promise((resolve) => {
const done = () => {
observer.disconnect();
clearTimeout(timer);
resolve();
};
const check = () => {
if (location.pathname !== path) return;
if (document.querySelector("main.loading")) return;
if (old && old.isConnected) return;
if (!document.querySelector(".alternatives, main.not-found")) return;
done();
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(done, WAIT_MS);
check();
});
}
function move(from, to, handOn) {
const root = document.documentElement;
const old = document.querySelector(".alternatives");
root.dataset.motion = direction(from, to);
name([...document.querySelectorAll(".alt-card")], "pm-out");
const transition = document.startViewTransition(async () => {
unname();
handOn();
await replaced(old, to);
name([...document.querySelectorAll(".alt-card")], "pm-in");
});
transition.finished.finally(() => {
unname();
delete root.dataset.motion;
});
}
document.addEventListener(
"click",
(ev) => {
if (still.matches || handed.has(ev) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = ev.target.closest && ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return;
if ((a.getAttribute("rel") || "").split(/\s+/).includes("external")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin || url.pathname === location.pathname) return;
ev.preventDefault();
ev.stopImmediatePropagation();
move(location.pathname, url.pathname, () => {
const again = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0 });
handed.add(again);
a.dispatchEvent(again);
});
},
true,
);
// Back and forward: the address has already changed when this runs,
// so the router is told a moment later, inside the transition.
let shown = location.pathname;
window.addEventListener(
"popstate",
(ev) => {
const to = location.pathname;
const from = shown;
shown = to;
if (still.matches || handed.has(ev) || from === to) return;
ev.stopImmediatePropagation();
move(from, to, () => {
const again = new PopStateEvent("popstate", { state: ev.state });
handed.add(again);
window.dispatchEvent(again);
});
},
true,
);
// Keep `shown` right after the router's own pushes.
for (const method of ["pushState", "replaceState"]) {
const original = history[method].bind(history);
history[method] = (...args) => {
const result = original(...args);
shown = location.pathname;
return result;
};
}
})();
+158
View File
@@ -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.
+127
View File
@@ -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
View File
@@ -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.
+118 -16
View File
@@ -51,7 +51,17 @@ action invite appliesTo { principal: [User], resource: [Group] };
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Principal {
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
User { sub: String, groups: Vec<String> },
/// 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.
@@ -60,12 +70,12 @@ pub enum Principal {
impl Principal {
pub fn anonymous() -> Self {
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
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() },
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
None => Principal::anonymous(),
}
}
@@ -221,16 +231,27 @@ impl Policy {
}
}
if let Some(st) = &alt.self_transition {
let from = crate::answers::OPEN_STATE;
emit(
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
format!(
"permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};",
quote(&st.bucket), quote(from), quote(&st.to)
),
&mut text, &mut rules,
);
// One rule per state the move may be made from: a
// person stays in dialogue with their case for as
// long as it runs, and `from` says how long that
// is. 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(
@@ -300,7 +321,7 @@ impl Policy {
};
let mut entities: Vec<Entity> = Vec::new();
let principal_uid = match principal {
Principal::User { sub, groups } => {
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()));
@@ -433,6 +454,7 @@ 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);
@@ -447,7 +469,22 @@ pub async fn serve(
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(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context),
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());
@@ -476,7 +513,7 @@ mod tests {
}
fn user(groups: &[&str]) -> Principal {
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
}
#[test]
@@ -536,6 +573,71 @@ mod tests {
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
}
// The applicant written to for a missing drawing sends it from
// `incomplete`, and may withdraw at any point up to the signature.
// Before `from`, every one of those moves was compiled as if it
// could only be made on the day the case arrived.
#[test]
fn a_submitter_stays_in_dialogue_for_as_long_as_the_case_runs() {
let files = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: Apply\n record_as: cases\n - name: Send the papers\n self_transition: { bucket: cases, to: complete, from: [open, incomplete], label: Send }\n - name: Withdraw\n self_transition: { bucket: cases, to: withdrawn, from: [open, incomplete, complete], label: Withdraw }\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
let aggregates = parse_aggregates_yaml(
"aggregates:\n - bucket: cases\n initial: open\n states:\n open: { event: a }\n incomplete: { event: b }\n complete: { event: c }\n withdrawn: { event: d }\n transitions:\n open: [incomplete, complete, withdrawn]\n incomplete: [complete, withdrawn]\n complete: [withdrawn]\n",
)
.unwrap();
let p = Policy::from_content(&questions, &aggregates);
assert_eq!(p.validate(), Ok(()), "{}", p.text);
let bucket = Resource::Bucket { name: "cases".into() };
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" });
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow);
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow);
// And only where it was said: the paperwork cannot be sent
// after the case is already withdrawn.
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow);
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow);
}
// Omitted, `from` is the initial state alone - what every
// unsubscribe link written before this existed already meant.
#[test]
fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() {
let p = site();
assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed"));
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
}
// "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();
+110 -7
View File
@@ -41,15 +41,58 @@ pub struct AggregateSchema {
/// that enters it, so content decides when a person hears
/// something and the mailer never has to know a site.
pub mail_for_state: HashMap<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>,
/// How long a record stays in the list once it has entered a
/// state, when that is not for ever: a card taken off the front
/// page leaves the desk's list a little later, instead of standing
/// there greyed out. The event log keeps it.
pub leaves_for_state: HashMap<String, LeavesSpec>,
}
/// `leaves_after: { minutes: 10 }` on a state: how long a record that
/// has entered it stays in its bucket's list. Days, hours and minutes
/// add up; the sweep that takes it out runs every minute.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LeavesSpec {
#[serde(default, skip_serializing_if = "is_zero")]
pub days: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub hours: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub minutes: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
impl LeavesSpec {
pub fn millis(&self) -> i64 {
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
}
}
/// 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) or one fixed address. Subject and body are
/// templates: `{field}` for any field of the record, 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.
/// 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 {
@@ -64,6 +107,19 @@ impl MailSpec {
}
}
/// A move made by a clock: `after: { days: 42, to: no_answer }` on the
/// state a record is waiting in. The wait is measured from when the
/// record entered that state, and `to` must be an edge the same schema
/// already declares - a deadline may only do what a person could have
/// done. Days, because every deadline a public body keeps is in days
/// and a shorter unit would invite this to be used as a job queue.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AfterSpec {
pub days: u32,
pub to: String,
}
impl AggregateSchema {
pub fn has_state(&self, state: &str) -> bool {
self.event_for_state.contains_key(state)
@@ -73,6 +129,14 @@ impl AggregateSchema {
self.mail_for_state.get(state)
}
pub fn grants_for(&self, state: &str) -> Option<&str> {
self.grants_for_state.get(state).map(String::as_str)
}
pub fn after_for(&self, state: &str) -> Option<&AfterSpec> {
self.after_for_state.get(state)
}
pub fn allowed(&self, state: &str) -> &[String] {
self.transitions
.get(state)
@@ -86,6 +150,12 @@ struct RawState {
event: String,
#[serde(default)]
mail: Option<MailSpec>,
#[serde(default)]
grants: Option<String>,
#[serde(default)]
after: Option<AfterSpec>,
#[serde(default)]
leaves_after: Option<LeavesSpec>,
}
#[derive(Debug, serde::Deserialize)]
@@ -156,14 +226,44 @@ 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();
let mut leaves_for_state = HashMap::new();
for (state, raw_state) in raw_schema.states {
if let Some(leaves) = raw_state.leaves_after {
if leaves.millis() == 0 {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: leaves_after needs some days, hours or minutes");
}
leaves_for_state.insert(state.clone(), leaves);
}
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");
}
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
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` or one address, not {:?}",
"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
);
}
@@ -181,6 +281,9 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
transitions: raw_schema.transitions,
attended_by: raw_schema.attended_by,
mail_for_state,
grants_for_state,
after_for_state,
leaves_for_state,
},
);
}
+2
View File
@@ -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?;
+132 -4
View File
@@ -24,10 +24,39 @@ 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>,
/// Set by a desk listing, never stored: the record is in a final
/// state (one with no move out). A desk shows the newest few of
/// those and offers the rest on request (`resource::cap_finished`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub finished: bool,
}
/// 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 +73,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 +99,10 @@ pub async fn store_answer(
responses: responses.clone(),
state: initial_state,
submitted_ms,
storyline,
decided_ms: None,
notes: Vec::new(),
finished: false,
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
@@ -87,6 +120,73 @@ pub async fn store_answer(
Ok(())
}
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// it (the event log keeps them). Run after every store, so two sends
/// racing each other - a double click - both settle on the same one.
#[cfg(feature = "ssr")]
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
let own = own_records(js, bucket, username).await;
let Ok(store) = js.get_key_value(bucket).await else { return };
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
let Some(user) = user else { return };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
if req.kind == "hidden" {
continue;
}
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same
/// alternative.
@@ -111,9 +211,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 +248,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,
@@ -268,7 +370,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).await;
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.
@@ -281,6 +388,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
@@ -349,7 +457,19 @@ pub async fn self_transition_answer(
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
);
if !decision.allow {
return Err(ServerFnError::new(if answer.state == OPEN_STATE { "not found" } else { "already processed" }));
// 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();
@@ -379,7 +499,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).await;
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);
@@ -390,6 +511,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
@@ -417,8 +539,11 @@ 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(),
finished: false,
},
Answer {
id: "def456".to_string(),
@@ -427,8 +552,11 @@ 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(),
finished: false,
},
];
let value = serde_json::Value::Array(
+1065 -44
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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";
+50 -15
View File
@@ -9,20 +9,27 @@ use super::*;
/// 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<SiteConfig> {
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(SiteConfig::default());
return Ok(Storylines::one(SiteConfig::default()));
}
};
let site: SiteConfig =
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
Ok(site)
// 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
@@ -107,11 +114,18 @@ pub(crate) async fn fetch_gitea_file(
pub async fn load_aggregates_from_gitea(
repo_url: &str,
branch: &str,
) -> anyhow::Result<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
) -> 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?;
crate::aggregates::parse_aggregates_yaml(&raw)
// 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")]
@@ -119,7 +133,7 @@ pub async fn load_questions_from_gitea(
repo_url: &str,
branch: &str,
subdir: &str,
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
) -> anyhow::Result<Storylines<std::collections::HashMap<String, Question>>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
@@ -161,7 +175,20 @@ pub async fn load_questions_from_gitea(
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
files.push((path[prefix.len()..].to_string(), raw));
}
build_questions(&files)
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
@@ -171,6 +198,13 @@ pub async fn load_questions_from_gitea(
#[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,
@@ -185,11 +219,11 @@ pub async fn watch_for_reload(
repo_url: String,
branch: String,
subdir: String,
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
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 {
@@ -202,7 +236,7 @@ pub async fn watch_for_reload(
use futures::StreamExt;
while sub.next().await.is_some() {
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
Ok(loaded) => with_builtin_aggregates(loaded),
Ok(loaded) => loaded,
Err(e) => {
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
continue;
@@ -221,7 +255,7 @@ pub async fn watch_for_reload(
};
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
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;
}
@@ -233,6 +267,7 @@ pub async fn watch_for_reload(
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");
+6 -1
View File
@@ -74,14 +74,19 @@ pub async fn site_asset_handler(
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, "no-cache")],
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes,
)
.into_response()
+64
View File
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
/// Markdown that a person wrote into a record - a bio on a card - as
/// opposed to the site's own content. Paragraphs, line breaks as they
/// were typed, lists, emphasis and links; no images (a picture has its
/// own field, and an image in text is a way to load something from
/// elsewhere into every visitor's page), no raw HTML, headings as plain
/// paragraphs. Links go to https, mailto or tel only, and say they are
/// the writer's, not the site's (`rel="nofollow ugc"`).
pub fn render_written_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
let mut in_dropped_link = 0usize;
let mut in_image = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Image { .. }) => {
in_image += 1;
None
}
Event::End(TagEnd::Image) => {
in_image = in_image.saturating_sub(1);
None
}
_ if in_image > 0 => None,
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
// What someone typed on a new line is on a new line.
Event::SoftBreak => Some(Event::HardBreak),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
{
in_dropped_link += 1;
None
}
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
in_dropped_link -= 1;
None
}
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
}
#[cfg(test)]
mod written_tests {
use super::render_written_markdown as md;
#[test]
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
// Everything that would put something else in the page is gone.
assert_eq!(md("<script>alert(1)</script>"), "");
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
assert_eq!(md("before ![pixel](https://tracker.example/p.gif) after"), "<p>before after</p>");
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
}
}
+483 -6
View File
@@ -13,12 +13,16 @@ use serde::{Deserialize, Serialize};
// Loading is server-only; the rest is shared with the browser build.
#[cfg(feature = "ssr")]
mod gitea;
#[cfg(feature = "ssr")]
mod variants;
mod handlers;
mod markdown;
mod validate;
#[cfg(feature = "ssr")]
pub use gitea::*;
#[cfg(feature = "ssr")]
pub use variants::*;
pub use handlers::*;
pub use markdown::*;
pub use validate::*;
@@ -84,6 +88,57 @@ pub struct Question {
/// task lands in the `portal_events` desk (see `announce.rs`).
#[serde(default)]
pub event: Option<EventConfig>,
/// Where this question is about, when it is about somewhere: a
/// still map under the question, drawn from this origin. `event`
/// makes a page an occasion with a time; this makes one a place,
/// and a `type: location` field on the page opens its map here
/// rather than on the whole world.
#[serde(default)]
pub place: Option<Place>,
/// Pictures across the page, under the question: a strip the full
/// width of the screen that a visitor swipes through, each picture
/// with its caption laid over it. What the business has built,
/// shown rather than described.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slideshow: Option<Slideshow>,
}
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
/// to 22 (a doorstep); 15 shows a street and its neighbours. `name` is
/// what to call it in words, for anyone who cannot see the map.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Place {
pub lat: f64,
pub lon: f64,
#[serde(default = "default_zoom")]
pub zoom: f64,
#[serde(default)]
pub name: Option<String>,
}
fn default_zoom() -> f64 {
15.0
}
impl Place {
/// `lat,lon,zoom`, the form a `type: location` field takes as its
/// opening view.
pub fn as_value(&self) -> String {
format!("{},{},{}", self.lat, self.lon, self.zoom)
}
pub fn validate(&self) -> Result<(), String> {
if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) {
return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon));
}
if !(0.0..=22.0).contains(&self.zoom) {
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
}
if self.name.as_ref().is_some_and(|n| n.trim().is_empty()) {
return Err("place.name is empty; leave it out or say where this is".to_string());
}
Ok(())
}
}
/// A page's announcement window. `starts` is RFC 3339 with an offset
@@ -162,6 +217,35 @@ impl Question {
}
}
/// The signed-in person's own details into the fields that ask for
/// them: `value: "{user.email}"` becomes their address and the field
/// hidden, since asking someone who is known to say who they are is
/// the form doubting them. Without a session the placeholder is
/// emptied, and the field asks. Only the whole of a value is filled
/// this way; a value that mixes a user placeholder with other text is
/// left alone.
pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) {
for alt in &mut question.alternatives {
for feature in &mut alt.features {
for req in &mut feature.requirements {
let Some(value) = req.value.as_deref() else { continue };
let filled = match value {
"{user.email}" => user.and_then(|u| u.email.clone()),
"{user.name}" => user.map(|u| u.display_name.clone()),
_ => continue,
};
match filled.filter(|v| !v.trim().is_empty()) {
Some(v) => {
req.value = Some(v);
req.kind = "hidden".to_string();
}
None => req.value = None,
}
}
}
}
}
/// An alternative's `action` may carry `{field}` / `{field.sub}`
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
@@ -222,6 +306,13 @@ pub struct Responsible {
/// A mailto address or plain email - deliberately just a string,
/// same as everywhere else content keeps contact info simple.
pub contact: String,
/// The group that holds the responsibility, when it is a group's
/// and not one person's: the board's inbox, not the board's
/// secretary. Then the address gets no account of its own (see
/// `seed.rs`), and any signed-in member of the group may suggest a
/// change to the page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
/// Whether to render `question`'s alternatives or its sign-in gate for
@@ -252,6 +343,14 @@ pub struct Alternative {
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled: bool,
/// Each signed-in person keeps one record here: their own, which
/// sending again replaces. The form opens with what their record
/// says, a file left empty keeps the one already there, and the
/// older record leaves the list. For what a person says about
/// themselves - a card on the front page, a profile. Needs
/// `record_as`, and someone signed in.
#[serde(default)]
pub one_per_person: bool,
#[serde(default)]
pub consequence: Vec<String>,
#[serde(default)]
@@ -292,6 +391,13 @@ pub struct Alternative {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invite {
pub group: String,
/// This invite's own mail, in place of the site's (`mail.invite`):
/// an invitation to a conversation at an hour, say. Besides
/// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`,
/// `{site}` and `{site_name}`, every field of the form is a
/// placeholder; a date and time is written out in words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail: Option<MailTemplate>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -299,6 +405,33 @@ pub struct SelfTransition {
pub bucket: String,
pub to: String,
pub label: String,
/// The states this move may be made from. A person is in dialogue
/// with a case for as long as it runs, not only on the day they
/// sent it: an applicant written to for a missing drawing answers
/// from `incomplete`, and may withdraw from anywhere along the
/// way.
///
/// Empty means the bucket's own initial state, which is the
/// one-shot "confirm" or "unsubscribe" link every one of these was
/// before it could say otherwise. Its own initial state, not the
/// word `open`: a bucket whose records start at `pending` has a
/// confirmation link that fires from `pending`, and reading the
/// literal here is what made redoal's watch-confirm button a rule
/// the engine would never match.
#[serde(default)]
pub from: Vec<String>,
}
impl SelfTransition {
/// The states this move may be made from, with the bucket's
/// initial state standing in when the content named none.
pub fn from_states(&self, initial: &str) -> Vec<String> {
if self.from.is_empty() {
vec![initial.to_string()]
} else {
self.from.clone()
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -346,12 +479,35 @@ pub struct ResourceSpec {
/// `requires_group` regardless of this flag.
#[serde(default)]
pub public: bool,
/// The record states this resource shows, for a `Kv` source; empty
/// shows every one. A register that is public by law is public
/// about what was decided, not about what was withdrawn or turned
/// down, and the desk that works the same bucket is a different
/// spec and still sees all of it.
#[serde(default)]
pub states: Vec<String>,
/// Show only the records whose `where.field` holds `where.equals`,
/// for a `Kv` source. With a `type: record` field on the other
/// side, this is the half that reads: an application's page lists
/// the objections that name it, because each objection stored the
/// application's id in a field and this asks for exactly those.
/// `equals` takes the page's own `{key}` like a resource key does.
#[serde(default, rename = "where")]
pub filter: Option<FieldIs>,
/// The moves a listed answer may make, one button each - empty
/// means read-only. Server calls are checked against this
/// allow-list, so a client can never fire a transition content
/// didn't declare. `Kv` sources only.
#[serde(default)]
pub transitions: Vec<Transition>,
/// A way from each listed record to a page about it: `to` is the
/// page, with `{id}` for the record's id and `{field}` for any of
/// its fields, and `label` the words on the link. The other half
/// of `type: record`, seen from the list: a notice on a board links
/// to the page where a neighbour answers it, and that page's form
/// carries the notice's id back in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<RowLink>,
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
/// fetched value before it reaches the frontend - e.g.
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
@@ -373,7 +529,6 @@ pub struct ResourceSpec {
pub enum ResourceSource {
Kv { bucket: String },
GiteaStarred { username: String },
GiteaOrgRepos { org: String },
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
/// for private repos too (shape the link with jq: a private
/// release's html_url 404s for anonymous visitors, so map it to
@@ -393,7 +548,6 @@ impl ResourceSpec {
match &self.source {
ResourceSource::Kv { bucket } => Some(bucket),
ResourceSource::GiteaStarred { .. }
| ResourceSource::GiteaOrgRepos { .. }
| ResourceSource::GiteaReleases { .. }
| ResourceSource::Url { .. } => None,
}
@@ -457,9 +611,24 @@ pub struct Requirement {
/// A preset value the field starts with. On a dynamic page the
/// URL segment substitutes into `{name}` placeholders here, like
/// resource keys - `value: "{key}"` on a hidden field carries the
/// page's key into the answer.
/// page's key into the answer. `{user.email}` and `{user.name}`
/// are the signed-in person's, and a field they fill is carried
/// rather than asked (see `fill_from_user`); for a visitor they
/// are empty, and the field asks as usual.
#[serde(default)]
pub value: Option<String>,
/// `type: record` only - the bucket the referenced record lives in.
/// This is how one case belongs to another: an objection names the
/// application it objects to, an appeal the decision it appeals.
/// The stored value is the parent's record id, and a submission
/// naming a record that is not there is refused, so a reference in
/// a record is one that resolved at least once.
///
/// A person never types one of these: the id is a chain hash. It
/// arrives the way it was given out - `value: "{key}"` on a dynamic
/// page, so the link in the letter carries the case it is about.
#[serde(default)]
pub of: Option<String>,
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
/// the drawing widget connects to for live echoes of similar
/// strokes. Absent means the widget works offline: the drawn path
@@ -473,6 +642,84 @@ pub struct Requirement {
/// value. When the sibling changes, `resource` is fetched with the
/// value as a parameter and the result replaces this field's value
/// (an empty sibling never fetches, and never clears an edit).
/// "Show the ones whose `field` is `equals`." The whole of what a
/// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back.
/// A strip of pictures across a page (`Question.slideshow`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slideshow {
/// How tall the strip may be, in hundredths of the small viewport
/// height (`svh`): 60 is six tenths of the screen. Every picture is
/// that tall and as wide as its own shape makes it.
#[serde(default = "default_slideshow_height")]
pub max_height: u8,
pub slides: Vec<Slide>,
}
fn default_slideshow_height() -> u8 {
60
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slide {
/// A path in the content repo (`images/built/01.webp`, served from
/// this origin under `/site/`) or an `https://` address.
pub image: String,
/// Laid over the foot of the picture, and its alternative text.
#[serde(default)]
pub caption: String,
/// The picture's own size in pixels, when known: the browser then
/// holds its place in the strip before it has loaded, so pictures
/// further along can wait until they are swiped to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
impl Slide {
/// Where the browser fetches the picture: https as given, anything
/// else as a content-repo path under `/site/`.
pub fn src(&self) -> String {
if self.image.starts_with("https://") || self.image.starts_with("/site/") {
self.image.clone()
} else {
format!("/site/{}", self.image.trim_start_matches('/'))
}
}
}
/// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink {
pub to: String,
pub label: String,
}
impl RowLink {
/// The link for one record: `{id}` is the record's id, `{name}`
/// any string field of its responses. A placeholder nothing fills
/// stays as it is, which is visible, rather than a wrong page.
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
let mut out = self.to.replace("{id}", id);
if let Some(fields) = responses.as_object() {
for (k, v) in fields {
if let Some(s) = v.as_str() {
out = out.replace(&format!("{{{k}}}"), s);
}
}
}
out
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FieldIs {
pub field: String,
pub equals: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bind {
/// The sibling requirement (same alternative) to watch.
@@ -527,6 +774,14 @@ pub struct SiteConfig {
/// intrinsic pixel height.
#[serde(default)]
pub wordmark_height: Option<f32>,
/// Draw an SVG wordmark in the page's own ink instead of its own
/// colours: the file becomes a mask over the text colour, so it
/// follows the page's light and dark (and any theme a site adds),
/// where an `<img>` only ever knows the system's. Opacity in the
/// file still shows, so a lighter line stays lighter. For a
/// one-colour mark; a mark with a colour of its own keeps `false`.
#[serde(default)]
pub wordmark_ink: bool,
/// BCP 47-ish language code for the portal's own chrome strings
/// ("Asked by", the nav lead...) and the html lang attribute.
/// `None` means "en"; unknown codes fall back to English per key.
@@ -543,6 +798,17 @@ pub struct SiteConfig {
pub favicon: Option<String>,
#[serde(default)]
pub hero: HeroConfig,
/// Whether this instance can draw maps: set from `MAPBOX_TOKEN` on
/// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two
/// number inputs.
#[serde(default)]
pub maps: bool,
/// Whether this instance has sign-in at all (a provider is
/// configured): set on the way to the browser like `maps`, so the
/// header knows whether to offer it.
#[serde(default)]
pub sign_in: bool,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
@@ -889,6 +1155,25 @@ pub fn resolve_question(
#[cfg(feature = "ssr")]
pub fn build_questions(
files: &[(String, String)],
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
// The first storyline, once every list of texts has been held to
// the site's arity (see `variants.rs`); the machinery reads this
// one, and `build_storylines` builds the rest for the visitors.
variants::arity(files)?;
build_one_storyline(&variants::storyline(files, 0))
}
/// Every storyline of the content, each a full map of questions.
#[cfg(feature = "ssr")]
pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
let arity = variants::arity(files)?;
let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::<anyhow::Result<Vec<_>>>()?;
Ok(variants::Storylines { each })
}
#[cfg(feature = "ssr")]
fn build_one_storyline(
files: &[(String, String)],
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
// Sections first: (directory prefix, config), shallowest first so a
// later (deeper) section overrides an outer one field-by-field.
@@ -1024,6 +1309,95 @@ alternatives:
std::collections::HashMap::from([(question.id.clone(), question)])
}
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /object
name: Object to it
alternatives:
- name: The register
features:
- name: All
resource:
public: true
source: {{ kind: kv, bucket: things }}
- name: Object
action: /object
record_as: things
features:
- name: Yours
requirements:
- name: what
{field_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
// An objection belongs to the application it objects to. The
// bucket it points at has to be one this repo declares, or the
// reference could never resolve.
#[test]
fn a_record_can_belong_to_another_record() {
let good = question_with_record(" - { name: case, type: record, of: things }");
validate_questions(&good, &schema_fixture()).unwrap();
let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }");
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("has no aggregate declared"), "{err}");
let unsaid = question_with_record(" - { name: case, type: record }");
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("must say `of:"), "{err}");
// `of` means one thing, so it belongs to one type.
let stray = question_with_record(" - { name: case, of: things }");
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("only a type: record field has"), "{err}");
// The id arrives on the link; it is not looked up.
let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }");
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("the link it came in on carries the id"), "{err}");
}
// A state that grants a group makes an account for someone, so the
// form that fills the bucket has to have asked who they are.
#[test]
fn a_state_that_grants_a_group_needs_a_name_and_an_address_on_file() {
let granting = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: things\n initial: open\n states:\n open: { event: opened }\n middle: { event: advanced }\n done: { event: finished, grants: approved_firms }\n transitions:\n open: [middle]\n middle: [done]\n",
)
.unwrap();
let asks = question_with_record(" - { name: name }\n - { name: email, type: email }");
validate_questions(&asks, &granting).unwrap();
let no_name = question_with_record(" - { name: email, type: email }");
let err = validate_questions(&no_name, &granting).unwrap_err();
assert!(err.to_string().contains("grant a group"), "{err}");
// The same form is fine against a schema that grants nothing.
validate_questions(&no_name, &schema_fixture()).unwrap();
}
// A deadline may only make a move a person could have made.
#[test]
fn a_states_deadline_is_held_to_the_same_graph_as_a_button() {
let ok = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions:\n open: [no_answer]\n",
)
.unwrap();
assert_eq!(ok["hearings"].after_for("open").unwrap().days, 42);
let undeclared = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n",
);
assert!(undeclared.unwrap_err().to_string().contains("not a declared transition"));
}
#[test]
fn declared_edge_passes() {
let questions = question_with_transitions(
@@ -1038,6 +1412,77 @@ alternatives:
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
}
/// A page with one `type: location` field, and whatever else the
/// test needs bolted onto the requirement.
fn question_with_location(extra_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /t
name: Where is it?
alternatives:
- name: A
action: /t
features:
- name: Where
requirements:
- name: place
label: Where
type: location
optional: true
{extra_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn a_location_field_puts_its_own_value_there() {
let questions = question_with_location("");
validate_questions(&questions, &schema_fixture()).unwrap();
// It cannot also be loaded from somewhere.
let bound = question_with_location(
" bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }",
);
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("the visitor puts the pin"), "{err}");
}
#[test]
fn a_location_fields_opening_view_is_a_point_on_earth() {
let good = question_with_location(" value: \"59.8938,11.0332,15\"");
validate_questions(&good, &schema_fixture()).unwrap();
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
validate_questions(&no_zoom, &schema_fixture()).unwrap();
for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] {
let questions = question_with_location(&format!(" value: {bad}"));
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
}
}
#[test]
fn a_page_about_a_place_says_where_and_how_close() {
let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) };
place.validate().unwrap();
assert_eq!(place.as_value(), "59.8938,11.0332,15");
// The default is a street and its neighbours, not the globe.
let bare: Place = serde_yaml::from_str("lat: 59.8938\nlon: 11.0332").unwrap();
assert_eq!(bare.zoom, 15.0);
assert!(bare.name.is_none());
for bad in [
Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None },
Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None },
Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None },
Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) },
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
}
#[test]
fn undeclared_from_state_is_rejected() {
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
@@ -1080,6 +1525,7 @@ id: /b
name: B
alternatives:
- name: A
action: /b
features:
- name: ""
requirements:
@@ -1121,7 +1567,7 @@ alternatives:
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
@@ -1392,7 +1838,7 @@ alternatives:
fn recorded_buckets_must_be_attended() {
let write_only = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n".to_string(),
)];
let questions = build_questions(&write_only).unwrap();
let err = validate_questions(&questions, &Default::default()).unwrap_err();
@@ -1403,7 +1849,7 @@ alternatives:
let with_desk = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
),
(
"review/index.yaml".to_string(),
@@ -1519,6 +1965,37 @@ alternatives:
assert_eq!(render_inline_markdown("a\nb"), "a b");
}
#[test]
fn a_signed_in_person_is_not_asked_who_they_are() {
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
let q: Question = serde_yaml::from_str(yaml).unwrap();
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let mut signed_in = q.clone();
fill_from_user(&mut signed_in, Some(&user));
let reqs = &signed_in.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
// Mixed text is not a user field, and is left as written.
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
// A visitor is asked: the placeholder goes, the field stays.
let mut visitor = q.clone();
fill_from_user(&mut visitor, None);
let reqs = &visitor.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
}
#[test]
fn a_slide_is_fetched_from_this_origin_unless_it_is_https() {
let q: Question = serde_yaml::from_str("name: Q?\nslideshow:\n slides:\n - { image: images/built/01.webp, caption: The canteen, width: 3207, height: 1400 }\n - { image: \"https://cdn.example/a.jpg\" }\n").unwrap();
let show = q.slideshow.unwrap();
assert_eq!(show.max_height, 60);
assert_eq!(show.slides[0].src(), "/site/images/built/01.webp");
assert_eq!(show.slides[0].width, Some(3207));
assert_eq!(show.slides[1].src(), "https://cdn.example/a.jpg");
assert_eq!(show.slides[1].caption, "");
}
#[test]
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
+213 -3
View File
@@ -36,6 +36,12 @@ pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
/// 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:
@@ -53,9 +59,33 @@ aggregates:
initial: open
states:
open: { event: invited }
expired: { event: expired }
done: { event: done }
transitions:
open: [done]
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
@@ -115,6 +145,78 @@ pub fn validate_questions(
}
}
}
// 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() {
@@ -124,6 +226,11 @@ pub fn validate_questions(
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!(
@@ -190,9 +297,12 @@ pub fn validate_questions(
.flat_map(|f| &f.requirements)
.any(|r| r.name == name && !r.optional)
};
if !has("name") || !has("email") {
// 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 required `name` and `email` fields",
"question {:?} alternative {:?}: an invite must collect a required `name` and a required `email` or `phone` field",
question.id, alternative.name
);
}
@@ -205,6 +315,24 @@ pub fn validate_questions(
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
@@ -246,6 +374,59 @@ pub fn validate_questions(
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())
@@ -266,6 +447,14 @@ pub fn validate_questions(
let Some(schema) = aggregates.get(bucket) else {
continue;
};
for state in &resource.states {
if !schema.has_state(state) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: states {:?} is not a declared state for bucket {:?}",
question.id, alternative.name, feature.name, state, bucket
);
}
}
for transition in &resource.transitions {
if !schema.has_state(&transition.from) {
anyhow::bail!(
@@ -323,6 +512,27 @@ pub fn validate_questions(
}
for question in questions.values() {
for alternative in &question.alternatives {
// A form is sent with its button, and the button is only
// drawn for an alternative that says where to go afterwards:
// without `action` the person fills it in and cannot send it.
let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
&& alternative.action.is_none()
&& alternative.self_transition.is_none()
{
anyhow::bail!(
"question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)",
question.id,
alternative.name
);
}
if alternative.one_per_person && alternative.record_as.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
question.id,
alternative.name
);
}
if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket)
|| aggregates
+224
View File
@@ -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);
}
}
+262
View File
@@ -0,0 +1,262 @@
//! 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");
}
}
}
/// Records leaving their list: once a record has been in a state with
/// `leaves_after` for that long, it is taken out of the bucket. Not a
/// move - nothing is decided, no mail goes - and the event log keeps
/// the record. Every minute, over the buckets that use it and no
/// others.
pub async fn run_leaving(state: AppState) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
if let Err(e) = sweep_leaving(&state).await {
tracing::warn!(error = %e, "leaving sweep failed");
}
}
}
/// Whether a record has stayed out its time in the state it is in.
pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> bool {
schema
.leaves_for_state
.get(&answer.state)
.is_some_and(|leaves| now_ms >= waiting_since(answer) + leaves.millis())
}
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
let now_ms = chrono::Utc::now().timestamp_millis();
let aggregates = state.aggregates.load();
for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) {
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(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if has_left(schema, &answer, now_ms) {
match store.delete(&id).await {
Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"),
Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"),
}
}
}
}
Ok(())
}
/// 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![], finished: false,
storyline: 0,
}
}
#[test]
fn a_record_leaves_its_list_when_its_time_in_the_state_is_up() {
let s = schema(
"aggregates:\n - bucket: people\n initial: open\n states:\n open: { event: shown }\n gone: { event: gone, leaves_after: { minutes: 10 } }\n transitions:\n open: [gone]\n",
);
let t = 1_000_000_000_000;
let mut answer = waiting("gone", t - 60 * 60 * 1000);
answer.decided_ms = Some(t);
assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending");
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
// A state without it keeps its records.
assert!(!has_left(&s, &waiting("open", 0), t));
assert!(crate::aggregates::parse_aggregates_yaml("aggregates:\n - bucket: p\n initial: open\n states:\n open: { event: o, leaves_after: {} }\n transitions: {}\n").is_err());
}
#[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}");
}
}
+4
View File
@@ -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";
+57
View File
@@ -22,7 +22,15 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send",
("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.",
("en", "show_older") => "Show older",
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
("en", "upload_forbidden") => "Files cannot be sent from here by you, so nothing was sent. Are you signed in?",
("en", "current_file") => "Now:",
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start",
("en", "done") => "Done.",
@@ -33,6 +41,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 ",
@@ -40,7 +59,15 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send",
("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "show_older") => "Vis eldre",
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
("no", "upload_forbidden") => "Du kan ikke sende filer herfra, så ingenting ble sendt. Er du logget inn?",
("no", "current_file") => "Nå:",
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.",
@@ -51,6 +78,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,
})
@@ -76,13 +114,32 @@ mod tests {
"if_stuck",
"send",
"sign_in",
"sign_out",
"nothing_here_yet",
"show_older",
"upload_failed",
"upload_too_large",
"upload_unavailable",
"upload_forbidden",
"current_file",
"keep_file",
"nothing_here",
"back_to_start",
"done",
"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}");
+22
View File
@@ -13,6 +13,12 @@ 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;
@@ -25,6 +31,22 @@ 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;
+299
View File
@@ -0,0 +1,299 @@
//! 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;
let mut findings_out: 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(),
"--needs-tasks" => tasks_out = args.next(),
"--skim" => skim = true,
"--needs-sim" => sim_out = args.next(),
"--access" => show_access = true,
"--findings" => findings_out = args.next(),
"--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>) [--findings <out.jsonl>] [--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}");
write_findings(findings_out.as_deref(), &[schema_finding(&e.to_string())]);
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(
findings_out.as_deref(),
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}");
// One schema error is no reason to hide the rest: the needs
// pass reads the same content and says what else is wrong,
// so a fix round can take them all at once.
let mut all = vec![schema_finding(&e.to_string())];
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
let findings = needs::check(&file, &questions, &aggregates_map);
print_findings(&findings);
all.extend(findings);
}
write_findings(findings_out.as_deref(), &all);
std::process::exit(1);
}
}
}
/// A content error the schema pass stopped at, as a finding: the repo
/// is its scope and the message its subject, since it has no other.
fn schema_finding(message: &str) -> needs::Finding {
needs::Finding {
need: String::new(),
level: needs::Level::Fail,
message: message.to_string(),
scope: "repo",
code: "schema",
subject: message.to_string(),
}
}
/// `--findings`: every finding as one JSON line (id, level, code,
/// scope, need, subject, message, fix), the same ones the text output
/// prints - an empty file when there are none, so a reader can tell
/// "clean" from "not run".
fn write_findings(out: Option<&str>, findings: &[needs::Finding]) {
let Some(out) = out else { return };
let lines: String = findings.iter().map(|f| format!("{}\n", f.to_json())).collect();
if let Err(e) = std::fs::write(out, lines) {
eprintln!("WARN: could not write the findings to {out}: {e}");
}
}
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
fn print_findings(findings: &[needs::Finding]) -> usize {
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}: {} {:?}: {}", finding.scope, finding.need, finding.message);
}
failed
}
/// 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(
findings_out: Option<&str>,
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 {
write_findings(findings_out, &[]);
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}");
write_findings(findings_out, &[schema_finding(&e.to_string())]);
std::process::exit(1);
}
};
let findings = needs::check(&file, questions, aggregates_map);
let failed = print_findings(&findings);
write_findings(findings_out, &findings);
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 && !n.paused).count();
let paused = file.needs.iter().filter(|n| n.paused).count();
println!(
"OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
file.needs.len() - planned - paused,
planned,
paused,
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(())
}
+105
View File
@@ -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()))
}
+53
View File
@@ -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)
}
}
}
+1561
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -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(())
}
+639 -10
View File
@@ -19,6 +19,10 @@ use std::time::Duration;
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;
@@ -41,6 +45,123 @@ pub struct Outgoing {
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.
@@ -130,6 +251,123 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
vars
}
/// What every mail portal sends may say, whatever made it - a state
/// mail, an invite, a suggestion's answer: `{site}` (the instance's
/// public URL) and `{site_name}` (site.yaml's title). One place, so a
/// template written for one kind of mail reads the same in another
/// (docs/mail.md, Variables).
pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map<String, serde_json::Value> {
let mut vars = serde_json::Map::new();
vars.insert("site".into(), public_url().into());
vars.insert("site_name".into(), site_name(site).into());
vars
}
/// Where a mail about a record can send its reader (docs/mail.md):
///
/// - `{self_link}`: the record's own page for the person it belongs
/// to - a page offering a self-service move on this bucket from the
/// state the record is in, with the `?chain=&email=` that
/// lets them act without signing in;
/// - `{desk_link}`: the desk that moves the record - the page whose
/// list over the bucket carries transitions, else one that reads it;
/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}`
/// in one to anyone else, and the site itself when there is neither.
///
/// Empty strings, never missing keys, for a link the content has no
/// page for: a template naming one renders blank there rather than
/// with a broken URL.
pub fn record_links(
questions: &std::collections::HashMap<String, crate::content::Question>,
initial: &str,
bucket: &str,
id: &str,
state: &str,
email: &str,
to_submitter: bool,
) -> serde_json::Map<String, serde_json::Value> {
let mut pages: Vec<&crate::content::Question> = questions.values().collect();
pages.sort_by(|a, b| a.id.cmp(&b.id));
// Only a move the engine would accept from where the record is:
// a link to a button that refuses is worse than none.
let self_page = pages.iter().find(|q| {
q.alternatives
.iter()
.filter_map(|a| a.self_transition.as_ref())
.any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state))
});
let reads = |q: &&crate::content::Question, moving: bool| {
q.alternatives
.iter()
.flat_map(|a| &a.features)
.filter_map(|f| f.resource.as_ref())
.any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty()))
};
let desk_page = pages.iter().find(|q| reads(q, true)).or_else(|| pages.iter().find(|q| reads(q, false)));
let site = public_url();
let self_link = match self_page {
Some(q) if email.contains('@') => {
let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect();
format!("{site}{}?chain={id}&email={email}", q.id)
}
_ => String::new(),
};
let desk_link = desk_page.map(|q| format!("{site}{}", q.id)).unwrap_or_default();
let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) {
(true, false, _) => self_link.clone(),
(false, _, false) => desk_link.clone(),
_ => site,
};
let mut vars = serde_json::Map::new();
vars.insert("self_link".into(), self_link.into());
vars.insert("desk_link".into(), desk_link.into());
vars.insert("link".into(), link.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());
@@ -142,16 +380,32 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
/// 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) {
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
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 = state.site.load();
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 vars = vars_for(bucket, id, to_state, responses);
let mut vars = vars_for(bucket, id, to_state, responses);
parent_vars(state, bucket, responses, &mut vars).await;
vars.extend(site_vars(site));
{
let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let questions = state.questions.load();
let initial = state.aggregates.load().at(storyline).get(bucket).map(|s| s.initial.clone()).unwrap_or_default();
vars.extend(record_links(questions.at(storyline), &initial, bucket, id, to_state, &email, spec.to_submitter()));
}
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(),
@@ -161,7 +415,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
} else {
spec.to.clone()
// 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 {
@@ -180,10 +440,226 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
/// 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();
// The form's own fields first, so the invite's fixed placeholders
// win a clash; a date and time reads as one.
for (key, value) in &invited.fields {
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
if let Some(text) = text {
vars.insert(key.clone(), text.into());
}
}
vars.insert("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into());
vars.insert("questions".into(), list.into());
vars.extend(site_vars(&site));
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().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 = site_vars(&site);
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}`.
/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local`
/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday
/// 6 October at 15:00". Anything else is left as it is.
pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
use chrono::{Datelike, NaiveDateTime, Timelike};
let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?;
let day = t.weekday().num_days_from_monday() as usize;
let month = t.month0() as usize;
Some(if norwegian {
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
} else {
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
})
}
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
@@ -191,7 +667,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
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) {
let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), 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(),
@@ -199,7 +675,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
),
(None, false, true) => (
"Du har fått plass i {group} på {site_name}".to_string(),
"Hei {name},\n\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".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(),
@@ -207,7 +683,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
),
(None, false, false) => (
"You now have a place in {group} at {site_name}".to_string(),
"Hello {name},\n\nYour account at {site} now has a place in {group}. Sign in as usual.".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();
@@ -221,9 +697,8 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
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.extend(site_vars(&site));
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 {
@@ -244,8 +719,69 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
#[cfg(test)]
mod tests {
#[test]
fn a_record_mail_links_its_reader_to_where_they_act() {
let files = vec![
("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()),
("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()),
("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()),
("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()),
];
let questions = crate::content::build_questions(&files).unwrap();
let site = public_url();
let to_tenant = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", true);
assert_eq!(to_tenant["self_link"], format!("{site}/trekk?chain=abc&email=kari%40x.no"));
assert_eq!(to_tenant["desk_link"], format!("{site}/styret"));
assert_eq!(to_tenant["link"], to_tenant["self_link"]);
let to_board = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", false);
assert_eq!(to_board["link"], to_board["desk_link"]);
// Past where the self-service move can be made: the person has
// no page of their own, so `{link}` is the site.
let after = record_links(&questions, "open", "feil", "abc", "utbedret", "kari@x.no", true);
assert_eq!(after["self_link"], "");
assert_eq!(after["link"], site);
}
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 });
@@ -256,6 +792,13 @@ mod tests {
assert_eq!(vars["state"], "fixed");
}
#[test]
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
assert_eq!(spoken_datetime("Anders Kluge", true), None);
}
#[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)");
@@ -280,5 +823,91 @@ mod tests {
};
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(),
finished: false,
}
}
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");
}
}
+161 -27
View File
@@ -1,6 +1,32 @@
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
/// `portal iris` checks a content repo the way the site reads it (the
/// iris keeps bad content out of the portal; `portal lint` is the same);
/// `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("iris" | "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, iris (or 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 +36,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 +56,12 @@ 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));
@@ -86,9 +111,6 @@ async fn main() -> anyhow::Result<()> {
site.clone(),
access.clone(),
));
// The same decisions, answerable by other apps over NATS.
tokio::spawn(portal::access::serve(nats.clone(), site_name_for_subjects(), access.clone()));
let state = AppState {
leptos_options: leptos_options.clone(),
nats,
@@ -104,9 +126,25 @@ 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()));
tokio::spawn(portal::deadlines::run_leaving(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
@@ -121,11 +159,52 @@ 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. More than a hundred requests in ten seconds is a script: a
// burst of 100, refilled at ten a second. Someone reloading a page
// again and again stays well inside it, the more so since the
// files a page is made of (stylesheet, scripts, fonts, pictures)
// are not counted at all - see `assets` below. 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_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let governor = (rate_per_second > 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_millisecond((1000 / rate_per_second).max(1))
.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_per_second, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
}
async fn server_fn_handler(
State(state): State<AppState>,
request: Request<Body>,
@@ -151,24 +230,20 @@ 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 page_motion = format!("{}/page-motion.js", leptos_options.site_root);
let app = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
// 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("/gitea-repo", get(content::gitea_repo_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))
// The files a page is made of, from this disk: the stylesheet, the
// scripts, the fonts. Not counted by the rate limit - they cost
// next to nothing, and refusing one is what turns a reloaded page
// into an unstyled mess - and no session is loaded for them.
// Anything that makes portal ask someone else (content pictures
// from Gitea, map tiles) stays below, counted.
let assets: Router<AppState> = Router::new()
// 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
@@ -193,6 +268,47 @@ async fn main() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
// Same name every release, so revalidated like /pkg.
.route_service(
"/page-motion.js",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new(page_motion)),
)
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css));
let app = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
// 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))
.leptos_routes_with_context(
&state,
routes,
@@ -207,11 +323,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,
};
let app = assets.merge(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(())
}
+246
View File
@@ -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(&not), 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");
}
}
+404
View File
@@ -0,0 +1,404 @@
//! 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,
mail: None,
fields: Default::default(),
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(),
finished: false,
}
}
#[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"]);
}
}
+625 -34
View File
@@ -8,14 +8,108 @@
//! 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 first-credential resets and nothing else,
//! so a leaked token cannot touch existing credentials or admins.
//! 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)]
@@ -29,6 +123,48 @@ pub struct Invited {
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,
/// The invite's own mail and the form it was sent from, kept with
/// the invite so that sending it again sends the same words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail: Option<crate::content::MailTemplate>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub fields: serde_json::Map<String, serde_json::Value>,
}
/// 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.
@@ -44,9 +180,22 @@ fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
}
}
/// 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,
/// What this site's groups are called in a Kanidm it shares with
/// other sites: `KANIDM_GROUP_PREFIX=klingenberg_` makes the
/// content's `kunder` the group `klingenberg_kunder` there. The
/// OAuth2 client's claim map takes the prefix off again, so content
/// and policy never see it. Empty for a Kanidm of the site's own.
group_prefix: String,
}
impl Kanidm {
@@ -55,7 +204,8 @@ impl Kanidm {
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 })
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string();
Some(Kanidm { base, token, group_prefix })
}
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
@@ -77,55 +227,141 @@ impl Kanidm {
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: create, then email, then group, then the link.
/// 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> {
let username = username_for(email);
let created = if self.person_exists(&username).await? {
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", self.group_prefix), 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", self.group_prefix), 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] } })),
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}");
}
let (status, text) = self
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
}
true
};
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 {
// 24 hours: the longest Kanidm allows, since the link travels
// by email and is read when the person gets to it.
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::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None)
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
anyhow::bail!("kanidm: adding {username} to {group}: 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)?;
let expires = intent.expiry_time.as_ref().and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t| t.timestamp_millis());
(Some(format!("{}/ui/reset?token={}", self.base, intent.token)), expires)
}
let (reset_link, link_expires_ms) = if created {
let (link, expires) = self.reset_link(&username).await?;
(Some(link), expires)
} else {
(None, None)
};
@@ -133,14 +369,223 @@ impl Kanidm {
username,
display_name: name.to_string(),
email: email.to_string(),
group: group.to_string(),
group: group.unwrap_or_default().to_string(),
reset_link,
link_expires_ms,
created,
mail: None,
fields: Default::default(),
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 {
@@ -159,6 +604,68 @@ pub fn username_for(email: &str) -> String {
}
}
/// 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::*;
@@ -171,6 +678,90 @@ mod tests {
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(),
finished: false,
}
}
#[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));
+53
View File
@@ -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)
}
+236 -13
View File
@@ -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,13 +33,13 @@ 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(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.resource`-backed counterpart to `get_resource` -
@@ -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()
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
fetch_resource_value(&state, resource, &params).await
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
}
/// The `Requirement.bind`-backed counterpart to
@@ -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()
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
fetch_resource_value(&state, &bind.resource, &params).await
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
}
/// Scoped to the named alternative first, not flattened across all of
@@ -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
@@ -188,6 +189,89 @@ async fn authorize_resource(
}
}
/// Whether a stored record passes the resource's `states` filter. No
/// filter shows everything, which is what every resource written
/// before this existed means. A record with no `state` at all is not
/// an aggregate and is left alone.
#[cfg(feature = "ssr")]
fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Value) -> bool {
if resource.states.is_empty() {
return true;
}
match value.get("state").and_then(|s| s.as_str()) {
Some(state) => resource.states.iter().any(|s| s == state),
None => true,
}
}
/// Whether a stored record passes the resource's `where` filter. The
/// field is looked for in the record's `responses`, which is where a
/// form's answers live, and then on the record itself, so a listing
/// can be narrowed by what was submitted or by what the runtime
/// stamped on it. A missing field never matches: "everything that did
/// not say" is not something a page should be able to ask for by
/// accident.
///
/// `equals: "{user.email}"` is the signed-in person's own address: a
/// list of "my cases". It is filled from the session, never from the
/// caller, and with nobody signed in it matches nothing.
#[cfg(feature = "ssr")]
pub(crate) 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 equals = if filter.equals.contains("{user.email}") {
match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
Some(email) => filter.equals.replace("{user.email}", email),
None => return false,
}
} else {
filter.equals.clone()
};
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
// Addresses compare without case; everything else as written.
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
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)) => same(s),
Some(other) => same(&other.to_string()),
None => false,
}
}
/// The parameter a `where` reads the signed-in person's address from.
/// Set by the server from the session; a caller's own is thrown away.
#[cfg(feature = "ssr")]
pub(crate) const USER_EMAIL: &str = "__user_email";
/// The caller's parameters with the session's person put in and
/// anything pretending to be them taken out.
#[cfg(feature = "ssr")]
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
params.retain(|k, _| !k.starts_with("__"));
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
}
params
}
#[cfg(feature = "ssr")]
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
use crate::auth::{User, SESSION_USER_KEY};
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
Err(_) => None,
};
with_person(params, user.as_ref())
}
#[cfg(feature = "ssr")]
async fn fetch_resource_value(
state: &crate::server::AppState,
@@ -216,7 +300,14 @@ async fn fetch_resource_value(
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?
let value: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
// A state filter holds for one record as well as a
// listing, or naming the key would walk around it.
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
return Err(ServerFnError::new("resource key not found"));
}
value
}
(None, Some(store)) => {
use futures::TryStreamExt;
@@ -231,10 +322,17 @@ 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);
}
}
}
}
if !resource.transitions.is_empty() {
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
items = cap_finished(items, schema, finished_wanted(params));
}
}
serde_json::Value::Array(items)
}
}
@@ -242,9 +340,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?
@@ -258,6 +353,41 @@ async fn fetch_resource_value(
}
}
/// How many finished records a desk listing should show: three, or
/// what the page asked for with `__finished` after "show older".
pub const FINISHED_PARAM: &str = "__finished";
pub const FINISHED_SHOWN: usize = 3;
#[cfg(feature = "ssr")]
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
params.get(FINISHED_PARAM).and_then(|n| n.parse().ok()).unwrap_or(FINISHED_SHOWN)
}
/// A desk is for the cases still moving. Every record still open stays;
/// of those in a final state (no move out), only the `shown` most
/// recently decided, plus one more so the page knows there are older
/// ones to offer. Each finished record is marked, so the page can tell
/// them apart without knowing the state machine.
#[cfg(feature = "ssr")]
pub(crate) fn cap_finished(items: Vec<serde_json::Value>, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec<serde_json::Value> {
let is_final = |v: &serde_json::Value| {
v.get("state").and_then(|s| s.as_str()).is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
};
let when = |v: &serde_json::Value| {
v.get("decided_ms").and_then(|d| d.as_i64()).or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())).unwrap_or(0)
};
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
finished.truncate(shown + 1);
for v in &mut finished {
if let Some(obj) = v.as_object_mut() {
obj.insert("finished".into(), true.into());
}
}
open.extend(finished);
open
}
/// Fetches JSON from a path on the same Gitea instance content is
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
/// set - confirmed live against `project.uhhm.no` that at least the
@@ -470,6 +600,70 @@ 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, &params));
assert!(!matches_filter(&children, &theirs, &params));
// A record that did not say is not everyone's child.
assert!(!matches_filter(&children, &neither, &params));
// "My cases": the session's address, never the caller's.
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
let asked = with_person(forged.clone(), Some(&kari));
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
// Nobody signed in: the forged parameter is dropped, and nothing matches.
let anonymous = with_person(forged, None);
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
// 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, &params));
}
#[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" })), &params));
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), &params));
}
/// 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 ""
@@ -604,3 +798,32 @@ mod tests {
assert_eq!(out, serde_json::json!(["id: /x\n"]));
}
}
#[cfg(all(test, feature = "ssr"))]
mod finished_tests {
use super::*;
#[test]
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
let schema = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
)
.unwrap()
.remove("leads")
.unwrap();
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
items.extend((1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)));
let ids = |v: &[serde_json::Value]| v.iter().map(|x| x["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
let shown = cap_finished(items.clone(), &schema, 3);
// Both open cases, then the three newest finished and one more
// to tell the page there are older ones.
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
assert!(shown[2..].iter().all(|v| v["finished"] == true));
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
// Asking for all of them: no extra, so no "show older".
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
}
}
+455
View File
@@ -0,0 +1,455 @@
//! 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,
mail: None,
fields: Default::default(),
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"));
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
#![cfg(feature = "ssr")]
pub mod oidc;
pub mod storyline;
use arc_swap::ArcSwap;
use axum::extract::FromRef;
@@ -21,18 +22,18 @@ 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.
+49 -6
View File
@@ -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,17 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Whether a provider is configured.
pub fn enabled(&self) -> bool {
self.inner.is_some()
}
/// 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 +154,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 +164,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 +258,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 +297,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))
}
+70
View File
@@ -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
View File
@@ -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()))
}
}
+3935
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More