Compare commits

..

295 Commits

Author SHA1 Message Date
renovate-bot 453d236442 Update dependency postgres to v4
CI / full-gate (push) Failing after 26s
Release-Bump: major
2026-11-01 04:22:16 +00:00
lilleman c2389f871d Record the comprehension check: overall 6.50 then 6.38, under the 7.0 floor; the README's REQUIRE_SECURE_SECRETS drift is filed as item 56
CI / full-gate (push) Successful in 3m24s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 14:09:54 +01:00
lilleman 8e73d165da Answer prose round 1: 0 🔴, 5 🟡 (0 misread); the comments move to the guard, the changelog says variable
CI / full-gate (push) Successful in 3m28s
2026-10-31 13:56:46 +01:00
lilleman 04545274f3 Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); approved 2026-10-31 13:56:46 +01:00
lilleman 8ffbb0b70d Answer stability round 1: 0 🔴, 4 🟡 (0 misread); the driver decides what parses so a multi-host DSN still works, and the nine-key test shrinks to four 2026-10-31 13:56:46 +01:00
lilleman b8686377c9 Answer security round 1: 0 🔴, 0 🟡 (0 misread); /security-review found nothing, and the full scan didn't fire: the change removes a leak and opens no boundary 2026-10-31 13:49:38 +01:00
lilleman 217a36252e Keep a URL config value out of the boot error that rejects it
CI / full-gate (push) Failing after 1m6s
2026-10-31 13:48:43 +01:00
lilleman 03bcf216a7 Answer security round 1: 0 🔴, 0 🟡 (0 misread); /security-review found nothing, the full scan's cost prompt had no user to answer it
CI / full-gate (push) Successful in 3m23s
Mirror / github-mirror (push) Successful in 5s
2026-10-31 13:43:04 +01:00
lilleman 306f322b62 Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); the README says a mistyped value names the plugin and the variable 2026-10-31 13:43:04 +01:00
lilleman 396ce93a2f Answer prose round 1: 0 🔴, 0 🟡 (0 misread); the redundant comment goes, the changelog entry shrinks to one sentence 2026-10-31 13:43:04 +01:00
lilleman 5be561c4ba Answer stability round 1: 0 🔴, 3 🟡 (0 misread); the tests quote each non-secret value and run one case per type, the config.ts URL leak is filed as item 55 2026-10-31 13:43:04 +01:00
lilleman 8f7bc59b38 Keep a secret setting's raw value out of the boot error that rejects it
CI / full-gate (push) Successful in 3m23s
2026-10-31 13:38:27 +01:00
lilleman 622059101f Record the comprehension check: PR #200 ruled same, scored 6.50 and 6.25; filed items 46-54
CI / full-gate (push) Successful in 3m20s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 13:31:41 +01:00
lilleman 3592a30f98 Answer prose round 1: 0 🔴, 2 🟡 (0 misread); the view header calls the link a URL, the always-present code loses its guard, the test comment goes
CI / full-gate (push) Successful in 3m31s
2026-10-31 13:11:21 +01:00
lilleman f340933e39 Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); the notice tells the admin to send the link and the code 2026-10-31 13:11:21 +01:00
lilleman 00fe8b0dd2 Answer stability round 1: 0 🔴, 2 🟡 (0 misread); the admin recovery journey gets an E2E, the Kratos comments no longer claim a mailed link 2026-10-31 13:11:21 +01:00
lilleman 767c76b16c Answer security round 1: 0 🔴, 0 🟡 (0 misread); /security-review found nothing, the link comes from Kratos' admin response behind users:write 2026-10-31 13:05:32 +01:00
lilleman 4cb21d8909 Link the admin's recovery-code notice to the flow the code was minted on
CI / full-gate (push) Failing after 1m1s
2026-10-31 13:05:06 +01:00
lilleman ccb4d32d39 Answer prose round 1: 0 🔴, 4 🟡 (0 misread); comments say what the forms post where, restating test comments go
CI / full-gate (push) Successful in 3m23s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 12:59:28 +01:00
lilleman 053438f968 Answer product-owner round 1: 0 🔴, 2 🟡 (0 misread); the submit sends a same-origin Referer, Kratos' redirect to this host keeps the chosen locale 2026-10-31 12:59:28 +01:00
lilleman 29d496a02e Answer stability round 1: 0 🔴, 2 🟡 (0 misread); the submit test pins manual redirects and the 5xx boundary, recovery gets its E2E, the Referer is sent 2026-10-31 12:59:28 +01:00
lilleman 4779a9a947 Answer security round 1: 0 🔴, 0 🟡 (0 misread); the full scan could not run as a subagent, so /security-review ran 2026-10-31 12:46:06 +01:00
lilleman 03d7be61b3 Keep the code-flow submit within the function limits and list its routes
CI / full-gate (push) Failing after 1m2s
2026-10-31 12:43:16 +01:00
lilleman ead604f49d Accept a verification or recovery code pasted with whitespace around it
CI / full-gate (push) Failing after 16s
2026-10-31 12:41:11 +01:00
lilleman 7cf37acb1b Record the reading cost of the last chunk 2026-10-31 12:41:11 +01:00
lilleman c78e5509b2 Rewrap the fallback block comment
CI / full-gate (push) Successful in 3m20s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 12:25:28 +01:00
lilleman 03544e8d8d Answer prose round 1: 0 🔴, 3 🟡 (0 misread); the changelog names the engines below :has(), the block comment names what it hides, the test comment goes 2026-10-31 12:25:23 +01:00
lilleman eda07269d1 Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); both 🟢s fixed in the stability and prose commits 2026-10-31 12:25:23 +01:00
lilleman 2170ca728b Answer stability round 1: 0 🔴, 1 🟡 (0 misread); the order test requires both base rules, a chip's dot goes where the native box shows 2026-10-31 12:25:23 +01:00
lilleman d0e1de96b8 Show which filter segment or chip is selected and focused without :has()
CI / full-gate (push) Failing after 1m3s
2026-10-31 12:19:21 +01:00
lilleman 8a93d59988 Answer prose round 1: 0 🔴, 7 🟡 (0 misread); the header opens with what it checks, README says what fails even guarded and how a plugin is checked, the changelog stops claiming a floor change
CI / full-gate (push) Successful in 3m23s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 12:13:32 +01:00
lilleman 7539f8ec0d Answer product-owner round 1: 0 🔴, 3 🟡 (0 misread); a media feature the floor lacks passes, plugins/ is checked, the no-:has filter state is filed as item 44 2026-10-31 12:13:32 +01:00
lilleman 5a204facdb Answer stability round 1: 0 🔴, 7 🟡 (0 misread); range and or media syntax fail, an or operand guards nothing, the anchoring guard splits from position-try-fallbacks, the casts go, the filters and at-rules are tested 2026-10-31 12:13:32 +01:00
lilleman 7f874edb4d Answer security round 1: 0 🔴, 0 🟡 (0 misread); clean 2026-10-31 11:52:57 +01:00
lilleman 7ff8140e07 Fail the gate on a stylesheet feature the browser floor lacks, unless an @supports guards it
CI / full-gate (push) Successful in 4m45s
2026-10-31 11:52:17 +01:00
lilleman cf8fa437cd Answer product-owner round 2: 0 🔴, 1 🟡 (0 misread); the no-clone trial path closes Quick start instead of interrupting the clone-only steps
CI / full-gate (push) Successful in 3m20s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 11:20:31 +01:00
lilleman a7781869fd Answer prose round 1: 0 🔴, 6 🟡 (0 misread); the never-on-main reason sits at on:, the overview paragraph stops narrating its test, the AGENTS bullet splits into sentences 2026-10-31 11:19:11 +01:00
lilleman ee4d09df06 Answer product-owner round 1: 1 🔴, 1 🟡 (0 misread); the image-only start is a Quick start trial path, pinned to the release the Hub page names 2026-10-31 11:19:11 +01:00
lilleman 282c4b17da Answer stability round 1: 0 🔴, 3 🟡 (0 misread); the description test refuses an empty one and needs a sentence start, the PATCH body is a tested pure function, the trial-stack block moves to Quick start 2026-10-31 11:19:11 +01:00
lilleman 1adeb776a2 Answer security round 1: 0 🔴, 0 🟡 (0 misread); clean 2026-10-31 11:13:39 +01:00
lilleman b6afdf1ecc Push auto-release tags as renovate-bot so release.yml fires, and give the Docker Hub page its short description
CI / full-gate (push) Successful in 3m20s
2026-10-31 11:12:23 +01:00
lilleman b478cc7735 Answer prose round 1: 0 🔴, 3 🟡 (0 misread); the pageError comment is true, nested-group removal is unambiguous, the trade-off lives only at the guard
CI / full-gate (push) Successful in 3m20s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 11:02:47 +01:00
lilleman 8906340cf8 Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); the refusal names the permissions you would lose 2026-10-31 11:02:47 +01:00
lilleman 35f71d435b Answer stability round 1: 0 🔴, 2 🟡 (0 misread); tests pin all three user-page modes and the refusal page's group, the guard checks membership before the per-permission walk, logs the action and what was lost 2026-10-31 11:02:47 +01:00
lilleman c36f393fff Answer security round 1: 0 🔴, 0 🟡 (0 misread); clean 2026-10-31 10:48:49 +01:00
lilleman ad812e17c2 Refuse a group change that would lock the acting admin out, and title a read-only user page View user
CI / full-gate (push) Successful in 3m22s
2026-10-31 10:47:47 +01:00
lilleman fcc5e04e0d Record the reading cost of the chunk before item 3 2026-10-31 10:47:47 +01:00
lilleman 6202b3223b Answer stability round 1: 0 🔴, 0 🟡 (0 misread); the two 🟢 comment nits went with the prose round
CI / full-gate (push) Successful in 3m20s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 10:37:15 +01:00
lilleman c1abc67a92 Answer prose round 1: 0 🔴, 2 🟡 (0 misread); the harness and plugins-test comments and the README clause are gone, the admin pointers name the full path 2026-10-31 10:37:15 +01:00
lilleman 71b12a6c0a Split src/http/app.test.ts into one file per concern, and reset the reading-cost trend
CI / full-gate (push) Successful in 3m20s
2026-10-31 10:35:08 +01:00
lilleman 72528f01ef Answer architecture round 1: 0 🔴, 2 🟡 (0 misread); CACHE_TEMPLATES also caches /public/ asset versions, the serve path hashes without a sync read
CI / full-gate (push) Successful in 3m19s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 10:25:19 +01:00
lilleman 65e2340e49 Answer stability round 1: 0 🔴, 4 🟡 (0 misread); the serve path streams its hash and answers an unreadable file 500 with a log, cached deployments skip the per-href stat, tests for the pre-context error page and the size half of the memo 2026-10-31 10:25:19 +01:00
lilleman c38ad74e3a Answer prose round 1: 0 🔴, 4 🟡 (0 misread); a changed file, not a release, changes the hash; the memo comment, the CHANGELOG scope and smaller comment cuts 2026-10-31 10:16:05 +01:00
lilleman a7135763bb Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); scripts a plugin links stay unversioned until item 43, assetHref listed as reserved, the per-render stat goes to the architect 2026-10-31 10:16:05 +01:00
lilleman 236f4c4610 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 10:01:31 +01:00
lilleman 6358858103 File the rising reading cost as item 42
CI / full-gate (push) Failing after 1m8s
2026-10-31 10:00:52 +01:00
lilleman 3d757d4726 item-34 - Serve every /public/ asset under a content-versioned URL with an immutable Cache-Control 2026-10-31 10:00:03 +01:00
lilleman 910ac18798 Answer product-owner round 2: 0 🔴, 0 🟡 (0 misread); one 🟢 answered by filing the Edit user heading as item 40
CI / full-gate (push) Successful in 3m19s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 09:48:53 +01:00
lilleman 48c89baf8e Answer stability round 2: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 09:48:53 +01:00
lilleman b863ea26d9 Remove the shipped items 4, 7, 22 and 39 from the plan, and file the Edit user heading and the one-call Keto save 2026-10-31 09:48:49 +01:00
lilleman 3c0878ed1a Answer prose round 1: 0 🔴, 4 🟡 (0 misread); correct the CHANGELOG session claim and order, rewrite the partial copy and the baseline comments, one helper for info-msg 2026-10-31 09:46:17 +01:00
lilleman 21877aeadd Answer product-owner round 1: 1 🔴, 3 🟡 (0 misread); View moves to the admin catalog, a repeated save is no longer refused, the partial-save copy no longer promises a retry, the notice's card is the form's fragment, the CHANGELOG names the new admin keys 2026-10-31 09:46:17 +01:00
lilleman 85bfadd040 Answer stability round 1: 1 🔴, 4 🟡 (0 misread); allow the stale-save 409 in the e2e console guard, report a partial save when Keto cannot be read again, add the missing group, no-op and plain-login tests 2026-10-31 09:46:17 +01:00
lilleman 346d232cf7 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 09:35:22 +01:00
lilleman f4969d5c1f Refuse a stale permission save, confirm or report it, show View to read-only holders, keep an absolute return_to beside a locale
CI / full-gate (push) Failing after 2m53s
2026-10-31 09:34:05 +01:00
lilleman 76995008eb Remove the shipped item 20 from the plan
CI / full-gate (push) Successful in 3m17s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 09:15:12 +01:00
lilleman dfd26ada34 File the absolute return_to dropped by a chosen locale as item 39
CI / full-gate (push) Successful in 3m17s
2026-10-31 09:11:39 +01:00
lilleman 5c84cc77fe Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); one 🟢 declined 2026-10-31 09:10:57 +01:00
lilleman 943520566e Answer prose round 1: 0 🔴, 5 🟡 (0 misread); shorten the return-target comment, state why /settings is public, drop the test restatements 2026-10-31 09:10:57 +01:00
lilleman 8d1e8be6fc Answer stability round 1: 0 🔴, 2 🟡 (0 misread); pin the 503 on a non-401 failure and the language-only return target 2026-10-31 09:10:57 +01:00
lilleman dbd8af08ba Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 09:05:26 +01:00
lilleman 2a4ed30e93 Redirect an anonymous hit on /settings to sign-in with the page as return_to
CI / full-gate (push) Successful in 3m25s
2026-10-31 09:04:55 +01:00
lilleman 15c5a09667 Remove the shipped item 21 from the plan
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 09:01:42 +01:00
lilleman 73e6d5fb08 Answer stability round 2: 0 🔴, 1 🟡 (0 misread); pin the IIFE callee check with a callback argument
CI / full-gate (push) Successful in 3m16s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 08:57:42 +01:00
lilleman f527850107 Answer prose round 1: 0 🔴, 3 🟡 (0 misread); trim the header comment and AGENTS.md rule, list the guard in the file map 2026-10-31 08:56:32 +01:00
lilleman 6f689e517b Answer stability round 1: 2 🔴, 2 🟡 (0 misread); check every same-named sibling, ratchet ceilings down, widen the measure sample 2026-10-31 08:56:32 +01:00
lilleman ea259f09a8 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 08:52:16 +01:00
lilleman ec35aaaa9e Fail the gate on a function over 50 lines or complexity 20
CI / full-gate (push) Successful in 3m36s
2026-10-31 08:51:49 +01:00
lilleman 14c09a76c0 Re-score item 32: L 4, E 2, I 2, S 7, so it ships alone 2026-10-31 08:51:49 +01:00
lilleman 2be8ebfa67 Record the reading cost of the previous chunk 2026-10-31 08:51:49 +01:00
lilleman 5b7ad118ab Answer prose round 1: 1 🔴, 1 🟡 (0 misread); trim comments, README file map
CI / full-gate (push) Successful in 3m16s
Mirror / github-mirror (push) Successful in 5s
2026-10-31 08:41:59 +01:00
lilleman 437a2e8580 Answer stability round 1: 0 🔴, 3 🟡 (0 misread); pin dashboard heading and branding, fix stale comment 2026-10-31 08:41:59 +01:00
lilleman 57a1a2899f Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 08:38:10 +01:00
lilleman 352de29013 Retire shell-context and render the dashboard from ctx.chrome
CI / full-gate (push) Successful in 3m18s
2026-10-31 08:37:45 +01:00
lilleman de4eb7d9ee Record the reading cost of the previous chunk 2026-10-31 08:37:45 +01:00
lilleman a8a9744744 Record the comprehension check: paired ruling better for PR #187
CI / full-gate (push) Successful in 3m19s
Mirror / github-mirror (push) Successful in 5s
2026-10-31 08:26:23 +01:00
lilleman 10152d2c6f Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); approved
CI / full-gate (push) Successful in 3m22s
2026-10-31 08:24:18 +01:00
lilleman 4e7d897516 Answer prose round 1: 0 🔴, 4 🟡 (0 misread); document the e2e typecheck, tighten comments and changelog 2026-10-31 08:24:18 +01:00
lilleman cd46c77332 Answer stability round 1: 0 🔴, 2 🟡 (0 misread); e2e tsconfig extends the root, icon removal declined per AGENTS.md 2026-10-31 08:24:18 +01:00
lilleman 16c9822c29 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-31 08:20:44 +01:00
lilleman 520d2c928d Visual E2E at five viewports, typecheck e2e-tests in the gate, drop unused icons
CI / full-gate (push) Successful in 3m29s
2026-10-31 08:20:16 +01:00
lilleman be3553c1e2 Merge remote-tracking branch 'origin/main' into item-26
CI / full-gate (push) Successful in 3m5s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 08:14:04 +01:00
renovate-bot cef2d4f4f2 Update axllent/mailpit Docker tag to v1.33.0
CI / full-gate (push) Successful in 3m59s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 04:18:54 +00:00
lilleman b336dee6d7 Record the comprehension check: paired ruling better for PR #184
CI / full-gate (push) Successful in 3m5s
2026-10-31 02:19:51 +01:00
lilleman af157c95fe Answer product-owner round 2: 0 🔴, 0 🟡 (0 misread); approved
CI / full-gate (push) Successful in 3m3s
2026-10-31 02:18:12 +01:00
lilleman 3f30cfafe6 Answer product-owner round 1: 1 🔴, 3 🟡 (1 misread); fixes landed with the prose round, the about-ten figure waits on the owner
CI / full-gate (push) Successful in 3m5s
2026-10-31 02:17:30 +01:00
lilleman 25b24a1057 Answer prose round 1: 1 🔴, 5 🟡 (1 misread); manifest is plugin.ts, rename permission token to permission name, narrow the denylist claim, move the scale into Plugin dependencies, add Safari to the :has() comment 2026-10-31 02:17:30 +01:00
lilleman 1905d2c310 Add a README glossary and intended scale; correct the TOKENS comment
CI / full-gate (push) Successful in 3m6s
2026-10-31 02:14:39 +01:00
lilleman 16da74f03f Record the reading cost of the last chunks 2026-10-31 02:14:39 +01:00
lilleman bb8e6bc8e8 Record the comprehension check: paired ruling better for PR #183
CI / full-gate (push) Successful in 3m2s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 02:09:41 +01:00
lilleman 0f20b386e2 File the stale TOKENS comment in the plan
CI / full-gate (push) Successful in 3m15s
2026-10-31 02:04:47 +01:00
lilleman 6815caa47e Answer prose round 1: 0 🔴, 4 🟡 (0 misread); drop the false header bullet, match the changelog and test wording 2026-10-31 02:04:47 +01:00
lilleman 9e5b60a333 Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); name Firefox in the changelog entry 2026-10-31 02:04:47 +01:00
lilleman 21a00f895a Answer stability round 1: 0 🔴, 1 🟡 (0 misread); test the drawer opens and a link in it navigates, keep the in-flow sidebar from stretching 2026-10-31 02:04:47 +01:00
lilleman 2ab482e774 Keep the mobile nav reachable on engines without :has(), and fix the stylesheet header
CI / full-gate (push) Successful in 3m8s
2026-10-31 01:49:13 +01:00
lilleman f46e97383e File the stale styles.css header comment in the plan
CI / full-gate (push) Successful in 3m0s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 01:44:14 +01:00
lilleman ce69269ae4 Remove the shipped items 27, 29, 31 and 35 from the plan 2026-10-31 01:43:40 +01:00
lilleman 364b04795c Record the comprehension check: paired ruling better for PR #182 2026-10-31 01:43:26 +01:00
lilleman 19987ba1b0 Answer product-owner round 2: 0 🔴, 0 🟡 (0 misread); no change
CI / full-gate (push) Successful in 3m4s
2026-10-31 01:34:42 +01:00
lilleman c12fd2742f Answer stability round 2: 0 🔴, 2 🟡 (0 misread); make the order check able to fail, measure the page button, brand mark and forced dark 2026-10-31 01:34:42 +01:00
lilleman 48655c0ad2 Answer prose round 1: 1 🔴, 4 🟡 (0 misread); reworded the changelog and comments
CI / full-gate (push) Successful in 3m8s
2026-10-31 01:32:27 +01:00
lilleman a8d4139dd8 Answer product-owner round 1: 1 🔴, 1 🟡 (0 misread); fixed with the stability round's commit 2026-10-31 01:32:26 +01:00
lilleman a190166421 Answer stability round 1: 1 🔴, 6 🟡 (0 misread); hide the theme switch after its base rule, hide inert nav toggles, test the rendered result 2026-10-31 01:32:26 +01:00
lilleman ac52abd330 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no change
CI / full-gate (push) Successful in 3m1s
2026-10-31 01:25:02 +01:00
lilleman a2edfbee0c File the mobile nav's :has() dependency in the plan 2026-10-31 01:25:02 +01:00
lilleman 635006b5fc Show the whole nav tree without :has(), raise text contrast to 4.5:1, drop unused auth rules
CI / full-gate (push) Successful in 3m1s
2026-10-31 01:24:34 +01:00
lilleman 3e70768433 Record the comprehension check: paired ruling better for PR #181
CI / full-gate (push) Successful in 3m2s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 01:16:05 +01:00
lilleman 56ff987c97 Add the scoring-run fields the comprehension seats fill their prompts from
CI / full-gate (push) Successful in 2m58s
2026-10-31 01:14:09 +01:00
lilleman d8df129fca Answer security round 1: 0 🔴, 0 🟡 (0 misread); no change
CI / full-gate (push) Successful in 3m0s
2026-10-31 01:07:34 +01:00
lilleman 270d80c8d0 Answer prose round 1: 0 🔴, 3 🟡 (0 misread); state the colour rule in the README and the changelog
CI / full-gate (push) Successful in 3m1s
2026-10-31 01:07:30 +01:00
lilleman 2a3ace885d Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); no change 2026-10-31 01:07:30 +01:00
lilleman e9a7c0e726 Answer stability round 1: 0 🔴, 2 🟡 (0 misread); explain the failure correctly and test each stylesheet 2026-10-31 01:07:30 +01:00
lilleman 7e3e8acb28 Give every palette colour a hex value an engine without oklch can read
CI / full-gate (push) Successful in 3m5s
2026-10-31 01:00:42 +01:00
lilleman eed1050c08 File versioned immutable assets for 0.4.21 and drop the page-cache question
CI / full-gate (push) Successful in 5s
Mirror / github-mirror (push) Successful in 4s
2026-10-31 00:56:04 +01:00
lilleman 6f11a9928b File the decision on caching public pages
CI / full-gate (push) Successful in 3m3s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 21:34:08 +01:00
lilleman 070d745a4b Answer stability round 2: 0 🔴, 0 🟡 (0 misread); no change 2026-10-30 21:33:34 +01:00
lilleman 9370fb36f2 Answer prose round 1: 0 🔴, 5 🟡 (0 misread); tighten the CSRF notes
CI / full-gate (push) Successful in 3m7s
2026-10-30 21:32:56 +01:00
lilleman 3c5c0c911c Answer product-owner round 1: 0 🔴, 2 🟡 (0 misread); document when a route may override 2026-10-30 21:32:56 +01:00
lilleman 44a777dc3f Answer stability round 1: 1 🔴, 0 🟡 (0 misread); share no page 2026-10-30 21:32:56 +01:00
lilleman 1dfc6aac3a Set Cache-Control on every rendered page, and record the CSRF token's unbinding
CI / full-gate (push) Successful in 3m23s
2026-10-30 21:28:14 +01:00
lilleman 0c0a3bc905 Move the macOS check to 0.5.0
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 21:22:51 +01:00
lilleman 935674bc49 Record the browser floor and file what breaks below it
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 21:21:35 +01:00
lilleman e8dbb2a809 Record answers to the 0.4.21 questions
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 21:02:10 +01:00
lilleman 79f26491db Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); add a changelog entry
CI / full-gate (push) Successful in 3m2s
Mirror / github-mirror (push) Successful in 3s
2026-10-30 20:50:33 +01:00
lilleman 77b7e6143f Answer stability round 1: 0 🔴, 1 🟡 (0 misread); file the scheduling example's inset 2026-10-30 20:50:33 +01:00
lilleman 18b6a5a64a Answer prose round 1: 1 🔴, 0 🟡 (0 misread); name the boxes --pad-x governs 2026-10-30 20:50:33 +01:00
lilleman ad3bb3198e Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-30 20:50:33 +01:00
lilleman 74ebf2e7ec Merge remote-tracking branch 'origin/main' into pad-x-insets 2026-10-30 20:48:16 +01:00
lilleman 53e996394f Add a goal for users of the building blocks, and plan todo.md as a scored table
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 20:45:24 +01:00
lilleman 3491e660e9 Say the host stores nothing, rather than that no database exists
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 20:37:00 +01:00
lilleman 667d589401 State the project's goals and audience in the README
CI / full-gate (push) Successful in 4s
2026-10-30 20:32:28 +01:00
lilleman 5a968fd076 Inset the topbar, filter bar, pager, form page and empty table by --pad-x
CI / full-gate (push) Successful in 3m11s
2026-10-30 15:22:25 +01:00
renovate-bot b81c136fe5 Update dependency lucide-static to v1.64.0
CI / full-gate (push) Successful in 5m12s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 17s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-10-30 04:18:42 +00:00
renovate-bot c9e25f2852 Update Node.js to v24.22.2
CI / full-gate (push) Successful in 5m19s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 4s
Release-Bump: patch
2026-10-29 04:18:28 +00:00
renovate-bot 603e29e8f1 Update dependency lucide-static to v1.62.0
CI / full-gate (push) Successful in 5m7s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-28 04:18:34 +00:00
renovate-bot 055bfd6506 Update renovate/renovate Docker tag to v44.186.6
CI / full-gate (push) Successful in 5m8s
Mirror / github-mirror (push) Successful in 4s
2026-10-27 04:18:33 +00:00
renovate-bot 80cc692634 Update renovate/renovate Docker tag to v44.181.9
CI / full-gate (push) Successful in 5m13s
Mirror / github-mirror (push) Successful in 4s
2026-10-26 04:18:20 +00:00
renovate-bot ebf34deda4 Update renovate/renovate Docker tag to v44.181.5
CI / full-gate (push) Successful in 5m8s
Mirror / github-mirror (push) Successful in 4s
2026-10-25 04:18:38 +00:00
renovate-bot 937a0739ca Update dependency lucide-static to v1.61.0
CI / full-gate (push) Successful in 4m54s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-10-24 04:19:14 +00:00
renovate-bot 44813be8a7 Update axllent/mailpit Docker tag to v1.32.0
CI / full-gate (push) Successful in 5m1s
Mirror / github-mirror (push) Successful in 4s
2026-10-23 04:19:03 +00:00
renovate-bot 785230673b Update Node.js to v24.22.1
CI / full-gate (push) Successful in 4m47s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 23s
Release / publish-overview (push) Successful in 4s
Release-Bump: patch
2026-10-22 04:19:07 +00:00
renovate-bot 80981eb791 Update dependency lucide-static to v1.60.0
CI / full-gate (push) Successful in 4m53s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 20s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-10-21 04:19:26 +00:00
renovate-bot 0097481b4c Update renovate/renovate Docker tag to v44.171.1
CI / full-gate (push) Successful in 3m3s
Mirror / github-mirror (push) Successful in 4s
2026-10-20 04:18:09 +00:00
renovate-bot b09f8b7eb6 Update Node.js to v24.22.0
CI / full-gate (push) Successful in 4m56s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 24s
Release / publish-overview (push) Successful in 7s
Release-Bump: minor
2026-10-19 04:19:17 +00:00
lilleman fb464acf54 Plan publishing every release to Docker Hub, with a short description
CI / full-gate (push) Successful in 3s
Mirror / github-mirror (push) Successful in 4s
2026-10-18 11:37:56 +02:00
renovate-bot 8220b34fd0 Update dependency lucide-static to v1.59.0
CI / full-gate (push) Successful in 4m38s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-18 04:18:36 +00:00
renovate-bot 194557fb49 Update dependency lucide-static to v1.58.0
CI / full-gate (push) Successful in 3m9s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-17 04:18:09 +00:00
renovate-bot 4a0fc2b493 Update axllent/mailpit Docker tag to v1.31.6
CI / full-gate (push) Successful in 4m6s
Mirror / github-mirror (push) Successful in 4s
2026-10-16 04:17:53 +00:00
renovate-bot 946c45663f Update Playwright to v1.64.1
CI / full-gate (push) Successful in 4m48s
Mirror / github-mirror (push) Successful in 3s
2026-10-15 04:19:16 +00:00
renovate-bot 41ab968b20 Update dependency lucide-static to v1.56.0
CI / full-gate (push) Successful in 4m55s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-14 04:18:41 +00:00
renovate-bot ff548a30d2 Update Playwright to v1.64.0
CI / full-gate (push) Successful in 4m4s
Mirror / github-mirror (push) Successful in 3s
2026-10-13 04:18:09 +00:00
renovate-bot f80c6c6426 Update dependency lucide-static to v1.55.0
CI / full-gate (push) Successful in 3m0s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-12 04:18:07 +00:00
renovate-bot 3658797764 Update axllent/mailpit Docker tag to v1.31.5
CI / full-gate (push) Successful in 2m59s
Mirror / github-mirror (push) Successful in 4s
2026-10-11 04:17:52 +00:00
renovate-bot 23f1158211 Update dependency @types/node to v24.19.2
CI / full-gate (push) Successful in 3m12s
Mirror / github-mirror (push) Successful in 4s
2026-10-10 04:18:04 +00:00
renovate-bot bf9302ce8e Update dependency lucide-static to v1.53.0
CI / full-gate (push) Successful in 3m11s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-09 04:18:47 +00:00
renovate-bot 2ed134fc86 Update renovate/renovate Docker tag to v44.145.1
CI / full-gate (push) Successful in 2m59s
Mirror / github-mirror (push) Successful in 4s
2026-10-08 04:18:53 +00:00
renovate-bot e07edeb6ae Update renovate/renovate Docker tag to v44.141.0
CI / full-gate (push) Successful in 3m2s
Mirror / github-mirror (push) Successful in 4s
2026-10-07 04:18:18 +00:00
renovate-bot e9cd7ba2b3 Update dependency lucide-static to v1.52.0
CI / full-gate (push) Successful in 3m10s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-10-06 04:18:18 +00:00
renovate-bot a49bd4f7b0 Update dependency @types/node to v24.19.1
CI / full-gate (push) Successful in 3m0s
Mirror / github-mirror (push) Successful in 3s
2026-10-05 04:18:14 +00:00
renovate-bot e680552961 Update axllent/mailpit Docker tag to v1.31.4
CI / full-gate (push) Successful in 4m29s
Mirror / github-mirror (push) Successful in 4s
2026-10-04 04:18:27 +00:00
renovate-bot 12977bc296 Update dependency lucide-static to v1.50.0
CI / full-gate (push) Successful in 4m20s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-10-03 04:18:40 +00:00
renovate-bot dac189b124 Update dependency lucide-static to v1.49.0
CI / full-gate (push) Successful in 4m1s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-09-30 04:18:43 +00:00
renovate-bot c9e2f1a9e4 Update renovate/renovate Docker tag to v44.117.2
CI / full-gate (push) Successful in 3m11s
Mirror / github-mirror (push) Successful in 3s
2026-09-29 04:18:14 +00:00
renovate-bot 7a17ec11de Update axllent/mailpit Docker tag to v1.31.3
CI / full-gate (push) Successful in 3m36s
Mirror / github-mirror (push) Successful in 4s
2026-09-28 04:18:22 +00:00
renovate-bot ec1579a039 Update renovate/renovate Docker tag to v44.115.10
CI / full-gate (push) Successful in 3m7s
Mirror / github-mirror (push) Successful in 4s
2026-09-27 04:18:21 +00:00
renovate-bot 153e77174d Update dependency @types/node to v24.19.0
CI / full-gate (push) Successful in 3m36s
Mirror / github-mirror (push) Successful in 5s
2026-09-26 04:18:08 +00:00
renovate-bot 6e21eb2d4b Update dependency lucide-static to v1.48.0
CI / full-gate (push) Successful in 3m18s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-09-25 04:18:38 +00:00
renovate-bot d8a31def52 Update renovate/renovate Docker tag to v44.111.4
CI / full-gate (push) Successful in 4m5s
Mirror / github-mirror (push) Successful in 4s
2026-09-24 04:18:07 +00:00
renovate-bot 5b49bce690 Update renovate/renovate Docker tag to v44.108.2
CI / full-gate (push) Successful in 2m55s
Mirror / github-mirror (push) Successful in 3s
2026-09-23 04:18:13 +00:00
renovate-bot 584a383d22 Update renovate/renovate Docker tag to v44.106.0
CI / full-gate (push) Successful in 2m54s
Mirror / github-mirror (push) Successful in 4s
2026-09-22 04:18:10 +00:00
renovate-bot 28dbc1a0ff Update renovate/renovate Docker tag to v44.104.2
CI / full-gate (push) Successful in 2m55s
Mirror / github-mirror (push) Successful in 3s
2026-09-21 04:17:58 +00:00
renovate-bot 0f0a842f34 Update axllent/mailpit Docker tag to v1.31.2
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 3s
2026-09-20 04:17:54 +00:00
renovate-bot 88c5357268 Update dependency @types/node to v24.13.6
CI / full-gate (push) Successful in 3m54s
Mirror / github-mirror (push) Successful in 3s
2026-09-19 04:18:32 +00:00
renovate-bot 3bd9adc73b Update dependency lucide-static to v1.47.0
CI / full-gate (push) Successful in 3m3s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 18s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-09-18 04:18:38 +00:00
renovate-bot 51c95b32c1 Update renovate/renovate Docker tag to v44.95.0
CI / full-gate (push) Successful in 3m45s
Mirror / github-mirror (push) Successful in 2s
2026-09-17 04:18:20 +00:00
renovate-bot 3593b39e43 Update dependency @types/node to v24.13.5
CI / full-gate (push) Successful in 3m4s
Mirror / github-mirror (push) Successful in 2s
2026-09-16 04:18:00 +00:00
lilleman 5f9d74ae4f Record that state lives in the URL or on the server, never in a cookie
CI / full-gate (push) Successful in 2s
Mirror / github-mirror (push) Successful in 3s
2026-09-15 14:21:44 +02:00
renovate-bot 79cee25b66 Update dependency lucide-static to v1.46.0
CI / full-gate (push) Successful in 3m3s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-09-15 04:18:12 +00:00
lilleman 2993b462a1 Ask for dependent form fields in steps, one GET form each
CI / full-gate (push) Successful in 2s
Mirror / github-mirror (push) Successful in 3s
2026-09-14 17:06:03 +02:00
renovate-bot 7645f0302a Update renovate/renovate Docker tag to v44.83.2
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 2s
2026-09-14 04:18:17 +00:00
renovate-bot e5531a44cd Update renovate/renovate Docker tag to v44.82.5
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 2s
2026-09-13 04:17:52 +00:00
renovate-bot 3b1cd891ed Update dependency lucide-static to v1.45.0
CI / full-gate (push) Successful in 3m52s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 19s
Release / publish-overview (push) Successful in 4s
Release-Bump: minor
2026-09-12 04:18:26 +00:00
renovate-bot f06040b511 Update dependency lucide-static to v1.44.0
CI / full-gate (push) Successful in 3m4s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 23s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-09-11 04:18:09 +00:00
lilleman 58767cb53e Say what the comment is for, not what the bug was
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 17s
Release / publish-overview (push) Successful in 3s
2026-09-10 16:16:35 +02:00
lilleman 16873662b9 Keep the reader in place when the drawer closes, and stop the CSS promising a header it deleted 2026-09-10 16:16:35 +02:00
lilleman 53ab35bfb8 A page is a document: drop the sticky chrome and the bounded-frame opt-out 2026-09-10 16:16:35 +02:00
lilleman 60aa8a888e Point the contract doc at a heading that exists, and let the table say which axes it scrolls 2026-09-10 16:16:35 +02:00
lilleman 2f29853121 Make the scroll-lock test able to fail, and name the tell a broken chain actually shows 2026-09-10 16:16:35 +02:00
lilleman 41aa59d05d One home per fact: cut the copied rationale, and give the seam test room to mean something 2026-09-10 16:16:35 +02:00
lilleman cb4f8d6e98 Name the table's local after the class it applies, and let a broken chain show itself 2026-09-10 16:16:35 +02:00
lilleman 696da397e7 Split the fill seam where ownership splits: the shell bounds, the page fills 2026-09-10 16:16:35 +02:00
lilleman 7f2c0cc2c2 Name the bounded frame: fill:true on the shell, and the contract minor that ships it 2026-09-10 16:16:35 +02:00
lilleman b9129fba08 Scroll the document by default; a bounded region is a page's own opt-in 2026-09-10 16:16:35 +02:00
lilleman cb4468ff77 Regenerate the icon sprite: lucide 1.39.0 redraws circle-check
CI / full-gate (push) Successful in 2m57s
Mirror / github-mirror (push) Successful in 3s
2026-09-10 16:16:31 +02:00
renovate-bot 5ffa682fe7 Update dependency lucide-static to v1.39.0
Release-Bump: minor
2026-09-10 16:16:31 +02:00
renovate-bot fbc1633a1a Update renovate/renovate Docker tag to v44.75.1
CI / full-gate (push) Successful in 2m48s
Mirror / github-mirror (push) Successful in 3s
2026-09-10 16:14:07 +02:00
renovate-bot bebefeafde Update Node.js to v24.21.0
CI / full-gate (push) Successful in 3m2s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-09-10 04:18:10 +00:00
renovate-bot 465055e699 Update renovate/renovate Docker tag to v44.69.13
CI / full-gate (push) Successful in 2m48s
Mirror / github-mirror (push) Successful in 3s
2026-09-09 04:18:05 +00:00
renovate-bot db56a32e29 Update renovate/renovate Docker tag to v44.69.7
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 3s
2026-09-08 04:18:04 +00:00
renovate-bot 51219a3ee5 Update renovate/renovate Docker tag to v44.65.5
CI / full-gate (push) Successful in 2m48s
Mirror / github-mirror (push) Successful in 3s
2026-09-07 04:17:57 +00:00
renovate-bot f5c3d93837 Update axllent/mailpit Docker tag to v1.31.1
CI / full-gate (push) Successful in 2m46s
Mirror / github-mirror (push) Successful in 3s
2026-09-06 04:17:53 +00:00
renovate-bot ea320f6a28 Update Playwright to v1.63.0
CI / full-gate (push) Successful in 3m47s
Mirror / github-mirror (push) Successful in 2s
2026-09-05 04:18:23 +00:00
renovate-bot d3151f222a Update renovate/renovate Docker tag to v44.61.6
CI / full-gate (push) Successful in 5m14s
Mirror / github-mirror (push) Successful in 4s
2026-09-04 04:18:28 +00:00
lilleman 54956fe627 Release the gate change as 0.3.0, and start a changelog
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 22s
Release / publish-overview (push) Successful in 3s
2026-09-03 17:58:09 +02:00
lilleman 9912dd64f1 Offer a signed-in visitor the page they can reach, and say where the assignee join belongs
CI / full-gate (push) Successful in 2m53s
Mirror / github-mirror (push) Successful in 4s
2026-09-03 17:22:49 +02:00
lilleman 76fa6a96ea Merge branch 'main' into session-gate
CI / full-gate (push) Successful in 2m51s
2026-09-03 12:00:18 +02:00
renovate-bot e81c281c1a Update renovate/renovate Docker tag to v44.59.3
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 4s
2026-09-03 04:18:29 +00:00
lilleman 1fb19f6c26 Merge branch 'main' into session-gate
CI / full-gate (push) Successful in 2m53s
2026-09-02 19:51:46 +02:00
lilleman 18dc4f3136 Refuse an emailless identity where the session is minted, and rows the upstream should not have sent
CI / full-gate (push) Successful in 2m53s
2026-09-02 18:24:56 +02:00
lilleman 390ac5f112 Say where the one-gate rule applies, and record why allows stays open 2026-09-02 14:03:06 +02:00
lilleman 6a0d11d9d3 Name exactly one gate on every declaration, and own a shift by identity id 2026-09-02 14:00:04 +02:00
lilleman dfb043c3bd Read one gate everywhere, and check a declaration's gate in one pass 2026-09-02 12:32:51 +02:00
lilleman 5d1e8f2309 Say the gate rule once, and key the example's shifts by the identifier it scopes on
CI / full-gate (push) Successful in 3m4s
2026-09-02 08:50:44 +02:00
lilleman bf146c07e7 Bump the contract for the new gate, and close the ways it could read as open
CI / full-gate (push) Successful in 2m45s
2026-09-02 08:36:16 +02:00
lilleman a17ed96b54 Say gate where the code now gates, in the two contracts that still said permission
CI / full-gate (push) Failing after 2m38s
2026-09-02 08:17:10 +02:00
lilleman c7e6d66750 Scope My shifts at the upstream, and give Route and NavNode one gate declaration
CI / full-gate (push) Successful in 2m56s
2026-09-02 08:15:08 +02:00
renovate-bot a113d14e42 Update renovate/renovate Docker tag to v44.56.3
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 4s
2026-09-02 05:55:37 +00:00
lilleman 8da75b4ca7 Gate a route or nav node on a session, not only a permission
CI / full-gate (push) Successful in 3m6s
2026-09-02 07:36:08 +02:00
renovate-bot 4ad8653a06 Update renovate/renovate Docker tag to v44.53.0
CI / full-gate (push) Successful in 2m52s
Mirror / github-mirror (push) Successful in 3s
2026-09-01 06:03:12 +00:00
renovate-bot cfcb7a7dc2 Update renovate/renovate Docker tag to v44.52.1
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 3s
2026-08-31 04:18:59 +00:00
renovate-bot 814005d267 Update renovate/renovate Docker tag to v44.51.2
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 3s
2026-08-30 04:18:55 +00:00
renovate-bot d7bc7fbd36 Update renovate/renovate Docker tag to v44.50.3
CI / full-gate (push) Successful in 4m6s
Mirror / github-mirror (push) Successful in 3s
2026-08-29 10:42:26 +00:00
renovate-bot 7bde22713f Update Node.js to v24.20.0
CI / full-gate (push) Successful in 3m13s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: minor
2026-08-28 04:18:08 +00:00
renovate-bot 566c286f87 Update renovate/renovate Docker tag to v44.46.4
CI / full-gate (push) Successful in 2m53s
Mirror / github-mirror (push) Successful in 4s
2026-08-27 04:18:29 +00:00
lilleman 0ad4c6b09c Say what the browser gives a popover trigger, and pin the part all engines can prove
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 3s
2026-08-26 09:10:46 +02:00
renovate-bot ebc1398906 Update renovate/renovate Docker tag to v44.45.4
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 4s
2026-08-26 04:18:07 +00:00
lilleman cf48b3014c Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 4s
2026-08-25 15:09:02 +02:00
lilleman 43bb004ae7 Keep the control-choosing rule where a caller picks one
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 3s
2026-08-25 09:38:33 +02:00
lilleman f4693af3df Let a multi-select say what a capped list left out, inside the panel
CI / full-gate (push) Successful in 2m52s
2026-08-25 09:15:30 +02:00
lilleman c702a347dc Offer a long filter list as a popover multi-select instead of a wall of chips
CI / full-gate (push) Successful in 2m51s
2026-08-25 09:10:23 +02:00
renovate-bot fb480973b7 Update renovate/renovate Docker tag to v44.41.1
CI / full-gate (push) Successful in 2m51s
2026-08-25 04:18:24 +00:00
renovate-bot 26ba278eb3 Update dependency lucide-static to v1.34.0
CI / full-gate (push) Successful in 3m0s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-25 04:18:15 +00:00
renovate-bot 952af3f107 Update renovate/renovate Docker tag to v44.39.3
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 2s
2026-08-24 05:41:22 +00:00
lilleman 83c9fa68a9 Merge branch 'main' into plugin-settings
Release / retag-image (push) Failing after 4s
Release / publish-overview (push) Has been skipped
Mirror / github-mirror (push) Successful in 6s
2026-08-23 16:23:14 +02:00
lilleman fa7cad1d65 Declare plugin settings in the manifest, resolve them from the environment 2026-08-23 13:17:03 +02:00
renovate-bot 3f74bf8832 Update renovate/renovate Docker tag to v44.39.2 2026-08-23 04:18:02 +00:00
lilleman aff47c8b90 Merge branch 'main' into auto-release-only-when-affected
Mirror / github-mirror (push) Successful in 5s
Release / retag-image (push) Failing after 4s
Release / publish-overview (push) Has been skipped
2026-08-22 22:16:22 +02:00
lilleman 9a1bfcc69d Follow mailpit to v1.31.0 in the published quick start 2026-08-22 21:58:14 +02:00
lilleman 075cd2f090 Merge branch 'main' into renovate/renovate-renovate-44.x 2026-08-22 21:54:19 +02:00
lilleman fd32bea28e Merge branch 'main' into auto-release-only-when-affected 2026-08-22 21:53:53 +02:00
lilleman 47f4498fff Use one Docker Hub credential for images and the overview 2026-08-22 14:27:31 +02:00
lilleman bcff5967ad Name the scope the overview actually needs 2026-08-22 14:21:14 +02:00
lilleman cbf55bebae Give each version mismatch its own remedy, and stop publishing a moving bare-major tag 2026-08-22 12:27:43 +02:00
lilleman 9e0cb26b3e Refuse a 0.x minor mismatch, and republish the overview from the named release's tree 2026-08-22 12:13:41 +02:00
lilleman 02617a935e Keep the proven always() guard on the overview job 2026-08-22 12:03:18 +02:00
lilleman 8fd492e544 Scope the sidecar trailer by package so a mixed branch cannot lose it 2026-08-22 12:01:35 +02:00
lilleman 6c5eddf0c9 Accept the README-only gate skip, and say why it is safe 2026-08-22 11:58:28 +02:00
lilleman f416abf936 Manage the published quick start's pins, and fail closed on every release-tooling edge 2026-08-22 11:45:46 +02:00
lilleman e44b86f532 Gate the overview's manual trigger on the same contract check as a tag 2026-08-22 11:27:35 +02:00
lilleman bfcf4ed072 Trim the prose to what is true now 2026-08-22 11:24:16 +02:00
lilleman a3d9a3df5f Keep the release at v0.1.0 — nothing consumed the old contract 2026-08-22 11:21:42 +02:00
renovate-bot 5befa2775a Update renovate/renovate Docker tag to v44.39.1 2026-08-22 04:18:35 +00:00
renovate-bot d3b227eef8 Update axllent/mailpit Docker tag to v1.31.0 2026-08-22 04:18:30 +00:00
renovate-bot a96a2b8abd Update renovate/renovate Docker tag to v44.37.1 2026-08-21 04:18:10 +00:00
lilleman d545445ea8 Release this as v0.2.0, and give the Hub overview its own job, token and template 2026-08-20 23:32:10 +02:00
lilleman c35ba3fb4e Make the plugin contract version the release version, and publish the Docker Hub overview from CI 2026-08-20 23:16:11 +02:00
lilleman 27a8cdc385 Release nothing when no Renovate commit reached the app 2026-08-20 23:02:21 +02:00
lilleman 4b48bc2416 Scope Release-Bump to the surfaces that ship, and drop the inert package.json versions 2026-08-20 22:43:36 +02:00
lilleman ef96ebd1f4 Rewrap the auto-release paragraph 2026-08-20 22:04:23 +02:00
lilleman ecf33733a1 Lift the pre-release freeze — releases resume and the plugin contract goes live 2026-08-20 22:02:29 +02:00
lilleman c16fb2449b Merge branch 'main' into renovate/renovate-renovate-44.x 2026-08-20 21:52:27 +02:00
lilleman d823f830c3 Merge remote-tracking branch 'origin/main' into plugin-storage
# Conflicts:
#	package-lock.json
#	package.json
2026-08-20 21:47:22 +02:00
renovate-bot 1a1aad90e3 Update dependency lucide-static to v1.33.0
Release-Bump: minor
2026-08-20 04:17:56 +00:00
lilleman 45b3cfc010 Merge remote-tracking branch 'origin/main' into plugin-storage
# Conflicts:
#	package-lock.json
#	package.json
2026-08-19 20:34:30 +02:00
lilleman a3cb1f4840 Merge branch 'main' into renovate/renovate-renovate-44.x 2026-08-19 20:29:09 +02:00
renovate-bot 0f40d06f3b Update renovate/renovate Docker tag to v44.33.2
Release-Bump: minor
2026-08-19 04:18:20 +00:00
renovate-bot b39defe39d Update dependency lucide-static to v1.32.0
Release-Bump: minor
2026-08-19 04:18:11 +00:00
lilleman e0046e5068 Warn rather than refuse on a storage URL mismatch, and scrub the provisioning DSN before discovery 2026-08-19 01:03:40 +02:00
lilleman 5589472e25 Keep role re-assertion within a non-superuser provisioner's rights, and test the second boot 2026-08-19 00:44:49 +02:00
lilleman e66a8a3e89 Isolate the storage CI stack, prove least-privilege provisioning, drop the secret before discovery 2026-08-19 00:20:17 +02:00
lilleman 060535c8ab Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL 2026-08-19 00:08:35 +02:00
lilleman d2211cf75a Refuse a throwaway plugin storage secret in bootstrap, before any role is created 2026-08-18 23:24:34 +02:00
lilleman c7013be2f0 Give a plugin a Postgres database of its own 2026-08-18 23:12:13 +02:00
lilleman 9ff8f57509 Todo: note the code-field hint landed, the paste fix did not 2026-08-18 22:07:21 +02:00
lilleman 8f3fc7414a Hint the code field's digits-only rule, so the browser's refusal isn't bare 2026-08-18 22:07:12 +02:00
lilleman 552cc6bd97 Todo: record the flow-POST proxy findings for the verification-code fix 2026-08-18 22:00:31 +02:00
lilleman 52a3228503 Todo: record the manifest-over-.env decision for plugin config 2026-08-18 21:55:35 +02:00
lilleman ee7b78b94d Let Renovate reach the example plugins' manifests 2026-08-18 21:55:35 +02:00
lilleman 65e76b69fd Refuse a stray package.json or node_modules in config/ by name 2026-08-18 21:55:32 +02:00
lilleman e00dad8ed7 Merge branch 'main' into plugin-dependencies 2026-08-18 18:33:24 +02:00
lilleman cb59eee76d Refuse a node_modules at the plugins/ root, where it outranks the host's 2026-08-18 08:14:21 +02:00
lilleman 82af77356f Follow symlinked plugin folders, and keep a plugin .npmrc out of the image 2026-08-18 07:49:51 +02:00
lilleman 94dc581593 Fail loud on a null package.json and a stray plugins/package.json 2026-08-17 22:50:29 +02:00
lilleman 453058c67b Refuse a shadowing barrel copy, and record the packaging contract 2026-08-17 22:34:56 +02:00
lilleman af974cfa36 Let a plugin carry its own package.json and npm dependencies 2026-08-17 22:23:53 +02:00
145 changed files with 8061 additions and 2471 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: | run: |
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
+66 -7
View File
@@ -1,13 +1,29 @@
name: Release name: Release
# The Hub overview names a released tag, so it is published here only, never on a push to main.
on: on:
push: push:
tags: ['v[0-9]+.[0-9]+.[0-9]+'] tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
overview_version:
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
required: true
jobs: jobs:
retag-image: retag-image:
if: github.event_name == 'push'
runs-on: docker-host runs-on: docker-host
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
# Before anything is published: the contract version IS the release version, so a tag that
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
- name: Refuse a tag that disagrees with HOST_API_VERSION
env:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest - name: Promote the commit-hash image to semver + latest
env: env:
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
@@ -15,34 +31,77 @@ jobs:
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }} REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \ docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } || { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do # No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$REPO:$TAG" docker tag "$REPO:$COMMIT" "$REPO:$TAG"
docker push "$REPO:$TAG" docker push "$REPO:$TAG"
done done
- name: Sync the release tags to Docker Hub - name: Sync the release tags to Docker Hub
env: env:
DOCKERHUB_REPO: docker.io/${{ github.repository }} DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do TAGS="$VERSION ${VERSION%.*} latest"
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG" if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
docker push "$DOCKERHUB_REPO:$TAG" for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG"
done done
- name: Log out of the registries - name: Log out of the registries
if: always() if: always()
run: | run: |
docker logout gitea.larvit.se set -uo pipefail
docker logout docker.io # Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
# fail a release that published, nor skip the overview job that follows.
docker logout gitea.larvit.se || true
docker logout docker.io || true
publish-overview:
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
needs: [retag-image]
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
if: github.event_name == 'push'
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
# release's sidecar pins. A version that was never released fails here.
- uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch'
with:
ref: refs/tags/v${{ inputs.overview_version }}
- name: Publish the Docker Hub overview
env:
DOCKERHUB_REPO: ${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.overview_version }}
run: |
set -euo pipefail
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.22.2-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+15 -8
View File
@@ -21,25 +21,25 @@ jobs:
-e RENOVATE_PLATFORM=gitea \ -e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \ -e RENOVATE_TOKEN \
renovate/renovate:44.33.2 renovate/renovate:44.186.6
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit # trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's # (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the # pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
auto-release: auto-release:
runs-on: docker-host runs-on: docker-host
needs: renovate needs: renovate
if: vars.AUTO_RELEASE == 'true'
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
# A persisted token outranks the PAT in the push URL, and Gitea runs nothing for its pushes.
persist-credentials: false
- name: Tag a release for what Renovate merged - name: Tag a release for what Renovate merged
env: env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
@@ -57,8 +57,15 @@ jobs:
fi fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ if [ -z "$BUMPS" ]; then
node auto-release/next-version.ts "$LATEST" $BUMPS) echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
+147 -53
View File
@@ -36,11 +36,22 @@ branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`). **A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** reached over their `position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
REST APIs with built-in `fetch` — no SDK. New capabilities ship as **plugin folders** under reverse-engineer, and is one more thing to undo before the content under it can be reached.
`plugins/` that fetch their data from upstream services, not as core code. Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
document rather than holding it still, and have no other spelling — the document keeps scrolling
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
nothing in a test or a console says content is unreachable below the fold.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
service or their own database, not as core code.
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, 3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types; `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
limit nullable and multi-option types. limit nullable and multi-option types.
@@ -79,6 +90,49 @@ Revisit only if the stated reason stops holding.
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which `node_modules`: two instances of the barrel break `instanceof` across the boundary, which
`plugin-api.test.ts` guards by asserting both paths reach one module. `plugin-api.test.ts` guards by asserting both paths reach one module.
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
both halves, because one value imported from the wrong module breaks it invisibly). It is never
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
packaging promise, and a plugin must still depend on its own driver.
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
containment, and README says so rather than implying a sandbox. Consistent with priority #7
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
point, so move it earlier if anything, **never later**. **Valid while plugins are
operator-installed code, not third-party uploads.**
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
so under the least-privilege provisioning account the README recommends it would report success
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
rename. Reconsider when a third seeding concern lands.
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves - **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment. dependencies; if that changes, it needs the same package treatment.
@@ -137,20 +191,39 @@ Revisit only if the stated reason stops holding.
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
verb — a route table must answer "what does this need?" on its own. verb — a route table must answer "what does this need?" on its own.
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
builds header nodes that carry no gate. Making `allows` fail closed would hide every
operator-grouped section. `session` exists because a plugin whose data is
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
name; the alternative, granting every newly registered user a permission, couples the identity
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
who would then inherit the previous holder's rows.
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry - **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders, `canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
need a `curl` against Keto to undo), and a permission held *through a group* renders need a `curl` against Keto to undo), and a permission held *through a group* renders
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group ticked-but-disabled, because unticked stated the opposite of the truth. The group paths are
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it guarded too: unticking a permission on a group you are in, removing yourself (or a nested group you
can still strip your own access. The robust "last effective holder" check needs a reverse Keto are in) from its members, or deleting the group is refused when you would lose a permission you
query and is deferred. don't hold directly.
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write` - **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
adds you to any group, including one holding every permission; `users:write` mints a recovery code adds you to any group, including one holding every permission; `users:write` mints a recovery code
for any account. The containment the split buys is real on the **read** half only (`users:read` is for any account. The containment the split buys is real on the **read** half only (`users:read` is
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs. a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed - **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
Kratos' wire shape — don't rename it. Kratos' wire shape — don't rename it.
@@ -173,7 +246,8 @@ Revisit only if the stated reason stops holding.
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list - **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`, page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's `localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
`data` so a collision loses the key instead of breaking the shell. `data` so a collision loses the key instead of breaking the shell — as is `assetHref`
(`src/http/static.ts`), which stays undocumented for plugins because calling it is not yet contract.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US` - **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
@@ -209,11 +283,18 @@ Revisit only if the stated reason stops holding.
open-state style and the old-browser fallback both read; the partial **requires a caller-named open-state style and the old-browser fallback both read; the partial **requires a caller-named
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one — `id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the `aria-haspopup` is written**: every engine maintains the first itself on a declarative
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where `popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
declarative attribute's alone** — open a panel from script and no engine applies it, so
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step. the two in step.
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
a label click focuses it, and a browser scrolls a focused element into view.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
@@ -252,31 +333,23 @@ Revisit only if the stated reason stops holding.
console message only appears in the engine that renders the page (`ORY_FREE` in console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend, `e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
so widening them means a stack per engine. so widening them means a stack per engine.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root** — no test, build step or - **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
load-bearing. `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown tests read, and a README-only change skips them. Accepted:
the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are illustrative, and
the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page command the
README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated change. **Valid while no markdown file is rendered or executed.**
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites. single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
- **Plainpages is pre-announcement: no tags, no releases.** `auto-release` is gated behind the
`AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe direction on every unknown-`vars`
path) — a version only communicates to consumers and there are none. Two couplings:
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged, so
with zero tags a hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune`
(its `fetch-tags: true` is load-bearing), so a tag or Release created on GitHub is swept away and
releases are cut on Gitea only. Valid until the maintainer says Plainpages is ready to show people.
- **A stricter manifest rule breaks already-copied plugins**, and while `HOST_API_VERSION` is frozen
the failure names a symptom rather than the cause — `checkApiVersion` would refuse a stale plugin by
*version*, but only once the freeze lifts. Until then a stricter rule ships with a README →
Upgrading entry and a re-copy hint in the discovery error. Fail-loud stays right either way: the
alternative is a route gating on a name nobody can be granted, i.e. a permanent silent 403.
**Valid while `HOST_API_VERSION` stays frozen.**
## Docker only — no host tooling ## Docker only — no host tooling
@@ -286,6 +359,7 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
```bash ```bash
docker compose up # dev server, live reload docker compose up # dev server, live reload
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory) docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
docker compose run --rm --no-deps web npm test # tests docker compose run --rm --no-deps web npm test # tests
docker compose -f compose.yml up --build -d # production docker compose -f compose.yml up --build -d # production
``` ```
@@ -298,7 +372,7 @@ docker compose -f compose.yml up --build -d # production
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
copy-pasteable; deeper detail lives in its own section, linked. copy-pasteable; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered 2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Glossary → Users, groups
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
map → Extending. Place a new section by how early an adopter needs it. **Users, groups & map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
@@ -319,21 +393,30 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
`package.json`s, so `.js` is ESM. `package.json`s, so `.js` is ESM.
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit. - **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
- Before finishing a change, run the typecheck and tests above; both must pass. - Before finishing a change, run the typechecks and tests above; all must pass.
- Tests use the built-in `node --test` runner — no test framework dependency. - Tests use the built-in `node --test` runner — no test framework dependency.
- English everywhere. - English everywhere.
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never - Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
by tag. by tag.
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive - **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
contract changes. With no third-party plugin in the wild a bump can only produce noise. The rules name exactly what carries the trailer, so a dependency outside them never escalates the
promotion trigger is the first external plugin — from then on follow the versioning table in release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
README → Contract versioning. **The frozen surface includes `views/partials/*.ejs`**: the view type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
resolver makes every core partial an `include()` root for a plugin's views, so their option names and let it ride the next patch.
and emitted markup are author-visible. Know the hole that leaves — discovery fails loud on a bad - **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
`apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
must cover the partial vocabulary too. digit may lag on purpose: `checkApiVersion`
- **The frozen surface also includes the packaging promises** (README → Plugin dependencies): the ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root
for a plugin's views, so their option names and emitted markup are author-visible. Know the hole
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot
police for you.
- **The contract surface also includes the packaging promises** (README → Plugin dependencies): the
barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is
mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the
partials — move the publish point, rename the package or start hoisting and every installed plugin partials — move the publish point, rename the package or start hoisting and every installed plugin
@@ -341,9 +424,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
build-time dedupe for baked images stays open, module-instance sharing stays unpromised. build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is - **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a `private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
broken tree. The trigger is the same as the freeze's: the first external plugin, which is also the broken tree. The trigger is the first plugin author outside this repo — the first who cannot
first author who cannot typecheck against a mounted host tree. Whoever does it must first make the typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
artifact self-contained (types-only `.d.ts`, or move the barrel into `plugin-api/`). (types-only `.d.ts`, or move the barrel into `plugin-api/`).
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built - A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire. the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
@@ -352,11 +435,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
into a small `withX` wrapper — see `examples/plugins/admin/`. into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking - **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin `src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; `grandfathered` list or raise a ceiling there.
what remains is orchestration. Planned split along those seams; don't grow it further without
taking one out.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents. - Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in - **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own `src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
@@ -369,9 +450,12 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
it matters. **Held by the author, never by a test:** slightly different wording is often the right it matters. **Held by the author, never by a test:** slightly different wording is often the right
call, and a build-failing check takes that judgment away. call, and a build-failing check takes that judgment away.
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for - Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and
`ids=x,y`. pagination. A message for the page a redirect lands on rides its query string — `info-msg`,
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`).
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
## Comments ## Comments
@@ -379,3 +463,13 @@ Default to **no comment**. Delete one that restates the adjacent code, repeats a
elsewhere, justifies self-evident code, or records history. Write one only for what a competent elsewhere, justifies self-evident code, or records history. Write one only for what a competent
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
constraint. See [Prose discipline](#prose-discipline). constraint. See [Prose discipline](#prose-discipline).
## Scoring run
The comprehension panel's seats fill their prompts from these fields.
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
- **Kind:** a server-rendered web GUI host with a plugin system
- **Domain:** web authentication, authorization and server-rendered admin GUIs
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
+241
View File
@@ -0,0 +1,241 @@
# Changelog
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
## Unreleased
### A malformed URL variable's boot error no longer prints the value
When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed,
the boot error names the variable and not its value, so a DSN whose password breaks parsing doesn't
put the password in the log.
### A secret plugin setting's bad value stays out of the boot error
When a `secret` setting's value fails validation, the boot error no longer prints it, so a malformed
DSN doesn't put its password in the log.
### The admin's recovery-code notice links to the recovery flow the code belongs to
After an admin generates a recovery code for a user, the notice asks them to send the user both the
code and its link. The link opens the recovery flow the code belongs to, where the user enters it.
Before, the notice linked a new recovery flow at its email step, which doesn't accept the code.
### A verification or recovery code pasted with whitespace around it is accepted
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows.
### A filter shows its selected and focused choice in browsers without `:has()`
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
their native radio or checkbox, which marks the selected choice and takes the keyboard focus ring.
Before, they showed neither. Newer browsers look as before.
### The tests fail on a stylesheet feature the browser floor lacks
`npm test` checks every stylesheet in `public/`, `examples/` and `plugins/` against Chromium 87,
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an `@supports` around it
names that feature. Mount a plugin in `plugins/<id>/` to check its stylesheets. Pages render as
before in every browser.
### Every release reaches Docker Hub, and the Hub page has a short description
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
the Gitea registry; they are now, and every later release is. The Hub page gains a one-line
description.
### The admin example stops an administrator locking themselves out through a group
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested
group you are in) from its members, or deleting the group is refused when it would take away a
permission you don't hold directly. The page names the permissions, and another administrator can
make the change. The check errs towards refusing: it also refuses when you would keep the permission
through another group, or keep your membership another way.
### The admin example titles a read-only user page "View user"
A holder of `users:read` without `users:write` sees the user page titled "View user", with a "View"
breadcrumb, instead of "Edit user".
### Browsers cache stylesheets instead of downloading them on every page
Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as
immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and
the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script
a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or
`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses.
### A language chosen on a sign-in link no longer drops an absolute return target
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
language is lost across that round-trip instead.
### The admin example refuses a permission save made from an out-of-date page
Two administrators editing one user's or group's permissions no longer overwrite each other. The
form carries a token for the grants it showed. A save that would change something, made after those
grants changed, is refused with "These permissions changed while you had the page open", and the page
shows the current grants. A POST with no token is refused the same way, so a script posting to
`/admin/users/:id/permissions` or `/admin/groups/:name/permissions` must first read the token from the
page's `baseline` field.
### The admin example confirms a permission save and reports a partial one
A save now lands on the page with "Permissions saved." If the permission service fails midway, the
page says the save stopped part-way instead of showing the generic error page. On a user, the
changes that landed still reach the user's live sessions at once when the revocation denylist is on.
### A read-only holder sees View, not Edit, on the admin example's users list
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
example's catalogs gain `admin.grants.partial`, `admin.grants.saved`, `admin.grants.stale` and
`admin.users.view`, so a translated catalog mounted under `locales/plugins/admin/` must add them.
### Every page carries a smaller icon sprite
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
another id from `views/partials/icons.ejs`, or inline your own SVG.
### The README defines its terms and states its intended scale
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin
and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins
per deployment.
### The mobile nav stays reachable on engines without `:has()`
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
### The whole nav tree shows on engines without `:has()`
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
`:has()`, is hidden there.
### Button and badge text meets 4.5:1 contrast
The primary button and the current page button take their text colour from the new `--on-accent`
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
### Unused auth stylesheet rules are gone
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
view that did must carry its own rules.
### Colours render on engines without `oklch()`
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
should use hex, `rgb()` or `hsl()` too.
### One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together.
### Pages are not kept by a shared cache
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
whose page is the same for everyone can still set its own `cache-control` through
`RouteResult.headers`; assets are unchanged.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
### The document scrolls, and the chrome scrolls with it
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
Either let it, or give the region its own height in the plugin's stylesheet.
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
**Sign out** — sits at the end of that page rather than the bottom of the screen.
## 0.3.0
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
### A session is a gate of its own
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
fails the boot instead.
- Naming **two** is refused, as before.
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
while reading as if they set one.
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
host still drops a header whose children all filtered out.
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
### Filter bars take a multi-select
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
### Fixed
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
and no way to tell why.
### Dependencies
- Node 24.20.0.
### Upgrading a plugin
1. Set `apiVersion: "0.3.0"`.
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
`public: true` outright.
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.19.0-alpine3.24 FROM node:24.22.2-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules # Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root). # instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+406 -84
View File
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -68,9 +68,22 @@ From here, render real pages against the app shell and fetch upstream data — s
[Building plugins](#building-plugins) and the runnable reference in [Building plugins](#building-plugins) and the runnable reference in
[`examples/plugins/scheduling/`](examples/plugins/scheduling/). [`examples/plugins/scheduling/`](examples/plugins/scheduling/).
**Without a clone**, the image runs a trial stack on its own. Save the `compose.yml` the
[Docker Hub page](https://hub.docker.com/r/larvit/plainpages) shows, then extract the Ory config the
image ships and start, with `X.Y.Z` the release that page names. The page goes on to a first plugin:
```bash
docker run --rm larvit/plainpages:X.Y.Z tar -cf - ory | tar -xf -
mkdir -p plugins
docker compose up -d
```
## Contents ## Contents
- [Goals](#goals)
- [Audience](#audience)
- [Overview](#overview) - [Overview](#overview)
- [Glossary](#glossary)
- [Users, groups & permissions](#users-groups--permissions) - [Users, groups & permissions](#users-groups--permissions)
- [naming a permission](#naming-a-permission) - [naming a permission](#naming-a-permission)
- [a worked example](#a-worked-example) - [a worked example](#a-worked-example)
@@ -89,6 +102,8 @@ From here, render real pages against the app shell and fetch upstream data — s
- [hooks](#hooks) - [hooks](#hooks)
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
- [dependencies](#plugin-dependencies) - [dependencies](#plugin-dependencies)
- [settings](#plugin-settings)
- [storage](#plugin-storage)
- [local dev & test](#local-dev--test-story) - [local dev & test](#local-dev--test-story)
- [The menu system](#the-menu-system) - [The menu system](#the-menu-system)
- [Building blocks](#building-blocks) - [Building blocks](#building-blocks)
@@ -106,7 +121,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [security model](#security-model) - [security model](#security-model)
- [Email](#email) - [Email](#email)
- [Architecture](#architecture) - [Architecture](#architecture)
- [Stateless](#stateless) - [Stateless core](#stateless-core)
- [Testing](#testing) - [Testing](#testing)
- [end-to-end](#end-to-end-playwright) - [end-to-end](#end-to-end-playwright)
- [the full gate](#the-full-gate-one-command) - [the full gate](#the-full-gate-one-command)
@@ -119,6 +134,64 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Project layout](#project-layout) - [Project layout](#project-layout)
- [Extending the core](#extending-the-core) - [Extending the core](#extending-the-core)
## Goals
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
its own domain, not at sign-in, a menu or a table.
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
forms built only from the core building blocks.
- Counter-signal: the core grows features only one project needs.
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
2. **Achieve:** Every building block works for the person using it: a non-technical user
gets what they meant on the first try, and can't hurt themselves by accident.
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
code being refused, a double click acting twice, or an admin locking themselves out.
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
know about.
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
plugin author.
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
else is a plugin.
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
on old and embedded browsers too. A newer browser only makes it nicer.
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
database, never in local storage or JavaScript. A cookie carries only what must be bound
to the browser: the session and its CSRF token.
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
styles meaning, not looks. That keeps every page accessible.
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
host, and fewer plugins need a host change to get unblocked.
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
shows up at runtime instead of at boot.
- Why: freedom that breaks silently costs more than the boilerplate it saved.
8. **Achieve:** Speed: pages render fast and stay small.
- Signal: server time per page and bytes sent per page fall.
- Counter-signal: a building block loses a feature or accessibility to save bytes.
- Why: a fast page that does less only moves the work back into each plugin.
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
examples alone.
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
test on the first try.
- Counter-signal: the README grows to restate what the code already shows.
- Why: docs that pass the test by growing cost every later reader, human or LLM.
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
environment.
## Audience
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
configuration through environment variables and the drop-in mounts (`plugins/`,
`config/`, `locales/`), and on upgrades within a minor version that need no change on
their side.
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
work without JavaScript, with a keyboard and a screen reader.
## Overview ## Overview
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu, Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
@@ -134,11 +207,9 @@ public front with an authenticated area behind it.
- **You add:** everything else domain-specific, as **plugins** — built from the same building - **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the admin plugin uses. blocks the admin plugin uses.
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only, Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity, services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
same I/O-free JWT check, and there is no app database.
**Plugins are the extension model.** The plugin API is the product's main surface: powerful, **Plugins are the extension model.** The plugin API is the product's main surface: powerful,
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
@@ -150,6 +221,24 @@ feature removes the need for JS (theme switching, popovers, disclosure) we use i
avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible
(see [AGENTS.md](AGENTS.md)). (see [AGENTS.md](AGENTS.md)).
## Glossary
- **Host** — the `web` container: the Node process that discovers plugins, serves every page and
owns no state of its own. Everything outside it (a plugin's upstream, Ory) is reached over the network.
- **Plugin** — a folder under `plugins/` that adds domain features.
- **Manifest** — the object a plugin's `plugin.ts` exports as its default: its routes, nav,
permissions and hooks. The host checks it at boot and refuses a bad one ([The manifest](#the-manifest)).
- **Chrome** — everything on a page that isn't the page's own content: brand, menu, user, theme and
CSRF token. A handler gets it as `ctx.chrome` ([RequestContext](#requestcontext)).
- **Nav fragment** — the `nav` entries one plugin contributes. The host merges every plugin's
fragment into the one global menu and filters it per user ([The menu system](#the-menu-system)).
- **Permission name** — a `<resource>:<action>` string such as `scheduling:write`, granted in Keto and
carried in the session JWT's `permissions` claim ([Naming a permission](#naming-a-permission)).
- **System plugin** — a plugin that administers Plainpages' own identity stack, through the
privileged `ctx.system` ([System capabilities](#system-capabilities-the-ctxsystem-surface)).
- **Denylist** — the optional in-memory list, per instance, of subjects revoked just now, so a
deactivation or a user's own permission change lands before the JWT expires ([Instant revoke](#instant-revoke-the-optional-denylist)).
## Users, groups & permissions ## Users, groups & permissions
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
@@ -229,6 +318,7 @@ Against the reference plugins' actual routes:
| Request | Gate | alice | bob | carol | anonymous | | Request | Gate | alice | bob | carol | anonymous |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ | | `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` |
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
@@ -347,9 +437,9 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and gate-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
@@ -359,7 +449,7 @@ export default definePlugin({
{ description: "Create and edit things", name: "things:write" }, { description: "Create and edit things", name: "things:write" },
], ],
// Route handlers, mounted under the plugin's path (/things). `permission` gates first. // Route handlers, mounted under the plugin's path (/things). The gate runs first.
routes: [ routes: [
{ method: "GET", path: "/", permission: "things:read", handler: listThings }, { method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings }, { method: "POST", path: "/", permission: "things:write", handler: createThings },
@@ -376,22 +466,24 @@ folder-derived `id` to produce the loaded `Plugin`.
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). | | `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
| `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). |
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
### Routes & handlers ### Routes & handlers
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login` the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page. with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
`public: true` means no gate at all (see [Public pages](#public-pages--menu-items)).
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. `method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
@@ -428,12 +520,15 @@ export async function listThings(ctx: RequestContext) {
nested names like `"things/edit"` work, out-of-bounds names are refused. The template may nested names like `"things/edit"` work, out-of-bounds names are refused. The template may
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. `/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
The host adds the file's content hash to every `/public/` href the shell renders, so browsers and
any cache in front of Plainpages keep the file for a year; a `/public/` URL your own markup writes
is kept for five minutes, then revalidated.
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`: - **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and `requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw `check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
`new GuardError(403, …)` after a failed `can`/`check` to render the 403 page. `new GuardError(403, …)` after a failed `can`/`check` to render the 403 page.
- The handler **fetches its own data** from upstream; plugins hold no state (see - The handler **fetches its own data** — from upstream, or from the plugin's own
[Stateless](#stateless)). [storage](#plugin-storage); the host holds none of it (see [Stateless core](#stateless-core)).
- Default status: `200` for `view`/`html`/`json`, `303` for `redirect`. - Default status: `200` for `view`/`html`/`json`, `303` for `redirect`.
#### Escaping & the trust boundary #### Escaping & the trust boundary
@@ -466,7 +561,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "0.4.0",
home: landing, // owns "/" — the public front page home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home dashboard: board, // owns "/dashboard" — the post-login app home
}); });
@@ -565,20 +660,28 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the permissions in the session JWT: a applies the central override and then **filters per user** by the permissions in the session JWT: a
node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon` node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
there.
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the **Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
children need *different* permissions, leave the header ungated and gate each child — `composeNav` children need *different* permissions, mark the header `public: true` — it then gates nothing, each
drops a header whose children all filtered out. That only works while the header carries **no child decides, and `composeNav` drops a header whose children all filtered out. That only works while
`href`**: give it one and it survives as an ungated leaf, visible to everyone. the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
#### Public pages & menu items #### Public pages & menu items
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu. A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu —
That is the same as omitting `permission`, but stated outright so public is a deliberate choice open stated outright, so it is a deliberate choice rather than a forgotten gate.
rather than a forgotten gate. The two are **mutually exclusive** — declaring both is refused at boot.
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
plugin instead of publishing a page.
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden, shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
@@ -591,22 +694,30 @@ works without editing host config.
### Contract versioning ### Contract versioning
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was
against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one
manifest/handler change and **minor** on an additive one. At discovery it parses both with version off the image they run and writes it down — there is no separate contract number. Both
`parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift.
semantics in `checkApiVersion`:
Patch releases are invisible here — `checkApiVersion` ignores the patch digit, which is what lets
dependency updates ship continuously without touching any plugin. At discovery the host parses both
versions with `parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies
provider/consumer semantics in `checkApiVersion`:
| Plugin `apiVersion` vs host | Result | Host action | | Plugin `apiVersion` vs host | Result | Host action |
| --- | --- | --- | | --- | --- | --- |
| same major, same minor (patch ignored) | `ok` | load | | same major, same minor (patch ignored) | `ok` | load |
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist | | **major `0`**, plugin minor **<** host minor | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot |
| same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes |
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
| different major | `refuse` | **abort boot** — incompatible contract | | different major | `refuse` | **abort boot** — incompatible contract |
| missing / not a valid semver | `refuse` | **abort boot** — must be declared | | missing / not a valid semver | `refuse` | **abort boot** — must be declared |
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
the caret-style compatibility. the compatibility. One digit carries the whole release, so a **minor** means either the plugin
contract changed or a dependency moved far enough to warrant one.
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
### Conflict rules ### Conflict rules
@@ -624,7 +735,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a `home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
route/nav node setting both `public` and `permission`. route/nav node that does not name [exactly one gate](#public-pages--menu-items).
### Hooks ### Hooks
@@ -632,10 +743,13 @@ Optional, for reacting to system actions. A plugin's `hooks` may implement:
| Hook | When | May | | Hook | When | May |
| --- | --- | --- | | --- | --- | --- |
| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config | | `onBoot(host)` | after discovery, before the server listens | warm caches, validate upstream config, open a [storage](#plugin-storage) connection |
| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` | | `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` |
| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response | | `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response |
`onBoot`'s `host` is a `BootContext`, carrying `storage` for a plugin that declared it. A hook
written without a parameter stays valid.
Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that
reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits
— later hooks and the route handler are skipped, and that result renders against its own plugin's — later hooks and the route handler are skipped, and that result renders against its own plugin's
@@ -653,7 +767,7 @@ getting its folder there.
bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up. bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up.
**2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin **2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin
folder onto `/app/plugins/<id>` with a small compose override. Plugins are stateless, so folder onto `/app/plugins/<id>` with a small compose override. A plugin folder is code, not data —
mount it read-only: mount it read-only:
```yaml ```yaml
@@ -711,6 +825,9 @@ included, since the plugin folder *is* the repo. A baked image needs no extra st
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot. contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
- **Plainpages is sized for a handful of plugins per deployment, about ten.** Each plugin carries
its own dependency tree, so every one adds to disk use and to the memory of the `web` process;
hundreds of plugins is not a goal.
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package - **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
each get their own copy at their own version, so neither can break the other by upgrading — and each get their own copy at their own version, so neither can break the other by upgrading — and
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
@@ -724,6 +841,138 @@ barrel's types on disk: typecheck it mounted under the host tree, or vendor a ty
`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery `node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery
refuses, and it would travel with the folder you mount. refuses, and it would travel with the folder you mount.
### Plugin settings
A plugin declares the configuration it accepts, and the host resolves it from the environment at
boot. Each key becomes one variable — `PLUGIN_SETTING_<ID>_<KEY>`, the id's dashes and the key's
camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by
`PLUGIN_SETTING_SCHEDULING_UPSTREAM`.
```ts
export default definePlugin({
apiVersion: "0.4.0",
settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 },
{ key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" },
{ key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" },
],
hooks: {
onBoot: ({ settings }) => {
settings.upstream; // string — required, so the boot already refused without it
settings.pageSize; // number — defaulted, so always present
start(settings);
},
},
});
```
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
coerced and checked at boot, so a mistyped value names the plugin and the variable instead
of surfacing later as a broken page.
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives
follows them exactly: `required: true` is always present, a `default` is always present, and a
setting with neither is `T | undefined`, so the plugin has to handle its absence.
**Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not
in an error, not on the admin screen, which shows only whether it resolved and from where. With
`REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses
the boot — the same rule the host applies to its own secrets.
**Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing
`required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no
installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
`onBoot` warns too: they resolve, but nothing receives them.
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
each, and whether the value came from the environment or the declared default.
### Plugin storage
A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres
**database and login role of its own** — both named `plugin_<id>` — and hands the credentials to
`onBoot`:
```ts
import postgres from "postgres"; // your dependency, not the host's
import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>;
export default definePlugin({
apiVersion: "0.4.0",
storage: true,
hooks: {
onBoot: async (boot) => {
if (!boot.storage) throw new Error("things: storage was not provisioned");
sql = postgres(boot.storage.url);
// Every web instance runs onBoot, and concurrent CREATE TABLE IF NOT EXISTS is an error in
// Postgres — the lock is released when the transaction ends.
await sql.begin(async (tx) => {
await tx`SELECT pg_advisory_xact_lock(hashtext('things:schema'))`;
await tx`CREATE TABLE IF NOT EXISTS things (id uuid PRIMARY KEY, name text NOT NULL)`;
});
},
},
});
```
`boot.storage` is a `StorageCredentials` — `database`, `host`, `password`, `port`, `user`, and `url`,
the same values pre-assembled as a DSN, which most clients take directly. It is typed optional, so
the guard above is expected of every storage plugin rather than a sign something is wrong.
**Credentials, not a client.** The host has no opinion on how you reach Postgres: depend on
`postgres`, `pg`, a query builder or an ORM ([Plugin dependencies](#plugin-dependencies)). No driver
is part of the contract, so upgrading yours is yours alone to time. The flip side is that pool sizing
is yours too — keep yours under `PLUGIN_DB_CONNECTION_LIMIT` (default 10), the per-role ceiling the
host sets so one plugin cannot exhaust the Postgres this stack shares with Ory.
**The schema is yours, migrations included.** The host creates the database empty, never reads or
writes inside it, and ships no migration machinery — evolving your tables compatibly (expand, then
contract, so a rolled-back version still runs) is yours to own. Create your tables in `onBoot`: it
runs before the server listens, so a failure aborts boot instead of surfacing later as a broken page.
What the host does guarantee:
- **One database and one role per plugin**, with `CONNECT` revoked from `PUBLIC`. This bounds
*accidents* — a wrong database name, a mistyped DSN, a stray query — and it is not a security
boundary: plugins share the `web` process, so a plugin that goes looking can reach another's
credentials. Install plugins you trust ([Security model](#security-model)).
- **Provisioning is idempotent and runs every boot**, so a plugin dropped in later is picked up by
the next `docker compose up -d` — the same rule as permission seeding. Each boot re-applies the
role's password, connection limit, and `NOCREATEDB`/`NOCREATEROLE`.
- **Your data is never dropped.** Removing a plugin folder leaves its database untouched; deleting it
is a deliberate act by an operator. Each boot logs any `plugin_*` database no installed plugin
claims, so what you left behind stays findable — read that list before dropping anything, since a
second Plainpages stack sharing this server will have its databases named there too.
**Passwords are derived, never stored** — each is `HMAC-SHA256(PLUGIN_DB_SECRET, <plugin id>)`, so
`bootstrap` and `web` compute the same value independently and nothing has to be written down.
Rotate every plugin's password by changing `PLUGIN_DB_SECRET` and running `docker compose up -d`,
which re-applies each role's password and leaves the data alone — restart every `web` instance as
part of it, since one still holding the old secret can open no new connections. Treat the secret as
you would a database password: whoever holds it holds every plugin database. Under
`REQUIRE_SECURE_SECRETS` a missing, empty or throwaway secret is refused — in `bootstrap` before it
creates any role, so no database is ever given a password derivable from a constant in this repo.
**Only `bootstrap` holds provisioning credentials.** It alone gets `PLUGIN_DB_ADMIN_URL`, an account
with `CREATEDB` and `CREATEROLE` (superuser works but is more than it needs; the dev stack simply
reuses Ory's). Keep using the same account: Postgres gives a `CREATEROLE` account admin rights only
over the roles it created itself, so if you swap it for a fresh one, grant that one `ADMIN OPTION` on
the existing `plugin_*` roles first, or the next boot cannot re-apply their passwords. `web` gets `PLUGIN_DB_URL`, which names the server and must carry no credentials —
supply one with a username or password and boot fails, rather than leaving a privileged password in
the process that runs plugin code. Set both, plus `PLUGIN_DB_SECRET`
([Configuration](#configuration)); the dev stack sets them for you.
Storage stays off until `PLUGIN_DB_URL` is set, and a plugin declaring it while that is unset
**aborts boot** naming itself — rather than serving pages without its data. One naming limit: a
storage plugin's folder may be at most **56 characters**, so `plugin_<id>` fits Postgres' 63-byte
identifier.
### Local dev & test story ### Local dev & test story
A plugin is a normal folder of TypeScript, tested the same way the core is — everything in Docker. A plugin is a normal folder of TypeScript, tested the same way the core is — everything in Docker.
@@ -765,9 +1014,10 @@ The menu is **driven entirely by config** and assembled from two sources:
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
``` ```
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`. whenever a child does. The rendered tree is **filtered per user** from the session JWT (no
per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
@@ -776,6 +1026,11 @@ recovery / front pages — so it looks identical signed in or out and just shows
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
chrome-free layout opts out with the shell's `menu: false`. chrome-free layout opts out with the shell's `menu: false`.
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
pane owns that in its own stylesheet.
## Building blocks ## Building blocks
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers, Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
@@ -799,6 +1054,28 @@ underneath it — cramped, but nothing is unreachable. List state
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
URL is the only state the UI keeps. URL is the only state the UI keeps.
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
decorate a page but never decide whether something shows or can be read. `npm test` fails on a
stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless an `@supports`
around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as
`@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a
browser without it skips the query; `src/ui/stylesheet-floor.ts` lists the rules.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
to the URL without it) above the next step's form, which carries the earlier choices as hidden inputs,
plus `locale` from `localeParam`. The form that writes comes last and posts to the URL naming every
choice, so its handler reads them from `ctx.query`, never from the body; a value the query names that
the step does not offer is that step's error, never a silent fallback. Don't redraw a half-filled form
through a submit that writes nothing instead: a `required` field blocks it, and the choice never
reaches the URL.
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML. into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
The baseline never depends on it. The baseline never depends on it.
@@ -911,7 +1188,7 @@ Rules worth knowing:
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
to them. Keep your catalog for your domain words. to them. Keep your catalog for your domain words.
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`, - **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
`dir`. They are merged after your `data`, so a colliding key of yours is ignored rather than `dir`, and the host-internal `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter. breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string **Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
@@ -934,9 +1211,9 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| --- | --- | --- | | --- | --- | --- |
| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) | | `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) |
| `PORT` | `3000` | web listen port | | `PORT` | `3000` | web listen port |
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) | | `CACHE_TEMPLATES` | `false` | cache compiled EJS templates and `/public/` asset versions (`true` in prod) |
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) | | `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` must be supplied and differ from the dev throwaway | | `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` | | `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
| `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) | | `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) |
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment | | `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
@@ -952,6 +1229,11 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) | | `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew | | `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` | | `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
| `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot |
| `PLUGIN_SETTING_<ID>_<KEY>` | per declaration | one variable per key a plugin declares in `settings`; see [Plugin settings](#plugin-settings) |
| `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` |
| `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage |
| `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning |
### Canonical host (one public URL) ### Canonical host (one public URL)
@@ -1082,8 +1364,8 @@ records that subject as revoked-now; the hot path then rejects every token for i
the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A
fresh re-login passes, so a downgrade lands immediately without locking the account. fresh re-login passes, so a downgrade lands immediately without locking the account.
It is an in-memory, auto-evicting map — no database, so it stays inside the stateless model — and It is an in-memory, auto-evicting map — host-owned state would break the [stateless
the check is pure CPU, keeping Keto off the hot path. Entries self-evict after `REVOCATION_TTL_SEC` core](#stateless-core) — and the check is pure CPU, keeping Keto off the hot path. Entries self-evict after `REVOCATION_TTL_SEC`
(default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the **single instance** (default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the **single instance**
that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared
store for hard multi-instance revoke), and a **group** membership change is transitive across many store for hard multi-instance revoke), and a **group** membership change is transitive across many
@@ -1142,6 +1424,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config. obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
still set cookies, so host nothing untrusted on one.
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands **Offboarding is not instant by default** — a revoked permission or deactivated identity lands
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on. within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
@@ -1198,27 +1485,31 @@ of it over their **REST APIs using Node's built-in `fetch`** — no SDK dependen
In **dev** the host-facing Ory ports are published — Kratos public `4433` and Hydra public `4444`; In **dev** the host-facing Ory ports are published — Kratos public `4433` and Hydra public `4444`;
prod keeps them internal. prod keeps them internal.
Runtime deps stay tiny and pinned: **`ejs`**, **`lucide-static`**, and **`@larvit/log`**. Auth, Runtime deps stay tiny and pinned: **`ejs`**, **`lucide-static`**, **`@larvit/log`**, and
sessions, SSO and OAuth2 add *services*, not npm packages. **`postgres`** — the last one has no sub-dependencies of its own and is used in a single module, to
provision [plugin storage](#plugin-storage) at boot. Auth, sessions, SSO and OAuth2 add *services*,
not npm packages.
### Stateless ### Stateless core
Plainpages holds **no state of its own**. The only database in the stack is **Postgres**, used by The host holds **no state of its own**: it owns no schema and keeps nothing between requests. The
Ory; the `web` app never connects to it. stack's **Postgres** backs Ory, and gives every plugin that asks for one a database of its own
([Plugin storage](#plugin-storage)) — which the host provisions but never reads or writes.
A plugin reads and writes state by **calling an upstream service** from its route handler — a REST So a plugin gets its data one of two ways: by **calling an upstream service** from its route handler
API, an ERP, a plant historian, the customer's own backend — and renders the response with the — a REST API, an ERP, a plant historian, the customer's own backend — or from **its own database**.
building blocks. That keeps `web` trivially scalable and crash-safe: any instance can serve any Either keeps `web` trivially scalable and crash-safe: any instance can serve any request, because the
request, because the session lives in Kratos and the data lives upstream. session lives in Kratos and the data lives outside the process.
## Testing ## Testing
Type check and unit tests run off the Ory stack — `--no-deps` keeps `web` from dragging up its Type checks and unit tests run off the Ory stack — `--no-deps` keeps each service from dragging up
`depends_on` services: its `depends_on` services:
```bash ```bash
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
docker compose run --rm --no-deps web npm test # node --test (units) docker compose run --rm --no-deps web npm test # node --test (units)
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
``` ```
### End-to-end (Playwright) ### End-to-end (Playwright)
@@ -1231,7 +1522,7 @@ no Node/browsers on the host. Five suites, each on its own stack:
| `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) | | `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) |
| `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared | | `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared |
| `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code | | `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code |
| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout | | `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, emailed-code verification + recovery, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout |
| `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) | | `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) |
```bash ```bash
@@ -1257,7 +1548,7 @@ Ory-backed ones share one backend and stay on Chromium.
### The full gate (one command) ### The full gate (one command)
`ci.sh` is the whole gate in one reproducible command — typecheck → unit tests → `ci.sh` is the whole gate in one reproducible command — typecheck (the specs in `e2e-tests/` too) → unit tests →
each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
it into your CI service: it into your CI service:
@@ -1278,10 +1569,10 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
| Workflow | Trigger | Does | | Workflow | Trigger | Does |
| --- | --- | --- | | --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | | `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub | | `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green; the release-tag job only runs when `AUTO_RELEASE` is `true` | | `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
`main` is not re-tested on push — its commits are meant to arrive already green from a `main` is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`. gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
@@ -1303,11 +1594,27 @@ pattern-based org cleanup rule for this package — its age/count heuristics can
release tags and would delete images the workflow protects. release tags and would delete images the workflow protects.
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs **Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`; `release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
break and a moving `:0` would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. The Docker Hub tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
repository **description** is maintained by hand from [`README-dockerhub.md`](README-dockerhub.md).
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
**The Docker Hub overview** is published by a separate `publish-overview` job from
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an `overview_version` input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
disagrees with the tree being published, is refused. It uses
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is **GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
@@ -1323,18 +1630,19 @@ images, and the Playwright runner + its browser image — and every bump keeps t
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
`CI / full-gate (push)` is green; only a red gate needs a human. `CI / full-gate (push)` is green; only a red gate needs a human.
**Releases are paused.** Plainpages is pre-announcement: the repository carries **no tags**, so **Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
neither `release.yml` nor Docker Hub has a version to promote. Turn releasing back on by setting the `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
Actions variable `AUTO_RELEASE` to `true`, or cut a `vX.Y.Z` tag by hand **on `main`'s tip** — with **skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
nothing tagged the nightly cleanup keeps only branch-head images, so an older commit's image is trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
already gone and `release.yml` would fail loud. `Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
[`renovate.json`](renovate.json) name them — and
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, gated on [`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
`AUTO_RELEASE`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
since the last tag, and **skips** when the tip isn't a Renovate commit or nothing new merged. version, an update big enough to reach a **minor** stops the job rather than tagging: bump
Renovate stamps each commit with a `Release-Bump: <updateType>` trailer and `HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
[`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next `nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
version — pre-1.0 it never auto-crosses into `1.0.0`. It is **tag-only**: the tag hands off to so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is **tag-only**: the tag hands off to
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by `release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
@@ -1343,7 +1651,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou
| Actions var / secret | Value | | Actions var / secret | Value |
| --- | --- | | --- | --- |
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | | `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a read/write token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). | | `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | | `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | | `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | | `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
@@ -1371,6 +1679,11 @@ the one-shot bootstrap) — and mounts no source. Secrets come from the environm
running insecure. Before going live, supply the production secrets and any SSO credentials — the running insecure. Before going live, supply the production secrets and any SSO credentials — the
**only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)). **only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)).
**Back up the `pgdata` volume.** Once a plugin declares [storage](#plugin-storage), Postgres holds
business data that exists nowhere else, alongside Ory's identities — the stack stops being
reproducible from the image and config alone. Snapshot the volume, or `pg_dump` each database on a
schedule, and rehearse the restore.
Every response carries security headers (`src/http/security-headers.ts`): a strict Every response carries security headers (`src/http/security-headers.ts`): a strict
`Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts), `Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts),
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`,
@@ -1378,6 +1691,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
header per-response via `RouteResult.headers`. header per-response via `RouteResult.headers`.
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
never keeps one user's page for the next. A route may override it only for a page that is the same for
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
every plugin route.
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
@@ -1403,9 +1721,9 @@ docker compose up -d --build
``` ```
Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the
manifest change the error names. Once [`HOST_API_VERSION`](#contract-versioning) starts moving a manifest change the error names. A host contract change big enough to move
stale plugin will be refused by **version** instead; it is frozen at `1.0.0` until the first external [`HOST_API_VERSION`](#contract-versioning) shows up earlier and more precisely — discovery refuses the
plugin exists, so for now the error names the rule it tripped. plugin by **version** before any rule gets a chance to trip.
Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers: Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers:
`node_modules/` must not exist (deps live at `/node_modules`, and anything at `/app/node_modules` `node_modules/` must not exist (deps live at `/node_modules`, and anything at `/app/node_modules`
@@ -1511,7 +1829,7 @@ unnecessary here — the signature itself is already invalid.
``` ```
src/ The app — strict tsc, no build step. *.test.ts sit beside their module. src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
*.test.ts Topology guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate) *.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie, http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
static, safe-url (safeUrl + localPath), security-headers static, safe-url (safeUrl + localPath), security-headers
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) · auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
@@ -1521,9 +1839,10 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
english · view-locals · locales/ (the core en-US + sv-SE catalogs) english · view-locals · locales/ (the core en-US + sv-SE catalogs)
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
(ctx.system) · discovery · router · hooks · view-resolver (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) · storage-provisioning (the DDL; bootstrap-only, holds the driver)
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503, views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
@@ -1540,9 +1859,12 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul
config/menu.ts, and shifts-upstream/ (the dev mock backend) config/menu.ts, and shifts-upstream/ (the dev mock backend)
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml; e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
.gitea/workflows/ Gitea Actions — see CI/CD .gitea/workflows/ Gitea Actions — see CI/CD
README-dockerhub.md The Docker Hub repository description, pasted over by hand when it changes
``` ```
## Extending the core ## Extending the core
+32
View File
@@ -53,6 +53,11 @@ docker compose build web
step "Typecheck" step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck docker compose run --rm --no-deps web npm run typecheck
# The specs import @playwright/test, which only the runner image installs, so they typecheck there
# rather than in the web image. --no-deps skips web; the visual file just names the e2e service.
step "Typecheck e2e-tests"
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck
step "Unit tests" step "Unit tests"
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; } units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
echo "$units" | grep -E '^. (tests|pass|fail) ' || true echo "$units" | grep -E '^. (tests|pass|fail) ' || true
@@ -60,6 +65,33 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true) count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; } [ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
step "Plugin storage (real Postgres)"
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
storage_rc=0
storage_proj=plainpages-storage
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
storage_out=""
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
if [ "$storage_rc" -eq 0 ]; then
# --build like the e2e suites: this stack mounts no source, so without it the step would test
# whatever `web` image that project last baked.
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
fi
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The # Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite. # per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
# --user: the runner writes screenshots + the report into the checkout, so they must belong to # --user: the runner writes screenshots + the report into the checkout, so they must belong to
+17 -5
View File
@@ -1,5 +1,9 @@
# Development overrides, merged automatically by `docker compose up`. # Development overrides, merged automatically by `docker compose up`.
# Mounts the source for live editing and restarts on change via `node --watch`. # Mounts the source for live editing and restarts on change via `node --watch`.
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
services: services:
web: web:
command: node --watch src/server.ts command: node --watch src/server.ts
@@ -13,9 +17,12 @@ services:
CACHE_TEMPLATES: "false" CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines) LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info) LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
# works with no further config; the secret falls back to the dev throwaway (config.ts).
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes: volumes:
- .:/app - .:/app
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise): # Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
@@ -29,15 +36,20 @@ services:
# It belongs here and not in the base file, where it would desynchronise prod and collide with the # It belongs here and not in the base file, where it would desynchronise prod and collide with the
# e2e stacks, which bind individual plugins *inside* /app/plugins. # e2e stacks, which bind individual plugins *inside* /app/plugins.
bootstrap: bootstrap:
# Provisions the plugin databases web connects to above, as the dev superuser.
environment:
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
volumes: volumes:
- .:/app - .:/app
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.19.0-alpine3.24 image: node:24.22.2-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -46,7 +58,7 @@ services:
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025). # Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env. # kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
mailpit: mailpit:
image: axllent/mailpit:v1.30.7 image: axllent/mailpit:v1.33.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
+21 -3
View File
@@ -17,10 +17,16 @@ services:
CACHE_TEMPLATES: "true" CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret} CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
# refuses to boot rather than run without its data. The URL carries no credentials — each
# plugin's own password is derived from the secret (README → Plugin storage).
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/ # Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed). # consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
# declares `storage` opens its connection in onBoot, before the server listens.
depends_on: depends_on:
bootstrap: bootstrap:
condition: service_completed_successfully condition: service_completed_successfully
@@ -30,14 +36,17 @@ services:
condition: service_healthy condition: service_healthy
hydra: hydra:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer. # Read-only — bootstrap is the only writer.
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped restart: unless-stopped
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here. # The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
# init/init.sql creates one database per service. Dev defaults below; supply # declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials.
# A plugin connects as its own role from inside web. Dev defaults below; supply
# POSTGRES_USER/PASSWORD via env in production. # POSTGRES_USER/PASSWORD via env in production.
postgres: postgres:
image: postgres:18.6-alpine3.23 image: postgres:18.6-alpine3.23
@@ -127,6 +136,8 @@ services:
condition: service_healthy condition: service_healthy
keto: keto:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
@@ -137,6 +148,13 @@ services:
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
+292
View File
@@ -0,0 +1,292 @@
# Comprehension history
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | better | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
Inherited architect, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | better | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
Inherited architect, decided by:
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
Inherited architect, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
## 2026-10-31T01:19:49Z, PR #184 at af157c9, against bb8e6bc
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | same | better | better |
| Inherited architect | better | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:12` `TOKENS` header comment: says all three theme rules need `:has()`, names the engines without it and points to the `@supports not selector(:has(a))` block, where the old text called the swaps "rock-solid"
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist and links each to its home section
- Self-sufficiency: `README.md:815` `Plugin dependencies`: the scale bullet states the plugin count the per-plugin dependency design assumes
Inherited architect, decided by:
- Self-sufficiency, Locality, Overall: `public/css/styles.css:12` `1. TOKENS` comment: an editor of the theme mapping learns of the distant `:has()` fallback from the comment, where the old text said the swap could not fail
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: a cold reader reaches "chrome" or "denylist" and their home sections in one hop
- Self-sufficiency: `README.md:815` `### Plugin dependencies`: the scale bullet gives the plugin count the dependency design assumes
## 2026-10-31T07:26:19Z, PR #187 at 10152d2, against b336dee
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | better | better | better |
| Inherited architect | same | same | better | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `idToken`: the helpers' signatures now say what `res`, `nonce` and the `codes` map hold
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: `(path: string): boolean` shows the routing predicate's contract
- Self-sufficiency: `e2e-tests/tsconfig.json:2` `extends`: the specs are held to the host's strict flags, and the Dockerfile comment explains the path link
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the comment "the icons the UI actually references" is true again
Inherited architect, decided by:
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `b64`: typed helpers replace a trace through the `createServer` callback
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line
- Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids
## 2026-10-31T12:28:23Z, PR #200 at 3592a30, against a8a9744
Ruling: same
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | worse | worse | better | same | same |
| Maintainability senior | same | same | better | same | same |
| Junior A | better | better | better | better | better |
Mid A, decided by:
- Navigation, Locality, Shape: `src/ui/chrome.ts:74` `shellUser`: the deleted `buildShellContext` and `buildDashboardModel` left two shell models side by side, and now every page reads one `chrome`
- Locality, Shape: `src/auth/routes.ts:58` `flowFailure`: a named unit shared by `flowPage` and `codeFlowSubmit`, where the old `flowPage` held the whole error ladder inline
- Shape: `src/auth/kratos-public.ts:117` `submitFlow`: takes a flow type and id instead of an action URL, and the Referer quirk is explained where it applies
- Locality: `src/http/static.ts:93` `versioned`: reads the module-level `hashes` map, so an asset href depends on what an earlier request hashed
- Navigation, Shape: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a 249-line regex walker that only tests use sits in `src/ui/` beside runtime modules, though the README now points straight at it
Inherited architect, decided by:
- Navigation: `public/css/styles.css:25` `:root`: the old comment next to the palette answered the 3am question and the new text drops it
- Navigation: `src/ui/stylesheet-floor.ts:1` `floorViolations`: its header never says an unsupported value in a custom property blanks every `var()` reading it, so a cold reader walks the regex walker to find the cause
- Navigation, Shape: `src/ui/stylesheet-floor.ts:12` `import`: test-only tooling that pulls a devDependency sits beside runtime modules and is missing from the README file map
- Locality: `src/http/static.ts:64` `hashes`: a module-level map shared by every `publicAssets()` instance makes a fresh href depend on request history elsewhere
- Locality: `src/http/app.ts:188` `renderPage`: `assetHref` is appended last at three render sites, and only a comment keeps a fourth from emitting unversioned hrefs
- Shape: `src/ui/chrome.ts:74` `shellUser`: removes the second shell model that duplicated `PageChrome`
- Shape: `src/auth/routes.ts:58` `flowFailure`: one named unit shared by `flowPage` and `codeFlowSubmit`, each handler reads in a screen
Maintainability senior, decided by:
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: the deleted `ShellModel` left two shapes for the same chrome, and now the shell has one input and one builder
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: the old `flowPage` mixed about 60 lines of error mapping with `return_to` handling, and now `flowReturnTo` and `flowFailure` read on their own
- Locality: `src/http/static.ts:64` `hashes`: a hidden module-level cache filled by two readers, and `assetHref` is merged by hand at three render sites
- Shape, Navigation: `src/ui/stylesheet-floor.ts:119` `positive`: a 249-line regex walker that only a test uses sits among runtime view modules
Junior A, decided by:
- Navigation, Self-sufficiency: `src/ui/stylesheet-floor.ts:1` `floorViolations`: the README's "Two browser floors" points here and the header lists what is checked, where the base had a prose rule and a hand-written regex test
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: one shell view model replaces `ShellModel` beside `PageChrome`, and its helpers sit beside their only caller
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: named units replace about 60 inline lines in `flowPage`, and `codeFlowSubmit` reuses them
- Self-sufficiency: `src/auth/kratos-admin.ts:68` `createRecoveryCode`: the comment no longer claims Kratos mails the link
- Locality: `src/http/static.ts:64` `hashes`: a module-level mutable map backs asset versioning, hidden state the base didn't have
## 2026-10-31T12:30:19Z, PR #200 at 3592a30
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Inherited architect | 7 | 5.5 | 5.5 | 7.5 | 6.5 |
| Mean | 7.00 | 5.88 | 6.25 | 6.75 | 6.50 |
Junior A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built `@supports` evaluator whose header says what it enforces but not how it decides a guard names a feature
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in one closure where each stage reads state an earlier stage set
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: its 4xx branches can't fire from `codeFlowSubmit` because `submitFlow` throws only on 5xx, and the call site doesn't say so
Mid A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-written CSS walker with three regex passes per declaration, opaque at line level
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a fixed order of about 15 stages that nothing in the code enforces
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment names the tuple `#members` where the code uses `granted`
- Locality: `src/auth/routes.ts:121` `codeFlowSubmit`: one idea split across `flow-view.ts`, `routes.ts` and `kratos-public.ts`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a 167-line closure of complexity 43 whose order across closure state nothing names as a contract
- Self-sufficiency, Locality: `src/auth/routes.ts:58` `flowFailure`: on the submit path every 4xx branch is dead and nothing at the call site says which can fire
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: the one module whose correctness can't be checked by reading
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` while the code writes `granted`
Inherited architect, would restructure first:
- Shape, Navigation: `src/auth/flow-view.ts:152` `formAction`: the flow-to-route mapping has two homes, here and `routes.ts`, so a code flow's route and its form target are decided in different files
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a CSS linter that never runs at runtime sits among the runtime view helpers
- Locality, Shape: `src/http/app.ts:185` `handleRequest`: a level of its own inside `createApp`
- Shape: `src/http/builtin-routes.ts:23` `matchBuiltinRoute`: holds no routes, because the table is inline in `app.ts:176` and `buildAuthRoutes`
## 2026-10-31T12:30:27Z, PR #200 rerun at 3592a30
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 5.5 | 6 | 6.5 | 6 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7 | 5.5 | 6 | 6.5 | 6 |
| Inherited architect | 7 | 6 | 6 | 6.5 | 6.5 |
| Mean | 7.00 | 5.75 | 6.13 | 6.50 | 6.25 |
Junior A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built CSS walker that stayed opaque without tracing it by hand
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 320 lines of interdependent locals, with a fixed stage order that only a top-to-bottom read shows
- Self-sufficiency, Shape: `src/auth/login.ts:40` `completeLogin`: the header says `#members` where the code uses `granted`
Mid A, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines of order-dependent stages, with `renderPage` reassigned partway
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: how it decides a guard names a feature stayed opaque
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `seedPermissions`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 167 lines and complexity 43 in a fixed order nobody states
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS walker, partly opaque
- Locality, Shape: `src/plugin-host/plugin.ts:126` `RESERVED_PLUGIN_IDS`: a hand-kept mirror of the host's path segments that has already drifted, as it omits `error`
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
Inherited architect, would restructure first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: the most concerns and the highest complexity, already grandfathered, and every request-wide concern lands in it
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
- Shape: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes six concerns, and this file with `flow-view.ts` is where the next flow lands
## 2026-10-31T13:06:16Z, PR #202 at 8e73d16
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6 | 6.5 | 6.5 |
| Maintainability senior | 7 | 6 | 6 | 6.5 | 6.5 |
| Inherited architect | 6.5 | 6 | 6 | 6.5 | 6.5 |
| Mean | 6.88 | 6.00 | 6.13 | 6.50 | 6.50 |
Junior A, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in a fixed order that only a top-to-bottom read shows
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
- Self-sufficiency: `src/plugin-host/settings.ts:35` `SettingsOf`: nested conditional types that need the charter's rule to decode
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: complexity 43 inside the 318-line `createApp` closure, and `switchBase` reads `builtinRoutes` declared 60 lines later
- Locality, Shape: `src/ui/stylesheet-floor.ts:119` `positive`: a regex CSS walker whose how stayed opaque
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: 167 lines and complexity 43 mixing twelve concerns whose order no type expresses
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS scanner traced by hand
- Self-sufficiency: `src/auth/gate.ts:13` `allows`: an empty gate is allowed, and the reason lives only in AGENTS.md
Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: the largest hub, where every request-wide concern lands
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes four concerns with no sub-folder
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: test tooling sits among the runtime UI modules
## 2026-10-31T13:06:21Z, PR #202 rerun at 8e73d16
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 5.5 | 6 | 6 | 6 |
| Mid A | 7 | 6 | 6 | 6 | 6.5 |
| Maintainability senior | 7 | 6 | 6 | 6 | 6.5 |
| Inherited architect | 7 | 5.5 | 6 | 7 | 6.5 |
| Mean | 7.00 | 5.75 | 6.00 | 6.25 | 6.38 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: closures over request state in a 318-line `createApp`, and it stayed opaque
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: the Kratos status semantics live outside the repo
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: one pipeline whose steps mutate what later steps read
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: traced by hand
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: an unstated, load-bearing step order in shared closure state
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled CSS scanner built on lookbehind regexes
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment is false on the security-relevant path
Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
+306
View File
@@ -0,0 +1,306 @@
# Reading cost
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
100624 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 9
- `README.md` 7
- `src/http/app.ts` 4
- `todo.md` 3
- `e2e-tests/playwright.config.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `ci.sh` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/auth/csrf.ts` 1
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
80251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 3
- `CHANGELOG.md` 2
- `public/css/styles.css` 2
- `AGENTS.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
167985 new input tokens before the first commit.
Read most (opens and searches):
- `public/css/styles.css` 17
- `todo.md` 7
- `README.md` 6
- `public/css/auth.css` 5
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/scheduling/public/scheduling.css` 1
- `examples/plugins/scheduling/views/overview.ejs` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/http/app.test.ts` 1
## Reading cost, chunk 91d11dbe63f9, finished 2026-10-31T01:13:44Z
69010 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `README.md` 4
- `public/css/styles.css` 4
- `CHANGELOG.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
## Reading cost, chunk 70e46c48db80, finished 2026-10-31T07:17:56Z
70843 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 18
- `todo.md` 6
- `public/css/styles.css` 5
- `CHANGELOG.md` 2
- `AGENTS.md` 1
- `src/auth/jwt-middleware.ts` 1
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
98975 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `ci.sh` 3
- `e2e-tests/visual.spec.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
- `e2e-tests/compose.visual.yml` 2
- `e2e-tests/language.spec.ts` 2
- `e2e-tests/mock-oidc.ts` 2
- `src/ci-gate.test.ts` 2
- `.dockerignore` 1
## Reading cost, chunk 25b7840ff611, finished 2026-10-31T07:46:18Z
73781 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 2
- `src/http/app.ts` 2
- `AGENTS.md` 1
- `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1
- `views/index.ejs` 1
- `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
154733 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 10
- `README.md` 6
- `package.json` 3
- `.gitignore` 1
- `.npmrc` 1
- `AGENTS.md` 1
- `ci.sh` 1
- `public/css/styles.css` 1
- `renovate.json` 1
- `src/e2e-console-guard.test.ts` 1
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
85029 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 4
- `README.md` 3
- `src/auth/routes.test.ts` 3
- `todo.md` 3
- `src/auth/kratos-public.ts` 2
- `src/http/app.test.ts` 2
- `ci.sh` 1
- `compose.yml` 1
- `e2e-tests/auth-refresh.spec.ts` 1
- `src/auth/flow-view.ts` 1
## Reading cost, chunk 3373527a4f72, finished 2026-10-31T08:52:59Z
138664 new input tokens before the first commit.
Read most (opens and searches):
- `examples/plugins/admin/admin-users.ts` 8
- `README.md` 7
- `src/http/app.test.ts` 7
- `e2e-tests/full-flow.spec.ts` 3
- `examples/plugins/admin/admin-groups.ts` 3
- `examples/plugins/admin/admin-users.test.ts` 3
- `examples/plugins/admin/i18n/en-US.ts` 3
- `src/ui/icons.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
## Reading cost, chunk 0118907cad57, finished 2026-10-31T09:32:49Z
116614 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 11
- `README.md` 7
- `src/http/app.ts` 6
- `src/ui/shell.test.ts` 5
- `e2e-tests/visual.spec.ts` 4
- `src/http/static.ts` 4
- `todo.md` 3
- `src/auth/flow-view.ts` 2
- `views/partials/shell.ejs` 2
- `AGENTS.md` 1
## Reading cost reset, 2026-10-31T09:34:55Z
## Reading cost, chunk 55cc9f01b477, finished 2026-10-31T09:42:24Z
94244 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 6
- `README.md` 4
- `src/http/app-plugins.test.ts` 4
- `todo.md` 3
- `AGENTS.md` 2
- `docs/reading-cost.md` 2
- `examples/plugins/admin/README.md` 2
- `ci.sh` 1
- `examples/plugins/admin/admin-clients.test.ts` 1
- `examples/plugins/admin/admin-grants.test.ts` 1
## Reading cost, chunk 4a8c9037acde, finished 2026-10-31T10:07:56Z
126616 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 7
- `examples/plugins/admin/admin-groups.ts` 4
- `README.md` 3
- `todo.md` 3
- `AGENTS.md` 2
- `examples/plugins/admin/README.md` 2
- `examples/plugins/admin/admin-grants.ts` 2
- `examples/plugins/admin/i18n/en-US.ts` 2
- `CHANGELOG.md` 1
- `examples/plugins/admin/admin-groups.test.ts` 1
## Reading cost, chunk c90b25a50479, finished 2026-10-31T10:40:23Z
90935 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `todo.md` 5
- `AGENTS.md` 3
- `CHANGELOG.md` 1
- `package.json` 1
- `registry-cleanup/cleanup.ts` 1
- `release-tooling/dockerhub-overview.md.tmpl` 1
- `release-tooling/dockerhub-overview.test.ts` 1
- `release-tooling/dockerhub-overview.ts` 1
- `src/ci-gate.test.ts` 1
## Reading cost, chunk 984f4d4c8a76, finished 2026-10-31T11:18:20Z
209334 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 6
- `todo.md` 6
- `src/ui/stylesheet-floor.test.ts` 4
- `src/ui/stylesheet-floor.ts` 4
- `CHANGELOG.md` 3
- `AGENTS.md` 2
- `examples/plugins/scheduling/public/scheduling.css` 2
- `package.json` 2
- `public/css/styles.css` 2
- `ci.sh` 1
## Reading cost, chunk de26287532f7, finished 2026-10-31T11:29:32Z
68410 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `public/css/styles.css` 3
- `src/ui/stylesheet-floor.test.ts` 3
- `CHANGELOG.md` 2
- `README.md` 2
- `AGENTS.md` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 4a258a237c53, finished 2026-10-31T12:04:07Z
154777 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 9
- `todo.md` 6
- `README.md` 5
- `src/auth/flow-view.ts` 5
- `src/auth/flow-view.test.ts` 4
- `src/auth/routes.ts` 4
- `src/auth/kratos-public.test.ts` 3
- `src/i18n/locales/en-US.ts` 3
- `e2e-tests/compose.full.yml` 2
- `src/auth/kratos-admin.ts` 2
## Reading cost, chunk d8b41620d631, finished 2026-10-31T12:37:38Z
70583 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `AGENTS.md` 2
- `README.md` 2
- `examples/plugins/admin/README.md` 2
- `src/auth/kratos-admin.ts` 2
- `src/http/app-admin.test.ts` 2
- `CHANGELOG.md` 1
- `e2e-tests/full-flow.spec.ts` 1
- `examples/plugins/admin/admin-users.test.ts` 1
- `examples/plugins/admin/admin-users.ts` 1
## Reading cost, chunk f0a49c300aac, finished 2026-10-31T12:47:27Z
63526 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 4
- `src/plugin-host/settings.test.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
- `src/plugin-host/settings.ts` 2
- `AGENTS.md` 1
+3 -1
View File
@@ -1,6 +1,6 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/. # Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network. # Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.62.1-noble FROM mcr.microsoft.com/playwright:v1.64.1-noble
WORKDIR /e2e-tests WORKDIR /e2e-tests
@@ -8,6 +8,8 @@ COPY e2e-tests/package.json e2e-tests/package-lock.json ./
RUN npm ci RUN npm ci
COPY e2e-tests/ ./ COPY e2e-tests/ ./
# e2e-tests/tsconfig.json extends it, so the specs are held to the host's strictness flags.
COPY tsconfig.json /tsconfig.json
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has # Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME. # no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
+18 -4
View File
@@ -1,5 +1,5 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A # login, emailed-code verification + recovery, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies # tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream: shifts-upstream:
image: node:24.19.0-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/server.ts"] command: ["node", "/server.ts"]
volumes: volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro - ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc: mock-oidc:
image: node:24.19.0-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -79,9 +79,20 @@ services:
timeout: 4s timeout: 4s
retries: 15 retries: 15
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
mailpit:
image: axllent/mailpit:v1.33.0
mem_limit: 128m
memswap_limit: 128m
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 2s
timeout: 4s
retries: 15
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:24.19.0-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
@@ -103,6 +114,8 @@ services:
dockerfile: e2e-tests/Dockerfile dockerfile: e2e-tests/Dockerfile
command: ["npx", "playwright", "test", "full-flow.spec.ts"] command: ["npx", "playwright", "test", "full-flow.spec.ts"]
depends_on: depends_on:
mailpit:
condition: service_healthy
mock-oidc: mock-oidc:
condition: service_healthy condition: service_healthy
proxy: proxy:
@@ -110,5 +123,6 @@ services:
environment: environment:
BASE_URL: http://proxy BASE_URL: http://proxy
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
MAILPIT_URL: http://mailpit:8025
volumes: volumes:
- ./e2e-tests/artifacts:/e2e-tests/artifacts - ./e2e-tests/artifacts:/e2e-tests/artifacts
+116 -2
View File
@@ -1,5 +1,5 @@
import type { Browser, Page } from "@playwright/test"; import type { Browser, Page } from "@playwright/test";
import { expect, test, watchedPage } from "./console-guard.ts"; import { allowConsole, expect, test, watchedPage } from "./console-guard.ts";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
// Full browser E2E: the real Playwright UI against the live stack via the same-origin // Full browser E2E: the real Playwright UI against the live stack via the same-origin
@@ -14,6 +14,8 @@ const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in. // Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
async function loginPassword(page: Page): Promise<void> { async function loginPassword(page: Page): Promise<void> {
@@ -136,7 +138,27 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => { // Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens.
test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => {
test.setTimeout(90_000);
const email = `admin-recovery-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/admin/users");
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(editHref!);
await page.getByRole("button", { name: "Generate recovery code" }).click();
const code = await page.locator(".recovery-code code").innerText();
const link = await page.locator(".alert-body a").getAttribute("href");
const user = await watchedPage(await browser.newContext());
await user.goto(link!);
await user.fill('input[name="code"]', code);
await user.getByRole("button", { name: "Continue" }).click();
await expect(user).toHaveURL(/\/settings/);
await user.context().close();
});
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form // A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
@@ -150,6 +172,10 @@ test.describe.serial("authenticated admin journey", () => {
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed // Permissions are declared in plugin code, so the group's detail page offers them as a fixed
// checkbox list rather than a create form — there is no Permissions screen to visit. // checkbox list rather than a create form — there is no Permissions screen to visit.
await page.goto(`/admin/groups/${group}`); await page.goto(`/admin/groups/${group}`);
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
await staleTab.goto(`/admin/groups/${group}`);
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]'); const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked(); await expect(scheduling).not.toBeChecked();
@@ -157,7 +183,16 @@ test.describe.serial("authenticated admin journey", () => {
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click(); await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`)); await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.getByText("Permissions saved.")).toBeVisible();
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
await staleTab.context().close();
}); });
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => { test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
@@ -193,6 +228,23 @@ test.describe.serial("authenticated admin journey", () => {
await page.goto("/scheduling/shifts"); await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts"); await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
await page.goto("/scheduling/mine");
await expect(page.locator("h1")).toHaveText("My shifts");
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
});
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings");
// The reference plugin's one declared setting, and the variable an operator would set for it.
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
await expect(scheduling).toContainText("upstream");
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
}); });
test("logout: signing out ends the session and returns to the login page", async () => { test("logout: signing out ends the session and returns to the login page", async () => {
@@ -223,6 +275,17 @@ test("return_to: a deep link while logged out returns to that page after login",
await expect(page.locator("h1")).toHaveText("Users"); await expect(page.locator("h1")).toHaveText("Users");
}); });
test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/settings");
await expect(page).toHaveURL(/\/login(\?|$)/);
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click();
await expect(page).toHaveURL(/\/settings(\?|$)/);
await expect(page.locator("h1")).toHaveText("Account settings");
});
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => { test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
test.setTimeout(90_000); test.setTimeout(90_000);
await page.goto("/login"); await page.goto("/login");
@@ -233,3 +296,54 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden. // A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
}); });
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
async function mailedCode(to: string): Promise<string> {
let code = "";
await expect.poll(async () => {
const search = (await (await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`)).json()) as { messages: { ID: string }[] };
const id = search.messages[0]?.ID;
if (!id) return "";
const message = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as { Text: string };
code = /\b(\d{6})\b/.exec(message.Text)?.[1] ?? "";
return code;
}, { timeout: 30_000 }).not.toBe("");
return code;
}
async function createIdentity(email: string): Promise<void> {
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
body: JSON.stringify({ schema_id: "default", traits: { email } }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(created.status).toBe(201);
}
// Fills the email step, then pastes the mailed code with whitespace around it, as a paste often brings.
async function enterMailedCode(page: Page, email: string): Promise<void> {
await page.fill('input[name="email"]', email);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.locator('input[name="code"]')).toBeVisible();
await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `);
await page.getByRole("button", { name: "Continue" }).click();
}
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => {
test.setTimeout(90_000);
const email = `verify-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/verification");
await enterMailedCode(page, email);
await expect(page.getByText("You successfully verified your email address.")).toBeVisible();
});
test("recovery: a pasted code signs in to the settings page, in the chosen language", async ({ page }) => {
test.setTimeout(90_000);
const email = `recover-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/recovery?locale=en-US");
await enterMailedCode(page, email);
await expect(page).toHaveURL(/\/settings\?.*locale=en-US/);
await expect(page.locator("h1")).toHaveText("Account settings");
});
+5 -5
View File
@@ -2,7 +2,7 @@
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an // never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single // RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks). // host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
import { createServer } from "node:http"; import { createServer, type ServerResponse } from "node:http";
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto"; import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000"; const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
@@ -15,8 +15,8 @@ const KID = "mock-1";
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" }; const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce) { function idToken(nonce: string): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" }); const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
const payload = b64({ const payload = b64({
@@ -27,8 +27,8 @@ function idToken(nonce) {
return `${header}.${payload}.${sig}`; return `${header}.${payload}.${sig}`;
} }
const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token) const codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
createServer((req, res) => { createServer((req, res) => {
const url = new URL(req.url ?? "/", ISSUER); const url = new URL(req.url ?? "/", ISSUER);
+397 -23
View File
@@ -1,24 +1,24 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.1" "@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"typescript": "7.0.2"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.62.1", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.62.1" "playwright": "1.64.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -27,44 +27,376 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/fsevents": { "node_modules/@types/node": {
"version": "2.3.2", "version": "24.19.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.62.1", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.62.1" "playwright-core": "1.64.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.62.1", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -73,6 +405,48 @@
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
} }
} }
} }
+5 -3
View File
@@ -1,13 +1,15 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"private": true, "private": true,
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).", "description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "playwright test" "test": "playwright test",
"typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.1" "@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"typescript": "7.0.2"
} }
} }
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
testDir: ".", testDir: ".",
outputDir: "artifacts/test-output", outputDir: "artifacts/test-output",
fullyParallel: true, fullyParallel: true,
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
forbidOnly: true, forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]], reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: { use: {
+1 -1
View File
@@ -11,7 +11,7 @@ const PORT = Number(process.env.PORT ?? 80);
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the // Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app. // browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`)); const toKratos = (path: string): boolean => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
createServer((req, res) => { createServer((req, res) => {
const target = toKratos(req.url ?? "/") ? KRATOS : WEB; const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "lib": ["ESNext", "DOM"] },
"include": ["*.ts"]
}
+157 -10
View File
@@ -10,6 +10,15 @@ const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies i
const shot = (page: Page, name: string): Promise<Buffer> => const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` }); page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
// Either side of the stylesheet's 860px and 560px breakpoints.
const VIEWPORTS = [
{ name: "desktop-xl", width: 1920, height: 1080 },
{ name: "laptop", width: 1280, height: 800 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "phone", width: 390, height: 844 },
{ name: "phone-small", width: 320, height: 568 },
] as const;
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it // gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. // with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
@@ -28,6 +37,45 @@ test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
}); });
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
// (Firefox's synthetic event never reaches the document).
for (const [name, path, tail] of [
["the starter dashboard", "/dashboard", ".form-actions .btn"],
["the public landing", "/", ".landing-actions .btn"],
] as const) {
for (const { width } of VIEWPORTS) {
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
await page.setViewportSize({ width, height: 200 });
await page.goto(path);
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
await page.keyboard.press("End");
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
});
}
}
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 200 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
await expect(page.locator("#nav-toggle")).toBeChecked();
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
// what is under test is closing the drawer, not how the reader got down the page.
await page.evaluate(() => window.scrollTo(0, 120));
const at = await page.evaluate(() => window.scrollY);
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
// the drawer and a centre click lands on the panel instead.
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
await expect(page.locator("#nav-toggle")).not.toBeChecked();
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
});
test("captures the live pages for review", async ({ page }) => { test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
@@ -38,13 +86,21 @@ test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
await shot(page, "live-03-dark"); await shot(page, "live-03-dark");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await shot(page, "live-04-mobile");
await page.setViewportSize({ width: 1280, height: 800 });
}); });
// The page is a document, so a column wider than the viewport can only show as sideways scroll.
for (const { name, width, height } of VIEWPORTS) {
for (const [path, label] of [["/dashboard", "dashboard"], ["/", "landing"], ["/scheduling", "plugin"]] as const) {
test(`the ${label} page fits ${width}px wide, with no sideways scroll`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto(path);
const [scrollWidth, clientWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth] as const);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await shot(page, `live-${label}-${name}`);
});
}
}
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => { test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
const missing = await page.evaluate(() => { const missing = await page.evaluate(() => {
@@ -94,6 +150,7 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
}); });
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
@@ -108,6 +165,21 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
expect(offCanvas).toBe(true); expect(offCanvas).toBe(true);
}); });
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
const sidebar = page.locator(".sidebar");
await expect(sidebar).toBeInViewport({ ratio: 1 });
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
const link = sidebar.locator(".nav-children a.nav-self").first();
const target = await link.getAttribute("href");
await link.click();
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
});
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => { test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit). // The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
@@ -134,6 +206,68 @@ test("the public landing at / is ungated and links to sign in + register", async
await shot(page, "live-05-public-landing"); await shot(page, "live-05-public-landing");
}); });
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
const contrastOf = (el: Element): number => {
const luminance = (rgb: string): number => {
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
const v = Number(c) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const style = getComputedStyle(el);
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
await context.clearCookies();
await page.emulateMedia({ colorScheme });
await page.goto("/");
if (forceDark) await page.locator("#theme-dark").check({ force: true });
// No page of this stack has a second page of results, so add the current-page button.
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
}
});
}
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const child = page.locator('.sidebar a[href="/scheduling"]');
await expect(child).toBeHidden();
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
await expect(child).toBeVisible();
});
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
await page.goto("/scheduling");
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
] as const);
expect(inset).toBe(padX);
});
// transferSize is 0 only for a response the browser's own cache served; a 304 still costs a round trip.
test("a repeat page view fetches no stylesheet again", async ({ page }) => {
const stylesheets = (): Promise<{ name: string; transferSize: number }[]> => page.evaluate(() =>
performance.getEntriesByType("resource")
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming && entry.initiatorType === "link" && entry.name.includes(".css"))
.map(({ name, transferSize }) => ({ name, transferSize })));
await page.goto("/");
const first = await stylesheets();
expect(first.length, "the landing links styles.css and auth.css").toBeGreaterThanOrEqual(2);
for (const { name, transferSize } of first) expect(transferSize, `${name} on the first view`).toBeGreaterThan(0);
await page.goto("/");
const repeat = await stylesheets();
expect(repeat.map(({ name }) => name)).toEqual(first.map(({ name }) => name));
for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0);
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => { test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
const res = await page.goto("/no-such-page"); const res = await page.goto("/no-such-page");
@@ -142,11 +276,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible(); await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
}); });
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is // The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, // gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full // list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
// E2E (full-flow.spec). Side-effect-free. // Side-effect-free.
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => { test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => {
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// probes are genuinely anonymous. // probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in. // The public overview is reachable with no session (200), not bounced to sign in.
@@ -165,10 +299,23 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
expect(res.status()).toBe(303); expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
expect(mine.status()).toBe(303);
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav, // The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out. // but the gated Shifts leaf is filtered out.
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// and never a claim about what is assigned.
await page.goto("/scheduling/mine");
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
}); });
+1 -1
View File
@@ -8,4 +8,4 @@ across (or bind-mount your own) and restart.
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
+1 -1
View File
@@ -62,4 +62,4 @@ property of a user or a group, edited as a checkbox list on those two screens (`
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`. `src/http/app-admin.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
+1 -1
View File
@@ -1,6 +1,6 @@
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client // Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The // is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts. // HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in src/http/app-admin.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
+48 -2
View File
@@ -1,9 +1,10 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the // The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts. // two screens render from it. The Keto writes and the HTTP round trip are covered in src/http/app-admin.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api"; import type { PermissionDecl } from "@plainpages/plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts"; import type { KetoClient } from "@plainpages/plugin-api";
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [ const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" }, { description: "View users", name: "users:read" },
@@ -81,3 +82,48 @@ test("buildPermissionPicker says so when no plugin declares a permission, rather
assert.deepEqual(picker.choices, []); assert.deepEqual(picker.choices, []);
assert.ok(picker.empty); assert.ok(picker.empty);
}); });
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
const token = baselineToken(declared, ["users:read", "users:write"]);
assert.match(token, /^[0-9a-f]+$/);
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
assert.notEqual(baselineToken(declared, ["users:read"]), token);
assert.notEqual(baselineToken(declared, []), token);
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
});
test("isStale refuses a missing or old token, but only for a save that would change something", () => {
const held = ["users:read"];
const change = grantDiff(declared, held, ["users:read", "users:write"]);
const nothing = grantDiff(declared, held, ["users:read"]);
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
assert.equal(isStale(current, declared, held, change), false);
assert.equal(isStale(old, declared, held, change), true);
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
});
test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
const log: string[] = [];
const keto = (failOn?: string): KetoClient => ({
check: async () => false,
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
});
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
const ok = await applyGrants(keto(), userSubject("u1"), diff);
assert.deepEqual(ok, { applied: diff });
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
log.length = 0;
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
assert.ok(failed.error instanceof Error);
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
});
+38 -4
View File
@@ -6,11 +6,17 @@
// what the installed plugins declare in code. Nothing here invents a name, which is why the old // what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are. // Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api"; import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission"; const PERMISSION_NS = "Permission";
const GRANTED = "granted"; const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
// Only that exact value counts, so a crafted link can't put words on the page.
export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet }; export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
@@ -53,6 +59,7 @@ export interface PermissionChoice {
export interface PermissionPicker { export interface PermissionPicker {
action: string; action: string;
baseline: string; // baselineToken of the direct grants this form was rendered from
choices: PermissionChoice[]; choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
@@ -60,6 +67,7 @@ export interface PermissionPicker {
hint: string; hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string; legend: string;
notice?: string; // a confirmation, rendered above the list
// Set for a group: its members hold these transitively, so a change reaches them at their next // Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing. // re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined; pending: string | undefined;
@@ -90,6 +98,7 @@ export function buildPermissionPicker(opts: {
})); }));
return { return {
action: opts.action, action: opts.action,
baseline: baselineToken(opts.declared, opts.direct),
choices, choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined, empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD, field: PERMISSIONS_FIELD,
@@ -107,7 +116,7 @@ export function buildPermissionPicker(opts: {
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are // What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared // considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save. // name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } { export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): GrantChange {
const offered = new Set(declared.map((d) => d.name)); const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held); const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name))); const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
@@ -117,7 +126,32 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
}; };
} }
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> { // A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject)); // names the form offered. It is a digest so an empty set is still a token; it is a guard against a
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject)); // stale page, not a secret, since anyone who can read the page can compute it.
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
const offered = new Set(declared.map((d) => d.name));
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
}
// True when the form's token is missing or no longer matches Keto *and* the save would change
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
// harmless, so it is not refused.
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
export interface GrantChange { grant: string[]; revoke: string[] }
// KetoClient writes one tuple per call, so the writes and deletes run one by one and stop at the first
// failure. What was applied by then is returned beside the error, so the caller can still revoke live
// tokens and tell the admin the save is only partly done.
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<{ applied: GrantChange; error?: unknown }> {
const applied: GrantChange = { grant: [], revoke: [] };
try {
for (const name of diff.grant) { await keto.writeTuple(grantTuple(name, subject)); applied.grant.push(name); }
for (const name of diff.revoke) { await keto.deleteTuple(grantTuple(name, subject)); applied.revoke.push(name); }
} catch (error) {
return { applied, error };
}
return { applied };
} }
+1 -1
View File
@@ -1,7 +1,7 @@
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a // Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested // Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over // groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
// HTTP in app.test.ts. // HTTP in src/http/app-admin.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
+75 -20
View File
@@ -7,8 +7,8 @@
// each returning a RouteResult. // each returning a RouteResult.
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api"; import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
@@ -293,6 +293,32 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0; return page.tuples.length > 0;
} }
const isMember = (keto: KetoClient, actor: string, group: string): Promise<boolean> =>
keto.check({ namespace: GROUP_NS, object: group, relation: MEMBERS, subject_id: `user:${actor}` });
// The self-lockout guard, the group-side twin of the Users screen's self-revoke one: of the permissions
// a change takes from a group the actor is in, those they don't hold directly. One they also hold
// through another group is included, since telling those apart needs a reverse Keto query.
async function lostToSelf(keto: KetoClient, actor: string, taken: readonly string[]): Promise<string[]> {
if (taken.length === 0) return [];
const direct = new Set(await heldPermissions(keto, userSubject(actor)));
return taken.filter((name) => !direct.has(name));
}
// Leaving a group takes everything it confers, including through its own parents.
async function lostOnLeaving({ ctx, keto, user }: GroupsDeps, group: string): Promise<string[]> {
return lostToSelf(keto, user.id, await effectivePermissions(keto, groupSubject(group), ctx.declaredPermissions));
}
async function lostOnDelete(deps: GroupsDeps, group: string): Promise<string[]> {
return (await isMember(deps.keto, deps.user.id, group)) ? lostOnLeaving(deps, group) : [];
}
async function refuseSelfLockout(deps: GroupsDeps, name: string, action: string, lost: string[]): Promise<RouteResult> {
deps.ctx.log.warn("admin: refused a group change that would lock the actor out", { action, actor: deps.user.id, group: name, lost: lost.join(",") });
return { ...(await groupDetailPage(deps, name, { pageError: deps.ctx.t("admin.groups.error.selfLockout", { permissions: lost.join(", ") }) })), status: 400 };
}
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on // Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route // a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these. // below is a thin handler over these.
@@ -347,13 +373,15 @@ export const groupsCreate = withGroups(async (deps) => {
// GET /admin/groups/new — the create form. // GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write"); export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// GET /admin/groups/:name — the detail + membership page. // The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => { // page, for any change refused because it would lock the actor out.
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string; pageError?: string } = {}): Promise<RouteResult> {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
const subject = groupSubject(name); const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]); const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions = buildPermissionPicker({ const permissions: PermissionPicker = {
...buildPermissionPicker({
action: `${detailHref(name)}/permissions`, action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions, declared: ctx.declaredPermissions,
direct, direct,
@@ -361,22 +389,38 @@ export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, nam
readOnly: !can(ctx, permissionName("groups", "write")), readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t, t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
}); }),
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" }; ...(say.error ? { error: say.error } : {}),
}); ...(say.notice ? { notice: say.notice } : {}),
};
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
}
// GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold // POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint — // a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group. // the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => { export const groupsPermissions = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const subject = groupSubject(name); const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD)); const held = await heldPermissions(keto, subject);
await applyGrants(keto, subject, diff); const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (diff.grant.length > 0 || diff.revoke.length > 0) { if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") }); const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
const { applied, error } = await applyGrants(keto, subject, diff);
if (applied.grant.length > 0 || applied.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
} }
return { redirect: detailHref(name) }; if (error) {
ctx.log.error("admin: group permissions only partly saved", { actor: user.id, error: String(error), group: name });
return partialSave(ctx, async () => ({ ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.partial") })), status: 502 }));
}
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
}); });
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest). // POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -388,19 +432,24 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
}); });
// GET /admin/groups/:name/delete — the deliberate confirm step. // GET /admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName((deps, name) => { export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
const base = detailHref(name); const base = detailHref(name);
const tt = deps.ctx.t; const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ return { data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" };
}, "write"); }, "write");
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist). // POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => { export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its // Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone // tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name. // re-created a group with the same name.
@@ -413,9 +462,15 @@ export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
}); });
// POST /admin/groups/:name/members/delete — remove one member. // POST /admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async ({ ctx, keto }, name) => { export const groupsRemoveMember = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim()); const tuple = memberTuple(name, (form.get("member") ?? "").trim());
if (tuple) await keto.deleteTuple(tuple); if (!tuple) return { redirect: detailHref(name) };
// Removing the actor, or a nested group they are in, can take them out of this group.
const removesSelf = tuple.subject_set ? await isMember(keto, user.id, tuple.subject_set.object) : tuple.subject_id === `user:${user.id}`;
const lost = removesSelf ? await lostOnLeaving(deps, name) : [];
if (lost.length > 0) return refuseSelfLockout(deps, name, "remove", lost);
await keto.deleteTuple(tuple);
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
}); });
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
const CATALOG: readonly PluginSettings[] = [
{
pluginId: "scheduling",
settings: [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
],
},
{ pluginId: "quiet", settings: [] },
];
test("a row carries the variable to set and where the value came from", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const rows = model.groups[0]?.table.rows ?? [];
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
assert.deepEqual(rows[0]?.cells, [
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
]);
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
assert.equal(rows[2]?.cells[5], "Not set");
});
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
assert.deepEqual(model.groups[1]?.table.rows, []);
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
});
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
const settings: readonly PluginSettings[] = [{
pluginId: "billing",
settings: [
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
],
}];
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
assert.equal(rows[0]?.cells[6], "Secret — set");
assert.equal(rows[1]?.cells[6], "Secret — not set");
});
test("two tables on one page need distinct row-action id stems", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const stems = model.groups.map((g) => g.table.actionsId);
assert.equal(new Set(stems).size, stems.length);
});
@@ -0,0 +1,75 @@
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
// environment at boot, so changing one is a deploy, not a form.
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
interface SettingsGroup {
emptyText: string;
pluginId: string;
table: {
actionsId: string;
caption: string;
columns: { label: string }[];
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
};
}
// One group per installed plugin, including those declaring nothing — an operator who cannot find
// their plugin here has not installed it, which is the other half of what this screen answers.
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
const t = opts.t ?? ADMIN_EN;
return {
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
chrome: opts.chrome,
groups: opts.settings.map((plugin): SettingsGroup => ({
emptyText: t("admin.pluginSettings.none"),
pluginId: plugin.pluginId,
table: {
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
columns: [
{ label: t("admin.pluginSettings.column.key") },
{ label: t("admin.pluginSettings.column.description") },
{ label: t("admin.pluginSettings.column.type") },
{ label: t("admin.pluginSettings.column.required") },
{ label: t("admin.pluginSettings.column.variable") },
{ label: t("admin.pluginSettings.column.source") },
{ label: t("admin.pluginSettings.column.value") },
],
rows: plugin.settings.map((setting) => ({
cells: [
{ rowHeader: { text: setting.key } },
setting.description ?? "",
typeLabel(setting),
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
setting.envName,
t(`admin.pluginSettings.source.${setting.source}`),
valueLabel(setting, t),
],
name: setting.key,
})),
},
})),
title: t("admin.pluginSettings.title"),
};
}
// An enum's choices are the useful half of its type — they are what the operator must pick from.
function typeLabel(setting: SettingSummary): string {
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
}
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
// resolved and from where is what an operator needs, and the source column already says the rest.
function valueLabel(setting: SettingSummary, t: Translate): string {
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
return setting.value ?? t("admin.pluginSettings.unset");
}
// GET /admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
};
+8 -8
View File
@@ -1,6 +1,6 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the // (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts. // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app-admin.test.ts.
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses. // Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ---- // ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => { test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "admin");
// No gate on the header: a user may hold one screen's permission and not another's. composeNav // No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of the three hides the section. // drops a header left with no visible children, so holding none of them hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf, // Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included. // ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined); assert.equal(ADMIN_NAV.href, undefined);
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes // Labels are catalog keys; the host translates them with this plugin's catalog when it composes
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients", "admin.nav.pluginSettings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
}); });
+17 -5
View File
@@ -12,12 +12,13 @@ export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
// One resource per screen — the `<resource>` half of every permission this plugin gates on. // One resource per screen — the `<resource>` half of every permission this plugin gates on.
// `oauth2-clients` rather than `clients` because permission names are one global namespace. // `oauth2-clients` rather than `clients` because permission names are one global namespace.
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so // There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens. // holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "users"; export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users";
export type AdminAction = "read" | "write"; export type AdminAction = "read" | "write";
@@ -34,19 +35,20 @@ export function actionForMethod(method: string): AdminAction {
return verb === "GET" || verb === "HEAD" ? "read" : "write"; return verb === "GET" || verb === "HEAD" ? "read" : "write";
} }
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own // The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
// read permission. The header carries no `permission` because a user may hold one screen's and not // permission. composeNav drops a header left with no visible children, so a user holding none of
// another's; composeNav drops a header left with no visible children, so a user holding none of the // them never sees the section. The host current-marks the active item — no `current`/`open` here.
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
], ],
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
}; };
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already // The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
@@ -81,6 +83,16 @@ export function unavailable(ctx: RequestContext, what: string): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" }; return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
} }
// The page a partial permission save reports on reads Keto again. If that read fails too, the report
// stands alone on the notice page, so the admin still hears that part of the save landed.
export async function partialSave(ctx: RequestContext, render: () => Promise<RouteResult>): Promise<RouteResult> {
try {
return await render();
} catch {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.grants.partial"), title: ctx.t("admin.unavailable.title") }, status: 502, view: "notice" };
}
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields // Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs. // (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: { export function buildConfirmModel(opts: {
+22 -1
View File
@@ -1,5 +1,5 @@
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP // Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts. // routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app-admin.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { Identity } from "@plainpages/plugin-api"; import type { Identity } from "@plainpages/plugin-api";
@@ -60,6 +60,14 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
// Edit link points at the per-identity route. // Edit link points at the per-identity route.
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`)); assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
});
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
}); });
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => { test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
@@ -92,6 +100,19 @@ test("buildUserFormModel: edit mode prefills, locks email, and exposes state/del
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate"); assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
}); });
test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a users:read holder", () => {
const cases: [Parameters<typeof buildUserFormModel>[0], string, string][] = [
[{}, "New user", "New"],
[{ identity: identity(3) }, "Edit user", "Edit"],
[{ canWrite: false, identity: identity(3) }, "View user", "View"],
];
for (const [opts, title, crumb] of cases) {
const m = buildUserFormModel(opts);
assert.equal(m.title, title);
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
}
});
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => { test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), { assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
schema_id: "default", schema_id: "default",
+27 -16
View File
@@ -4,8 +4,8 @@
// a shared `withUser` gate. // a shared `withUser` gate.
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -138,12 +138,12 @@ export function buildUsersListModel(opts: {
canWrite: opts.canWrite !== false, canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t), filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t), table: listTable(rows, state, sort, t, opts.canWrite !== false),
title: t("admin.users.title"), title: t("admin.users.title"),
}; };
} }
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) { function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
return { return {
actions: true, actions: true,
caption: t("admin.users.title"), caption: t("admin.users.title"),
@@ -153,7 +153,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true }; return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}), }),
rows: rows.map((u) => ({ rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }], actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }],
cells: [ cells: [
{ user: { initials: u.initials, name: u.name } }, { user: { initials: u.initials, name: u.name } },
u.email, u.email,
@@ -243,8 +243,9 @@ export function buildUserFormModel(opts: {
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" }); if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
const canWrite = opts.canWrite !== false; const canWrite = opts.canWrite !== false;
const mode = !editing ? "new" : canWrite ? "edit" : "view";
return { return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: t({ edit: "common.edit", new: "common.new", view: "admin.users.view" }[mode]) }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
edit: editing ? { edit: editing ? {
deleteAction: `${idPath}/delete`, deleteAction: `${idPath}/delete`,
@@ -258,7 +259,7 @@ export function buildUserFormModel(opts: {
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") }, form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined, permissions: editing ? opts.permissions : undefined,
recovery: opts.recovery, recovery: opts.recovery,
title: editing ? t("admin.users.edit") : t("admin.users.new"), title: t({ edit: "admin.users.edit", new: "admin.users.new", view: "admin.users.viewTitle" }[mode]),
}; };
} }
@@ -329,7 +330,7 @@ export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx
// GET /admin/users/:id — the edit form, prefilled. // GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, id) => { export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id); const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) }); return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
}); });
@@ -338,7 +339,7 @@ const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName(
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where // The checkbox list of declared permissions: ticked where this user holds one, and disabled where
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired — // the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
// the rest of the edit page still works. // the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> { async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined; if (!deps.keto) return undefined;
const subject = userSubject(id); const subject = userSubject(id);
const [direct, effective] = await Promise.all([ const [direct, effective] = await Promise.all([
@@ -355,6 +356,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string)
t: deps.ctx.t, t: deps.ctx.t,
}), }),
...(error ? { error } : {}), ...(error ? { error } : {}),
...(notice ? { notice } : {}),
}; };
} }
@@ -366,21 +368,30 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto")); if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id); const subject = userSubject(id);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD)); const held = await heldPermissions(keto, subject);
const refuse = async (error: string, status: number): Promise<RouteResult> => {
const permissions = await userPermissionPicker(deps, id, error);
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
};
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can // Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very // remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request — leaving a `curl` against Keto as the only way back in. // next request — leaving a `curl` against Keto as the only way back in.
if (id === user.id && diff.revoke.length > 0) { if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") }); ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke")); return refuse(ctx.t("admin.grants.selfRevoke"), 400);
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
} }
await applyGrants(keto, subject, diff); const { applied, error } = await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) { if (applied.grant.length > 0 || applied.revoke.length > 0) {
revoke?.(id); revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id }); ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
} }
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; if (error) {
ctx.log.error("admin: user permissions only partly saved", { actor: user.id, error: String(error), target: id });
return partialSave(ctx, () => refuse(ctx.t("admin.grants.partial"), 502));
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
}); });
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400). // POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
+28 -2
View File
@@ -53,9 +53,12 @@ const messages = {
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.", "admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions", "admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.", "admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.partial": "Saving stopped part-way because the permission service failed, so only some changes were applied. Check the permissions and make any remaining changes again.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).", "admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.save": "Save permissions", "admin.grants.save": "Save permissions",
"admin.grants.saved": "Permissions saved.",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.", "admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
"admin.groups.actions": "Group actions", "admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member", "admin.groups.addMember": "Add a member",
@@ -65,6 +68,7 @@ const messages = {
"admin.groups.create": "Create group", "admin.groups.create": "Create group",
"admin.groups.delete": "Delete group", "admin.groups.delete": "Delete group",
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.", "admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
"admin.groups.error.selfLockout": "This change would lock you out of {{permissions}}, which you hold through this group. Ask another administrator to make it.",
"admin.groups.field.name": "Group name", "admin.groups.field.name": "Group name",
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.", "admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.groups.filter": "Filter groups", "admin.groups.filter": "Filter groups",
@@ -83,9 +87,29 @@ const messages = {
"admin.nav.clients": "OAuth2 clients", "admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups", "admin.nav.groups": "Groups",
"admin.nav.pluginSettings": "Plugin settings",
"admin.nav.section": "Admin", "admin.nav.section": "Admin",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
"admin.pluginSettings.column.description": "Description",
"admin.pluginSettings.column.key": "Key",
"admin.pluginSettings.column.required": "Required",
"admin.pluginSettings.column.source": "Source",
"admin.pluginSettings.column.type": "Type",
"admin.pluginSettings.column.value": "Value",
"admin.pluginSettings.column.variable": "Variable",
"admin.pluginSettings.no": "No",
"admin.pluginSettings.none": "This plugin declares no settings.",
"admin.pluginSettings.secretSet": "Secret — set",
"admin.pluginSettings.secretUnset": "Secret — not set",
"admin.pluginSettings.source.default": "Default",
"admin.pluginSettings.source.env": "Environment",
"admin.pluginSettings.source.unset": "Not set",
"admin.pluginSettings.title": "Plugin settings",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Yes",
"admin.notFound.message": "That item doesn't exist.", "admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found", "admin.notFound.title": "Not found",
@@ -117,8 +141,8 @@ const messages = {
"admin.users.new": "New user", "admin.users.new": "New user",
"admin.users.pagination": "Users pagination", "admin.users.pagination": "Users pagination",
"admin.users.reactivate": "Reactivate", "admin.users.reactivate": "Reactivate",
"admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):", "admin.users.recovery.body": "Send the user this link and code. They open the link and enter the code to set a new password (generate a fresh code if it has expired):",
"admin.users.recovery.link": "the password-reset screen", "admin.users.recovery.link": "password-reset link",
"admin.users.recovery.generate": "Generate recovery code", "admin.users.recovery.generate": "Generate recovery code",
"admin.users.recovery.title": "Recovery code generated", "admin.users.recovery.title": "Recovery code generated",
"admin.users.save": "Save changes", "admin.users.save": "Save changes",
@@ -129,6 +153,8 @@ const messages = {
"admin.users.status.inactive": "Inactive", "admin.users.status.inactive": "Inactive",
"admin.users.status.label": "Status", "admin.users.status.label": "Status",
"admin.users.title": "Users", "admin.users.title": "Users",
"admin.users.view": "View",
"admin.users.viewTitle": "View user",
}; };
export type AdminMessages = typeof messages; export type AdminMessages = typeof messages;
+28 -2
View File
@@ -53,9 +53,12 @@ const messages: AdminMessages = {
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.", "admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter", "admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.", "admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.partial": "Sparandet avbröts halvvägs eftersom behörighetstjänsten gav fel, så bara en del av ändringarna genomfördes. Kontrollera behörigheterna och gör de återstående ändringarna igen.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).", "admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.save": "Spara behörigheter", "admin.grants.save": "Spara behörigheter",
"admin.grants.saved": "Behörigheterna är sparade.",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.", "admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
"admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
"admin.groups.actions": "Gruppåtgärder", "admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem", "admin.groups.addMember": "Lägg till en medlem",
@@ -65,6 +68,7 @@ const messages: AdminMessages = {
"admin.groups.create": "Skapa grupp", "admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp", "admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.", "admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
"admin.groups.error.selfLockout": "Ändringen skulle låsa ute dig från {{permissions}}, som du har genom den här gruppen. Be en annan administratör att göra den.",
"admin.groups.field.name": "Gruppnamn", "admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", "admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.groups.filter": "Filtrera grupper", "admin.groups.filter": "Filtrera grupper",
@@ -83,9 +87,29 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter", "admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper", "admin.nav.groups": "Grupper",
"admin.nav.pluginSettings": "Tilläggsinställningar",
"admin.nav.section": "Administration", "admin.nav.section": "Administration",
"admin.nav.users": "Användare", "admin.nav.users": "Användare",
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
"admin.pluginSettings.column.description": "Beskrivning",
"admin.pluginSettings.column.key": "Nyckel",
"admin.pluginSettings.column.required": "Obligatorisk",
"admin.pluginSettings.column.source": "Källa",
"admin.pluginSettings.column.type": "Typ",
"admin.pluginSettings.column.value": "Värde",
"admin.pluginSettings.column.variable": "Variabel",
"admin.pluginSettings.no": "Nej",
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
"admin.pluginSettings.secretSet": "Hemlighet — satt",
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
"admin.pluginSettings.source.default": "Standardvärde",
"admin.pluginSettings.source.env": "Miljövariabel",
"admin.pluginSettings.source.unset": "Inte satt",
"admin.pluginSettings.title": "Tilläggsinställningar",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Ja",
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
@@ -117,8 +141,8 @@ const messages: AdminMessages = {
"admin.users.new": "Ny användare", "admin.users.new": "Ny användare",
"admin.users.pagination": "Sidnavigering för användare", "admin.users.pagination": "Sidnavigering för användare",
"admin.users.reactivate": "Aktivera igen", "admin.users.reactivate": "Aktivera igen",
"admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):", "admin.users.recovery.body": "Skicka länken och koden till användaren. Användaren öppnar länken och anger koden för att sätta ett nytt lösenord (skapa en ny kod om den hunnit gå ut):",
"admin.users.recovery.link": "sidan för lösenordsåterställning", "admin.users.recovery.link": "länk för lösenordsåterställning",
"admin.users.recovery.generate": "Skapa återställningskod", "admin.users.recovery.generate": "Skapa återställningskod",
"admin.users.recovery.title": "Återställningskod skapad", "admin.users.recovery.title": "Återställningskod skapad",
"admin.users.save": "Spara ändringar", "admin.users.save": "Spara ändringar",
@@ -129,6 +153,8 @@ const messages: AdminMessages = {
"admin.users.status.inactive": "Inaktiv", "admin.users.status.inactive": "Inaktiv",
"admin.users.status.label": "Status", "admin.users.status.label": "Status",
"admin.users.title": "Användare", "admin.users.title": "Användare",
"admin.users.view": "Visa",
"admin.users.viewTitle": "Visa användare",
}; };
export default messages; export default messages;
+7 -4
View File
@@ -35,17 +35,20 @@ test("every nav permission is one the manifest declares", () => {
} }
}; };
walk(manifest.nav); walk(manifest.nav);
assert.equal(navPermissions.length, 3); assert.equal(navPermissions.length, 4);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`); for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
}); });
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => { test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are // Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
// declared in plugin code, so holding one is edited on the user or group that holds it. // declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
// There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so
// holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [ assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write", "groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write", "oauth2-clients:read", "oauth2-clients:write",
"plugin-settings:read",
"users:read", "users:write", "users:read", "users:write",
]); ]);
}); });
@@ -58,5 +61,5 @@ test("GET routes gate on read and mutations on write, so a reader can open a scr
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write"; const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`); assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`);
} }
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per CRUD screen; plugin settings has none
}); });
+6 -1
View File
@@ -7,6 +7,7 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts"; import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
@@ -24,9 +25,10 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
const users = on("users"); const users = on("users");
const groups = on("groups"); const groups = on("groups");
const clients = on("oauth2-clients"); const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
@@ -37,6 +39,7 @@ export default definePlugin({
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" }, { description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" }, { description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" }, { description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
], ],
routes: [ routes: [
@@ -68,5 +71,7 @@ export default definePlugin({
clients("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete), clients("POST", "/clients/:id/delete", clientsDelete),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
], ],
}); });
@@ -7,13 +7,16 @@
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
into an accidental revoke, because grantDiff compares against the direct grants only. into an accidental revoke, because grantDiff compares against the direct grants only.
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }). Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
%> %>
<section class="form-card" aria-labelledby="permissions-h"> <section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2> <h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.error) { -%> <% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %> <%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%> <% } -%>
<% if (permissions.notice) { -%>
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
<% } -%>
<% if (permissions.empty) { -%> <% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p> <p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%> <% } else { -%>
@@ -26,8 +29,10 @@
<% }) -%> <% }) -%>
</fieldset> </fieldset>
<% } else { -%> <% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>"> <%# The fragment brings the card, and the notice in it, into view on a long page. %>
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
<fieldset class="check-group"> <fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend> <legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%> <% permissions.choices.forEach((c) => { -%>
@@ -2,7 +2,7 @@
Admin user create/edit form body, captured into the shell content slot. Config: Admin user create/edit form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] } form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only) edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
recovery? { code? } shown after a recovery code is generated (recovery is code-based) recovery? { code, link } shown after a recovery code is generated; link is the URL of the flow the code was minted on, the only flow that accepts the code
error? string shown when a write was rejected error? string shown when a write was rejected
%><% %><%
const form = locals.form; const form = locals.form;
@@ -14,7 +14,7 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%> <% } -%>
<% if (recovery) { -%> <% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div> <div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= recovery.link %>"><%= t("admin.users.recovery.link") %></a></span><span class="recovery-code"><code><%= recovery.code %></code></span></div></div>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
@@ -0,0 +1,24 @@
<%#
Plugin settings admin list: one section per installed plugin, each a table of what it declares
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
let body = "";
for (const group of model.groups) {
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
body += '<h2 class="h2">' + group.pluginId + "</h2>";
body += group.table.rows.length === 0
? '<p class="muted">' + group.emptyText + "</p>"
: include("partials/data-table", group.table);
}
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+18 -7
View File
@@ -15,8 +15,12 @@ What it demonstrates:
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial. reusing the core `field` partial.
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / - **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
user-changeable and can be reassigned to someone else, who would then inherit those rows.
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including - **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
the views carry the visitor's language onto their links with `localeHref()`. the views carry the visitor's language onto their links with `localeHref()`.
@@ -27,7 +31,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
## Upstream ## Upstream
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box. mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook). A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
@@ -38,9 +42,14 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
| Route | Request | Success | Response body | | Route | Request | Success | Response body |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) | | `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) | | `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
does that join at create time and stores the `assigneeId` alongside the name.
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
form re-renders. The plugin only validates that `title` and `assignee` are non-empty. form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
@@ -50,6 +59,8 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access ## Granting access
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write` A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
to create). The one-command bootstrap grants both to the demo admin, so the seeded `scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately. `admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
@@ -13,12 +13,16 @@ const messages = {
"scheduling.filter.searchLabel": "Search shifts", "scheduling.filter.searchLabel": "Search shifts",
"scheduling.filter.searchPlaceholder": "Search title or assignee…", "scheduling.filter.searchPlaceholder": "Search title or assignee…",
"scheduling.form.submit": "Create shift", "scheduling.form.submit": "Create shift",
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
"scheduling.mine.title": "My shifts",
"scheduling.nav.mine": "My shifts",
"scheduling.nav.overview": "Overview", "scheduling.nav.overview": "Overview",
"scheduling.nav.section": "Scheduling", "scheduling.nav.section": "Scheduling",
"scheduling.nav.shifts": "Shifts", "scheduling.nav.shifts": "Shifts",
"scheduling.new.title": "New shift", "scheduling.new.title": "New shift",
"scheduling.overview.lead": "scheduling.overview.lead":
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.", "Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
"scheduling.overview.mine": "See my shifts",
"scheduling.overview.signIn": "Sign in to view shifts", "scheduling.overview.signIn": "Sign in to view shifts",
"scheduling.overview.title": "Scheduling", "scheduling.overview.title": "Scheduling",
"scheduling.overview.view": "View shifts", "scheduling.overview.view": "View shifts",
@@ -9,12 +9,16 @@ const messages: SchedulingMessages = {
"scheduling.filter.searchLabel": "Sök pass", "scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…", "scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"scheduling.form.submit": "Skapa pass", "scheduling.form.submit": "Skapa pass",
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
"scheduling.mine.title": "Mina pass",
"scheduling.nav.mine": "Mina pass",
"scheduling.nav.overview": "Översikt", "scheduling.nav.overview": "Översikt",
"scheduling.nav.section": "Schemaläggning", "scheduling.nav.section": "Schemaläggning",
"scheduling.nav.shifts": "Pass", "scheduling.nav.shifts": "Pass",
"scheduling.new.title": "Nytt pass", "scheduling.new.title": "Nytt pass",
"scheduling.overview.lead": "scheduling.overview.lead":
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.", "Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
"scheduling.overview.mine": "Visa mina pass",
"scheduling.overview.signIn": "Logga in för att se passen", "scheduling.overview.signIn": "Logga in för att se passen",
"scheduling.overview.title": "Schemaläggning", "scheduling.overview.title": "Schemaläggning",
"scheduling.overview.view": "Visa pass", "scheduling.overview.view": "Visa pass",
+23 -10
View File
@@ -3,19 +3,20 @@
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream). // stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000"; // over — which is after this manifest is built, hence the getter.
const upstream = createUpstream(upstreamUrl); let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a // onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. // settings. A malformed URL already failed the boot by then; the host validated the declared type.
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog // Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling" // (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
@@ -24,11 +25,13 @@ export default definePlugin({
nav: [{ nav: [{
children: [ children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true }, { href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ }, { href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
], ],
icon: "i-cal", icon: "i-cal",
id: "scheduling", id: "scheduling",
label: "scheduling.nav.section", label: "scheduling.nav.section",
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}], }],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
@@ -37,12 +40,22 @@ export default definePlugin({
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
], ],
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
settings: [
{
default: "http://shifts-upstream:4000",
description: "Base URL of the backend this plugin reads shifts from and writes them to",
key: "upstream",
type: "url",
},
],
}); });
@@ -4,3 +4,9 @@
.scheduling-page .table-wrap { .scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem); margin-top: var(--space-3, 0.75rem);
} }
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
.scheduling-page > p,
.scheduling-page > .alert,
.scheduling-page > .btn {
margin-inline: var(--pad-x);
}
+70 -30
View File
@@ -4,29 +4,29 @@ import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may // Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } from "./shifts.ts";
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
} }
const SHIFTS: Shift[] = [ const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" }, { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" }, { assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
]; ];
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over }); const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
@@ -35,27 +35,25 @@ const asView = (r: RouteResult | void) => {
return r as { data: Record<string, unknown>; status?: number; view: string }; return r as { data: Record<string, unknown>; status?: number; view: string };
}; };
// ---- upstream config validation (the onBoot hook) ---- // ---- the upstream URL as a declared setting ----
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => { test("the manifest declares its upstream as a URL setting the host validates", async () => {
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM")); const manifest = (await import("./plugin.ts")).default;
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM")); assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
}); });
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => { test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
const prev = process.env["SCHEDULING_UPSTREAM"]; let baseUrl = "http://first:4000";
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below const seen: string[] = [];
try { const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
const manifest = (await import("./plugin.ts")).default; const upstream = createUpstream(() => baseUrl, http);
assert.equal(typeof manifest.hooks?.onBoot, "function"); await upstream.list();
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud baseUrl = "http://second:4000";
} finally { await upstream.list();
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"]; assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
}); });
// ---- upstream client (fetch injected) ---- // ---- upstream client (fetch injected) ----
@@ -65,23 +63,23 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
const http = (async (url, init) => { const http = (async (url, init) => {
seen = String(url); seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json"); assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch; }) as typeof fetch;
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts"); assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]); assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]);
}); });
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch; const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
}); });
test("createUpstream.create POSTs the input as JSON", async () => { test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = ""; let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch; const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" }; const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream("http://up:4000", http).create(input); await createUpstream(() => "http://up:4000", http).create(input);
assert.equal(method, "POST"); assert.equal(method, "POST");
assert.deepEqual(body, input); assert.deepEqual(body, input);
}); });
@@ -117,14 +115,21 @@ test("listShifts degrades to a recoverable error page when the upstream is down
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ---- // ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => { test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no permissions const anon = asView(await overview()(fakeCtx())); // user null, no permissions
assert.equal(anon.view, "overview"); assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME); assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
assert.equal(anon.data["signedIn"], false);
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] }))); const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
// Signed in but ungranted: the page must not invite them to sign in again.
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
assert.equal(member.data["canRead"], false);
assert.equal(member.data["signedIn"], true);
assert.equal(member.data["mineHref"], "/scheduling/mine");
}); });
// ---- create handler ---- // ---- create handler ----
@@ -173,3 +178,38 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
assert.equal(title.error, "needed"); assert.equal(title.error, "needed");
assert.equal(fields.find((f) => f.name === "start")!.required, undefined); assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
}); });
// ---- the session-gated page: the visitor's own rows ----
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
let asked: { assigneeId?: string } | undefined;
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.equal(r.view, "mine");
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
// `session: true` guarantee the contract cannot state in the handler's type.
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
});
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
});
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
});
+53 -21
View File
@@ -6,7 +6,7 @@
// pure functions against a mock upstream with no network (README.md → Local dev & test story). // pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins). // One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request: // The plugin's own English (its catalog, then the host's), for a view model built outside a request:
@@ -16,12 +16,14 @@ const EN: Translate = englishTranslator(enUS);
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
export const READ = "scheduling:read"; // the permission gating the list + nav export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift { export interface Shift {
id: string; id: string;
assignee: string; assignee: string; // display name, rendered in the table
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
end: string; end: string;
start: string; start: string;
title: string; title: string;
@@ -46,37 +48,30 @@ export class UpstreamError extends Error {
export interface ShiftsUpstream { export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>; create(input: ShiftInput): Promise<void>;
list(): Promise<Shift[]>; // `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
} // Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
export function assertHttpUrl(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
} }
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client // defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network. // span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream { // `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
const base = baseUrl.replace(/\/+$/, ""); // these handlers has already been built.
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = (): string => baseUrl().replace(/\/+$/, "");
return { return {
async create(input) { async create(input) {
const res = await fetchImpl(`${base}/shifts`, { const res = await fetchImpl(`${base()}/shifts`, {
body: JSON.stringify(input), body: JSON.stringify(input),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
}); });
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
}, },
async list() { async list(opts = {}) {
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } }); const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json(); const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : []; return Array.isArray(data) ? data.map(toShift) : [];
@@ -88,7 +83,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
function toShift(raw: unknown): Shift { function toShift(raw: unknown): Shift {
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) }; return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
} }
// ---- view models (pure; the EJS views read these) ----------------------------------- // ---- view models (pure; the EJS views read these) -----------------------------------
@@ -196,6 +191,41 @@ export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" }); return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
} }
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const user = requireSession(ctx);
let shifts: Shift[] = [];
let error: string | undefined;
try {
// Join on the id, never the email: an address is user-changeable and can be reassigned to
// someone else, which would hand them the previous holder's rows. The re-filter is
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
error = ctx.t("scheduling.upstream.list");
}
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
};
}
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
const t = opts.t ?? EN;
return {
breadcrumbs: [{ label: t("scheduling.mine.title") }],
chrome: opts.chrome,
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}),
table: {
caption: t("scheduling.mine.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
emptyText: t("scheduling.mine.empty", { email: opts.email }),
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
},
title: t("scheduling.mine.title"),
};
}
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data // gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone // (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
@@ -206,7 +236,9 @@ export function overview(): RouteHandler {
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }], breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
canRead: can(ctx, READ), canRead: can(ctx, READ),
chrome: ctx.chrome, chrome: ctx.chrome,
mineHref: ctx.localeHref(MINE_PATH),
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
signedIn: ctx.user !== null,
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`), signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
title: ctx.t("scheduling.overview.title"), title: ctx.t("scheduling.overview.title"),
}, },
@@ -0,0 +1,19 @@
<%#
Scheduling · the visitor's own shifts (reference plugin).
Data: chrome, title, breadcrumbs, count, table, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
-%>
<%- include("partials/shell", {
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme,
title,
user: chrome.user,
}) %>
@@ -3,11 +3,15 @@
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome. it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
// and sign-in is offered to nobody who already has one.
const cta = canRead const cta = canRead
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>' ? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
: signedIn
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>'; : '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
+1 -1
View File
@@ -2,7 +2,7 @@
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
service; this view renders them with the core building blocks inside the native app shell service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table, (ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL. alert) — see the README. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error? Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
+13 -7
View File
@@ -1,9 +1,9 @@
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the // Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part // customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM // of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
// at your real service in production. // at your real service in production.
// //
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] // GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end }) // POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -11,10 +11,12 @@ import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 4000); const PORT = Number(process.env.PORT ?? 4000);
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
const shifts = [ const shifts = [
{ id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" }, { id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
{ id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" }, { id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
{ id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" }, { id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
]; ];
const json = (res, status, body) => { const json = (res, status, body) => {
@@ -33,10 +35,14 @@ const readBody = (req) =>
createServer(async (req, res) => { createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts); if (url.pathname === "/shifts" && req.method === "GET") {
const assigneeId = url.searchParams.get("assigneeId");
if (assigneeId === null) return json(res, 200, shifts);
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
}
if (url.pathname === "/shifts" && req.method === "POST") { if (url.pathname === "/shifts" && req.method === "POST") {
const b = await readBody(req); const b = await readBody(req);
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") }; const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
shifts.push(shift); shifts.push(shift);
return json(res, 201, shift); return json(res, 201, shift);
} }
+9 -1
View File
@@ -1,6 +1,14 @@
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER. -- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
-- One database per Ory service: each owns its schema and runs its own migrations, -- One database per Ory service: each owns its schema and runs its own migrations,
-- so they never collide. The web app never connects here (stateless — see README). -- so they never collide. A plugin's database does not belong here: bootstrap provisions those on
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
CREATE DATABASE kratos; CREATE DATABASE kratos;
CREATE DATABASE keto; CREATE DATABASE keto;
CREATE DATABASE hydra; CREATE DATABASE hydra;
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
-- POSTGRES_USER, which owns these and keeps its access.
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
+48 -14
View File
@@ -1,20 +1,21 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.31.0" "lucide-static": "1.64.0",
"postgres": "4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.13.3", "@types/node": "24.19.2",
"acorn": "8.19.0",
"typescript": "7.0.2" "typescript": "7.0.2"
}, },
"engines": { "engines": {
@@ -30,6 +31,13 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@mdn/browser-compat-data": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.7.tgz",
"integrity": "sha512-TJ+rX530bFoE8678PuH2gjCSzwpBcaLlcElRhnnxFdkLHHT1clUAxtjImPFZDAlWQ005px9xaJ4Nd+aQp/rylQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@types/ejs": { "node_modules/@types/ejs": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
@@ -38,13 +46,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"node_modules/@typescript/typescript-aix-ppc64": { "node_modules/@typescript/typescript-aix-ppc64": {
@@ -387,6 +395,19 @@
"node": ">=16.20.0" "node": ">=16.20.0"
} }
}, },
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ejs": { "node_modules/ejs": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
@@ -400,11 +421,24 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.31.0", "version": "1.64.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.31.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
"integrity": "sha512-XFX9NO+gcLsOkXISmeQZYGJa2siGWZc/lgT/BK1b83h9RdOiz3cj1eQP5nWhELiB1KhW5ryk0nmMopOgm/CuSA==", "integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-4.0.0.tgz",
"integrity": "sha512-kBh8qXtcvvIAc00DftQyOxz7M/3Zxww1tRMXZPLzipNXXl5PIzHml+9zB3Mrge2U8ldc0WF1ZdLp1iYQi7Kh3A==",
"license": "Unlicense",
"engines": {
"node": ">=20"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -441,9 +475,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.18.2", "version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
} }
+6 -4
View File
@@ -1,6 +1,5 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -14,16 +13,19 @@
"dev": "node --watch src/server.ts", "dev": "node --watch src/server.ts",
"gen-jwks": "node src/auth/gen-jwks.ts", "gen-jwks": "node src/auth/gen-jwks.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.31.0" "lucide-static": "1.64.0",
"postgres": "4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.13.3", "@types/node": "24.19.2",
"acorn": "8.19.0",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
} }
+5 -119
View File
@@ -1,17 +1,11 @@
/* =============================================================
Auth flow — login / register / forgot password
Built on the shared tokens in styles.css. Pure HTML/CSS:
- screens switch via :target (no JS)
- SSO is a config section toggled by a class/checkbox; providers
are a <ul> you extend by adding one <li class="sso-item">.
============================================================= */
.auth-stage { .auth-stage {
min-height: 100dvh;
display: grid; place-items: center; display: grid; place-items: center;
padding: 40px 20px 96px; padding: 40px 20px 96px;
background: var(--bg); background: var(--bg);
} }
@supports (min-height: 100dvh) {
.auth-stage { min-height: 100dvh; }
}
.auth { .auth {
width: 100%; max-width: 408px; width: 100%; max-width: 408px;
display: flex; flex-direction: column; gap: 22px; display: flex; flex-direction: column; gap: 22px;
@@ -34,13 +28,6 @@
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; } .landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); } .landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
/* ---- screen switching via :target (login is the default) ---- */
.auth-view { display: none; }
#login { display: block; }
#register:target, #forgot:target { display: block; }
body:has(#register:target) #login,
body:has(#forgot:target) #login { display: none; }
/* card */ /* card */
.auth-card { .auth-card {
background: var(--surface); background: var(--surface);
@@ -118,13 +105,13 @@ body:has(#forgot:target) #login { display: none; }
.input:focus { outline: none; border-color: var(--accent); .input:focus { outline: none; border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-bg); } box-shadow: 0 0 0 2px var(--accent-bg); }
/* gentle validation feedback only after the user has interacted */ /* gentle validation feedback only after the user has interacted */
@supports selector(:user-invalid) {
.input:user-invalid { border-color: var(--neg); } .input:user-invalid { border-color: var(--neg); }
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); } .input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
}
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); } .field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
.remember { margin-top: 2px; }
/* full-width primary button (extends .btn / .btn-primary from styles.css) */ /* full-width primary button (extends .btn / .btn-primary from styles.css) */
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); } .btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
@@ -138,74 +125,6 @@ body:has(#forgot:target) #login { display: none; }
.auth-aside a { color: var(--accent); } .auth-aside a { color: var(--accent); }
.auth-aside a:hover { text-decoration: underline; } .auth-aside a:hover { text-decoration: underline; }
/* legal footnote */
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
/* =============================================================
Template preview controls (delete this block + its markup for
production). Drives theme + SSO on/off with zero JS.
============================================================= */
.tpl-controls {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
z-index: 50;
display: flex; align-items: center; gap: 12px;
padding: 6px 8px 6px 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
}
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
.tpl-controls .theme-switch { width: 156px; }
/* on/off switch */
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
background: var(--border-2); position: relative; transition: background .15s ease; }
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
.tpl-toggle:has(input:focus-visible) .tpl-track {
outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 520px) {
.tpl-label { display: none; }
.tpl-controls { gap: 10px; padding: 6px 10px; }
}
/* =============================================================
Forgot-password feedback (Post/Redirect/Get, no JS).
The form POSTs to its own URL; on the redirect-GET the server
re-renders #forgot with a state class so the matching alert shows:
<section id="forgot" class="auth-view state-sent"> … </section>
(state-sent | state-error). No extra view is added.
The preview radios below demonstrate the same states live.
============================================================= */
.forgot-alert { display: none; }
#forgot.state-sent .forgot-alert.is-sent,
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
#forgot.state-error .forgot-alert.is-error,
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
/* the Forgot-state control only appears while the forgot view is open */
.tpl-forgot { display: none; }
body:has(#forgot:target) .tpl-forgot { display: flex; }
.tpl-controls .segmented { padding: 1px; }
.tpl-controls .segmented label { padding: 3px 8px; }
/* =============================================================
Field-level errors (e.g. "email already used"), server-driven.
The server re-renders the field's form with a state class and
adds aria-invalid="true" to the input:
<section id="register" class="auth-view state-email-taken"> … </section>
Reusable: drop a <p class="field-error"> under any field and gate
it on whatever state your server sets.
============================================================= */
.field-error { .field-error {
display: none; align-items: center; gap: 6px; margin: 0; display: none; align-items: center; gap: 6px; margin: 0;
font-size: var(--fz-xs); color: var(--neg); font-size: var(--fz-xs); color: var(--neg);
@@ -213,36 +132,3 @@ body:has(#forgot:target) .tpl-forgot { display: flex; }
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */ .field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
.field-error > .ico { flex: 0 0 auto; color: var(--neg); } .field-error > .ico { flex: 0 0 auto; color: var(--neg); }
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; } .field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
/* register form-level error banner (pairs with field-level errors) */
.register-alert { display: none; }
/* --- state A: email already taken — field-level error only --- */
#register.state-email-taken .err-email,
body:has(#rstate-taken:checked) #register .err-email { display: flex; }
#register.state-email-taken #reg-email,
body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); }
/* --- state B: multiple errors — banner + several field errors together --- */
#register.state-invalid .register-alert,
body:has(#rstate-combined:checked) #register .register-alert { display: flex; }
#register.state-invalid .err-email,
#register.state-invalid .err-password,
body:has(#rstate-combined:checked) #register .err-email,
body:has(#rstate-combined:checked) #register .err-password { display: flex; }
#register.state-invalid #reg-email,
#register.state-invalid #reg-password,
body:has(#rstate-combined:checked) #register #reg-email,
body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); }
/* red focus ring on any invalid register input */
#register.state-email-taken #reg-email:focus,
#register.state-invalid #reg-email:focus,
#register.state-invalid #reg-password:focus,
body:has(#rstate-taken:checked) #register #reg-email:focus,
body:has(#rstate-combined:checked) #register #reg-email:focus,
body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
/* the Register-state control only appears while the register view is open */
.tpl-register { display: none; }
body:has(#register:target) .tpl-register { display: flex; }
+122 -59
View File
@@ -2,8 +2,6 @@
App Shell Template — pure HTML/CSS, no JavaScript App Shell Template — pure HTML/CSS, no JavaScript
------------------------------------------------------------- -------------------------------------------------------------
Architecture Architecture
- Design tokens via light-dark() so theme follows system by
default and can be forced Light / Dark via radio + :has().
- Everything is a reusable "block": .nav-item, .filter, - Everything is a reusable "block": .nav-item, .filter,
.chip, .badge, .btn, .menu, .table — combine freely. .chip, .badge, .btn, .menu, .table — combine freely.
============================================================= */ ============================================================= */
@@ -11,43 +9,46 @@
/* ---------- 1. TOKENS ---------------------------------------- /* ---------- 1. TOKENS ----------------------------------------
Two raw palettes (--l-* light, --d-* dark) are defined once. Two raw palettes (--l-* light, --d-* dark) are defined once.
A small mapping (--bg: var(--l-bg) ...) picks the active set. A small mapping (--bg: var(--l-bg) ...) picks the active set.
Theme resolution (no JS): Theme resolution (no JS); all three rules below need :has():
• default + @media(dark) → follows the OS (Auto) • default + @media(dark) → follows the OS (Auto)
• html:has(#theme-light) → force light • html:has(#theme-light) → force light
• html:has(#theme-dark) → force dark • html:has(#theme-dark) → force dark
Engines without :has() (Chromium below 105, Firefox below 121,
Safari below 15.4) ignore all three and stay light; see the @supports not
selector(:has(a)) block further down.
We avoid light-dark()/color-scheme for app colors because their We avoid light-dark()/color-scheme for app colors because their
resolution is unreliable across engines; :has() variable swaps resolution is unreliable across engines. color-scheme is still
are rock-solid. color-scheme is still set as a hint for native set as a hint for native widgets (date picker, scrollbars,
widgets (date picker, scrollbars, selects). selects).
------------------------------------------------------------- */ ------------------------------------------------------------- */
:root { :root {
/* ---- raw LIGHT palette ---- */ /* ---- raw LIGHT palette ---- */
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc; --l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
--l-border:#e5e5e8; --l-border-2:#d6d6da; --l-border:#e5e5e8; --l-border-2:#d6d6da;
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2; --l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256); --l-accent:#3669ac; --l-accent-bg:#e5f3ff;
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256); --l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
--l-pos:oklch(0.54 0.13 150); --l-pos-bg:oklch(0.95 0.03 150); --l-pos-bd:oklch(0.83 0.07 150); --l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
--l-neg:oklch(0.55 0.18 25); --l-neg-bg:oklch(0.96 0.03 25); --l-neg-bd:oklch(0.85 0.08 25); --l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
--l-warn:oklch(0.55 0.12 75); --l-warn-bg:oklch(0.96 0.04 85); --l-warn-bd:oklch(0.84 0.08 85); --l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
--l-info:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245); --l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
/* ---- raw DARK palette ---- */ /* ---- raw DARK palette ---- */
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e; --d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
--d-border:#2a2a31; --d-border-2:#3a3a43; --d-border:#2a2a31; --d-border-2:#3a3a43;
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74; --d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256); --d-accent:#6ba0e8; --d-accent-bg:#1d3351;
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256); --d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
--d-pos:oklch(0.74 0.14 150); --d-pos-bg:oklch(0.30 0.05 150); --d-pos-bd:oklch(0.45 0.07 150); --d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
--d-neg:oklch(0.74 0.16 25); --d-neg-bg:oklch(0.30 0.07 25); --d-neg-bd:oklch(0.47 0.10 25); --d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
--d-warn:oklch(0.80 0.13 85); --d-warn-bg:oklch(0.31 0.05 80); --d-warn-bd:oklch(0.48 0.07 80); --d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
--d-info:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245); --d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
/* ---- active mapping: default = LIGHT ---- */ /* ---- active mapping: default = LIGHT ---- */
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3); --bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2); --border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint); --text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd); --pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd); --neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd); --warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
@@ -58,7 +59,7 @@
--radius: 5px; --radius: 5px;
--nav-w: 264px; --nav-w: 264px;
--row-h: 34px; --row-h: 34px;
--pad-x: 12px; --pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
--fz: 13px; --fz: 13px;
--fz-sm: 12px; --fz-sm: 12px;
--fz-xs: 11px; --fz-xs: 11px;
@@ -68,13 +69,14 @@
line-height: 1.45; line-height: 1.45;
} }
@supports selector(:has(a)) {
/* shared DARK mapping, applied to Auto-via-OS and forced-dark */ /* shared DARK mapping, applied to Auto-via-OS and forced-dark */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) { :root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2); --border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd); --pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd); --neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd); --warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
@@ -88,7 +90,7 @@ html:has(#theme-dark:checked) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2); --border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd); --pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd); --neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd); --warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
@@ -101,17 +103,17 @@ html:has(#theme-light:checked) {
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3); --bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2); --border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint); --text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd); --pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd); --neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd); --warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
--info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd); --info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd);
color-scheme: light; color-scheme: light;
} }
}
/* ---------- 2. RESET ---------------------------------------- */ /* ---------- 2. RESET ---------------------------------------- */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); body { margin: 0; background: var(--bg); color: var(--text);
-webkit-font-smoothing: antialiased; } -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; } button { font: inherit; color: inherit; }
@@ -120,11 +122,13 @@ a { color: inherit; text-decoration: none; }
summary::-webkit-details-marker { display: none; } summary::-webkit-details-marker { display: none; }
summary { list-style: none; cursor: pointer; } summary { list-style: none; cursor: pointer; }
@supports selector(:focus-visible) {
:focus-visible { :focus-visible {
outline: 2px solid var(--focus); outline: 2px solid var(--focus);
outline-offset: 1px; outline-offset: 1px;
border-radius: 3px; border-radius: 3px;
} }
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; } .sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
} }
@@ -151,8 +155,9 @@ summary { list-style: none; cursor: pointer; }
.app { .app {
display: grid; display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr); grid-template-columns: var(--nav-w) minmax(0, 1fr);
height: 100dvh; }
overflow: hidden; @supports (min-height: 100dvh) {
.app { min-height: 100dvh; }
} }
/* ---------- 4. SIDEBAR -------------------------------------- */ /* ---------- 4. SIDEBAR -------------------------------------- */
@@ -160,7 +165,6 @@ summary { list-style: none; cursor: pointer; }
grid-column: 1; grid-column: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
@@ -172,9 +176,9 @@ summary { list-style: none; cursor: pointer; }
.brand-mark { .brand-mark {
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
background: var(--accent); background: var(--accent);
display: grid; place-items: center; color: #fff; display: grid; place-items: center; color: var(--on-accent);
} }
.brand-mark .ico { stroke: #fff; } .brand-mark .ico { stroke: var(--on-accent); }
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; } .brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
.brand-name { font-weight: 650; letter-spacing: -.01em; } .brand-name { font-weight: 650; letter-spacing: -.01em; }
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; } .brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
@@ -192,9 +196,10 @@ summary { list-style: none; cursor: pointer; }
• CLICKABLE = label is <a href> → navigates on click. • CLICKABLE = label is <a href> → navigates on click.
STATIC = label is <span> → not a navigation target. STATIC = label is <span> → not a navigation target.
So "header" and "clickable" are independent: any combination works. So "header" and "clickable" are independent: any combination works.
Children sit in a sibling <ul class="nav-children">, revealed by Children sit in a sibling <ul class="nav-children">. Where :has() works, a
:has() on THIS row's own <details open> (scoped with > so a node branch is folded unless THIS row's own <details open> (scoped with > so a node
only ever controls its own subtree — infinite depth, no JS). only ever controls its own subtree — infinite depth, no JS). Without :has()
every branch stays open, since nothing could reopen one.
============================================================ */ ============================================================ */
.nav-row { display: flex; align-items: center; gap: 2px; } .nav-row { display: flex; align-items: center; gap: 2px; }
@@ -218,7 +223,10 @@ summary { list-style: none; cursor: pointer; }
min-height: 30px; padding: 4px 8px; min-height: 30px; padding: 4px 8px;
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text-muted); font-size: var(--fz); color: var(--text-muted); font-size: var(--fz);
position: relative; user-select: none; position: relative;
}
@supports (user-select: none) {
.nav-self { user-select: none; }
} }
a.nav-self:hover { background: var(--surface-2); color: var(--text); } a.nav-self:hover { background: var(--surface-2); color: var(--text); }
.nav-self .ico { color: var(--text-faint); } .nav-self .ico { color: var(--text-faint); }
@@ -238,8 +246,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.nav-self[aria-current="page"] .ico { color: var(--accent); } .nav-self[aria-current="page"] .ico { color: var(--accent); }
/* children + infinite indentation with a guide line per level */ /* children + infinite indentation with a guide line per level */
@supports selector(:has(a)) {
.nav-node > .nav-children { display: none; } .nav-node > .nav-children { display: none; }
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; } .nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
}
.nav-children { padding-left: 22px; position: relative; } .nav-children { padding-left: 22px; position: relative; }
.nav-children::before { .nav-children::before {
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px; content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
@@ -274,6 +284,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.theme-switch label:hover { color: var(--text); } .theme-switch label:hover { color: var(--text); }
.theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
@supports selector(:has(:focus-visible)) {
.theme-switch label:has(input:checked) { .theme-switch label:has(input:checked) {
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550;
@@ -281,6 +292,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.theme-switch label:has(input:focus-visible) { .theme-switch label:has(input:focus-visible) {
outline: 2px solid var(--focus); outline-offset: 1px; outline: 2px solid var(--focus); outline-offset: 1px;
} }
}
.footer-actions { display: flex; align-items: center; gap: 4px; } .footer-actions { display: flex; align-items: center; gap: 4px; }
.profile { .profile {
@@ -312,10 +324,15 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.btn:hover { background: var(--surface-2); } .btn:hover { background: var(--surface-2); }
.btn-primary { background: var(--accent); border-color: var(--accent); .btn-primary { background: var(--accent); border-color: var(--accent);
color: #fff; font-weight: 550; } color: var(--on-accent); font-weight: 550; }
.btn-primary:hover { filter: brightness(1.06); } .btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; } .btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); } .btn-ghost:hover { background: var(--surface-2); }
.btn-menu::after {
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
transform: rotate(45deg);
}
.icon-btn { .icon-btn {
width: 30px; height: 30px; padding: 0; justify-content: center; width: 30px; height: 30px; padding: 0; justify-content: center;
color: var(--text-muted); color: var(--text-muted);
@@ -326,7 +343,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.content { .content {
grid-column: 2; grid-column: 2;
display: flex; flex-direction: column; display: flex; flex-direction: column;
min-width: 0; min-height: 0; min-width: 0;
background: var(--bg); background: var(--bg);
} }
@@ -334,7 +351,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.topbar { .topbar {
flex: 0 0 auto; height: 48px; flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 0 16px; border-bottom: 1px solid var(--border); padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
} }
.hamburger { display: none; } /* shown only on narrow */ .hamburger { display: none; } /* shown only on narrow */
@@ -350,7 +367,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.filters { .filters {
flex: 0 0 auto; background: var(--surface); flex: 0 0 auto; background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 10px 16px; padding: 10px var(--pad-x);
display: flex; flex-direction: column; gap: 9px; display: flex; flex-direction: column; gap: 9px;
} }
.filter-row { .filter-row {
@@ -399,7 +416,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position: relative; display: inline-flex; align-items: center; position: relative; display: inline-flex; align-items: center;
} }
.select select { .select select {
appearance: none; height: 30px; padding: 0 26px 0 9px; height: 30px; padding: 0 26px 0 9px;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
font: inherit; font-size: var(--fz-sm); cursor: pointer; font: inherit; font-size: var(--fz-sm); cursor: pointer;
@@ -409,8 +426,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint); width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint);
border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg); border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg);
} }
@supports (appearance: none) {
.select select { appearance: none; }
}
@supports selector(:focus-visible) {
.select select:focus-visible { border-color: var(--accent); outline: none; .select select:focus-visible { border-color: var(--accent); outline: none;
box-shadow: 0 0 0 2px var(--accent-bg); } box-shadow: 0 0 0 2px var(--accent-bg); }
}
/* segmented toggle (radios) */ /* segmented toggle (radios) */
.segmented { .segmented {
@@ -425,11 +447,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.segmented label:hover { color: var(--text); } .segmented label:hover { color: var(--text); }
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
@supports selector(:has(:focus-visible)) {
.segmented label:has(input:checked) { .segmented label:has(input:checked) {
background: var(--surface); color: var(--text); font-weight: 550; background: var(--surface); color: var(--text); font-weight: 550;
box-shadow: 0 1px 2px rgba(0,0,0,.06); box-shadow: 0 1px 2px rgba(0,0,0,.06);
} }
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; } .segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
}
.seg-count { font-size: var(--fz-xs); color: var(--text-faint); } .seg-count { font-size: var(--fz-xs); color: var(--text-faint); }
/* chips (multi-select checkboxes) */ /* chips (multi-select checkboxes) */
@@ -445,12 +469,25 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.chip .chip-dot { width: 7px; height: 7px; border-radius: 50%; .chip .chip-dot { width: 7px; height: 7px; border-radius: 50%;
background: var(--border-2); } background: var(--border-2); }
@supports selector(:has(:focus-visible)) {
.chip:has(input:checked) { .chip:has(input:checked) {
background: var(--accent-bg); border-color: var(--accent-bd); background: var(--accent-bg); border-color: var(--accent-bd);
color: var(--text); font-weight: 550; color: var(--text); font-weight: 550;
} }
.chip:has(input:checked) .chip-dot { background: var(--accent); } .chip:has(input:checked) .chip-dot { background: var(--accent); }
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; } .chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
}
/* Without :has() every nav branch stays open and no theme rule applies, so the disclosure toggles and
the theme switch are hidden. The filter inputs are shown instead: the native control is then the
only sign of a choice or of focus, and the dot goes, since it would stay grey beside a ticked box.
This block must come after the base rules, since @supports adds no specificity. */
@supports not selector(:has(a)) {
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
.theme-switch { display: none; }
.segmented input, .chip input { position: static; opacity: 1; margin: 0; }
.chip .chip-dot { display: none; }
}
/* date range */ /* date range */
.daterange { .daterange {
@@ -471,8 +508,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
display: inline-flex; align-items: center; gap: 7px; display: inline-flex; align-items: center; gap: 7px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer;
} }
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent); .check input, .radio input { width: 15px; height: 15px; margin: 0; cursor: pointer; }
margin: 0; cursor: pointer; } @supports (accent-color: auto) {
.check input, .radio input { accent-color: var(--accent); }
}
.check:hover, .radio:hover { color: var(--text); } .check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the /* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
@@ -480,7 +519,9 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } .check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; } .check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; } .check-group .check input:disabled { cursor: default; }
@supports selector(:has(a)) {
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; } .check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
}
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); } .check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover] /* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
@@ -491,16 +532,24 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.menu { display: inline-flex; } .menu { display: inline-flex; }
.menu-pop { .menu-pop {
position: absolute; inset: auto; margin: 6px 0 0; position: absolute; inset: auto; margin: 6px 0 0;
position-anchor: auto; min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
position-try-fallbacks: flip-block, flip-inline; max-height: 60vh; overflow-y: auto;
top: anchor(bottom); right: anchor(right);
min-width: 210px; padding: 6px;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
} }
.menu-pop.up { margin: 0 0 6px; }
@supports (overflow-wrap: anywhere) {
.menu-pop { overflow-wrap: anywhere; }
}
@supports (position-anchor: auto) and (top: anchor(bottom)) {
.menu-pop { position-anchor: auto; top: anchor(bottom); right: anchor(right); }
.menu-pop.left { right: auto; left: anchor(left); } .menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; } .menu-pop.up { top: auto; bottom: anchor(top); }
}
@supports (position-try-fallbacks: flip-block, flip-inline) {
.menu-pop { position-try-fallbacks: flip-block, flip-inline; }
}
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its /* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
trigger rather than stranding Sign out and the language picker behind a button that does nothing. trigger rather than stranding Sign out and the language picker behind a button that does nothing.
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes — The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
@@ -531,7 +580,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%; .menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; } padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
.menu-check:hover { background: var(--surface-2); } .menu-check:hover { background: var(--surface-2); }
.menu-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; } .menu-check input { width: 15px; height: 15px; margin: 0; }
@supports (accent-color: auto) {
.menu-check input { accent-color: var(--accent); }
}
/* active filter pills */ /* active filter pills */
.active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } .active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
@@ -553,13 +605,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.pill-clear:hover { text-decoration: underline; } .pill-clear:hover { text-decoration: underline; }
/* ---------- 9. TABLE --------------------------------------- */ /* ---------- 9. TABLE --------------------------------------- */
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; } .table-wrap { overflow-x: auto; }
table.table { table.table {
width: 100%; border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0;
font-size: var(--fz); font-variant-numeric: tabular-nums; font-size: var(--fz); font-variant-numeric: tabular-nums;
} }
.table thead th { .table thead th {
position: sticky; top: 0; z-index: 10;
background: var(--surface-3); background: var(--surface-3);
border-bottom: 1px solid var(--border-2); border-bottom: 1px solid var(--border-2);
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs); color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
@@ -574,15 +625,19 @@ table.table {
} }
.table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */ .table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */
.table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); } .table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
@supports selector(:has(a)) {
.table tbody tr:has(.row-select:checked) td, .table tbody tr:has(.row-select:checked) td,
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); } .table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
}
.col-check { width: 36px; text-align: center; padding: 0; } .col-check { width: 36px; text-align: center; padding: 0; }
.col-check input { accent-color: var(--accent); width: 15px; height: 15px; .col-check input { width: 15px; height: 15px; margin: 0; vertical-align: middle; }
margin: 0; vertical-align: middle; } @supports (accent-color: auto) {
.col-check input { accent-color: var(--accent); }
}
.col-num { text-align: right; } .col-num { text-align: right; }
.cell-strong { font-weight: 550; } .cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); } .cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; } .table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
.cell-mono { font-variant-numeric: tabular-nums; .cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); } font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
@@ -640,13 +695,15 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.kebab:hover { background: var(--surface-2); color: var(--text); } .kebab:hover { background: var(--surface-2); color: var(--text); }
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an /* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
engine that doesn't know :popover-open would drop the hover style along with it. */ engine that doesn't know :popover-open would drop the hover style along with it. */
@supports selector(.kebab:has(+ .menu-pop:popover-open)) {
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); } .kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
}
/* ---------- 10. PAGINATION --------------------------------- */ /* ---------- 10. PAGINATION --------------------------------- */
.pager { .pager {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 8px 16px; border-top: 1px solid var(--border); padding: 8px var(--pad-x); border-top: 1px solid var(--border);
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted); background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
} }
.pager .spacer { flex: 1 1 auto; } .pager .spacer { flex: 1 1 auto; }
@@ -661,20 +718,25 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
.page-btn:hover { background: var(--surface-2); color: var(--text); } .page-btn:hover { background: var(--surface-2); color: var(--text); }
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent); .page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
color: #fff; font-weight: 600; } color: var(--on-accent); font-weight: 600; }
.page-btn:disabled { opacity: .4; cursor: not-allowed; } .page-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */ /* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
.scrim { display: none; } .scrim { display: none; }
@media (max-width: 860px) { @media (max-width: 860px) {
.app { grid-template-columns: minmax(0, 1fr); } .app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
.content { grid-column: 1; grid-row: 2; }
.search { min-width: 150px; flex: 1 1 auto; }
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
stays in the flow above the content and the hamburger stays hidden. */
@supports selector(:has(a)) {
.sidebar { .sidebar {
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w)); position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
z-index: 60; transform: translateX(-100%); z-index: 60; transform: translateX(-100%);
box-shadow: 2px 0 24px rgba(0,0,0,.18); box-shadow: 2px 0 24px rgba(0,0,0,.18);
} }
.content { grid-column: 1; }
.hamburger { display: inline-flex; } .hamburger { display: inline-flex; }
/* checkbox-driven open state */ /* checkbox-driven open state */
@@ -683,7 +745,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
display: block; position: fixed; inset: 0; z-index: 50; display: block; position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.42); background: rgba(0,0,0,.42);
} }
.search { min-width: 150px; flex: 1 1 auto; } }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.crumbs, .page-sub { display: none; } .crumbs, .page-sub { display: none; }
@@ -691,10 +753,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
/* the nav-toggle checkbox itself is visually hidden but focusable */ /* the nav-toggle checkbox itself is visually hidden but focusable */
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; } #nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */ /* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } .form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card { .form-card {
display: flex; flex-direction: column; gap: 14px; display: flex; flex-direction: column; gap: 14px;
padding: 18px; background: var(--surface); padding: 18px; background: var(--surface);
@@ -716,5 +778,6 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* Chromeless shell: a page may drop the sidebar for a focused single column. */ /* Chromeless shell: a page may drop the sidebar for a focused single column. */
.app-bare { grid-template-columns: minmax(0, 1fr); } .app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; } .app-bare .content { grid-column: 1; }
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */ /* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } .shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
test("auto-release's checkout doesn't persist credentials, so the tag push authenticates as renovate-bot", () => {
const workflow = readFileSync(".gitea/workflows/renovate.yml", "utf8");
const job = workflow.slice(workflow.indexOf("\n auto-release:"));
const checkout = job.slice(job.indexOf("actions/checkout@"), job.indexOf("\n - name:"));
assert.match(checkout, /^\s+persist-credentials: false$/m);
});
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
});
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
// is the only thing keeping the copies honest.
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
const [major, minor] = host.split(".");
for (const file of [
"README.md",
"examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
for (const sample of found) {
const [sMajor, sMinor] = (sample ?? "").split(".");
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
}
}
});
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
});
test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
});
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
});
+49
View File
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
export type ContractCheck = { ok: true } | { ok: false; error: string };
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
export function readHostApiVersion(source: string): string | null {
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
}
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
// releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found", ok: false };
}
const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
const h = SEMVER.exec(hostApiVersion);
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
return {
error:
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
"then tag.",
ok: false,
};
}
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
let source = "";
try {
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
} catch (err) {
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) {
process.stderr.write(`${pluginPath}: ${result.error}\n`);
process.exit(1);
}
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
}
@@ -2,14 +2,15 @@
A self-hostable foundation for server-rendered web apps — public or gated pages from a A self-hostable foundation for server-rendered web apps — public or gated pages from a
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in. zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
Every domain feature is a drop-in plugin folder; the app is stateless, no build step. Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
one; the host itself is stateless, and there is no build step.
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>** **Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
([GitHub mirror](https://github.com/larvit/plainpages)) ([GitHub mirror](https://github.com/larvit/plainpages))
## Tags ## Tags
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build. `X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build.
Pin the exact `X.Y.Z` you deploy. Pin the exact `X.Y.Z` you deploy.
## Quick start ## Quick start
@@ -21,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
```yaml ```yaml
services: services:
web: web:
image: larvit/plainpages:0.0.2 image: larvit/plainpages:{{VERSION}}
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
@@ -40,7 +41,7 @@ services:
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user. # One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
bootstrap: bootstrap:
image: larvit/plainpages:0.0.2 image: larvit/plainpages:{{VERSION}}
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
depends_on: depends_on:
kratos: kratos:
@@ -53,7 +54,7 @@ services:
restart: "on-failure:5" restart: "on-failure:5"
postgres: postgres:
image: postgres:18.4-alpine3.23 image: postgres:18.6-alpine3.23
environment: environment:
POSTGRES_DB: ory POSTGRES_DB: ory
POSTGRES_PASSWORD: ory POSTGRES_PASSWORD: ory
@@ -130,7 +131,7 @@ services:
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025 # Catches Kratos' recovery/verification emails — UI on http://localhost:8025
mailpit: mailpit:
image: axllent/mailpit:v1.30.1 image: axllent/mailpit:v1.33.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
@@ -142,7 +143,7 @@ volumes:
Extract the Ory config the image ships, then start: Extract the Ory config the image ships, then start:
```bash ```bash
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf - docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
mkdir -p plugins mkdir -p plugins
docker compose up -d docker compose up -d
``` ```
@@ -162,7 +163,7 @@ Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The co
| --- | --- | --- | | --- | --- | --- |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it | | `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) | | `CACHE_TEMPLATES` | `false` | cache compiled templates and `/public/` asset versions (`true` in prod) |
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod | | `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints | | `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs | | `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
@@ -181,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { DESCRIPTION, hubPatch, jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
const template = () => readFileSync(TEMPLATE, "utf8");
test("renderOverview substitutes every occurrence, not just the first", () => {
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
});
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
});
test("the real template renders clean, and the release owns its own image tag", () => {
const rendered = renderOverview(template(), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
});
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
const pins = (source: string) =>
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
const ours = new Map([
...pins(readFileSync("compose.override.yml", "utf8")),
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
]);
const published = pins(template());
assert.ok(published.size > 0, "the template should pin sidecars");
for (const [image, tag] of published) {
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
}
});
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
assert.equal(jwtFrom({ token: "abc" }), "abc");
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
assert.equal(jwtFrom("<html>rate limited</html>"), null);
assert.equal(jwtFrom({}), null);
assert.equal(jwtFrom({ token: "" }), null);
assert.equal(jwtFrom({ token: 42 }), null);
});
test("the short description opens a README sentence and fits Docker Hub's 100 characters", () => {
const readme = readFileSync("README.md", "utf8").replaceAll("**", "");
assert.ok(DESCRIPTION.trim() !== "" && DESCRIPTION.length <= 100, `${DESCRIPTION.length} characters`);
const escaped = DESCRIPTION.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
assert.match(readme, new RegExp(`(?:\\n\\n|\\.\\s)${escaped}(?=[\\s.:,])`));
});
test("every command the overview shows, the README shows too", () => {
const commands = (source: string) =>
[...source.matchAll(/```bash\n(.*?)```/gs)].flatMap((block) =>
(block[1] ?? "")
.split("\n")
.map((line) => (line.split(" #")[0] ?? "").trim().replace(/larvit\/plainpages:\S+/g, "larvit/plainpages:X.Y.Z"))
.filter((line) => line !== ""),
);
const shown = new Set(commands(readFileSync("README.md", "utf8")));
const published = commands(template());
assert.ok(published.length > 0, `${TEMPLATE} should show commands`);
for (const command of published) assert.ok(shown.has(command), `${TEMPLATE} shows ${command}, which README.md doesn't`);
});
test("the PATCH carries the short description beside the overview", () => {
assert.deepEqual(hubPatch("# page"), { description: DESCRIPTION, full_description: "# page" });
});
+107
View File
@@ -0,0 +1,107 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const HUB = "https://hub.docker.com/v2";
const TIMEOUT_MS = 30_000;
const VERSION = /^\d+\.\d+\.\d+$/;
export const DESCRIPTION = "A self-hostable foundation for server-rendered web applications";
export function hubPatch(overview: string): { description: string; full_description: string } {
return { description: DESCRIPTION, full_description: overview };
}
export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version);
}
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
export function leftoverPlaceholders(rendered: string): string[] {
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
}
export function jwtFrom(body: unknown): string | null {
if (typeof body !== "object" || body === null || !("token" in body)) return null;
return typeof body.token === "string" && body.token !== "" ? body.token : null;
}
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
// fetch and its body readers throw; this is the one edge that converts that into a value.
async function post(url: string, init: RequestInit): Promise<Fetched> {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { json, ok: res.ok, status: res.status, text };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function main(): Promise<number> {
const fail = (message: string): number => {
process.stderr.write(`${message}\n`);
return 1;
};
const [, , version] = process.argv;
const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"];
if (!version || !repo || !user || !token) {
return fail(
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
"DOCKERHUB_TOKEN (README -> CI/CD)",
);
}
// The page is public, so never render a version that resolves to no image.
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
let template = "";
try {
template = readFileSync(templatePath, "utf8");
} catch (err) {
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
}
const body = renderOverview(template, version);
const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
const login = await post(`${HUB}/users/login`, {
body: JSON.stringify({ password: token, username: user }),
headers: { "content-type": "application/json" },
method: "POST",
});
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
const jwt = jwtFrom(login.json);
if (!jwt) return fail("Docker Hub login returned no token");
const res = await post(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify(hubPatch(body)),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH",
});
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
if (!res.ok) {
return fail(
`Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""),
);
}
process.stdout.write(`Docker Hub description and overview updated for ${repo} at ${version}\n`);
return 0;
}
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exitCode = await main();
}
@@ -35,6 +35,8 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0"); assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0"); assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4"); assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
}); });
test("nextVersion rejects a tag that is not vX.Y.Z", () => { test("nextVersion rejects a tag that is not vX.Y.Z", () => {
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
return `v${major}.${minor}.${patch + 1}`; return `v${major}.${minor}.${patch + 1}`;
} }
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag. // CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
if (process.argv[1]?.endsWith("/next-version.ts")) { if (process.argv[1]?.endsWith("/next-version.ts")) {
const [, , latestTag, ...updateTypes] = process.argv; const [, , latestTag, ...updateTypes] = process.argv;
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes))); process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
+40 -3
View File
@@ -4,8 +4,38 @@
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here", "description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
"ignorePaths": ["**/node_modules/**"], "ignorePaths": ["**/node_modules/**"],
"automerge": true, "automerge": true,
"commitBody": "Release-Bump: {{{updateType}}}",
"packageRules": [ "packageRules": [
{
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
"matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"],
"matchManagers": ["npm"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
"matchFileNames": ["Dockerfile"],
"matchManagers": ["dockerfile"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
"matchFileNames": ["compose.yml"],
"matchManagers": ["docker-compose"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
"matchDatasources": ["docker"],
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{ {
"description": "Ory services share one release train - update kratos, keto and hydra together", "description": "Ory services share one release train - update kratos, keto and hydra together",
"matchDatasources": ["docker"], "matchDatasources": ["docker"],
@@ -29,8 +59,15 @@
}, },
{ {
"customType": "regex", "customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)", "description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"], "managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
"depNameTemplate": "node", "depNameTemplate": "node",
"datasourceTemplate": "docker" "datasourceTemplate": "docker"
+64 -1
View File
@@ -5,7 +5,10 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -151,3 +154,63 @@ test("ensureJwks generates a key only when the file is absent", () => {
assert.equal(ensureJwks(path, { exists: () => true, write }), false); assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written assert.equal(writes.length, 1); // present → nothing written
}); });
// --- Plugin storage provisioning -----------------------------------------------------
// The provisioner is injected, so the branch decisions are testable without a Postgres.
const SILENT = createLogger({ level: "none" });
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
function recordingProvisioner(result: ProvisionResult = EMPTY) {
const calls: ProvisionOptions[] = [];
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
}
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
const { calls, provision } = recordingProvisioner();
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
});
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.pluginIds, []);
});
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
const { calls, provision } = recordingProvisioner();
await assert.rejects(
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
/PLUGIN_DB_ADMIN_URL.*things/s,
);
assert.deepEqual(calls, []);
});
test("the connection limit and derived secret reach the provisioner", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls[0]?.connectionLimit, 25);
assert.equal(calls[0]?.secret, "real");
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
});
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls.length, 1);
});
test("the same server spelled with an implicit port still agrees", () => {
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
});
+66 -6
View File
@@ -8,10 +8,15 @@
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { discoverPlugins } from "../plugin-host/discovery.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts"; import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>;
// --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- // --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -141,7 +146,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
// --- CLI (the bootstrap container entrypoint) ---------------------------------------- // --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() { async function main() {
const env = process.env; const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME. // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({ const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text", format: env["LOG_FORMAT"] === "json" ? "json" : "text",
@@ -150,10 +155,67 @@ async function main() {
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls. // runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
delete process.env["PLUGIN_DB_ADMIN_URL"];
delete process.env["PLUGIN_DB_SECRET"];
const plugins = await discoverPlugins();
await provisionPluginStorage(env, plugins, log);
await seedAdminAndPermissions(env, plugins, log);
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
// A database and login role for each plugin that asked for one. It happens here because bootstrap
// holds the stack's only provisioning credentials — web derives the same password and connects as
// the plugin's own role.
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
const ids = storagePluginIds(plugins);
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
// last storage plugin is exactly when an orphaned database needs naming.
if (ids.length === 0 && !adminUrl) return;
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
// Provisioned here, connected to from web: a different server means the role is created in one
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
const result = await provision({
adminUrl,
connectionLimit: resolvePluginDbConnectionLimit(env),
pluginIds: ids,
secret: resolvePluginDbSecret(env),
});
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
if (result.orphans.length > 0) {
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
}
}
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
// own boot error to raise, naming the plugin that wanted storage).
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
if (!webUrl) return null;
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
return `${admin} vs ${web}`;
}
function safeHostPort(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
} catch {
return null;
}
}
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the // Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit. // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name); async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared); const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) { if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") }); log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
@@ -171,8 +233,6 @@ async function main() {
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw. // The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password })); console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+11 -5
View File
@@ -100,9 +100,7 @@ test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf a
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]); assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
}); });
test("the code field guards a pasted space: one-time-code autofill + numeric inputmode + digits-only pattern", () => { test("the code field takes a pasted code: one-time-code autofill + numeric inputmode + digits, whitespace around them allowed", () => {
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
const view = buildFlowView( const view = buildFlowView(
flow([ flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }), node({ name: "csrf_token", type: "hidden", value: "tok" }),
@@ -113,18 +111,26 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
); );
assert.deepEqual(view.fields.find((f) => f.name === "code"), { assert.deepEqual(view.fields.find((f) => f.name === "code"), {
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format" hint: "Digits only.", // the pattern refusal alone reads as a bare "match the requested format"
icon: "i-shield", icon: "i-shield",
id: "field-code", id: "field-code",
inputmode: "numeric", inputmode: "numeric",
label: "Verification code", label: "Verification code",
name: "code", name: "code",
pattern: "[0-9]*", pattern: "\\s*[0-9]*\\s*",
required: true, required: true,
type: "text", type: "text",
}); });
}); });
test("the code flows post to the host, which trims the code; every other flow posts to Kratos", () => {
for (const type of ["recovery", "verification"] as const) assert.equal(buildFlowView(flow([]), type).action, `/${type}?flow=f1`);
for (const type of ["login", "registration", "settings"] as const) {
assert.equal(buildFlowView(flow([]), type).action, "http://127.0.0.1:4433/self-service/login?flow=f1");
}
assert.equal(buildFlowView({ ...flow([]), id: "a&b" }, "recovery").action, "/recovery?flow=a%26b");
});
test("chrome varies per flow type: registration alt, recovery back link", () => { test("chrome varies per flow type: registration alt, recovery back link", () => {
const reg = buildFlowView(flow([]), "registration"); const reg = buildFlowView(flow([]), "registration");
assert.equal(reg.title, "Create account"); assert.equal(reg.title, "Create account");
+15 -8
View File
@@ -1,8 +1,8 @@
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow // Kratos flow → themed view model. Pure: turns a fetched self-service Flow
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the // (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per // CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos // configured `oidc` provider. The form posts to `flow.ui.action`, except a CODE_FLOWS form, which
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons. // posts to the host first; Kratos owns the CSRF of both. No providers configured ⇒ no SSO buttons.
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts"; import type { Translate } from "../i18n/translate.ts";
@@ -17,7 +17,7 @@ export interface FlowField {
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code) inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
label: string; label: string;
name: string; name: string;
pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos pattern?: string; // client-side validity regex
required?: boolean; required?: boolean;
type: string; type: string;
value?: string; value?: string;
@@ -69,6 +69,9 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
"/verification": "verification", "/verification": "verification",
}; };
// The flows that take an emailed one-time code. Their form posts to the host, which trims the code.
export const CODE_FLOWS: ReadonlySet<FlowType> = new Set(["recovery", "verification"]);
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`. // Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = { const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
login: { alt: "/registration" }, login: { alt: "/registration" },
@@ -123,9 +126,9 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField { function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
const value = str(node.attributes["value"]); const value = str(node.attributes["value"]);
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted // The recovery/verification one-time code is numeric: a numeric keypad and a digits pattern. The
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in // pattern allows whitespace around the digits, as a paste brings; routes.ts → codeFlowSubmit trims it.
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node). // one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
const isCode = name === "code"; const isCode = name === "code";
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined); const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
const icon = iconFor(name, type); const icon = iconFor(name, type);
@@ -140,12 +143,16 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
...(autocomplete ? { autocomplete } : {}), ...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}), ...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}), ...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "\\s*[0-9]*\\s*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}), ...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}), ...(value ? { value } : {}),
}; };
} }
function formAction(flow: Flow, type: FlowType): string {
return CODE_FLOWS.has(type) ? `/${type}?flow=${encodeURIComponent(flow.id)}` : flow.ui.action;
}
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView { export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
const hidden: { name: string; value: string }[] = []; const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = []; const fields: FlowField[] = [];
@@ -174,7 +181,7 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
} }
return { return {
action: flow.ui.action, action: formAction(flow, type),
buttons, buttons,
fields, fields,
hidden, hidden,
+28
View File
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { User } from "../http/context.ts";
import { allows, gatesSet } from "./gate.ts";
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
assert.equal(allows({}, null), true);
assert.equal(allows({ public: true }, null), true);
assert.equal(allows({ session: true }, null), false);
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
assert.equal(allows({ permission: "x:read" }, null), false);
assert.equal(allows({ permission: "x:read" }, stranger), false);
assert.equal(allows({ permission: "x:read" }, holder), true);
});
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
assert.deepEqual(gatesSet({}), []);
assert.deepEqual(gatesSet({ session: true }), ["session"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
assert.deepEqual(gatesSet({ public: false, session: false }), []);
});
+22
View File
@@ -0,0 +1,22 @@
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
// may reach. README → Public pages & menu items.
import type { User } from "../http/context.ts";
const GATES = ["public", "session", "permission"] as const;
export interface Gate {
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
public?: boolean; // anyone, signed in or not
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
}
export function allows(gate: Gate, user: User | null): boolean {
if (gate.public === true) return true;
if (gate.session === true) return user !== null;
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
}
export function gatesSet(gate: Gate | null | undefined): string[] {
if (gate == null) return [];
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
}
+2 -2
View File
@@ -100,9 +100,9 @@ test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it nev
}); });
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => { test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" })); const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f" }));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID); const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" }); assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f" });
assert.equal(calls[0]!.method, "POST"); assert.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/); assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID }); assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
+3 -3
View File
@@ -25,7 +25,7 @@ export interface ListOptions {
pageToken?: string; pageToken?: string;
} }
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery"). // A one-time recovery code + the URL of the recovery flow it was minted on, the only flow that accepts it.
export interface RecoveryCode { export interface RecoveryCode {
code: string; code: string;
link: string; link: string;
@@ -65,8 +65,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Identity; return (await res.json()) as Identity;
}, },
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the // Mint a recovery code for an identity (admin "trigger recovery"). Kratos mails nothing, so the
// user by Kratos; the code/link are also returned so an operator can hand them over directly. // operator hands the code and link to the user.
async createRecoveryCode(userId, opts = {}) { async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: userId }; const body: Record<string, unknown> = { identity_id: userId };
if (opts.expiresIn) body.expires_in = opts.expiresIn; if (opts.expiresIn) body.expires_in = opts.expiresIn;
+20 -26
View File
@@ -1,6 +1,6 @@
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints. // Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping // Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the // (200/401/4xx, a submit's redirect, tokenized JWT). Live wiring is verified by the
// flow pages; these catch contract drift with a mock fetch. // flow pages; these catch contract drift with a mock fetch.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -17,12 +17,13 @@ function res(status: number, body?: unknown, setCookie: string[] = []): Response
// Records each call so a test can assert URL/method/headers/body. // Records each call so a test can assert URL/method/headers/body.
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = []; const calls: { body: string | undefined; headers: Headers; method: string; redirect: string | undefined; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => { const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ calls.push({
body: init?.body as string | undefined, body: init?.body as string | undefined,
headers: new Headers(init?.headers), headers: new Headers(init?.headers),
method: init?.method ?? "GET", method: init?.method ?? "GET",
redirect: init?.redirect,
url: String(input), url: String(input),
}); });
return handler(String(input), init); return handler(String(input), init);
@@ -57,35 +58,28 @@ test("getFlow throws a KratosError carrying the status when the flow is gone (41
); );
}); });
test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => { test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browser would, and relays the redirect + Set-Cookie", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"])); const { calls, fetchImpl } = recorder(() => new Response(null, {
headers: new Headers([["location", "http://app/settings?flow=s1"], ["set-cookie", "plainpages_session=s; Path=/"]]),
status: 303,
}));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }) const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
.submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" }); .submitFlow("recovery", "f 1", { body: "code=123456&method=code", cookie: "csrf_token=abc" });
assert.equal(out.ok, true); assert.deepEqual(out, { location: "http://app/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/"], status: 303 });
assert.equal(out.status, 200); assert.equal(calls[0]!.url, `${BASE}/self-service/recovery?flow=f+1`);
assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]);
assert.equal(calls[0]!.method, "POST"); assert.equal(calls[0]!.method, "POST");
assert.equal(calls[0]!.redirect, "manual");
assert.equal(calls[0]!.headers.get("accept"), "text/html");
assert.equal(calls[0]!.headers.get("referer"), `${BASE}/`);
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded"); assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
assert.equal(calls[0]!.body, "identifier=a&password=b"); assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc");
assert.equal(calls[0]!.body, "code=123456&method=code");
}); });
test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => { test("submitFlow reports a 4xx without a redirect as a null location, and throws on a Kratos 5xx", async () => {
const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } }; const k = (status: number) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(() => res(status, { error: "x" })).fetchImpl });
const { fetchImpl } = recorder(() => res(400, flow)); assert.deepEqual(await k(400).submitFlow("verification", "f", { body: "x=1" }), { location: null, setCookie: [], status: 400 });
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).submitFlow(`${BASE}/x`, { body: "x=1" }); await assert.rejects(k(500).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 500);
assert.equal(out.ok, false);
assert.equal(out.status, 400);
assert.deepEqual(out.body, flow);
});
test("submitFlow surfaces the redirect target — Location header or a 422 redirect_browser_to body", async () => {
const k = (handler: () => Response) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(handler).fetchImpl });
const viaHeader = await k(() => new Response(null, { headers: new Headers({ location: "http://app/" }), status: 303 }))
.submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(viaHeader.location, "http://app/");
const viaBody = await k(() => res(422, { redirect_browser_to: "http://app/login?flow=next" }))
.submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(viaBody.location, "http://app/login?flow=next");
}); });
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => { test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
+13 -28
View File
@@ -21,7 +21,7 @@ export interface UiNode {
} }
export interface FlowUi { export interface FlowUi {
action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF) action: string; // absolute Kratos URL the form submits to
messages?: UiText[]; messages?: UiText[];
method: string; method: string;
nodes: UiNode[]; nodes: UiNode[];
@@ -51,9 +51,7 @@ export interface LogoutFlow {
} }
export interface FlowSubmission { export interface FlowSubmission {
body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200 location: string | null; // where Kratos sends the browser next
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
ok: boolean; // status === 200
setCookie: string[]; setCookie: string[];
status: number; status: number;
} }
@@ -74,23 +72,10 @@ export interface KratosPublic {
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401) createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>; getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>; initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>; submitFlow(type: FlowType, flowId: string, opts: { body: string; cookie?: string }): Promise<FlowSubmission>;
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>; whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseBody(text: string): unknown {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic { export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
@@ -129,16 +114,16 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Flow; return (await res.json()) as Flow;
}, },
async submitFlow(action, opts) { async submitFlow(type, flowId, opts) {
const h = headers(opts.cookie); const url = new URL(`${base}/self-service/${type}`);
h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded"; url.searchParams.set("flow", flowId);
// Manual redirect so we can read a 303 Location instead of following it server-side. // Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF
const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" }); // check demands a same-origin Referer on an https request, and this server-side POST has none.
const body = parseBody(await res.text()); const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded", referer: `${base}/` };
const location = const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
res.headers.get("location") ?? if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
(isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null); await res.body?.cancel();
return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status }; return { location: res.headers.get("location"), setCookie: res.headers.getSetCookie(), status: res.status };
}, },
async whoami(opts = {}) { async whoami(opts = {}) {
+14 -2
View File
@@ -92,12 +92,24 @@ test("completeLogin returns null and touches nothing when there is no active ses
assert.equal(touched, false); assert.equal(touched, false);
}); });
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => { test("completeLogin throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: {} }; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/); await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
}); });
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
// browser a cookie every later request refuses.
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
const identity: Identity = { id: ID, traits: {} };
let touched = false;
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
});
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => { test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session }); const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
+9 -3
View File
@@ -31,7 +31,7 @@ export interface LoginDeps {
} }
export interface CompletedLogin { export interface CompletedLogin {
email: string | null; email: string;
userId: string; userId: string;
jwt: string; jwt: string;
permissions: string[]; permissions: string[];
@@ -61,7 +61,13 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
if (!session?.identity) return null; if (!session?.identity) return null;
const userId = session.identity.id; const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"]; const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null; const email = typeof emailTrait === "string" ? emailTrait : "";
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
// would hand the browser a cookie every later request refuses.
if (!email) {
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
return null;
}
const permissions = await readPermissions(deps.keto, userId); const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions }); await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
@@ -87,7 +93,7 @@ export interface Reminted {
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), user: null }; if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } }; return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
} }
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+10 -3
View File
@@ -1,9 +1,10 @@
// buildAuthRoutes registers only the endpoints the wired clients support — a missing client // buildAuthRoutes registers only the endpoints the wired clients support — a missing client
// means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is // means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is
// covered end-to-end through the app in src/http/app.test.ts. // covered end-to-end through the app in src/http/app-auth.test.ts.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { AUTH_FLOWS } from "./flow-view.ts"; import { AUTH_FLOWS } from "./flow-view.ts";
import { gatesSet } from "./gate.ts";
import type { HydraAdmin } from "./hydra-admin.ts"; import type { HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
@@ -25,12 +26,14 @@ test("nothing wired ⇒ only the flow-error sink exists", () => {
assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]); assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]);
}); });
test("kratos wired ⇒ the themed flow pages + POST /logout; no OAuth2, no /auth/complete", () => { test("kratos wired ⇒ the themed flow pages, the two code-flow submits + POST /logout; no OAuth2, no /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ kratos }))); const got = keys(buildAuthRoutes(deps({ kratos })));
assert.deepEqual(got, keys([ assert.deepEqual(got, keys([
...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })), ...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })),
{ method: "GET", path: "/error" }, { method: "GET", path: "/error" },
{ method: "POST", path: "/logout" }, { method: "POST", path: "/logout" },
{ method: "POST", path: "/recovery" },
{ method: "POST", path: "/verification" },
])); ]));
}); });
@@ -39,8 +42,12 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
}); });
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => { test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }))); const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }));
const got = keys(routes);
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) { for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
assert.ok(got.includes(key), key); assert.ok(got.includes(key), key);
} }
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
// at boot, so a route added here without a gate would be silently public.
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
}); });
+83 -46
View File
@@ -1,5 +1,5 @@
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table // The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
// (src/http/builtin-routes.ts): the themed Kratos self-service pages, the Hydra OAuth2 // (src/http/builtin-routes.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
// login/consent/logout challenges, login completion, the first-party logout, and Kratos' // login/consent/logout challenges, login completion, the first-party logout, and Kratos'
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing // flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
// client means the path simply doesn't exist (404). `view` results render the core views. // client means the path simply doesn't exist (404). `view` results render the core views.
@@ -8,11 +8,11 @@ import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
import type { RequestContext } from "../http/context.ts"; import type { RequestContext } from "../http/context.ts";
import { CSRF_FIELD } from "./csrf.ts"; import { CSRF_FIELD } from "./csrf.ts";
import { chosenLocale } from "../i18n/locale.ts"; import { chosenLocale } from "../i18n/locale.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { AUTH_FLOWS, buildFlowView, CODE_FLOWS } from "./flow-view.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import { type Flow, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts"; import { type Flow, type FlowSubmission, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts"; import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
import type { MenuConfig } from "../ui/menu-config.ts"; import type { MenuConfig } from "../ui/menu-config.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts"; import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
@@ -38,52 +38,36 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" };
const selfOrigin = (ctx: RequestContext, secure: boolean): string => const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`; `${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/ // Where Kratos lands the browser after sign-in, or undefined for its default. A host-relative
// verification/settings). // return_to (the gate's deep link) or a chosen language goes through /auth/complete, which mints the
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] { // session JWT first and carries the language across Kratos; any other target (the OAuth2 login
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => { // challenge) is passed as-is, since Kratos allow-lists it.
const pathname = ctx.url.pathname; function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | undefined {
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
const cookie = ctx.req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
let flow: Flow;
try {
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to"); const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw); const local = localPath(raw);
const chosen = chosenLocale(ctx); const chosen = chosenLocale(ctx);
let returnTo: string | undefined; if (raw && !local) return raw; // beside a chosen language too: the target wins, the language is lost
if (local || chosen) { if (!local && !chosen) return undefined;
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
// on the sign-in page survives the round-trip through Kratos and lands on the page after
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
// straight back to Accept-Language.
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`); const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
if (local) complete.searchParams.set("return_to", ctx.localeHref(local)); if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
if (chosen) complete.searchParams.set("locale", chosen); if (chosen) complete.searchParams.set("locale", chosen);
returnTo = complete.toString(); return complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
return { redirect: `${pathname}?flow=${initiated.id}` };
} }
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
} catch (err) { // What a failed Kratos call returns instead of the flow; rethrows what is genuinely unexpected.
function flowFailure(err: unknown, ctx: RequestContext, flowType: FlowType): RouteResult {
const pathname = ctx.url.pathname;
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=). // Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname }; if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// No Kratos session on a page that needs one. /settings is public, since recovery lands there with
// a Kratos session but no app JWT, so the gate never sends an anonymous visitor to sign in.
// Not for /login itself, which would redirect to itself.
if (err instanceof KratosError && err.status === 401 && flowType !== "login") {
return { redirect: `/login?return_to=${encodeURIComponent(pathname)}` };
}
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose // Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user // `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/ // is null and the "already signed in" short-circuit can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing // registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500. // login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) { if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
@@ -98,6 +82,31 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
} }
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected) throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
} }
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
// verification/settings).
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
const cookie = ctx.req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
let flow: Flow;
try {
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
const returnTo = flowReturnTo(ctx, secureCookies);
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
return { redirect: `${ctx.url.pathname}?flow=${initiated.id}` };
}
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
} catch (err) {
return flowFailure(err, ctx, flowType);
}
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the // Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token. // shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
csrf.setCookie(); csrf.setCookie();
@@ -105,6 +114,33 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
}; };
} }
// A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos
// refuses one with whitespace around it, then passes the form on to Kratos and sends the browser
// where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token
// isn't checked here.
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const pathname = ctx.url.pathname;
const flowId = ctx.url.searchParams.get("flow");
if (!flowId) return { redirect: pathname };
const form = await readFormBody(ctx.req);
const code = form.get("code");
if (code !== null) form.set("code", code.trim());
const cookie = ctx.req.headers.cookie;
let submitted: FlowSubmission;
try {
submitted = await kratos.submitFlow(flowType, flowId, { body: form.toString(), ...(cookie ? { cookie } : {}) });
} catch (err) {
return flowFailure(err, ctx, flowType);
}
if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie);
// Kratos names our own pages by absolute URL; made host-relative, the redirect keeps a chosen locale.
const own = selfOrigin(ctx, secureCookies);
const location = submitted.location?.startsWith(`${own}/`) ? submitted.location.slice(own.length) : submitted.location;
return { redirect: location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` };
};
}
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us. // OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed // Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
// login and returns here once signed in. Provider-only. // login and returns here once signed in. Provider-only.
@@ -240,20 +276,21 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
const routes: BuiltinRoute[] = []; const routes: BuiltinRoute[] = [];
if (kratos) { if (kratos) {
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) { for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path }); routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType, secureCookies), method: "POST", path, public: true });
} }
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" }); routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
} }
if (hydra && kratos) { if (hydra && kratos) {
const provider = { hydra, kratos }; const provider = { hydra, kratos };
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" }); routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" }); routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" }); routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
} }
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" }); if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
if (kratos && kratosAdmin && keto) { if (kratos && kratosAdmin && keto) {
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" }); routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
} }
routes.push({ handler: errorSink, method: "GET", path: "/error" }); routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
return routes; return routes;
} }
+7 -2
View File
@@ -29,14 +29,19 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
assert.doesNotMatch(step("docker push"), /^\s*if:/m); assert.doesNotMatch(step("docker push"), /^\s*if:/m);
}); });
test("every E2E suite the gate runs writes its artifacts as the invoking user", () => { test("every E2E runner container the gate starts runs as the invoking user", () => {
// The documented hand-run commands carry the same flag (src/compose.test.ts). // The documented hand-run commands carry the same flag (src/compose.test.ts).
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l)); const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(runs.length, 2, "the suite helper and the devstack run"); assert.equal(runs.length, 3, "the e2e-tests typecheck, the suite helper and the devstack run");
for (const line of runs) for (const line of runs)
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`); assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
}); });
test("the gate typechecks e2e-tests in the runner image, where @playwright/test resolves", () => {
assert.match(gate, /docker compose .*\brun\b.*--no-deps\b.*\be2e\b npm run typecheck/);
assert.match(read("e2e-tests/package.json"), /"typecheck": "tsc --noEmit"/);
});
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => { test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test"); assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over"); assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
+20 -4
View File
@@ -44,10 +44,11 @@ test("long-running Ory services declare readiness healthchecks", () => {
`${svc} probes :${port}/health/ready`); `${svc} probes :${port}/health/ready`);
}); });
test("web waits for kratos, keto and hydra to be healthy before starting", () => { test("web waits for kratos, keto, hydra and postgres to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies"); assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the OAuth2 login/consent handler talks to its admin API. // hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring
for (const svc of ["kratos", "keto", "hydra"]) // `storage` opens its connection in onBoot, before the server listens.
for (const svc of ["kratos", "keto", "hydra", "postgres"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`); `web waits for ${svc} healthy`);
}); });
@@ -78,6 +79,21 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env"); assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
}); });
test("the provisioning superuser DSN reaches bootstrap only, never web", () => {
// web runs plugin code, which can read its own environment — so the credentials that may CREATE
// DATABASE/ROLE must never be there. web gets the credential-free base URL and derives each
// plugin's own password from the shared secret instead.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
const overrideWeb = override.slice(override.indexOf("\n web:"), override.indexOf("\n bootstrap:"));
assert.match(boot, /PLUGIN_DB_ADMIN_URL:/, "bootstrap is given the superuser DSN");
// Reordering the override's services would empty this slice, and every doesNotMatch below would
// then pass against "".
assert.ok(overrideWeb.includes("PLUGIN_DB_URL"), "sliced the dev override's web block");
for (const [name, block] of [["base", webBlock], ["dev override", overrideWeb]] as const)
assert.doesNotMatch(block, /PLUGIN_DB_ADMIN_URL/, `${name} web never sees it`);
assert.match(webBlock, /PLUGIN_DB_URL:\s*\$\{PLUGIN_DB_URL/, "base wires web's base URL from env");
});
test("a one-shot bootstrap seeds the stack before web starts", () => { test("a one-shot bootstrap seeds the stack before web starts", () => {
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin + // MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified. // JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
@@ -113,7 +129,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
// filter would otherwise leave that command silently unguarded. // filter would otherwise leave that command silently unguarded.
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)] const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l)); .join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(documented.length, 6, "5 compose headers + 1 README block"); assert.equal(documented.length, 7, "5 compose headers + 2 README lines");
for (const l of documented) for (const l of documented)
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`); assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
// An absent mount source is daemon-created as root, and then that uid can't write it at all. // An absent mount source is daemon-created as root, and then that uid can't write it at all.
+55 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { loadConfig } from "./config.ts"; import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only // Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply. // thing a hardened deploy must supply.
@@ -9,6 +9,60 @@ const secureEnv = {
REQUIRE_SECURE_SECRETS: "true", REQUIRE_SECURE_SECRETS: "true",
}; };
// web reads the secret through loadConfig and bootstrap through resolvePluginDbSecret; the two
// deriving different passwords is invisible until a plugin's connection is refused at boot. Compose
// passes an unset variable through as "", which is the case that actually drifted.
test("web and bootstrap resolve the same plugin storage secret", () => {
for (const env of [{}, { PLUGIN_DB_SECRET: "" }, { PLUGIN_DB_SECRET: "a-real-secret" }]) {
assert.equal(loadConfig(env).pluginDbSecret, resolvePluginDbSecret(env), `for ${JSON.stringify(env)}`);
}
assert.match(resolvePluginDbSecret({ PLUGIN_DB_SECRET: "" }), /dev-insecure/); // empty is unset, not a secret
});
// bootstrap writes these passwords into Postgres, so it must refuse the publicly-known throwaway
// before creating a role with one — not leave web to notice afterwards.
test("bootstrap refuses a missing, empty or throwaway plugin storage secret when hardened", () => {
const hardened = { REQUIRE_SECURE_SECRETS: "true" };
for (const secret of [undefined, "", "dev-insecure-plugin-db-secret"]) {
const env = secret === undefined ? hardened : { ...hardened, PLUGIN_DB_SECRET: secret };
assert.throws(() => resolvePluginDbSecret(env), /PLUGIN_DB_SECRET/, `for ${JSON.stringify(secret)}`);
}
assert.equal(resolvePluginDbSecret({ ...hardened, PLUGIN_DB_SECRET: "a-real-secret" }), "a-real-secret");
});
// buildCredentials overwrites the userinfo, so a pasted admin DSN would *work* — and leave a
// privileged password in the process that runs plugin code. Refusing it is the whole guard.
test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten", () => {
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root:hunter2@db:5432/ory" }), /no username or password/);
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root@db:5432" }), /no username or password/);
assert.equal(loadConfig({ PLUGIN_DB_URL: "postgres://db:5432" }).pluginDbUrl, "postgres://db:5432");
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
});
test("a malformed URL value is refused by naming the variable, never echoing the value", () => {
// PLUGIN_DB_URL and OTLP_ENDPOINT can carry credentials; APP_URL and KRATOS_PUBLIC_URL stand for the other keys, one per reader.
const bad: Record<string, string> = {
APP_URL: "https://acme:pa/ss@[host",
KRATOS_PUBLIC_URL: "http://u:pa/ss@[kratos:4433",
OTLP_ENDPOINT: "http://otel:pa/ss@[collector:4318",
PLUGIN_DB_URL: "postgres://admin:pa/ss@db:5432/plugins",
};
for (const [key, value] of Object.entries(bad)) {
assert.throws(
() => loadConfig({ [key]: value }),
(err: Error) => err.message.includes(key) && !err.message.includes("pa/ss") && !err.message.includes(value),
key,
);
}
});
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
assert.equal(resolvePluginDbConnectionLimit({}), 10);
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "0" }), /positive integer/);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
});
test("loads dev defaults when the environment is empty", () => { test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({}); const c = loadConfig({});
assert.equal(c.port, 3000); assert.equal(c.port, 3000);
+50 -10
View File
@@ -6,6 +6,35 @@
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const; export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number]; export type LogLevel = (typeof LOG_LEVELS)[number];
const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret";
// The one resolution both processes use — they must agree exactly, or web connects with a password
// the role was never given. Compose passes an unset variable through as "", so empty means unset.
// `enforce` says whether storage is actually in play: web once PLUGIN_DB_URL is configured,
// bootstrap once a plugin declares storage. Enforced, the throwaway is refused — bootstrap is what
// writes these passwords into Postgres, so it must refuse *before* creating a role with one.
export function resolvePluginDbSecret(env: Env, enforce?: boolean): string {
return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false));
}
// Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place.
export function resolvePluginDbConnectionLimit(env: Env): number {
return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10);
}
// PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role.
// Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and
// leave a superuser password in the environment plugin code can read.
function readCredentiallessUrl(env: Env, key: string): string | undefined {
const value = readOptionalUrl(env, key);
if (value === undefined) return undefined;
const url = new URL(value);
if (url.username || url.password) {
throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`);
}
return value;
}
export interface Config { export interface Config {
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle) appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
cacheTemplates: boolean; cacheTemplates: boolean;
@@ -24,7 +53,10 @@ export interface Config {
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
port: number; port: number;
requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret`
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
@@ -66,26 +98,27 @@ function readEnum<T extends string>(env: Env, key: string, allowed: readonly T[]
throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`); throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`);
} }
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot. // Never echoes the value: a DSN whose password breaks URL parsing (a bare "/") would put the
function readOptionalUrl(env: Env, key: string): string | undefined { // password in the boot error and the log.
const value = env[key] || undefined; function assertUrl(key: string, value: string): void {
if (value === undefined) return undefined;
try { try {
new URL(value); new URL(value);
} catch { } catch {
throw new Error(`config: ${key} is not a valid URL: ${value}`); throw new Error(`config: ${key} must be an absolute URL (its value is not shown; percent-encode special characters in a password)`);
} }
}
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot.
function readOptionalUrl(env: Env, key: string): string | undefined {
const value = env[key] || undefined;
if (value !== undefined) assertUrl(key, value);
return value; return value;
} }
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot. // An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
function readUrl(env: Env, key: string, devDefault: string): string { function readUrl(env: Env, key: string, devDefault: string): string {
const value = env[key] ?? devDefault; const value = env[key] ?? devDefault;
try { assertUrl(key, value);
new URL(value);
} catch {
throw new Error(`config: ${key} is not a valid URL: ${value}`);
}
return value; return value;
} }
@@ -150,7 +183,14 @@ export function loadConfig(env: Env = process.env): Config {
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
// which is also why the secret is only enforced once a URL is configured.
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
port: readPort(env), port: readPort(env),
requireSecureSecrets: requireSecure,
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token). // tokenizer TTL + skew, so it outlasts any pre-revoke token).
+157
View File
@@ -0,0 +1,157 @@
// The limits and the counting are ESLint's defaults: `max-lines-per-function` (blank lines and
// comments count, an IIFE is not held to it) and `complexity`. Not ESLint itself: parsing TypeScript
// takes typescript-eslint, which needs the compiler API TypeScript 7 no longer ships. So acorn parses
// what Node's own type stripping leaves, which is the code Node runs.
import assert from "node:assert/strict";
import { globSync, readFileSync } from "node:fs";
import { stripTypeScriptTypes } from "node:module";
import { test } from "node:test";
import { parse } from "acorn";
const LIMITS = { lines: 50, complexity: 20 };
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
// leaves the entry once it fits, so a function that shrank can't grow back.
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
"src/http/app.ts createApp": { lines: 318, complexity: 28 },
"src/http/app.ts createApp > handleRequest": { lines: 167, complexity: 43 },
"src/auth/flow-view.ts buildFlowView": { complexity: 24 },
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
"src/config.ts loadConfig": { lines: 52 },
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
};
interface AstNode {
type: string;
loc: { start: { line: number }; end: { line: number } };
[key: string]: unknown;
}
interface Measured {
name: string;
lines: number;
complexity: number;
}
const isNode = (value: unknown): value is AstNode =>
typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string";
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
const BRANCHES = new Set([
"IfStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement",
"ForOfStatement", "WhileStatement", "DoWhileStatement", "CatchClause", "AssignmentPattern",
]);
function branches(node: AstNode): number {
if (BRANCHES.has(node.type)) return 1;
if (node.type === "SwitchCase") return node.test ? 1 : 0;
if (node.type === "AssignmentExpression") return ["&&=", "||=", "??="].includes(String(node.operator)) ? 1 : 0;
if (node.type === "MemberExpression" || node.type === "CallExpression") return node.optional === true ? 1 : 0;
return 0;
}
function nameOf(fn: AstNode, parent: AstNode | undefined): string {
let id = fn.id;
if (!id && parent?.type === "VariableDeclarator") id = parent.id;
if (!id && (parent?.type === "Property" || parent?.type === "MethodDefinition")) id = parent.key;
return isNode(id) && typeof id.name === "string" ? id.name : "anonymous";
}
// A function is named by the path of names from the outermost one, e.g. `createApp > handleRequest`,
// so an entry above doesn't depend on line numbers.
function measure(source: string): Measured[] {
const found: Measured[] = [];
const visit = (node: AstNode, parent: AstNode | undefined, enclosing: Measured | undefined): void => {
let current = enclosing;
if (FUNCTIONS.has(node.type)) {
const name = nameOf(node, parent);
const iife = parent?.type === "CallExpression" && parent.callee === node;
const lines = iife ? 0 : node.loc.end.line - node.loc.start.line + 1;
current = { name: enclosing ? `${enclosing.name} > ${name}` : name, lines, complexity: 1 };
found.push(current);
} else if (current) {
current.complexity += branches(node);
}
for (const value of Object.values(node)) {
for (const child of [value].flat()) if (isNode(child)) visit(child, node, current);
}
};
const program: unknown = parse(stripTypeScriptTypes(source), { ecmaVersion: "latest", sourceType: "module", locations: true });
assert.ok(isNode(program));
visit(program, undefined, undefined);
return found;
}
const root = new URL("..", import.meta.url);
// The drop-in mounts hold an operator's code, not this repo's. Tests are left out: each is a flat
// script, and a `describe` block wraps many of them.
const files = globSync("**/*.ts", {
cwd: root,
exclude: ["**/node_modules/**", "plugins/**", "config/**", "locales/**", "**/*.d.ts", "**/*.test.ts", "**/*.spec.ts"],
});
// A list, not a map: two unnamed siblings share a key, and each must be checked.
const measured = files.flatMap((file) =>
measure(readFileSync(new URL(file, root), "utf8")).map((m) => ({ ...m, key: `${file} ${m.name}` })),
);
const METRICS = ["lines", "complexity"] as const;
test("measure counts lines and complexity as ESLint does", () => {
const source = [
"function outer(a = 1): void {",
" if (a && b?.c) {}",
" for (const x of y) {}",
" for (const k in y) {}",
" for (;;) break;",
" while (a) a--;",
" do {} while (a);",
" try {} catch {}",
" switch (a) { case 1: break; default: }",
" a ||= 2; a &&= 3; a ??= 4;",
" b?.();",
" y.map(() => {});",
" const inner = () => (a ? 1 : 2);",
" const o = { prop() {}, key: function () {} };",
" (function () { if (a) {} })();",
"}",
"class K { method() {} }",
].join("\n");
assert.deepEqual(measure(source), [
{ name: "outer", lines: 16, complexity: 16 },
{ name: "outer > anonymous", lines: 1, complexity: 1 },
{ name: "outer > inner", lines: 1, complexity: 2 },
{ name: "outer > prop", lines: 1, complexity: 1 },
{ name: "outer > key", lines: 1, complexity: 1 },
{ name: "outer > anonymous", lines: 0, complexity: 2 },
{ name: "method", lines: 1, complexity: 1 },
]);
});
test(`every function fits ${LIMITS.lines} lines and complexity ${LIMITS.complexity}, or its grandfathered ceiling`, () => {
assert.ok(files.length > 50, "found too few files: is the glob broken?");
const over = measured.flatMap((m) =>
METRICS.filter((metric) => m[metric] > (grandfathered[m.key]?.[metric] ?? LIMITS[metric])).map(
(metric) => `${m.key}: ${metric} ${m[metric]}`,
),
);
assert.deepEqual(over, [], "split the function; never raise a ceiling");
});
test("every grandfathered ceiling equals its function's measurement, which is over the limit", () => {
const stale = Object.entries(grandfathered).flatMap(([key, ceiling]) => {
const found = measured.filter((m) => m.key === key);
if (found.length === 0) return [`${key}: no such function; rename the entry if it moved, else delete it`];
return METRICS.flatMap((metric) => {
const allowed = ceiling[metric];
if (allowed === undefined) return [];
const now = Math.max(...found.map((m) => m[metric]));
if (now <= LIMITS[metric]) return [`${key}: ${metric} ${now} fits; delete it`];
return now < allowed ? [`${key}: ${metric} ${now}; lower the ceiling to it`] : [];
});
});
assert.deepEqual(stale, []);
});
+609
View File
@@ -0,0 +1,609 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import type { AddressInfo } from "node:net";
import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { createApp, type AppOptions } from "./app.ts";
import { createDenylist } from "../auth/denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { staticJwks } from "../auth/jwks.ts";
import type { OAuth2Client } from "../auth/hydra-admin.ts";
import type { RelationTuple } from "../auth/keto-client.ts";
import type { Identity } from "../auth/kratos-admin.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}, sub = "admin1") {
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000);
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
return { get, post, token, url };
}
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
// The permission form's concurrency token, read off the page the way a browser would post it back.
const baselineOf = (html: string): string => /name="baseline" value="([0-9a-f]+)"/.exec(html)?.[1] ?? "";
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" });
assert.equal(anon.status, 303);
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
assert.equal((await get(path, [])).status, 403);
}
// Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
let lastCreate: { traits?: unknown } | undefined;
const kratosAdmin = stubAdmin({
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
createRecoveryCode: async (id) => ({ code: "123456", link: `http://kratos/self-service/recovery?flow=flow-${id}` }),
deleteIdentity: async (id) => { const i = store.findIndex((x) => x.id === id); if (i >= 0) store.splice(i, 1); },
getIdentity: async (id) => store.find((x) => x.id === id) ?? null,
listIdentities: async () => ({ identities: store, nextPageToken: null }),
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
});
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// The read/write split: users:read opens the list but is refused on every mutation, and the
// resources don't leak — a users holder is not a groups holder.
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/admin/users", {
body: `_csrf=${token}&email=nope@example.com`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
method: "POST",
redirect: "manual",
});
assert.equal(readOnlyPost.status, 403);
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/);
assert.match(listHtml, /href="\/admin\/users\/new"/);
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
// Create: the form renders; a valid post creates the identity and redirects to the list.
assert.match(await (await get("/admin/users/new")).text(), /Create user/);
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/users");
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
// A create with no CSRF token is refused and creates nothing.
const before = store.length;
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
assert.equal(store.length, before);
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
// collection: a delete with no token is refused (403) and removes nothing.
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
assert.ok(store.some((x) => x.id === store[1]!.id));
// Edit: email is read-only + prefilled; a post rewrites the name.
const target = store[0]!;
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
assert.match(editHtml, /name="email"[^>]*readonly/);
assert.match(editHtml, /value="ada@example\.com"/);
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true);
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
assert.equal(rec.status, 200);
const recHtml = await rec.text();
assert.match(recHtml, /Recovery code generated/);
assert.match(recHtml, /<code>123456<\/code>/);
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
assert.match(confirm, /Cancel/);
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
assert.equal(del.status, 303);
assert.ok(!store.some((x) => x.id === target.id));
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
assert.ok(store.some((x) => x.id === "admin1"));
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
assert.equal((await get("/admin/users/%ZZ")).status, 404);
});
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
const identities: Identity[] = [
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
];
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/groups");
// List: the existing group shows + the "add" link.
const listHtml = await (await get("/admin/groups")).text();
assert.match(listHtml, /href="\/admin\/groups\/eng"/);
assert.match(listHtml, /href="\/admin\/groups\/new"/);
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/groups/design");
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length;
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
assert.equal(tuples.length, before);
// Detail: lists the current member by email.
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
// Add a member, then remove it.
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/groups");
assert.ok(!tuples.some((tp) => tp.object === "eng"));
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
// orphaned grant would resurrect the moment someone re-created a group with the same name.
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
});
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
// so the checkboxes are a fixed list and the POST is the desired state.
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
// The user edit page renders one checkbox per declared permission, ticked where already held.
const edit = await (await get(`/admin/users/${ada}`)).text();
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
}
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
assert.doesNotMatch(edit, /Permissions saved/); // no confirmation before a save
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
const baseline = baselineOf(edit);
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303);
assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
["groups:read", "users:write"],
);
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
// The redirect lands on a page that confirms the save, and shows the new state.
const landing = saved.headers.get("location") ?? "";
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
const confirmed = await (await get(landing)).text();
assert.match(confirmed, /Permissions saved/);
assert.match(confirmed, /value="users:write"[^>]*checked/);
// A crafted POST can't grant something no plugin declares.
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
});
// Two admins open the same page; the second save would silently undo the first.
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
// Another admin grants users:write while this page is open.
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples);
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
assert.equal(refused.status, 409);
const page = await refused.text();
assert.match(page, /changed while you had the page open/);
assert.equal(JSON.stringify(tuples), before, "nothing was applied");
assert.match(page, /value="users:write"[^>]*checked/); // the re-render shows the other admin's grant
assert.notEqual(baselineOf(page), stale);
// Saving from the re-rendered page goes through.
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(retried.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
// Groups are checked the same way.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
assert.equal(groupRefused.status, 409);
assert.match(await groupRefused.text(), /changed while you had the page open/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
});
// A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [];
let writes = 0;
const keto = fakeKeto(tuples, {
writeTuple: async (tp) => {
if (++writes === 2) throw new Error("keto unreachable");
tuples.push(tp);
},
});
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(res.status, 502);
const page = await res.text();
assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
// A save that changes nothing has nothing to refuse and nothing to revoke.
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples);
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(repeated.status, 303);
assert.equal(JSON.stringify(tuples), before);
assert.equal(denylist.isRevoked(ada, 0), false);
});
// Keto answered the writes but not the re-read: the report must still reach the admin.
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [];
let writes = 0;
let down = false;
let failureTakesKetoDown = true;
const keto = fakeKeto(tuples, {
listRelations: async (q = {}) => {
if (down) throw new Error("keto unreachable");
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
},
writeTuple: async (tp) => {
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
tuples.push(tp);
},
});
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(res.status, 502);
assert.match(await res.text(), /stopped part-way/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
// The same on a group, whose page needs Keto for its members as well.
down = false;
writes = 0;
tuples.length = 0;
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(groupRes.status, 502);
assert.match(await groupRes.text(), /stopped part-way/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
// With Keto still answering, the group page itself reports it and shows what is stored.
failureTakesKetoDown = false;
down = false;
writes = 0;
tuples.length = 1;
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(again.status, 502);
const page = await again.text();
assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/);
});
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
assert.equal(refused.status, 400);
assert.match(await refused.text(), /lock yourself out/);
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// Granting yourself more is not a lockout, so it goes through.
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(granted.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
});
// A check over the tuple array that expands nested groups.
function resolvingCheck(tuples: RelationTuple[]) {
const reaches = (q: RelationTuple, ns: string, object: string, relation: string, seen: Set<string>): boolean => {
const key = `${ns}:${object}#${relation}`;
if (seen.has(key)) return false;
seen.add(key);
return tuples.some((tp) => tp.namespace === ns && tp.object === object && tp.relation === relation && (
(q.subject_id !== undefined && tp.subject_id === q.subject_id) ||
(q.subject_set !== undefined && tp.subject_set?.namespace === q.subject_set.namespace && tp.subject_set.object === q.subject_set.object) ||
(tp.subject_set !== undefined && reaches(q, tp.subject_set.namespace, tp.subject_set.object, tp.subject_set.relation, seen))));
};
return async (q: RelationTuple) => reaches(q, q.namespace, q.object, q.relation, new Set());
}
// A member value must be a UUID, so this test signs in as `you`.
test("admin groups: you can't take away permissions you hold only through a group you are in", async (t) => {
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
const you = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
const identities: Identity[] = [{ id: you, traits: { email: "you@example.com" } }, { id: ada, traits: { email: "ada@example.com" } }];
const member = (group: string, subject: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_id: subject });
const nested = (group: string, child: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
const grant = (permission: string, group: string): RelationTuple => ({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
const tuples: RelationTuple[] = [
member("eng", `user:${you}`), member("eng", `user:${ada}`), grant("users:write", "eng"), grant("groups:write", "eng"),
{ namespace: "Permission", object: "groups:write", relation: "granted", subject_id: `user:${you}` }, // also held directly
nested("leads", "eng"), grant("oauth2-clients:write", "leads"), // reached through eng
member("social", `user:${you}`), // confers nothing
member("design", `user:${ada}`), grant("users:write", "design"), // you are not in it
];
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
const baseline = baselineOf(await (await get("/admin/groups/eng")).text());
// A refusal renders that group's own page and names what you would lose.
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
assert.equal(res.status, 400);
const html = await res.text();
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
assert.match(html, new RegExp(`action="/admin/groups/${group}/members"`));
};
// Unticking users:write on eng would take it from you: refused, nothing revoked.
const before = JSON.stringify(tuples);
await refusedWithMessage(await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
// way; leaving eng also loses what it gets from leads.
await refusedWithMessage(await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
await refusedWithMessage(await post("/admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
await refusedWithMessage(await get("/admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
await refusedWithMessage(await post("/admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
assert.equal(JSON.stringify(tuples), before, "nothing changed");
// You hold groups:write directly too, so unticking it on eng costs you nothing.
assert.equal((await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
assert.equal((await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
assert.equal((await post("/admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
assert.equal((await post("/admin/groups/design/delete", `_csrf=${token}`)).status, 303);
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
});
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
test("admin screens render no write affordance for a read-only holder", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
// "no Register button" assertion below would pass without proving anything.
const reporting = { client_id: "existing", client_name: "Reporting" };
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"];
const list = await (await get("/admin/users", readOnly)).text();
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
assert.doesNotMatch(detail, /Save changes/);
assert.doesNotMatch(detail, /Generate recovery code/);
assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
const group = await (await get("/admin/groups/eng", readOnly)).text();
assert.doesNotMatch(group, /Add a member/);
assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/);
// The OAuth2-clients screen is held to the same rule.
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
const clients = await clientsRes.text();
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
// The detail page is where Delete lives, so check it too and not just the list.
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
assert.match(clientDetail, /Reporting/);
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
// rendering a form whose submit would 403.
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
assert.equal((await get(path, readOnly)).status, 403, path);
}
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
// A writer sees the affordances the reader didn't.
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.match(writable, /Save changes/);
assert.match(writable, /Save permissions/);
});
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
const store: OAuth2Client[] = [
{ client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" },
];
let seq = 0;
const hydra = stubHydra({
createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; },
deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); },
getClient: async (id) => store.find((c) => c.client_id === id) ?? null,
listClients: async () => ({ clients: store, nextPageToken: null }),
});
const { get, post, token, url } = await adminHarness(t, { hydra });
await assertAdminGate(url, get, "/admin/clients");
// List: the existing client shows + the "register" link.
const listHtml = await (await get("/admin/clients")).text();
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
assert.match(listHtml, /href="\/admin\/clients\/new"/);
assert.match(listHtml, /Reporting/);
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
// client and shows the one-time secret + id.
const formHtml = await (await get("/admin/clients/new")).text();
assert.match(formHtml, /Register client/);
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
assert.equal(created.status, 200); // not a redirect — the secret is shown once
const createdHtml = await created.text();
assert.match(createdHtml, /Client registered/);
assert.match(createdHtml, /secret-1/); // the one-time client_secret
assert.match(createdHtml, /gen-1/); // the generated client_id
assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic"));
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
const before = store.length;
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
assert.equal(store.length, before);
// Detail: read-only info, never the secret again, a delete control.
const detail = await (await get("/admin/clients/existing")).text();
assert.match(detail, /reporting\.example\/cb/);
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/clients");
assert.ok(!store.some((c) => c.client_id === "existing"));
// Unknown id → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
});
+439
View File
@@ -0,0 +1,439 @@
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import { test } from "node:test";
import { createApp } from "./app.ts";
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
import { staticJwks } from "../auth/jwks.ts";
import type { Identity } from "../auth/kratos-admin.ts";
import { KratosError, type Flow, type FlowSubmission, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
const loginFlow = (id: string): Flow => ({
id,
ui: {
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
method: "post",
nodes: [
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
node({ name: "password", required: true, type: "password" }, "Password"),
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
],
},
});
// GET dispatch for the themed auth pages: the same handler branches on session presence.
test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a signed-in user is sent home, except /settings", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
// Anonymous, no ?flow= → init one + relay Kratos' CSRF cookie.
const init = await fetch(url + "/login", { redirect: "manual" });
assert.equal(init.status, 303);
assert.equal(init.headers.get("location"), "/login?flow=new1");
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
// A stale flow id (Kratos 410) bounces back to a fresh init.
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
assert.equal(stale.status, 303);
assert.equal(stale.headers.get("location"), "/login");
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
for (const path of ["/login", "/registration"]) {
const res = await fetch(url + path, signedIn);
assert.equal(res.status, 303, `${path} while signed in → 303`);
assert.equal(res.headers.get("location"), "/dashboard");
}
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
});
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } }));
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const recover = await fetch(url + "/login", { redirect: "manual" });
assert.equal(recover.status, 303);
assert.equal(recover.headers.get("location"), "/auth/complete");
// return_to is carried through so the deep link still lands after the JWT is minted.
const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers");
// A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race).
const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } });
await new Promise<void>((r) => app2.listen(0, r));
t.after(() => app2.close());
const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`;
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
});
test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => {
let failure = new KratosError("Kratos init settings flow failed (401)", 401, "");
const throwing = async (): Promise<never> => { throw failure; };
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const init = await fetch(url + "/settings", { redirect: "manual" });
assert.equal(init.status, 303);
assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings");
const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" });
assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings");
// /login redirecting to itself would loop, so a 401 there stays the catch-all 500.
assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500);
// Only a 401 means "sign in": an Ory outage on /settings is still the honest 503.
failure = new KratosError("Kratos init settings flow failed (500)", 500, "");
assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503);
});
// return_to: a deep-link login lands back on the requested page. The gate redirects to
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
let lastReturnTo: string | undefined;
const kratos: KratosPublic = {
...mockKratos(async (_t, id) => loginFlow(id)),
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
};
const app = createApp({ kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
// A host-relative deep link → wrapped: Kratos returns to <origin>/auth/complete?return_to=<path>,
// so the JWT is minted before the user lands on the page (query preserved, re-encoded).
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
// An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
const abs = "http://localhost/oauth2/login?login_challenge=abc";
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
assert.equal(lastReturnTo, abs);
await fetch(url + "/login?return_to=" + encodeURIComponent("//evil.com"), { redirect: "manual" });
assert.equal(lastReturnTo, "//evil.com");
});
test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => {
let lastReturnTo: string | undefined;
const kratos: KratosPublic = {
...mockKratos(async (_t, id) => loginFlow(id)),
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
};
const app = createApp({ i18n: createI18n(await loadI18n()), kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
lastReturnTo = "unset";
await fetch(url + "/login", { redirect: "manual" });
assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default");
await fetch(url + "/login?locale=sv-SE", { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/);
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/);
// An absolute target still reaches Kratos beside a chosen language; the language is lost instead.
const abs = "http://localhost/oauth2/login?login_challenge=abc";
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" });
assert.equal(lastReturnTo, abs);
});
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
// generic "error on our end" 500 the catch-all renders.
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
const boom = () => { throw new KratosError("kratos down", 503, ""); };
const down: KratosPublic = { ...mockKratos(async () => boom()), initBrowserFlow: async () => boom() };
const app = createApp({ kratos: down });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const init = await fetch(url + "/login", { redirect: "manual" }); // init (no ?flow=) with Kratos down
assert.equal(init.status, 503);
assert.match(await init.text(), /unavailable/i);
assert.equal((await fetch(url + "/login?flow=f1")).status, 503); // fetching a flow, Kratos down
// A network-level throw (refused/timeout — not a KratosError) is treated the same way.
const refused: KratosPublic = { ...mockKratos(async () => { throw new Error("ECONNREFUSED"); }), initBrowserFlow: async () => { throw new Error("ECONNREFUSED"); } };
const app2 = createApp({ kratos: refused });
await new Promise<void>((r) => app2.listen(0, r));
t.after(() => app2.close());
assert.equal((await fetch(`http://localhost:${(app2.address() as AddressInfo).port}/login`, { redirect: "manual" })).status, 503);
});
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
assert.match(html, /name="identifier"/);
assert.match(html, /name="password"[^>]*type="password"/);
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google);
// `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
assert.match(html, /<div class="sso"/);
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
});
// Login completion: /auth/complete is where Kratos lands the browser after login.
test("login completion (/auth/complete): a live session mints the JWT cookie; no session → /login, no cookie", async (t) => {
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const q = returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : "";
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
};
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
assert.equal(ok.status, 303);
assert.equal(ok.headers.get("location"), "/dashboard");
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
// return_to: a safe host-relative target lands the user back where they were headed; an
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
// No Kratos session: nothing minted, bounce to /login with no cookie.
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
assert.equal(none.status, 303);
assert.equal(none.headers.get("location"), "/login");
assert.equal(none.headers.get("set-cookie"), null);
});
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
const csrfSecret = "logout-secret";
const app = createApp({ csrfSecret, kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(csrfSecret);
const post = (cookie: string, body: string) =>
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
assert.equal(out.status, 303);
assert.equal(out.headers.get("location"), logoutUrl);
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
// No active Kratos session → clear our cookie and land on /login ourselves.
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
assert.equal(none.status, 303);
assert.equal(none.headers.get("location"), "/login");
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
});
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
let acceptedSubject: string | undefined;
const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } });
const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) });
await new Promise<void>((r) => signedIn.listen(0, r));
t.after(() => signedIn.close());
const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`;
// Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL.
const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
assert.equal(accept.status, 303);
assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/);
assert.equal(acceptedSubject, identity.id);
// Missing login_challenge → 400 (someone hit the endpoint directly).
assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400);
// Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here.
const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) });
await new Promise<void>((r) => anon.listen(0, r));
t.after(() => anon.close());
const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" });
assert.equal(bounce.status, 303);
const loc = bounce.headers.get("location") ?? "";
assert.match(loc, /^\/login\?return_to=/);
assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/);
});
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing challenge", async (t) => {
const csrfSecret = "consent-secret";
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
const hydra = stubHydra({
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
});
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(csrfSecret);
const post = (body: string) =>
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
assert.equal(page.status, 200);
const html = await page.text();
assert.match(html, /Authorize Acme Reports/);
assert.match(html, /openid/);
assert.match(html, /profile/);
assert.match(html, /action="\/oauth2\/consent"/);
// Informed consent: the screen names the account being authorized + offers a sign-out escape.
assert.match(html, /Signed in as.*ada@x\.io/s);
assert.match(html, /action="\/logout".*Sign out/s);
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
// id_token claims from the Kratos identity.
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
assert.equal(allow.status, 303);
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
// Deny → 303 back to the client with access_denied.
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
assert.equal(deny.status, 303);
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
assert.equal((await post("decision=allow")).status, 403);
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
await new Promise<void>((r) => skip.listen(0, r));
t.after(() => skip.close());
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
assert.equal(auto.status, 303);
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
});
test("OAuth2 RP-initiated logout (/oauth2/logout): accepts the logout challenge → 303 to Hydra; missing → 400", async (t) => {
let acceptedChallenge: string | undefined;
const hydra = stubHydra({ acceptLogoutRequest: async (c) => { acceptedChallenge = c; return { redirect: "http://acme.example/post-logout" }; } });
const app = createApp({ hydra, kratos: withWhoami(async () => null) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
const ok = await fetch(base + "/oauth2/logout?logout_challenge=lc1", { redirect: "manual" });
assert.equal(ok.status, 303);
assert.equal(ok.headers.get("location"), "http://acme.example/post-logout");
assert.equal(acceptedChallenge, "lc1");
assert.equal((await fetch(base + "/oauth2/logout", { redirect: "manual" })).status, 400);
});
// All three OAuth2 challenge endpoints share one degrade contract (the documented "byte-identical"
// behaviour): a stale/consumed challenge (Hydra 4xx — back button, slow login) → recoverable 400,
// a genuine Hydra outage (5xx) → 500.
test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, outage 5xx → 500", async (t) => {
const endpoints: { make: (status: number) => Partial<HydraAdmin>; path: string }[] = [
{ make: (s) => ({ getLoginRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/login?login_challenge=x" },
{ make: (s) => ({ getConsentRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/consent?consent_challenge=x" },
{ make: (s) => ({ acceptLogoutRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/logout?logout_challenge=x" },
];
for (const { make, path } of endpoints) {
for (const [status, expected] of [[410, 400], [503, 500]] as const) {
const app = createApp({ hydra: stubHydra(make(status)), kratos: withWhoami(async () => null) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}${path}`, { redirect: "manual" });
assert.equal(res.status, expected, `${path} ${status} → ${expected}`);
}
}
});
test("code-flow POST: the host trims the code, relays the POST to Kratos and Kratos' redirect + cookies back", async (t) => {
const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = [];
let answer: () => Promise<FlowSubmission> = async () =>
({ location: "http://localhost:3000/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/; HttpOnly"], status: 303 });
const kratos: KratosPublic = {
...mockKratos(async () => { throw new Error("unused"); }),
submitFlow: async (type, flowId, opts) => { calls.push({ body: opts.body, cookie: opts.cookie, flowId, type }); return answer(); },
};
const app = createApp({ jwks: staticJwks([ecJwk]), kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const post = (path: string, body: string) => fetch(url + path, {
body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: "csrf_token_x=c" }, method: "POST", redirect: "manual",
});
const res = await post("/recovery?flow=f1", "csrf_token=tok&code=%C2%A0123456%09%0A&method=code");
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "http://localhost:3000/settings?flow=s1");
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_session=s/);
assert.deepEqual(calls[0], { body: "csrf_token=tok&code=123456&method=code", cookie: "csrf_token_x=c", flowId: "f1", type: "recovery" });
// The first step has no code field; its body passes through unchanged.
await post("/verification?flow=f2", "csrf_token=tok&email=a%40b.c&method=code");
assert.deepEqual(calls[1], { body: "csrf_token=tok&email=a%40b.c&method=code", cookie: "csrf_token_x=c", flowId: "f2", type: "verification" });
// No ?flow= → start over rather than post a flow-less submit.
const noFlow = await post("/recovery", "code=1");
assert.equal(noFlow.headers.get("location"), "/recovery");
assert.equal(calls.length, 2);
// Kratos' absolute redirect to this host becomes host-relative, so it keeps the chosen locale.
answer = async () => ({ location: `${url}/settings?flow=s1`, setCookie: [], status: 303 });
assert.equal((await post("/recovery?flow=f1&locale=en-US", "code=1")).headers.get("location"), "/settings?flow=s1&locale=en-US");
// Kratos answered without a redirect → back to the flow, whose page shows its state.
answer = async () => ({ location: null, setCookie: [], status: 400 });
assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1");
// Kratos down → the honest 503 the flow pages render, not a 500.
answer = async () => { throw new KratosError("bad gateway", 502, ""); };
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
answer = async () => { throw new TypeError("fetch failed"); };
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
// Sign-in posts straight to Kratos, so the host has no POST /login.
assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303);
});
+85
View File
@@ -0,0 +1,85 @@
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
import type { AddressInfo } from "node:net";
import type { TestContext } from "node:test";
import { createApp } from "./app.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
export const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
export function mintJwt(payload: Record<string, unknown>): string {
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
}
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
export const session = (permissions: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
export async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): Promise<string> {
const app = createApp(pluginsDir ? { plugins, pluginsDir } : { plugins });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return `http://localhost:${(app.address() as AddressInfo).port}`;
}
export function mockKratos(getFlow: KratosPublic["getFlow"]): KratosPublic {
return {
createLogoutFlow: async () => null,
getFlow,
initBrowserFlow: async (_t: FlowType) => ({ flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: ["csrf_token=abc; Path=/; HttpOnly"] }),
submitFlow: async () => { throw new Error("unused"); },
whoami: async () => null,
};
}
export const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
deleteIdentity: async () => {},
getIdentity: async () => null,
listIdentities: async () => ({ identities: [], nextPageToken: null }),
updateIdentity: async () => { throw new Error("unused"); },
updateMetadataPublic: async () => ({ id: "x" }),
...over,
});
const sameSet = (a?: SubjectSet, b?: SubjectSet): boolean =>
(!a && !b) || (!!a && !!b && a.namespace === b.namespace && a.object === b.object && a.relation === b.relation);
export const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boolean =>
(f.namespace === undefined || t.namespace === f.namespace) &&
(f.object === undefined || t.object === f.object) &&
(f.relation === undefined || t.relation === f.relation) &&
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
// A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens.
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
expand: async () => ({ type: "leaf" }),
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
...over,
});
export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
export const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
acceptLogoutRequest: async () => ({ redirect: "http://acme.example/post-logout" }),
createClient: async (c) => ({ ...c, client_id: "c1", client_secret: "s3cr3t" }),
deleteClient: async () => {},
getClient: async () => null,
getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }),
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
listClients: async () => ({ clients: [], nextPageToken: null }),
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
rejectLoginRequest: async () => { throw new Error("unused"); },
...over,
});
export const OAUTH_SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
+147
View File
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { createApp } from "./app.ts";
import { staticJwks } from "../auth/jwks.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, mintJwt } from "./app-harness.ts";
// ---- language (i18n) ----
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
async function localeApp(t: TestContext): Promise<string> {
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return `http://localhost:${(app.address() as AddressInfo).port}`;
}
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
assert.match(html, /Logga in/); // the landing page's own words
assert.doesNotMatch(html, /Operational web apps/);
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
assert.match(html, /href="\/login\?locale=sv-SE"/);
// …and the picker offers the other installed locale, pointing at this same page.
assert.match(html, /hreflang="en-US"/);
assert.match(html, /href="\/\?locale=en-US"/);
});
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
const url = await localeApp(t);
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
assert.match(swedish, /<html lang="sv-SE"/);
// The visitor never asked for a locale in the URL, so the links stay clean.
assert.match(swedish, /href="\/login"/);
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
});
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
const url = await localeApp(t);
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
assert.equal(res.status, 200);
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
}
});
test("a redirect the host emits keeps the visitor's language", async (t) => {
const url = await localeApp(t);
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
assert.equal(res.status, 303);
const location = res.headers.get("location") ?? "";
assert.match(location, /^\/login\?/);
assert.match(location, /locale=sv-SE/);
});
test("the error pages speak the visitor's language too", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE"/);
assert.match(html, /Sidan hittades inte/);
});
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true }));
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
const demo: Plugin = {
apiVersion: "1.0.0",
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
home: (ctx) => ({ html: ctx.t("demo.hello") }),
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
id: "demo",
};
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
});
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
// broken menu takes the error pages down with it.
let built = 0;
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const res = await fetch(`${url}/no-such-page`);
assert.equal(res.status, 404);
assert.match(await res.text(), /Page not found/);
assert.equal(built, 0);
});
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true }));
// The view renders the picker exactly as the shell does.
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
const demo: Plugin = {
apiVersion: "1.0.0",
id: "demo",
routes: [
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
],
};
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
// A POST whose path also answers GET → the picker points at that page.
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
// …and with no referer to fall back on, the front page.
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
});
+303
View File
@@ -0,0 +1,303 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { createApp } from "./app.ts";
import { readFormBody } from "./body.ts";
import { createDenylist } from "../auth/denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
import { staticJwks } from "../auth/jwks.ts";
import type { Identity } from "../auth/kratos-admin.ts";
import { KratosError, type Session } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = {
apiVersion: "1.0.0",
id: "demo",
routes: [
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
],
};
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true });
// The view also include()s a core building-block partial, proving plugin views reuse them.
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- include("partials/theme-switch") %>`);
writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}");
t.after(() => rmSync(dir, { force: true, recursive: true }));
const url = await startApp(t, [demoPlugin], dir);
// Path param + html
const hi = await fetch(url + "/demo/hello/world");
assert.equal(hi.status, 200);
assert.match(await hi.text(), /Hi world/);
// json
const data = await fetch(url + "/demo/data");
assert.match(data.headers.get("content-type") ?? "", /application\/json/);
assert.deepEqual(await data.json(), { ok: true });
// redirect (POST → 303 Location)
const go = await fetch(url + "/demo/go", { method: "POST", redirect: "manual" });
assert.equal(go.status, 303);
assert.equal(go.headers.get("location"), "/demo/hello/world");
// view rendered from the plugin's own views/, including a core partial
const page = await (await fetch(url + "/demo/page")).text();
assert.match(page, /Hello Plainpages/);
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
// static asset served from the plugin's own public/ at /public/<id>/
const css = await fetch(url + "/public/demo/app.css");
assert.equal(css.status, 200);
assert.match(css.headers.get("content-type") ?? "", /text\/css/);
assert.match(await css.text(), /\.demo/);
assert.equal((await fetch(url + "/public/demo/..%2f..%2fplugin.ts")).status, 403); // traversal still blocked
// gated route, anonymous → redirect to sign in (like the built-in screens), not a dead-end 403;
// the requested page is preserved as return_to so login lands the user back there.
const denied = await fetch(url + "/demo/secret", { redirect: "manual" });
assert.equal(denied.status, 303);
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
// a route marked public is reachable anonymously — no gate, no redirect.
const open = await fetch(url + "/demo/public-page", { redirect: "manual" });
assert.equal(open.status, 200);
assert.match(await open.text(), /open to all/);
// known path + wrong method → 405 with Allow; unknown path → 404
const wrong = await fetch(url + "/demo/data", { method: "DELETE" });
assert.equal(wrong.status, 405);
assert.match(wrong.headers.get("allow") ?? "", /GET/);
assert.equal((await fetch(url + "/demo/nope")).status, 404);
});
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
// The view composes the core shell from ctx.chrome — branding, the global nav — and its own
// CSRF-guarded form carrying chrome.csrfToken (the representative way a plugin form gets the token,
// independent of the shell's auth-dependent profile/sign-out block).
writeFileSync(join(dir, "panelkit", "views", "panel.ejs"),
`<%- include("partials/shell", { body: '<form method="post" action="/panelkit/save"><input type="hidden" name="_csrf" value="' + chrome.csrfToken + '" /></form>', brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), title, user: chrome.user }) %>`);
t.after(() => rmSync(dir, { force: true, recursive: true }));
const plugin: Plugin = {
apiVersion: "1.0.0",
id: "panelkit",
nav: [{ href: "/panelkit/panel", icon: "i-grid", id: "panelkit", label: "Panel kit" }],
routes: [
{ handler: (ctx) => ({ data: { chrome: ctx.chrome, title: "Panel" }, view: "panel" }), method: "GET", path: "/panel" },
{
handler: async (ctx) => {
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, "bad csrf");
return { redirect: "/panelkit/panel" };
},
method: "POST", path: "/save",
},
],
};
const secret = "test-csrf-secret";
const app = createApp({ csrfSecret: secret, plugins: [plugin], pluginsDir: dir });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie
// whose token is embedded in the plugin's own form (double-submit).
const res = await fetch(url + "/panelkit/panel");
assert.equal(res.status, 200);
const body = await res.text();
assert.match(body, /class="brand-name">Plainpages/);
assert.match(body, /Panel kit/);
const cookieTok = /plainpages_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
assert.ok(cookieTok, "a plugin route issues the CSRF cookie when fresh");
assert.equal(/name="_csrf" value="([^"]+)"/.exec(body)?.[1], cookieTok);
// POST with no token → 403 (ctx.verifyCsrf fails closed); matching cookie + field → 303.
assert.equal((await fetch(url + "/panelkit/save", { method: "POST", redirect: "manual" })).status, 403);
const tok = issueCsrfToken(secret);
const ok = await fetch(url + "/panelkit/save", {
body: `_csrf=${encodeURIComponent(tok)}`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${tok}` },
method: "POST", redirect: "manual",
});
assert.equal(ok.status, 303);
});
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
// Token carrying the gating permission → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
assert.equal(ok.status, 200);
assert.equal(await ok.text(), "secret");
// No cookie and an expired token both render anonymous → the gate bounces to sign in (303 → /login,
// remembering the gated page as return_to).
const noCookie = await secret();
assert.equal(noCookie.status, 303);
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
// composition + permission-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
assert.equal(dash.status, 200);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
assert.equal(anonDash.status, 303);
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
});
test("revocation denylist: a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
denylist.revoke("u1");
assert.equal((await secret(nowSec - 5)).status, 303); // the pre-revoke token now bounces to /login
assert.equal((await secret(nowSec + 5)).status, 200); // a fresh re-login (iat after the revoke) still works
});
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const ok = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } });
assert.equal(ok.status, 200);
assert.equal(await ok.text(), "secret");
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=/);
// Kratos session gone: no re-mint, the stale cookie is cleared, the now-anonymous request bounces to sign in.
const dead = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
await new Promise<void>((r) => dead.listen(0, r));
t.after(() => dead.close());
const denied = await fetch(`http://localhost:${(dead.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
assert.equal(denied.status, 303);
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.match(denied.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
// Ory unreachable (not a dead session): whoami throws → degrade to anonymous (bounce to /login, not 500),
// and leave the cookie untouched so the token can re-mint once Ory recovers.
const down = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => { throw new KratosError("kratos down", 503, ""); }), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
await new Promise<void>((r) => down.listen(0, r));
t.after(() => down.close());
const outage = await fetch(`http://localhost:${(down.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
assert.equal(outage.status, 303);
assert.equal(outage.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal(outage.headers.get("set-cookie"), null);
});
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
const guarded: Plugin = {
apiVersion: "1.0.0",
id: "guarded",
routes: [
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
],
};
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
assert.equal(anon.status, 303);
assert.equal(anon.headers.get("location"), "/login?return_to=%2Fguarded%2Fme");
const me = await fetch(url + "/guarded/me", auth([]));
assert.equal(me.status, 200);
assert.match(await me.text(), /hi a@b\.c/);
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
// check (live Keto): the keto verdict gates the handler.
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
assert.equal(gAnon.status, 303);
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
const gDenied = await fetch(url + "/guarded/gated", auth([]));
assert.equal(gDenied.status, 403);
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
assert.equal(sAnon.status, 303);
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
});
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
const seen: string[] = [];
const hooked: Plugin = {
apiVersion: "1.0.0",
hooks: {
onRequest: (c) => (c.url.pathname === "/hooked/blocked" ? { html: "blocked by hook", status: 403 } : undefined),
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
},
id: "hooked",
routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
};
const url = await startApp(t, [hooked]);
// onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the
// response so a form the hook renders has its matching double-submit cookie.
const blocked = await fetch(url + "/hooked/blocked");
assert.equal(blocked.status, 403);
assert.match(await blocked.text(), /blocked by hook/);
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
// A normal route runs the handler; onResponse observed its result.
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
assert.ok(seen.includes("/hooked/ok:handler ran"));
});
+84 -1213
View File
File diff suppressed because it is too large Load Diff
+37 -23
View File
@@ -8,7 +8,6 @@ import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts"; import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts"; import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts"; import { GuardError, loginRedirect } from "../auth/guards.ts";
import { ENGLISH_I18N } from "../i18n/english.ts"; import { ENGLISH_I18N } from "../i18n/english.ts";
@@ -26,20 +25,24 @@ import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"
import { remintSession } from "../auth/login.ts"; import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { PluginSettings } from "../plugin-host/settings.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { allows, type Gate } from "../auth/gate.ts";
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
import { securityHeaders } from "./security-headers.ts"; import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts"; import { publicAssets } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts"; import { renderPluginView } from "../plugin-host/view-resolver.ts";
const NO_STORE = "private, no-store";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions { export interface AppOptions {
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live cache?: boolean; // cache compiled EJS templates and /public/ asset versions (config.cacheTemplates); off ⇒ edits show live
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
@@ -54,6 +57,7 @@ export interface AppOptions {
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string; publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http) secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
viewsDir?: string; viewsDir?: string;
} }
@@ -88,11 +92,13 @@ export function createApp(options: AppOptions = {}): Server {
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function"); const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function"); const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
const permissionCatalog = declaredPermissions(plugins); const permissionCatalog = declaredPermissions(plugins);
const settingsCatalog = options.settingsCatalog ?? [];
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const publicDir = options.publicDir ?? join(rootDir, "public"); const publicDir = options.publicDir ?? join(rootDir, "public");
const assets = publicAssets(publicDir, pluginsDir, pluginIds, { cache });
const viewsDir = options.viewsDir ?? join(rootDir, "views"); const viewsDir = options.viewsDir ?? join(rootDir, "views");
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal). // Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies })); const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
@@ -126,10 +132,10 @@ export function createApp(options: AppOptions = {}): Server {
t: ctx.t, t: ctx.t,
url: ctx.url, url: ctx.url,
}); });
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of // i18n locals and `assetHref` go last: their names are reserved, so a handler's
// breaking the shell around it. // colliding key loses instead of breaking the shell around it.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) }); const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) }); const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
const sendHtml = (res: ServerResponse, status: number, html: string): void => { const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
@@ -153,7 +159,6 @@ export function createApp(options: AppOptions = {}): Server {
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page. // starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie(); csrf.setCookie();
if (dashboardPlugin) { if (dashboardPlugin) {
@@ -163,15 +168,15 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref); await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null; return null;
} }
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" }; return { data: { chrome: ctx.chrome }, view: "index" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
// routes.ts, capability-gated on the wired clients) plus the two landing slots above. // routes.ts, capability-gated on the wired clients) plus the two landing slots above.
const builtinRoutes: BuiltinRoute[] = [ const builtinRoutes: BuiltinRoute[] = [
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }), ...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
{ handler: serveHome, method: "GET", path: "/" }, { handler: serveHome, method: "GET", path: "/", public: true },
{ handler: serveDashboard, method: "GET", path: "/dashboard" }, { handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
]; ];
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every // The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
@@ -180,7 +185,7 @@ export function createApp(options: AppOptions = {}): Server {
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => { const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
// Error pages can render before this request has a context at all (a throw on the way to one), // Error pages can render before this request has a context at all (a throw on the way to one),
// so they start on the built-in English and switch to the visitor's locale once it is resolved. // so they start on the built-in English and switch to the visitor's locale once it is resolved.
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS }); let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS, assetHref: assets.href });
try { try {
const method = req.method ?? "GET"; const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
@@ -192,14 +197,18 @@ export function createApp(options: AppOptions = {}): Server {
// Before auth: assets don't need a verified user, and the JWT cookie rides every request. // Before auth: assets don't need a verified user, and the JWT cookie rides every request.
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds); await assets.serve(url, req, res, (err) => reqLog.error("static file error", { error: String(err) }));
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
return; return;
} }
// A cache in front of us must key on the language. Set after the static branch: an asset is // A cache in front of us must key on the language. Set after the static branch: an asset is
// the same bytes in every language, and a Vary there fragments its entry per raw header. // the same bytes in every language, and a Vary there fragments its entry per raw header.
res.setHeader("vary", "accept-language"); res.setHeader("vary", "accept-language");
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
res.setHeader("cache-control", NO_STORE);
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser, // Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the // the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
@@ -259,9 +268,9 @@ export function createApp(options: AppOptions = {}): Server {
// Base context (no route params), for the built-in routes. Every plugin-owned render — a // Base context (no route params), for the built-in routes. Every plugin-owned render — a
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead. // landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx); renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
@@ -275,15 +284,19 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
// the permission gets the 403 page.
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
};
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const routeCtx = contextFor(match.plugin.id, match.params); const routeCtx = contextFor(match.plugin.id, match.params);
if (!isAuthorized(match.route, routeCtx.permissions)) { if (!allows(match.route, routeCtx.user)) {
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply await refuse(match.route, routeCtx);
// lacks the permission gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
return; return;
} }
csrfMint.setCookie(); csrfMint.setCookie();
@@ -297,6 +310,7 @@ export function createApp(options: AppOptions = {}): Server {
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname); const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) { if (builtin) {
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale); await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
return; return;
} }
+2 -1
View File
@@ -3,6 +3,7 @@
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table // mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and // after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
// pipes the result through sendResult against the core views. // pipes the result through sendResult against the core views.
import type { Gate } from "../auth/gate.ts";
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "./context.ts";
import type { RouteResult } from "../plugin-host/plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
@@ -19,7 +20,7 @@ export interface RequestCsrf {
// own context — otherwise the plugin's keys render as bare keys on the pages it owns. // own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext; export type PluginContextFactory = (pluginId: string) => RequestContext;
export interface BuiltinRoute { export interface BuiltinRoute extends Gate {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself // Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views). // (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null; handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
+6
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
import type { PluginSettings } from "../plugin-host/settings.ts"; // type-only
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts"; import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
@@ -42,6 +43,9 @@ export interface RequestContext {
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is // screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
// what *this user holds*. Empty when no installed plugin declares any. // what *this user holds*. Empty when no installed plugin declares any.
declaredPermissions: readonly PermissionDecl[]; declaredPermissions: readonly PermissionDecl[];
// What each installed plugin declares it can be configured with, and how each key resolved — one
// entry per plugin, including those declaring nothing. A secret's value is never carried here.
declaredSettings: readonly PluginSettings[];
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
@@ -67,6 +71,7 @@ export interface BuildContextOptions {
// The host's factory is memoised, so the menu composes at most once per request across contexts. // The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome; chrome?: () => PageChrome;
declaredPermissions?: readonly PermissionDecl[]; declaredPermissions?: readonly PermissionDecl[];
declaredSettings?: readonly PluginSettings[];
user?: User | null; user?: User | null;
locale?: string; locale?: string;
localeHref?: (href: string) => string; localeHref?: (href: string) => string;
@@ -96,6 +101,7 @@ export function buildContext(
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [], declaredPermissions: options.declaredPermissions ?? [],
declaredSettings: options.declaredSettings ?? [],
user, user,
locale: options.locale ?? DEFAULT_LOCALE, locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href), localeHref: options.localeHref ?? ((href) => href),
+172
View File
@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";
import { contentTypeFor, publicAssets, resolveStaticPath, routePublic } from "./static.ts";
const IMMUTABLE = "public, max-age=31536000, immutable";
const SHORT = "public, max-age=300";
const root = mkdtempSync(join(tmpdir(), "static-test-"));
const publicDir = join(root, "public");
const pluginsDir = join(root, "plugins");
mkdirSync(join(publicDir, "css"), { recursive: true });
mkdirSync(join(pluginsDir, "demo", "public"), { recursive: true });
writeFileSync(join(publicDir, "css", "a.css"), "body { color: red }");
writeFileSync(join(pluginsDir, "demo", "public", "app.css"), ".demo {}");
const assets = publicAssets(publicDir, pluginsDir, new Set(["demo"]), { cache: false });
const assetHref = assets.href;
const readErrors: Error[] = [];
let base = "";
const server = createServer((req, res) => void assets.serve(new URL(req.url ?? "/", "http://localhost"), req, res, (err) => readErrors.push(err)));
before(() => new Promise<void>((r) => server.listen(0, () => {
base = `http://localhost:${(server.address() as AddressInfo).port}`;
r();
})));
after(() => {
server.close();
rmSync(root, { force: true, recursive: true });
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css");
});
test("contentTypeFor maps known and unknown extensions", () => {
assert.match(contentTypeFor("a.css"), /text\/css/);
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
});
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
const ids = new Set(["scheduling"]);
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
});
test("assetHref versions a /public/ file, core or plugin, and leaves every other href alone", () => {
assert.match(assetHref("/public/css/a.css"), /^\/public\/css\/a\.css\?v=[\w-]+$/);
assert.match(assetHref("/public/demo/app.css"), /^\/public\/demo\/app\.css\?v=[\w-]+$/);
assert.notEqual(assetHref("/public/css/a.css"), assetHref("/public/demo/app.css"));
for (const href of ["/public/css/missing.css", "/public/css", "/public/css/a.css?x=1", "/public/css/a.css#top", "/public/..%2f..%2fetc/passwd", "/public/%E0%A4%A", "https://cdn.example/a.css", "data:image/svg+xml,<svg/>", "/elsewhere/a.css"]) {
assert.equal(assetHref(href), href, href);
}
});
test("assetHref changes when the file's content does", () => {
const file = join(publicDir, "css", "changing.css");
writeFileSync(file, "a {}");
const first = assetHref("/public/css/changing.css");
assert.equal(assetHref("/public/css/changing.css"), first);
writeFileSync(file, "a { color: blue }");
assert.notEqual(assetHref("/public/css/changing.css"), first);
});
test("assetHref changes when the file's size does, even with its mtime restored", () => {
const file = join(publicDir, "css", "resized.css");
writeFileSync(file, "a {}");
const { atime, mtime } = statSync(file);
const first = assetHref("/public/css/resized.css");
writeFileSync(file, "a { color: blue }");
utimesSync(file, atime, mtime);
assert.notEqual(assetHref("/public/css/resized.css"), first);
});
test("with cache on, assetHref keeps a known version; a request naming it after an edit gets the short cache and the new ETag", async () => {
const cached = publicAssets(publicDir, pluginsDir, new Set(), { cache: true });
const file = join(publicDir, "css", "cached.css");
writeFileSync(file, "a {}");
const stale = cached.href("/public/css/cached.css");
writeFileSync(file, "a { color: green }");
assert.equal(cached.href("/public/css/cached.css"), stale);
const res = await fetch(base + stale);
assert.equal(res.status, 200);
assert.equal(await res.text(), "a { color: green }");
assert.equal(res.headers.get("cache-control"), SHORT);
const fresh = assetHref("/public/css/cached.css");
assert.notEqual(fresh, stale);
assert.equal(res.headers.get("etag"), `"${fresh.split("?v=")[1]}"`);
});
test("a version hashed while rendering equals the ETag hashed while serving the same bytes", async () => {
writeFileSync(join(publicDir, "css", "served-first.css"), "p {}");
writeFileSync(join(publicDir, "css", "rendered-first.css"), "p {}");
const etag = (await fetch(base + "/public/css/served-first.css")).headers.get("etag");
assert.equal(etag, `"${assetHref("/public/css/rendered-first.css").split("?v=")[1]}"`);
});
test("a URL carrying the current version is cached for a year as immutable", async () => {
for (const method of ["GET", "HEAD"]) {
const res = await fetch(base + assetHref("/public/css/a.css"), { method });
assert.equal(res.status, 200, method);
assert.equal(res.headers.get("cache-control"), IMMUTABLE, method);
}
assert.equal((await fetch(base + assetHref("/public/demo/app.css"))).headers.get("cache-control"), IMMUTABLE);
});
test("an unversioned or stale-versioned URL is cached briefly, with an ETag and Last-Modified to revalidate", async () => {
const lastModified = statSync(join(publicDir, "css", "a.css")).mtime.toUTCString();
for (const path of ["/public/css/a.css", "/public/css/a.css?v=stale"]) {
const res = await fetch(base + path);
assert.equal(res.status, 200, path);
assert.equal(await res.text(), "body { color: red }", path);
assert.equal(res.headers.get("cache-control"), SHORT, path);
assert.match(res.headers.get("etag") ?? "", /^"[\w-]+"$/, path);
assert.equal(res.headers.get("last-modified"), lastModified, path);
}
});
test("If-None-Match naming the current ETag answers 304 with no body, for GET and HEAD", async () => {
const etag = (await fetch(base + "/public/css/a.css")).headers.get("etag") ?? "";
for (const method of ["GET", "HEAD"]) {
for (const ifNoneMatch of [etag, `W/${etag}`, `"other", ${etag}`, "*"]) {
const res = await fetch(base + "/public/css/a.css", { headers: { "if-none-match": ifNoneMatch }, method });
assert.equal(res.status, 304, `${method} ${ifNoneMatch}`);
assert.equal(await res.text(), "");
assert.equal(res.headers.get("etag"), etag);
assert.equal(res.headers.get("cache-control"), SHORT);
}
}
const versioned = await fetch(base + assetHref("/public/css/a.css"), { headers: { "if-none-match": etag } });
assert.equal(versioned.status, 304);
assert.equal(versioned.headers.get("cache-control"), IMMUTABLE);
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-none-match": '"other"' } })).status, 200);
});
test("If-Modified-Since at or after the file's mtime answers 304; before it, or beside a failed If-None-Match, 200", async () => {
const lastModified = (await fetch(base + "/public/css/a.css")).headers.get("last-modified") ?? "";
for (const method of ["GET", "HEAD"]) {
const res = await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified }, method });
assert.equal(res.status, 304, method);
assert.equal(await res.text(), "");
}
const earlier = new Date(Date.parse(lastModified) - 1000).toUTCString();
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": earlier } })).status, 200);
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": "not a date" } })).status, 200);
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified, "if-none-match": '"other"' } })).status, 200);
});
test("unsafe or missing paths answer 400, 403 and 404", async () => {
assert.equal((await fetch(base + "/public/%E0%A4%A")).status, 400);
assert.equal((await fetch(base + "/public/..%2f..%2fetc%2fpasswd")).status, 403);
assert.equal((await fetch(base + "/public/css/missing.css")).status, 404);
assert.equal((await fetch(base + "/public/css/a.css/x")).status, 404); // ENOTDIR
assert.equal((await fetch(base + "/public/css")).status, 404);
assert.deepEqual(readErrors, []);
});
test("a read failure other than a missing file answers 500 and reaches onError", async () => {
symlinkSync("loop.css", join(publicDir, "css", "loop.css")); // root reads a mode-000 file, so a symlink loop makes the failure
const res = await fetch(base + "/public/css/loop.css");
assert.equal(res.status, 500);
assert.deepEqual(readErrors.splice(0).map((err) => (err as NodeJS.ErrnoException).code), ["ELOOP"]);
});
+106 -13
View File
@@ -1,6 +1,7 @@
import { createReadStream } from "node:fs"; import { createHash, type Hash } from "node:crypto";
import { createReadStream, readFileSync, statSync, type Stats } from "node:fs";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import type { ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { extname, isAbsolute, join, relative } from "node:path"; import { extname, isAbsolute, join, relative } from "node:path";
const contentTypes: Record<string, string> = { const contentTypes: Record<string, string> = {
@@ -38,8 +39,8 @@ export interface StaticRoute {
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered // Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are // plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes // URL-safe (no %-encoding), so the raw segment compares directly to the id set; the caller decodes
// and traversal-guards the sub-path as before. // and traversal-guards the sub-path.
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute { export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
const slash = restPath.indexOf("/"); const slash = restPath.indexOf("/");
const first = slash === -1 ? restPath : restPath.slice(0, slash); const first = slash === -1 ? restPath : restPath.slice(0, slash);
@@ -49,28 +50,103 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
return { dir: publicDir, subPath: restPath }; return { dir: publicDir, subPath: restPath };
} }
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
// repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
// then revalidated against the ETag (that same hash) or Last-Modified. A plugin has no way to version
// its own hrefs yet: `assetHref` is a reserved local but not contract, since offering it is a minor.
const IMMUTABLE = "public, max-age=31536000, immutable";
const SHORT = "public, max-age=300";
// An entry holds while the file's mtime and size match, so a file edited in place (a dev bind mount)
// gets a new hash.
const hashes = new Map<string, { hash: string; mtimeMs: number; size: number }>();
function memoized(filePath: string, info: Stats): string | undefined {
const known = hashes.get(filePath);
return known?.mtimeMs === info.mtimeMs && known.size === info.size ? known.hash : undefined;
}
// The one definition of a version. Two readers feed it. A view renders synchronously, so hashFileSync
// reads the file whole; hashFile streams it, so a large file doesn't stall every other request.
function remember(filePath: string, info: Stats, sha256: Hash): string {
const hash = sha256.digest("base64url").slice(0, 16);
hashes.set(filePath, { hash, mtimeMs: info.mtimeMs, size: info.size });
return hash;
}
function hashFileSync(filePath: string, info: Stats): string {
return memoized(filePath, info) ?? remember(filePath, info, createHash("sha256").update(readFileSync(filePath)));
}
async function hashFile(filePath: string, info: Stats): Promise<string> {
const known = memoized(filePath, info);
if (known !== undefined) return known;
const sha256 = createHash("sha256");
for await (const chunk of createReadStream(filePath)) sha256.update(chunk);
return remember(filePath, info, sha256);
}
// Without `cache`, a stat per href picks up an edited file. With it, a known version skips the stat:
// an edit reaches the hrefs once a request for the file hashes it again.
function versioned(href: string, route: StaticRoute, cache: boolean): string {
try {
const filePath = resolveStaticPath(route.dir, decodeURIComponent(route.subPath));
if (filePath === null) return href;
const known = cache ? hashes.get(filePath) : undefined;
if (known) return `${href}?v=${known.hash}`;
const info = statSync(filePath);
return info.isFile() ? `${href}?v=${hashFileSync(filePath, info)}` : href;
} catch {
return href; // malformed %-encoding, or a missing or unreadable file: serving it answers the error
}
}
// RFC 9110 §13.1.2, weak comparison: `W/"x"` matches `"x"`, and `*` matches any existing file.
function etagMatches(ifNoneMatch: string, etag: string): boolean {
return ifNoneMatch.split(",").some((tag) => {
const t = tag.trim();
return t === "*" || t.replace(/^W\//, "") === etag;
});
}
// RFC 9110 §13.2.2: If-None-Match, when present, decides alone; HTTP dates have whole seconds.
function notModified(req: IncomingMessage, etag: string, info: Stats): boolean {
const ifNoneMatch = req.headers["if-none-match"];
if (ifNoneMatch !== undefined) return etagMatches(ifNoneMatch, etag);
const since = Date.parse(req.headers["if-modified-since"] ?? "");
return !Number.isNaN(since) && Math.floor(info.mtimeMs / 1000) * 1000 <= since;
}
function plain(res: ServerResponse, status: number, body: string): void { function plain(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body); res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
} }
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so interface StaticRequest extends StaticRoute {
// static.ts stays standalone, while app.ts passes the request logger for structured output. version: string | null; // the request's `v` query parameter
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> { }
async function serveStatic(route: StaticRequest, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void): Promise<void> {
let decoded: string; let decoded: string;
try { try {
decoded = decodeURIComponent(requestedPath); decoded = decodeURIComponent(route.subPath);
} catch { } catch {
return plain(res, 400, "Bad Request"); return plain(res, 400, "Bad Request");
} }
const filePath = resolveStaticPath(dir, decoded); const filePath = resolveStaticPath(route.dir, decoded);
if (filePath === null) return plain(res, 403, "Forbidden"); if (filePath === null) return plain(res, 403, "Forbidden");
try { try {
const info = await stat(filePath); const info = await stat(filePath);
if (!info.isFile()) return plain(res, 404, "Not Found"); if (!info.isFile()) return plain(res, 404, "Not Found");
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) }); const hash = await hashFile(filePath, info);
if (head) return void res.end(); // headers only — skip opening the file const etag = `"${hash}"`;
const validators = { "cache-control": route.version === hash ? IMMUTABLE : SHORT, etag, "last-modified": info.mtime.toUTCString() };
if (notModified(req, etag, info)) return void res.writeHead(304, validators).end();
res.writeHead(200, { ...validators, "content-length": info.size, "content-type": contentTypeFor(filePath) });
if (req.method === "HEAD") return void res.end();
// Headers are already sent, so a mid-stream read error can't become an HTTP status — // Headers are already sent, so a mid-stream read error can't become an HTTP status —
// log and destroy the response to signal a truncated body, not a hung socket. // log and destroy the response to signal a truncated body, not a hung socket.
createReadStream(filePath) createReadStream(filePath)
@@ -79,7 +155,24 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
res.destroy(); res.destroy();
}) })
.pipe(res); .pipe(res);
} catch { } catch (err) {
plain(res, 404, "Not Found"); const { code } = err as NodeJS.ErrnoException;
if (code === "ENOENT" || code === "ENOTDIR") return plain(res, 404, "Not Found");
onError(err as Error);
plain(res, 500, "Internal Server Error");
} }
} }
export interface PublicAssets {
href: (href: string) => string; // a /public/ href with its version; any other href unchanged
// onError receives every failure to read a file except a missing one, which answers 404.
serve: (url: URL, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void) => Promise<void>;
}
export function publicAssets(publicDir: string, pluginsDir: string, pluginIds: Set<string>, { cache }: { cache: boolean }): PublicAssets {
const route = (pathname: string): StaticRoute => routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
return {
href: (href) => (href.startsWith("/public/") && !/[?#]/.test(href) ? versioned(href, route(href), cache) : href),
serve: (url, req, res, onError) => serveStatic({ ...route(url.pathname), version: url.searchParams.get("v") }, req, res, onError),
};
}
+2 -1
View File
@@ -9,7 +9,7 @@ const messages = {
"auth.continue": "Continue", "auth.continue": "Continue",
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses, // Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them. // keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
"auth.field.code.hint": "Digits only — no spaces.", "auth.field.code.hint": "Digits only.",
"auth.field.email": "Email", "auth.field.email": "Email",
"auth.field.identifier": "Email", "auth.field.identifier": "Email",
"auth.field.password": "Password", "auth.field.password": "Password",
@@ -100,6 +100,7 @@ const messages = {
"filter.remove": "Remove {{label}} filter", "filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset", "filter.reset": "Reset",
"filter.search": "Search", "filter.search": "Search",
"filter.selected": "{{label}}, {{count}} selected",
"filter.to": "To", "filter.to": "To",
"filter.toSeparator": "to", "filter.toSeparator": "to",
+2 -1
View File
@@ -2,7 +2,7 @@ import type { CoreMessages } from "./en-US.ts";
const messages: CoreMessages = { const messages: CoreMessages = {
"auth.continue": "Fortsätt", "auth.continue": "Fortsätt",
"auth.field.code.hint": "Endast siffror — inga mellanslag.", "auth.field.code.hint": "Endast siffror.",
"auth.field.email": "E-postadress", "auth.field.email": "E-postadress",
"auth.field.identifier": "E-postadress", "auth.field.identifier": "E-postadress",
"auth.field.password": "Lösenord", "auth.field.password": "Lösenord",
@@ -91,6 +91,7 @@ const messages: CoreMessages = {
"filter.remove": "Ta bort filtret {{label}}", "filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ", "filter.reset": "Återställ",
"filter.search": "Sök", "filter.search": "Sök",
"filter.selected": "{{label}}, {{count}} valda",
"filter.to": "Till", "filter.to": "Till",
"filter.toSeparator": "till", "filter.toSeparator": "till",
+50 -24
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test"; import { test, type TestContext } from "node:test";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "./discovery.ts";
import { HOST_API_VERSION } from "./plugin.ts";
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures // Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest. // default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
@@ -19,21 +20,27 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
} }
const full = (id: string): string => const full = (id: string): string =>
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` + `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`; `routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => { test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []); assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
}); });
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => { test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") }); const dir = scaffold(t, {
"beta/plugin.ts": full("beta"),
"alpha/plugin.ts": full("alpha"),
"gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`,
});
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
assert.equal(plugins[0]?.apiVersion, "1.0.0"); assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION);
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha"); assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
assert.equal(plugins[2]?.storage, true);
}); });
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it. // Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
@@ -45,18 +52,30 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s }, { name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s }, { name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s }, { name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s }, { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s },
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s }, { name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s },
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s }, { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not // A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
// only in the admin GUI, so it holds for a plugin installed without that GUI. // only in the admin GUI, so it holds for a plugin installed without that GUI.
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s }, { name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s }, { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s }, { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s }, { name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s }, { name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s }, { name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
@@ -64,8 +83,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
// `npm install --prefix plugins` — the documented command with one path segment dropped. // `npm install --prefix plugins` — the documented command with one path segment dropped.
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ }, { name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ }, { name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
for (const c of badCases) { for (const c of badCases) {
@@ -78,7 +97,7 @@ for (const c of badCases) {
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the // upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case. // rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => { test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
await assert.rejects(discoverPlugins({ dir }), (err: Error) => { await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
assert.match(err.message, /gates on "admin"/); // what is wrong assert.match(err.message, /gates on "admin"/); // what is wrong
assert.match(err.message, /re-copy it/); // …and what to do about it assert.match(err.message, /re-copy it/); // …and what to do about it
@@ -86,12 +105,19 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
}); });
}); });
test("a route + nav node may be marked public and load fine", async (t) => { test("a route + nav node may be marked public, or session, and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, {
"pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };`,
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
});
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 2);
assert.equal(plugins[0]?.routes?.[0]?.public, true); const pub = plugins.find((p) => p.id === "pub");
assert.equal(plugins[0]?.nav?.[0]?.public, true); const sess = plugins.find((p) => p.id === "sess");
assert.equal(pub?.routes?.[0]?.public, true);
assert.equal(pub?.nav?.[0]?.public, true);
assert.equal(sess?.routes?.[0]?.session, true);
assert.equal(sess?.nav?.[0]?.session, true);
}); });
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => { test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
@@ -102,7 +128,7 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou
}); });
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => { test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
assert.equal(typeof plugins[0]?.home, "function"); assert.equal(typeof plugins[0]?.home, "function");
@@ -117,7 +143,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`, "shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`, "shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` + "shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
`export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`, `export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
}); });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
@@ -144,7 +170,7 @@ test("a dangling plugin symlink fails loud rather than vanishing", async (t) =>
}); });
test("a shared permission name only warns — both plugins still load", async (t) => { test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; const shared = `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = []; const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });

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