Compare commits

...

466 Commits

Author SHA1 Message Date
renovate-bot 611116de59 Update Node.js to v26
CI / full-gate (push) Failing after 24s
Release-Bump: major
2026-11-02 04:22:44 +00:00
renovate-bot 171f37985d Update dependency lucide-static to v1.66.0
CI / full-gate (push) Successful in 3m35s
Mirror / github-mirror (push) Successful in 3s
Release / retag-image (push) Successful in 16s
Release / publish-overview (push) Successful in 3s
Release-Bump: minor
2026-11-01 04:19:23 +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
renovate-bot a261570796 Update renovate/renovate Docker tag to v44.32.6
CI / full-gate (push) Successful in 2m45s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-18 04:18:04 +00: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
lilleman 1ba6dbdc51 Merge branch 'main' into prose-diet
CI / full-gate (push) Successful in 2m42s
Mirror / github-mirror (push) Successful in 6s
2026-08-17 21:05:29 +02:00
renovate-bot 09ab2fcb85 Update renovate/renovate Docker tag to v44.31.0
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-17 04:17:51 +00:00
renovate-bot a977ecf1c7 Update renovate/renovate Docker tag to v44.30.3
CI / full-gate (push) Successful in 3m39s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-15 04:18:17 +00:00
renovate-bot 04a508c169 Update postgres Docker tag to v18.6
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-14 04:18:05 +00:00
renovate-bot d74989c4e0 Update renovate/renovate Docker tag to v44.27.0
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: minor
2026-08-13 04:18:02 +00:00
renovate-bot 43d7062d9e Update renovate/renovate Docker tag to v44.24.3
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-12 04:18:15 +00:00
renovate-bot d5ee0353d4 Update renovate/renovate Docker tag to v44.23.0
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-11 04:17:48 +00:00
renovate-bot 137c35478d Update dependency lucide-static to v1.31.0
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-10 04:17:44 +00:00
renovate-bot 352f2f2794 Update axllent/mailpit Docker tag to v1.30.7
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 2s
Release-Bump: patch
2026-08-09 04:18:09 +00:00
renovate-bot 3b6f2c1ed3 Update renovate/renovate Docker tag to v44.14.10
CI / full-gate (push) Successful in 3m36s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: patch
2026-08-08 04:18:02 +00:00
renovate-bot f99a029bc5 Update renovate/renovate Docker tag to v44.14.3
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-07 04:18:27 +00:00
renovate-bot 1daa2b1777 Update renovate/renovate Docker tag to v44.13.3
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-06 04:18:26 +00:00
lilleman 8a5d2dfd6c Make the two persona references self-contained now that the section is gone
CI / full-gate (push) Successful in 2m40s
2026-08-05 23:42:58 +02:00
lilleman a005acb93d Cut non-essential prose from docs and comments, and require the same of every future change
CI / full-gate (push) Successful in 2m38s
README loses the competitor comparison, the personas and the repeated philosophy; the
five near-identical E2E command blocks become a table plus one command, and the file
map a clause per entry. AGENTS.md keeps every decision but drops the narrative around
them. todo.md's completed items collapse to their task line — git holds the rest.

Comments lose restatement, README duplication and history ("used to", "originally",
dated notes). AGENTS.md gains a Prose discipline section making this a standing pass on
every change rather than a one-off cleanup.

src/compose.test.ts now expects 6 documented E2E run commands, not 10, since the README
states the command once instead of per suite.
2026-08-05 23:41:12 +02:00
lilleman f5240ef7f6 Record macOS as a supported dev host and the commands still unverified there
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 22:55:48 +02:00
lilleman 78f5f72151 Correct the artifacts upgrade runbook and the bootstrap exception's stated reason
CI / full-gate (push) Successful in 2m39s
2026-08-05 22:46:05 +02:00
lilleman 1cf34a0d45 Harden the artifact-ownership guards and document the root-owned upgrade trap
CI / full-gate (push) Successful in 2m38s
2026-08-05 22:40:36 +02:00
lilleman 073ec294e9 Run the E2E runner as the invoking user so its artifacts aren't root-owned
CI / full-gate (push) Successful in 2m37s
2026-08-05 22:25:17 +02:00
lilleman e8b91ecd09 Compress AGENTS.md and add a standing rule to trim it on every edit
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 21:52:56 +02:00
lilleman ab5c24deb7 Cut the node_modules prose to one home each; drop a stray tracked file
CI / full-gate (push) Successful in 2m38s
2026-08-05 19:52:09 +02:00
lilleman 3d3313c0ee Document the shadowing risk and the leftover dir; close two holes in the mount guard
CI / full-gate (push) Successful in 2m38s
2026-08-05 19:17:07 +02:00
lilleman bcf4d7fb1f Install deps above WORKDIR so no root-owned node_modules lands in the checkout
CI / full-gate (push) Successful in 2m45s
2026-08-05 18:18:49 +02:00
lilleman 2852722873 Record the stale-copy trap as accepted until the apiVersion freeze lifts
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 18:00:38 +02:00
lilleman 45b16824f1 Point a failed discovery at the stale plugins/ copy, and document upgrading
CI / full-gate (push) Successful in 2m39s
2026-08-05 17:52:43 +02:00
lilleman f76cd2a267 Todo updates
CI / full-gate (push) Successful in 2m42s
2026-08-05 17:48:55 +02:00
lilleman 38ebe40398 Never fail the boot on operator env; drop unusable ADMIN_PERMISSIONS with a warning
CI / full-gate (push) Successful in 2m40s
2026-08-05 17:36:04 +02:00
lilleman 1f0956dd58 Keep the group-delete confirm in user terms, not tuple mechanics
CI / full-gate (push) Successful in 2m39s
2026-08-05 15:39:08 +02:00
lilleman edcd9fefc8 Deleting a group revokes the permissions it granted 2026-08-05 15:39:08 +02:00
lilleman 1787754781 Extend the read-only treatment to OAuth2 clients and write-intent GETs 2026-08-05 15:38:56 +02:00
lilleman 765f349007 Model the read/write split in the UI: read-only views, self-revoke and inherited-grant guards 2026-08-05 15:38:37 +02:00
lilleman 29d654c012 Permissions are a fixed list from plugin code; grant them on Users and Groups 2026-08-05 15:38:21 +02:00
lilleman fb4382be9d Enforce the permission-name rule at discovery, for every plugin 2026-08-05 15:38:00 +02:00
lilleman 90cbc47607 Seed the demo admin from the mounted plugins, not the image's empty copy 2026-08-05 15:37:39 +02:00
lilleman 27fee5f8a3 Permission names are <resource>:<action>, replacing the catch-all admin permission 2026-08-05 15:37:39 +02:00
lilleman 9412b90946 More todos
CI / full-gate (push) Successful in 2s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 14:18:09 +02:00
lilleman 225569b08a More todos
CI / full-gate (push) Successful in 3s
Mirror / github-mirror (push) Successful in 2s
2026-08-05 12:08:09 +02:00
lilleman 32154fbbc0 Record the unpinned Playwright worker pool as an open decision
CI / full-gate (push) Successful in 2m43s
Mirror / github-mirror (push) Successful in 7s
2026-08-05 11:21:57 +02:00
lilleman 690d67728b Watch what a beforeAll logs, and record where each message came from
CI / full-gate (push) Successful in 2m48s
2026-08-05 11:15:37 +02:00
lilleman e808f87fbd Fail an E2E test on anything the browser logs, in all three engines
CI / full-gate (push) Successful in 2m51s
2026-08-05 11:00:25 +02:00
lilleman e5bdc15262 Prune deleted tags on the GitHub mirror so it stops advertising dropped versions
CI / full-gate (push) Successful in 2m40s
Mirror / github-mirror (push) Successful in 7s
2026-08-05 10:42:39 +02:00
lilleman 2fb5e695e1 Record the docs-skip and no-release decisions, and blind the guard to comments
CI / full-gate (push) Successful in 2m42s
2026-08-05 10:34:46 +02:00
lilleman 3ebd1fa507 Gate the auto-release tag job behind an AUTO_RELEASE variable
CI / full-gate (push) Successful in 2m41s
2026-08-05 10:15:09 +02:00
lilleman 81043edfc6 Count both paths of a rename in the docs-only CI skip 2026-08-05 10:15:09 +02:00
lilleman 6df90f3746 Merge branch 'main' into todo-yo
CI / full-gate (push) Successful in 7s
Mirror / github-mirror (push) Successful in 7s
2026-08-05 08:39:26 +02:00
lilleman b1b01f8bdc More todos
CI / full-gate (push) Successful in 7s
2026-08-05 08:35:08 +02:00
lilleman c54f5e2c51 New todos
CI / full-gate (push) Has been cancelled
2026-08-05 08:33:49 +02:00
lilleman a8217a4ff8 Merge branch 'main' into renovate/actions-checkout-7.x
CI / full-gate (push) Successful in 2m42s
Mirror / github-mirror (push) Successful in 6s
2026-08-05 08:31:53 +02:00
lilleman ea1596eabd Merge branch 'main' into renovate/actions-checkout-7.x
CI / full-gate (push) Successful in 2m38s
2026-08-05 08:28:47 +02:00
lilleman 1d4c7c88c4 Merge branch 'main' into menu-outside-click
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 6s
2026-08-05 08:28:33 +02:00
renovate-bot 612a0ab8f2 Update actions/checkout action to v7
CI / full-gate (push) Successful in 2m40s
Release-Bump: major
2026-08-05 04:20:10 +00:00
renovate-bot 19d90c6323 Update renovate/renovate Docker tag to v44.11.7
CI / full-gate (push) Successful in 2m40s
Mirror / github-mirror (push) Successful in 7s
Release-Bump: patch
2026-08-05 04:17:57 +00:00
lilleman 9cf6c05325 Name the chrome's language menu and guard anchor positioning in the fallback
CI / full-gate (push) Successful in 2m44s
2026-08-05 02:23:50 +02:00
lilleman cfeee10fa8 Wrap each popover menu and give it a caller-named id
CI / full-gate (push) Successful in 2m43s
2026-08-05 01:58:11 +02:00
lilleman 5d9bdebf59 Close the popup menus on an outside click, via the popover API
CI / full-gate (push) Successful in 2m40s
2026-08-05 01:37:58 +02:00
lilleman 64d1387df2 Scope the profile-menu assertion to the menu itself
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 7s
2026-08-05 01:08:09 +02:00
lilleman 3c64621515 Remove the dead Profile link from the sidebar profile menu
CI / full-gate (push) Successful in 2m39s
2026-08-05 01:01:14 +02:00
lilleman 2b09635ed5 Hold the one-verb rule in AGENTS.md instead of a unit test
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 6s
2026-08-05 00:52:09 +02:00
lilleman 9a9c63e625 Widen the one-verb guard to inflections and drop the last competing English string
CI / full-gate (push) Successful in 2m38s
2026-08-05 00:43:05 +02:00
lilleman 34b668d49f Use one verb per action in the English UI: sign in, sign out, create account
CI / full-gate (push) Successful in 2m39s
2026-08-05 00:34:05 +02:00
lilleman 46548ae758 Narrow the settings-cog test assertions and record the icon-registry contract
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 6s
2026-08-05 00:20:34 +02:00
lilleman 8f7ab55267 Remove the Settings cog and its Preferences menu from the sidebar footer
CI / full-gate (push) Successful in 2m37s
2026-08-05 00:08:35 +02:00
lilleman 7948596105 New todos
CI / full-gate (push) Successful in 7s
Mirror / github-mirror (push) Successful in 6s
2026-08-04 23:48:26 +02:00
lilleman 38f4cffa43 Merge branch 'main' into i18n
CI / full-gate (push) Successful in 2m40s
Mirror / github-mirror (push) Successful in 7s
2026-08-04 19:55:50 +02:00
lilleman 1dc5892290 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m36s
Mirror / github-mirror (push) Successful in 6s
2026-08-04 10:43:13 +02:00
lilleman c063b71d19 Merge branch 'main' into i18n
CI / full-gate (push) Successful in 2m39s
2026-08-04 10:43:05 +02:00
lilleman 89a234b841 Add a todo for guarding the double-clicked submit without client JS
CI / full-gate (push) Successful in 2m38s
2026-08-04 10:40:37 +02:00
lilleman e03c1d1a2f Let an operator mount plugin catalogs; document the end-user personas
CI / full-gate (push) Successful in 2m37s
2026-08-04 10:31:08 +02:00
lilleman bd76c981ee Carry the language through sign-in; warn when switching leaves the page; product-review copy fixes
CI / full-gate (push) Successful in 2m40s
2026-08-04 09:59:00 +02:00
lilleman 37b88b2fe6 Show the language picker on every page, targeting the nearest page that answers GET
CI / full-gate (push) Successful in 2m39s
2026-08-04 09:37:03 +02:00
renovate-bot bc659d7d49 Update renovate/renovate Docker tag to v44.11.0
CI / full-gate (push) Successful in 2m35s
Release-Bump: minor
2026-08-04 04:18:10 +00:00
renovate-bot 01abb2f99c Update Node.js to v24.19.0
CI / full-gate (push) Successful in 2m54s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-08-04 04:17:55 +00:00
lilleman 7e4c6940c9 Escape the values interpolated into the one markup-carrying message
CI / full-gate (push) Successful in 2m37s
2026-08-04 00:42:54 +02:00
lilleman 18e1a8d29d Keep the chrome lazy for error pages, guard the guard-error render, split the recovery link
CI / full-gate (push) Successful in 2m38s
2026-08-04 00:31:19 +02:00
lilleman 93139ea058 Stability round two: no language links on POST-rendered pages, scoped observers, Vary only where it varies
CI / full-gate (push) Successful in 2m37s
2026-08-04 00:10:31 +02:00
lilleman be3bc2bdbb Stability fixes: plugin-scoped contexts for owned pages, absent-href guard, checked locale mounts
CI / full-gate (push) Successful in 2m37s
2026-08-03 23:51:40 +02:00
lilleman 2b20497785 Pin the chrome's locale carrying in unit tests; keep one carrier list
CI / full-gate (push) Successful in 2m36s
2026-08-03 23:25:54 +02:00
lilleman b3df7084c4 Reserve the locale param, carry it on breadcrumbs, translate the permissions detail view
CI / full-gate (push) Successful in 2m38s
2026-08-03 23:21:38 +02:00
lilleman 6440c543e5 Architecture review fixes: partials carry the locale, mountable locales/, shared core words
CI / full-gate (push) Successful in 2m36s
2026-08-03 23:12:18 +02:00
lilleman 245d1ad5b5 Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s
2026-08-03 22:37:27 +02:00
lilleman c30cd95ebd Fix a stale ctx.identity reference in the file map
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 6s
2026-08-03 17:41:40 +02:00
lilleman f38b5373bd Say user throughout, noting Ory's identity naming in the docs 2026-08-03 17:41:40 +02:00
lilleman 9966b6bd46 Record the authorization vocabulary decision in AGENTS.md 2026-08-03 17:41:40 +02:00
lilleman 096720904e Rename the coarse gate from role to permission, matching RBAC 2026-08-03 17:41:40 +02:00
lilleman 04fe5b1e06 Cut the security-model threat table down to what the code cannot show 2026-08-03 17:41:40 +02:00
lilleman 3486e0ad00 Rename the Keto User namespace to Identity, matching Kratos 2026-08-03 17:41:40 +02:00
lilleman 8f9f79ac30 Document the users, groups and roles model in README 2026-08-03 17:41:40 +02:00
lilleman b580f7d06e Rename the plugin-API permission gate to role 2026-08-03 17:41:40 +02:00
lilleman f0662cbd0f Reflow the security-model prose to the file's wrap width 2026-08-03 17:41:40 +02:00
lilleman 4b4ac178ab Record the open CSRF token-binding decision as a todo item 2026-08-03 17:41:40 +02:00
lilleman 73a78d4404 Add the seeded admin login to the production secrets checklist 2026-08-03 17:41:40 +02:00
lilleman 5a5803b265 Review fixes: denylist-conditional revoke, Ory secret wiring, exp guard test 2026-08-03 17:41:40 +02:00
lilleman a2204782fa Review fixes: re-mint path, real session lifetime, full secret checklist 2026-08-03 17:41:40 +02:00
lilleman eb5aafdfaa Document the auth security model in README 2026-08-03 17:41:40 +02:00
lilleman 5c3af63847 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 7s
2026-08-03 12:10:24 +02:00
renovate-bot 21cd878447 Update renovate/renovate Docker tag to v44.7.2
CI / full-gate (push) Successful in 2m34s
Release-Bump: minor
2026-08-03 04:17:57 +00:00
renovate-bot 1abaa22a97 Update actions/checkout action to v4.4.0
CI / full-gate (push) Successful in 2m35s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-08-03 04:17:55 +00:00
lilleman 0919adf4ef Review fixes: plain depends_on merge, positive secret assertion
CI / full-gate (push) Successful in 2m46s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 16:10:35 +02:00
lilleman f5f4455b81 Browser E2E for the admin OAuth2-clients screen
CI / full-gate (push) Successful in 2m50s
2026-08-02 16:03:30 +02:00
lilleman 3f30f889c3 README: note GITHUB_ secret-name prefix is rejected too
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:48:04 +02:00
lilleman ae1479f55b Renovate: set GITHUB_COM_TOKEN for authenticated github.com lookups
CI / full-gate (push) Successful in 2m33s
2026-08-02 15:43:38 +02:00
lilleman bb6021adf7 Update lockfile for typescript 7.0.2
CI / full-gate (push) Successful in 2m51s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:29:13 +02:00
renovate-bot c4e6212189 chore(deps): update dependency typescript to v7
Release-Bump: major
2026-08-02 15:29:03 +02:00
lilleman 6f6aafad39 Import ejs as default export — the v6 ESM build exports only default
CI / full-gate (push) Successful in 2m52s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:25:51 +02:00
lilleman 4aada6eed9 Update lockfile for ejs 6.0.1 2026-08-02 15:25:51 +02:00
renovate-bot a574effb37 fix(deps): update dependency ejs to v6
Release-Bump: major
2026-08-02 15:25:51 +02:00
lilleman c259497627 Update lockfile for @types/node 24.13.3
CI / full-gate (push) Successful in 2m36s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:22:25 +02:00
renovate-bot 8e74532a77 chore(deps): update node.js to v24.18.1
Release-Bump: minor
2026-08-02 15:22:25 +02:00
lilleman 62f95afe63 Merge branch 'main' into remove-stability-auto-review
CI / full-gate (push) Successful in 5s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:19:13 +02:00
lilleman ffeec70f8f Update lockfile and regenerate icons.ejs for lucide-static 1.28.0
CI / full-gate (push) Successful in 2m44s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:15:12 +02:00
renovate-bot 62d4c8b7cd fix(deps): update dependency lucide-static to v1.28.0
Release-Bump: minor
2026-08-02 15:15:12 +02:00
lilleman 12cc2d54c2 Clarify todo.md reference and record manual review policy
CI / full-gate (push) Successful in 5s
2026-08-02 15:12:22 +02:00
lilleman bb6adc40af Remove stability reviewer auto-run from AGENTS.md
CI / full-gate (push) Successful in 6s
2026-08-02 15:06:48 +02:00
lilleman c64156a9d5 Update e2e-tests lockfile for @playwright/test 1.62.1
CI / full-gate (push) Successful in 2m42s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:06:47 +02:00
renovate-bot de2ad42f5a chore(deps): update playwright to v1.62.1
Release-Bump: minor
2026-08-02 15:06:47 +02:00
lilleman 9213e5a0de Make the CI web-image rebuild its own step; note the shared-workspace image-tag race
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:06:21 +02:00
lilleman 45054db5e6 Build the web image in ci.sh so typecheck and tests run the branch's own deps 2026-08-02 15:06:21 +02:00
lilleman 23bafd247d Fix typos in the AGENTS.md comment rules
CI / full-gate (push) Successful in 6s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:00:56 +02:00
lilleman 7c66599f35 Todo and agents updates
CI / full-gate (push) Successful in 6s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 14:11:33 +02:00
lilleman 6db0f57bf4 Trim README and workflow prose that restates the code
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 13:55:50 +02:00
lilleman 175717f04d Move the docs-only decision into ci.sh so it runs locally
CI / full-gate (push) Successful in 2m33s
2026-08-02 13:38:32 +02:00
lilleman 6c850b8923 Skip the test gate on docs-only branches
CI / full-gate (push) Successful in 2m41s
2026-08-02 13:31:07 +02:00
renovate-bot af4a70d904 chore(deps): update renovate/renovate docker tag to v44.6.0
CI / full-gate (push) Successful in 2m44s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-02 04:18:00 +00:00
renovate-bot a0244a32cd chore(deps): update renovate/renovate docker tag to v44.5.3
CI / full-gate (push) Successful in 4m4s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-01 04:18:15 +00:00
renovate-bot 6559f40142 chore(deps): update renovate/renovate docker tag to v44
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: major
2026-07-31 04:18:12 +00:00
renovate-bot d3154819f8 chore(deps): update renovate/renovate docker tag to v43.288.0
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-07-30 04:17:51 +00:00
renovate-bot 1cba6d470c chore(deps): update axllent/mailpit docker tag to v1.30.6
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: patch
2026-07-29 04:18:24 +00:00
renovate-bot 194c090bd1 chore(deps): update renovate/renovate docker tag to v43.285.3
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-07-28 04:17:59 +00:00
renovate-bot ff5094f7e9 chore(deps): update renovate/renovate docker tag to v43.281.1
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-07-27 04:18:12 +00:00
renovate-bot 419ee1750b chore(deps): update renovate/renovate docker tag to v43.280.5
CI / full-gate (push) Successful in 2m35s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: patch
2026-07-26 04:18:07 +00:00
renovate-bot cedac950be chore(deps): update renovate/renovate docker tag to v43.280.4
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: minor
2026-07-25 04:17:42 +00:00
renovate-bot ff455f1ef2 chore(deps): update axllent/mailpit docker tag to v1.30.5
CI / full-gate (push) Successful in 3m59s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: patch
2026-07-25 01:02:53 +00:00
renovate-bot 5bd26d773d chore(deps): update renovate/renovate docker tag to v43.270.0
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 2s
Release-Bump: minor
2026-07-19 04:17:22 +00:00
renovate-bot 4f60bad119 chore(deps): update axllent/mailpit docker tag to v1.30.4
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 2s
Release-Bump: patch
2026-07-18 21:13:02 +00:00
renovate-bot aea568ea1c chore(deps): update renovate/renovate docker tag to v43.252.1
CI / full-gate (push) Successful in 2m26s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-07-06 04:17:39 +00:00
lilleman 145db5b4cd CI: auto-release a version tag when Renovate merges a dependency update
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 2s
2026-07-05 20:49:49 +02:00
renovate-bot 7c39056188 chore(deps): update node.js to v24.18.0
CI / full-gate (push) Successful in 2m28s
Mirror / github-mirror (push) Successful in 3s
2026-07-05 07:02:10 +00:00
renovate-bot 9719586f51 chore(deps): update axllent/mailpit docker tag to v1.30.3
CI / full-gate (push) Successful in 2m29s
Mirror / github-mirror (push) Successful in 3s
2026-07-05 06:56:22 +00:00
lilleman 67d8a095a5 CI: Renovate for gated, self-hosted dependency updates
CI / full-gate (push) Successful in 2m30s
Mirror / github-mirror (push) Successful in 3s
2026-07-04 21:51:04 +02:00
lilleman 476ef6fce2 README-dockerhub: clone-free quick start - self-contained compose, Ory config extracted from the image
CI / full-gate (push) Successful in 2m30s
Mirror / github-mirror (push) Successful in 0s
2026-07-04 17:45:44 +02:00
lilleman 93fa751d6d Add README-dockerhub.md - the hand-maintained Docker Hub repo description
CI / full-gate (push) Successful in 2m29s
2026-07-04 17:37:02 +02:00
lilleman cc886936ed CI: sync release images to Docker Hub on vX.Y.Z tag push
CI / full-gate (push) Successful in 2m31s
Mirror / github-mirror (push) Successful in 2s
Release / retag-image (push) Successful in 14s
2026-07-04 17:08:31 +02:00
lilleman e3e582afef README: document the one-time org-package-to-repo link for the Packages tab
CI / full-gate (push) Successful in 2m29s
Mirror / github-mirror (push) Successful in 0s
2026-07-04 09:39:10 +02:00
lilleman 0644ec8f5a CI: nightly registry cleanup - prune hash images not release-tagged nor a branch head
CI / full-gate (push) Successful in 2m40s
Mirror / github-mirror (push) Successful in 0s
2026-07-04 09:18:07 +02:00
lilleman 058280934b CI: re-tag the gated image as semver + latest on vX.Y.Z tag push
CI / full-gate (push) Successful in 3m36s
Mirror / github-mirror (push) Successful in 2s
Release / retag-image (push) Successful in 2s
2026-07-04 08:35:48 +02:00
lilleman 50006dd1a7 CI: gate builds + pushes app image to Gitea registry tagged with the commit hash
CI / full-gate (push) Successful in 2m32s
Mirror / github-mirror (push) Successful in 0s
2026-07-03 16:50:37 +02:00
lilleman 6e60df7008 CI: mirror main + tags to GitHub after every merge to main; note true home in README
CI / full-gate (push) Successful in 2m28s
Mirror / github-mirror (push) Successful in 2s
2026-07-03 15:47:56 +02:00
lilleman c8981c12d3 CLAUDE.md: import AGENTS.md so Claude Code loads it every session
CI / full-gate (push) Successful in 2m28s
2026-07-03 14:55:13 +02:00
lilleman 7a3161d3ff README: document the merge gate on main (PR-only, required CI, fast-forward-only); check off todo
CI / full-gate (push) Successful in 2m33s
2026-07-03 14:50:54 +02:00
lilleman c78770a713 AGENTS.md: document the todo.md task workflow (interview scope, stability-review loop, PR flow) 2026-07-03 14:50:54 +02:00
lilleman 94654a2adc CI: Gitea Actions runs the full gate (ci.sh) on push to any branch except main; document runner setup
CI / full-gate (push) Failing after 0s
2026-07-02 14:24:19 +02:00
lilleman 8afaeccf2c AGENTS.md: sharpen simplicity/typing/comment guidance, prefer state-in-URL over POST 2026-07-02 13:05:54 +02:00
lilleman 535902e69b Split handleRequest into pipeline + internal route table; auth/OAuth2 endpoints become named handlers in src/auth/routes.ts 2026-07-02 13:05:54 +02:00
lilleman 8621cf24b6 todo: note the missing e2e for the admin plugin's OAuth2-clients (Hydra) screen 2026-07-02 09:10:55 +02:00
lilleman e8ea911b80 Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface 2026-07-02 08:01:15 +02:00
lilleman 2202bdbaa0 config/ becomes an empty drop-in mount; examples/ mirrors the mount dirs; plugins & config import the host via #plugin-api/#menu-config subpath imports 2026-07-01 23:19:48 +02:00
lilleman 4adf14f386 docs: restructure README around the plugin-author path, add examples/README, genericize the plugin walkthrough 2026-06-30 23:30:13 +02:00
lilleman fe97c3854a Move reference plugin to examples/scheduling-plugin; plugins/ ships empty as a drop-in mount point, e2e bind-mounts the example 2026-06-27 00:02:26 +02:00
lilleman d8cf257940 Move plugin-contract.md into README's Building plugins section; remove docs/, repoint all references 2026-06-26 23:12:40 +02:00
lilleman 2b88bf1c0d Add todo.md: src reorg done, plus logger redundancy + i18n follow-ups 2026-06-24 00:24:52 +02:00
lilleman de22f51c12 Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout 2026-06-24 00:23:55 +02:00
lilleman 6d316c4888 Drop .mjs: rename e2e/dev mock servers to .ts, update refs, document the convention in AGENTS.md 2026-06-23 23:54:42 +02:00
lilleman 913bd6813a Consolidate E2E into e2e-tests/ (Dockerfile + compose.{visual,auth,oauth,full,devstack}.yml, WORKDIR /e2e-tests); move ci.sh to repo root 2026-06-23 23:48:05 +02:00
lilleman bb612baa2c Reorganize README for two readers: first-run Quick start + nested ToC, overview-first ordering; document the convention in AGENTS.md 2026-06-23 23:15:24 +02:00
lilleman a9f25a7692 Remove completed todo.md + html-css-foundation mockups; strip dead §N phase refs from comments/docs (simplify visual E2E to drop the mockup-comparison oracle) 2026-06-23 22:49:28 +02:00
lilleman e22d24aa8a §10 - one menu everywhere (buildPluginChrome) + shell on every page; instructional starter dashboard; Kratos-native email docs
Collapse the three nav builders into buildPluginChrome: chrome.bestHref does longest-prefix matching so deep admin routes mark their leaf. Delete adminNav; buildConfirmModel and the admin model builders take the resolved nav. The same role-filtered sidebar now renders signed in or out, collapsing to a burger on narrow screens.

shell.ejs gains menu (default true; menu:false -> single-column .app-bare), docTitle (separate <title> from the topbar, so the body keeps the single <h1>), and hideSignIn (suppress the footer Sign-in on auth pages to avoid a login loop). auth/home/landing now render inside the shell.

Dashboard is a replaceable instructional starter (definePlugin snippet, no mock data). Email stays delegated to Kratos: documented its built-in courier.template_override_path instead of adding web-side SMTP.
2026-06-23 21:26:00 +02:00
lilleman af097a8885 Verification/recovery: guard the OTP code field against a pasted space (numeric inputmode + digits-only pattern + one-time-code autofill); Kratos doesn't trim, so a space-padded code was rejected as 'invalid or already used' 2026-06-21 23:26:23 +02:00
lilleman 166f38d5dd Merge pull request 'appurl-canonical-host' (#1) from appurl-canonical-host into main
Reviewed-on: #1
2026-06-21 22:18:46 +02:00
lilleman c8b4c3c23b Fix 500 on /login when a Kratos session exists but no app JWT (session_already_available)
Repro: register a new account, then click back from the password/verification step to /login →
500. Registration's `session` hook signs the user in at Kratos but routes to the verification UI,
not /auth/complete, so they hold a Kratos session with NO app JWT — ctx.user is null, the "already
signed in -> /dashboard" short-circuit can't fire, and initialising a login flow makes Kratos return
400 `session_already_available`. The flow-init catch only handled 403/404/410 and 5xx, so the 400
fell through to `throw` -> catch-all 500.

Recover instead: on `session_already_available` (already authenticated at Kratos), 303 to
/auth/complete to mint the JWT from the live session, preserving return_to. A genuinely unexpected
Kratos 400 still surfaces as 500. Verified live: /login (Kratos session, no JWT) now 303s to
/auth/complete, which mints plainpages_jwt and lands on /dashboard.

Also default LOG_LEVEL to debug in the dev override (compose.override.yml) for verbose local logs.

Tests-first: app.test asserts the session-race recovers to /auth/complete (return_to carried) while
an unrelated 400 stays a 500. typecheck + 361 units green.
2026-06-21 22:04:55 +02:00
lilleman 1d198acc97 Canonical host via APP_URL: stop login dumping users on /error from a host mismatch
The from-scratch dev login was broken: open the banner's http://localhost:3000, sign
in as the seeded admin, and you landed on http://127.0.0.1:3000/error "Page not found".
Root cause: the banner/APP_URL said localhost but kratos.yml hard-coded 127.0.0.1, and a
host-scoped Kratos CSRF cookie can't cross localhost<->127.0.0.1, so the cross-host login
POST lost it; Kratos redirected to its error sink, which the app had no route for (404).

Make APP_URL the single source of truth for the public host:
- Canonical-host redirect (app.ts): when APP_URL is set, an off-host GET/HEAD visitor is
  308'd to it (path+query kept) before a flow starts, so the browser, the themed forms and
  the cross-origin Kratos POST share one cookie host. After /public/ so static/health
  checks stay host-agnostic; GET/HEAD only so a 308 never replays a cross-host POST.
- Opt-in (no NODE_ENV / no magic default): unset => no redirect, so a prod deploy that
  forgets APP_URL can't bounce real users to a stale default. The dev stack sets it.
- kratos.yml browser URLs default to localhost (match APP_URL's dev value) and derive from
  ${APP_URL} via compose.override.yml; SERVE_PUBLIC_BASE_URL keeps the dev Ory port.
- Real /error page (views/error.ejs) replaces the catch-all 404 for genuine flow errors.

Tests-first: config (opt-in/validated), app (308 on mismatch, no-redirect on match, static
host-agnostic, POST untouched, /error page), updated kratos.test host pins. New devstack
regression (e2e/devstack-login.spec + compose.e2e-devstack.yml) drives the plain
docker-compose-up topology on the host network: login from localhost works and 127.0.0.1 is
canonicalised; wired into scripts/ci.sh. typecheck + 360 units + full ci.sh (visual 10 ·
auth 1 · oauth 2 · full 7 · devstack 2) green.
2026-06-21 21:52:20 +02:00
330 changed files with 19697 additions and 10277 deletions
+9 -2
View File
@@ -1,8 +1,15 @@
.git
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
node_modules
npm-debug.log
*.log
.DS_Store
html-css-foundation
e2e/artifacts
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
plugins/**/.npmrc
e2e-tests/artifacts
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
e2e-tests/Dockerfile
e2e-tests/compose.*.yml
+25
View File
@@ -0,0 +1,25 @@
name: CI
on:
push:
branches-ignore: [main]
jobs:
full-gate:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
- run: bash ci.sh
- name: Push app image tagged with the commit hash
env:
IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }}
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
run: |
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker build -t "$IMAGE" .
docker push "$IMAGE"
- name: Log out of the registry
if: always()
run: docker logout gitea.larvit.se
+21
View File
@@ -0,0 +1,21 @@
name: Mirror
on:
push:
branches: [main]
tags: ['**']
workflow_dispatch:
jobs:
github-mirror:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
fetch-tags: true # load-bearing for --prune below: no local tags would delete every remote one
# --prune so a tag deleted here doesn't live on at GitHub forever. It only removes refs a
# refspec DESTINATION matches — so tags; main is a non-glob dst, other branches match nothing.
- run: |
git push --force --prune \
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
+22
View File
@@ -0,0 +1,22 @@
name: Registry cleanup
on:
schedule:
- cron: '43 3 * * *'
workflow_dispatch:
jobs:
prune-stale-images:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- name: Delete hash images that are neither release-tagged nor a branch head
env:
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:26.13.0-alpine3.24 node registry-cleanup/cleanup.ts
+107
View File
@@ -0,0 +1,107 @@
name: Release
# The Hub overview names a released tag, so it is published here only, never on a push to main.
on:
push:
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:
retag-image:
if: github.event_name == 'push'
runs-on: docker-host
steps:
- 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:26.13.0-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest
env:
GIT_TAG: ${{ github.ref_name }}
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO: gitea.larvit.se/${{ github.repository }}
run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v}
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
# 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 push "$REPO:$TAG"
done
- name: Sync the release tags to Docker Hub
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
REPO: gitea.larvit.se/${{ github.repository }}
run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v}
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { 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
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG"
done
- name: Log out of the registries
if: always()
run: |
set -uo pipefail
# 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:26.13.0-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:26.13.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+71
View File
@@ -0,0 +1,71 @@
name: Renovate
on:
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
jobs:
renovate:
runs-on: docker-host
steps:
- name: Run Renovate against this repo
env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
docker run --rm \
-e GITHUB_COM_TOKEN \
-e LOG_LEVEL=info \
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.186.6
# 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
# 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), 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;
# 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).
auto-release:
runs-on: docker-host
needs: renovate
steps:
- uses: actions/checkout@v7.0.1
with:
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
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git fetch --force --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*'
if [ "$(git log -1 --format='%ae' origin/main)" != "renovate@larvit.se" ]; then
echo "main tip not authored by Renovate — a human owns this release; skipping"; exit 0
fi
LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
LATEST=${LATEST:-v0.0.0}
if [ -z "$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' --format='%H')" ]; then
echo "No untagged renovate commits since ${LATEST} — nothing to release"; exit 0
fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
if [ -z "$BUMPS" ]; then
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:26.13.0-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:26.13.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
+16 -2
View File
@@ -3,5 +3,19 @@
*.log
node_modules
# Playwright E2E outputs (screenshots, html report, traces)
e2e/artifacts/
# Playwright E2E outputs (screenshots, html report, traces). The dir itself is tracked: an absent
# bind-mount source is created by the daemon as root, which the unprivileged runner cannot write.
/e2e-tests/artifacts/*
!/e2e-tests/artifacts/.gitkeep
# plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference)
/plugins/*
!/plugins/.gitkeep
# config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
/config/*
!/config/.gitkeep
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
/locales/*
!/locales/.gitkeep
+449 -36
View File
@@ -3,34 +3,353 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout.
## Prose discipline
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
comments — and cut it back to what a competent reader could not infer:
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
the decision and the reason it *currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming.
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact** —
link to it instead of repeating it; the same sentence in five files is five chances to drift.
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
holding.
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
information-dense.
Trimming is not a separate task to schedule — do it in the same change, every time.
## How to work with tasks
Use the file `todo.md`.
For each todo item, interview the user extensively to deeply understand the scope and goal of
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode)
1. **Simplicity** — prefer the smallest, most readable solution.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`,
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
Prefer the Node standard library; justify any new dependency; do not add
frameworks. The app is
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
folders** under `plugins/` that fetch their data from upstream services, not as
core code. See `README.md` for the architecture.
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
reverse-engineer, and is one more thing to undo before the content under it can be reached.
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`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way.
4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
read once in `src/config.ts`. Compose files set the toggles per deployment.
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route)
has a Playwright E2E test, and a new surface ships *with* its E2E in the same change.
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
that way as it grows (never serialise on shared state); parallelism is what keeps it
fast. E2E runs in Docker against the live stack — see `README.md`.
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
limit nullable and multi-option types.
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config
toggle** read once in `src/config.ts`; compose files set them per deployment.
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
add domain features. It optimises for being powerful, predictable and overloadable, and the host
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
deliberate **non-goal**.
## Deliberate architectural deviations (don't re-flag)
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
Revisit only if the stated reason stops holding.
### Structure & contracts
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
co-located. Add a new module to the folder owning its concern. The core ships **no domain
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
either back to a relative path. Three consequences:
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
`apiVersion` bump, not a free refactor.
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
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
`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
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.
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
**Valid while nothing is installed in the wild.**
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
render as bare keys on the pages it owns.
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
### Authorization
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
admin GUI, which an operator removes by not copying it in.
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
than structural. Accepted — the alternative penalizes the sharing case.
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
declaration is not.
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
writer of grants.
- **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
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
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
`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
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
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. The group paths are
guarded too: 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 you would lose a permission you
don't hold directly.
- **`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
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.
- **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
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
Kratos' wire shape — don't rename it.
### i18n
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
`Accept-Language` takes the first regional catalog for it.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
- **`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`,
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
`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`
— 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
`en-US` per key), never one the host lacks.
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/<tag>.ts`
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
one replaces that catalog wholesale. Adding a language must never require forking the image. The
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
checked rather than trusted (one compared only against itself would boot green with the whole UI
rendering keys).
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
is expected on literally every page — if that softens, hiding it after a POST is simpler.
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
into a loud failure: a manifest with plain labels must keep working.
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
escaping into `t()` — every other value in a view would become the odd one out.
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
### UI
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
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 —
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
`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
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
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
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
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
catches anything reaching the nav).
### Build, test & release
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
files, `.dockerignore` the image).
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
outliving its test can log late and fail the next one.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
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,
so widening them means a stack per engine.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
`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
`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
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
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
## Docker only — no host tooling
@@ -40,23 +359,117 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
```bash
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 -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 -f compose.yml up --build -d # production
```
## README structure (keep it this way)
`README.md` serves two readers, in this order — preserve it when editing:
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
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.
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: Goals → Audience → Overview → Glossary → Users, groups
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
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 &
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
without the model, and it is the one home for that model.
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
The README earns its length on how to use and operate Plainpages, the external contracts, and
one-time setup. A file-map or table row gets a clause, not a paragraph.
## Rules
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
decorators. Import local modules with their `.ts` extension.
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
local modules with their `.ts` extension.
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
`package.json`s, so `.js` is ESM.
- **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.
- English everywhere. Keep code comments short and information-dense.
- Pin all dependencies and Docker images to exact, human-readable **semantic
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`).
- Run the stability reviewer agent after every implementation of something that can be like
a PR. That includes an implementation from the todo file that is pushed directly to master.
Skip this if the changes are purely documentation and/or comments.
- English everywhere.
- 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
by tag.
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
rules name exactly what carries the trailer, so a dependency outside them never escalates the
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
and let it ride the next patch.
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
digit may lag on purpose: `checkApiVersion`
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
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
breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone":
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
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
broken tree. The trigger is the first plugin author outside this repo — the first who cannot
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
(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
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.
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
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
into a small `withX` wrapper — see `examples/plugins/admin/`.
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
`grandfathered` list or raise a ceiling there.
- 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
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`.
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", "log
out" or "sign up", inflections included — a second spelling for one button reads as a second thing;
the noun ("a sign-in error") is unaffected. A plugin's catalog and every other locale follow the
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
call, and a build-failing check takes that judgment away.
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and
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
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
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
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
View File
@@ -0,0 +1 @@
@AGENTS.md
+12 -6
View File
@@ -1,14 +1,20 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.16.0-alpine3.24
FROM node:26.13.0-alpine3.24
# 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).
# Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc /deps/
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
WORKDIR /app
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . .
# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
ENV npm_config_cache=/tmp/.npm
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.ts"]
-12
View File
@@ -1,12 +0,0 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e/.
# Built/run via compose.e2e.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.49.1-noble
WORKDIR /e2e
COPY e2e/package.json e2e/package-lock.json ./
RUN npm ci
COPY e2e/ ./
CMD ["npx", "playwright", "test"]
+1761 -678
View File
File diff suppressed because it is too large Load Diff
Executable
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# The full CI gate: typecheck → unit tests → every E2E suite, each against a FRESH stack
# that is always torn down. One reproducible command — run it locally or wire it into your CI
# service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host).
#
# bash ci.sh
#
# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto).
set -euo pipefail
cd "$(dirname "$0")"
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break.
# The working tree counts too — a dirty tree carrying real code must never skip. Anything
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
# --no-renames on both channels: rename detection names only the destination, so `git mv src/app.ts
# notes.md` reads as a lone *.md — under --porcelain as one `R src/app.ts -> notes.md` line still
# ending in .md after cut -c4- — and the gate would skip over a source file that is gone.
docs_only() {
local base changed
git rev-parse --git-dir >/dev/null 2>&1 || return 1
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
changed=$(
{ git diff --name-only --no-renames "$base" HEAD \
&& git status --porcelain --no-renames --untracked-files=all | cut -c4-; } 2>/dev/null
) || return 1
[ -n "$changed" ] || return 1
! printf '%s\n' "$changed" | grep -qvE '\.md$'
}
if docs_only; then
step "Only *.md changed since main — nothing to test, skipping the gate"
exit 0
fi
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
img=$(grep -oE 'playwright:v[0-9.]+' e2e-tests/Dockerfile | grep -oE '[0-9.]+$' || true)
pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE '[0-9.]+' || true)
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
echo "ok ($img)"
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
step "Build web image"
docker compose build web
step "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"
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
# Sanity floor: catch a glob that matches too few files (a full empty glob already exits non-zero above).
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; }
# 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
# 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
# whoever ran the gate — root-owned output needs sudo to delete, and a dev box may have none.
e2e() {
step "E2E: $1"
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
local rc=0
docker compose -p "$proj" -f compose.yml -f "$1" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
}
e2e e2e-tests/compose.visual.yml # visual / design-system parity (Ory-free)
e2e e2e-tests/compose.auth.yml # token timeout + silent re-mint
e2e e2e-tests/compose.oauth.yml # OAuth2 login + consent
e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO), menu, CRUD, plugin, logout
# Dev-stack login regression — runs against the PLAIN `docker compose up` topology (base + override)
# with the runner on the HOST network, so it can't use the shared e2e() helper (which merges only
# compose.yml + the suite). Needs host networking + the host ports 3000/4433 free (Linux CI).
step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml)
rc=0
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; }
step "ALL GREEN"
-101
View File
@@ -1,101 +0,0 @@
# Full browser E2E (todo §8) — the real Playwright UI flow against the live stack: password +
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
# same-origin gateway (proxy, e2e/proxy.mjs) 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.
# docker compose -f compose.yml -f compose.e2e-full.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.e2e-full.yml down -v # tear down after
services:
web:
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
depends_on: !override
bootstrap:
condition: service_completed_successfully
kratos:
condition: service_healthy
keto:
condition: service_healthy
shifts-upstream:
condition: service_healthy
environment:
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 30
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
# non-loopback host like `proxy` — dev mode drops that so the session/CSRF cookies are stored.
kratos:
command: serve --dev -c /etc/config/kratos/kratos.yml -c /etc/config/kratos/e2e-proxy.yml --watch-courier
environment:
SELFSERVICE_METHODS_OIDC_ENABLED: "true"
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.16.0-alpine3.24
command: ["node", "/server.mjs"]
volumes:
- ./examples/shifts-upstream/server.mjs:/server.mjs:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/shifts"]
interval: 2s
timeout: 4s
retries: 15
# 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.
mock-oidc:
image: node:24.16.0-alpine3.24
command: ["node", "/mock-oidc.mjs"]
environment:
ISSUER: http://mock-oidc:9000
SSO_EMAIL: sso-user@plainpages.local
volumes:
- ./e2e/mock-oidc.mjs:/mock-oidc.mjs:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9000/.well-known/openid-configuration"]
interval: 2s
timeout: 4s
retries: 15
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e/proxy.mjs).
proxy:
image: node:24.16.0-alpine3.24
command: ["node", "/proxy.mjs"]
depends_on:
web:
condition: service_healthy
environment:
KRATOS_URL: http://kratos:4433
WEB_URL: http://web:3000
volumes:
- ./e2e/proxy.mjs:/proxy.mjs:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 30
e2e:
build:
context: .
dockerfile: Dockerfile.e2e
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
depends_on:
mock-oidc:
condition: service_healthy
proxy:
condition: service_healthy
environment:
BASE_URL: http://proxy
KRATOS_ADMIN_URL: http://kratos:4434
volumes:
- ./e2e/artifacts:/e2e/artifacts
-41
View File
@@ -1,41 +0,0 @@
# Playwright E2E. Brings up the app + a Playwright runner, screenshots the live pages and the
# html-css-foundation mockups, and asserts the live DOM computes the same design styles.
# docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e/) so spec edits are picked up.
# Screenshots + HTML report land in ./e2e/artifacts/ (git-ignored).
services:
web:
# The dashboard renders mock data — no Ory needed. Drop the base file's kratos/keto
# dependency so the visual suite stays fast and doesn't boot Postgres + the Ory stack.
depends_on: !reset []
# Dev throwaways are fine for tests; cache templates for production-like rendering.
environment:
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 15
e2e:
build:
context: .
dockerfile: Dockerfile.e2e
# Just the Ory-free visual suite; the full-stack auth spec runs via compose.e2e-auth.yml.
command: ["npx", "playwright", "test", "visual.spec.ts"]
depends_on:
web:
condition: service_healthy
environment:
BASE_URL: http://web:3000
volumes:
# The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves.
- ./html-css-foundation:/repo/html-css-foundation:ro
- ./public:/repo/public:ro
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated
# dashboard (§10) renders; web verifies it with the same key (the file it mounts read-only).
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
- ./e2e/artifacts:/e2e/artifacts
+59 -10
View File
@@ -1,25 +1,56 @@
# Development overrides, merged automatically by `docker compose up`.
# 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:
web:
command: node --watch src/server.ts
# Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed.
environment:
# Canonical public URL — the ONE knob. The web app redirects off-host visitors here, so
# localhost / 127.0.0.1 / any alias all funnel to one cookie host (Kratos' browser URLs below
# derive from it too). Override for a non-default host and the stack follows; see the note on
# kratos.SERVE_PUBLIC_BASE_URL for the single dev caveat (the published Ory port).
APP_URL: ${APP_URL:-http://localhost:3000}
CACHE_TEMPLATES: "false"
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)
# 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"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend
PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes:
- .:/app
- /app/node_modules
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
# Dev mock backend for the reference plugin (plugins/scheduling). A stand-in for the customer's
# real scheduling service — stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at
# the real backend instead. Uses the pinned app image so there's nothing extra to build/pull.
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only
# dev needs saying: the base file gives both services the image's baked copy, and it is the
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror,
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to
# bootstrap.ts appears to do nothing until someone remembers `--build`.
# 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.
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:
- .:/app
# 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
# 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 PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream:
image: node:24.16.0-alpine3.24
command: node /srv/server.mjs
image: node:26.13.0-alpine3.24
command: node /srv/server.ts
restart: unless-stopped
volumes:
- ./examples/shifts-upstream:/srv:ro
@@ -27,17 +58,35 @@ services:
# 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.
mailpit:
image: axllent/mailpit:v1.30.1
image: axllent/mailpit:v1.33.0
ports:
- "8025:8025"
restart: unless-stopped
# Ory Kratos dev: expose the public API so the browser can POST self-service flows to
# flow.ui.action (kratos.yml base_url = 127.0.0.1:4433). Prod fronts Ory same-origin,
# so the base file publishes no Ory ports.
# flow.ui.action. Prod fronts Ory same-origin, so the base file publishes no Ory ports.
kratos:
ports:
- "4433:4433"
# Every browser-facing Kratos URL derives from APP_URL — one knob, no second place to edit (the
# localhost-vs-127.0.0.1 disagreement that broke login was exactly this drift). Env overrides the
# kratos.yml defaults (Ory: env wins over config files).
environment:
# The public API the login form POSTs to. Its HOST must match APP_URL's (cookies are host-scoped,
# port-agnostic) but its PORT is the published Ory one (4433), so it can't be APP_URL verbatim.
# This is the ONE dev value to also change for a non-localhost APP_URL host (e.g. a LAN IP).
SERVE_PUBLIC_BASE_URL: ${KRATOS_PUBLIC_BROWSER_URL:-http://localhost:4433/}
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/
SELFSERVICE_ALLOWED_RETURN_URLS: ${APP_URL:-http://localhost:3000}
SELFSERVICE_FLOWS_ERROR_UI_URL: ${APP_URL:-http://localhost:3000}/error
SELFSERVICE_FLOWS_LOGIN_UI_URL: ${APP_URL:-http://localhost:3000}/login
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/auth/complete
SELFSERVICE_FLOWS_REGISTRATION_UI_URL: ${APP_URL:-http://localhost:3000}/registration
SELFSERVICE_FLOWS_SETTINGS_UI_URL: ${APP_URL:-http://localhost:3000}/settings
SELFSERVICE_FLOWS_RECOVERY_UI_URL: ${APP_URL:-http://localhost:3000}/recovery
SELFSERVICE_FLOWS_VERIFICATION_UI_URL: ${APP_URL:-http://localhost:3000}/verification
SELFSERVICE_FLOWS_VERIFICATION_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/
SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/login
# Ory Hydra dev: --dev permits the http issuer/redirect URLs; expose the public port
# so OAuth2 flows reach the host. Prod (base file) drops --dev for an https issuer.
+35 -12
View File
@@ -9,13 +9,24 @@ services:
# Supply CSRF_SECRET via env; the dev-throwaway fallback boots a clean clone but
# REQUIRE_SECURE_SECRETS refuses it in prod (config.ts), so a forgotten secret fails loud.
environment:
# Canonical public URL — set it to your domain to enable the canonical-host redirect (off when
# empty/unset, so a forgotten value never bounces real users). Your reverse proxy must preserve
# the public Host (or forward it) or a Host-rewriting proxy can loop. Kratos browser URLs and
# the banner derive from the same APP_URL. The dev override sets it to localhost.
APP_URL: ${APP_URL:-}
CACHE_TEMPLATES: "true"
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
# 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"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# 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:
bootstrap:
condition: service_completed_successfully
@@ -25,17 +36,20 @@ services:
condition: service_healthy
hydra:
condition: service_healthy
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
postgres:
condition: service_healthy
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer.
volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
# init/init.sql creates one database per service. Dev defaults below; supply
# The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
# 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:
image: postgres:18.4-alpine3.23
image: postgres:18.6-alpine3.23
environment:
POSTGRES_USER: ${POSTGRES_USER:-ory}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
@@ -112,7 +126,7 @@ services:
retries: 20
restart: unless-stopped
# One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
# One-shot first-boot seed (the MVP bar); see src/auth/bootstrap.ts. Idempotent, re-runs
# cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted
# read-write (the only writer) so the absent-JWKS safety net can land the key.
bootstrap:
@@ -122,26 +136,35 @@ services:
condition: service_healthy
keto:
condition: service_healthy
postgres:
condition: service_healthy
environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
# permission names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467
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:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/bootstrap.ts
command: node src/auth/bootstrap.ts
# Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent
# error must give up, not loop forever and hang `web` (gates on completion).
restart: "on-failure:5"
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
# login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev
# login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
hydra-migrate:
image: oryd/hydra:v26.2.0
View File
+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/`
-402
View File
@@ -1,402 +0,0 @@
# The Plainpages plugin contract
The authoritative reference for the plugin API — the product's main surface. A plugin is a
self-contained folder under `plugins/` that the host discovers at boot; there is no
registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the
single source of truth — this document explains them, the guarantees around them, and the rules
the host enforces.
**Design stance.** The audience is experienced developers. The API optimises for being
**powerful, predictable, and overloadable** — a plugin can take over as much of a page as it
wants. The host **fails loud at boot/discovery** rather than sandboxing at runtime: a malformed
manifest, a version mismatch, or a conflict stops startup with a clear message. Runtime
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
time, not in production.
> **Status.** This is the contract the §2 host implements. The types and pure rules
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
> permission gate, `RouteResult` → response), and the **per-plugin view resolver**
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard)
> and [public pages & menu items](#public-pages--menu-items) (§10), both documented below.
## Anatomy of a plugin
```
plugins/scheduling/ # folder name = the plugin id → mounted at /scheduling
plugin.ts # default export: the manifest (definePlugin(...))
shifts.ts # handlers, helpers — plain modules
views/ # EJS templates for this plugin's pages
shifts.ejs
public/ # static assets, served at /public/scheduling/
scheduling.css
```
**Identity comes from the folder.** The folder name *is* the plugin `id`, and the mount path is
`/<id>` — neither is written in the manifest, so they can't drift or be claimed twice. The id
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
convention) its nav/permission tokens.
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve
first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.)
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
Nothing else references it; the operator stays in control through the central menu override
(`config/menu.ts`).
## The manifest
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
`src/*` modules — the host may refactor those freely as long as the barrel holds.
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts";
export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
nav: [{
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
}],
// Permission tokens this plugin introduces. Declared for documentation, conflict detection, and
// bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional.
permissions: [
{ token: "scheduling:read", description: "View shifts" },
{ token: "scheduling:write", description: "Create and edit shifts" },
],
// Route handlers, mounted under the plugin's path (/scheduling). `permission` gates first.
routes: [
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
{ method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift },
],
});
```
`definePlugin()` only types the object and returns it unchanged — a manifest may equally be a
plain typed object. It types the authored shape (`PluginManifest`); the host attaches the
folder-derived `id` to produce the loaded `Plugin`. All validation happens at discovery. Note
there is **no `id` or `basePath`** in the manifest — both come from the folder
([Anatomy](#anatomy-of-a-plugin)).
| Field | Required | Notes |
| --- | --- | --- |
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. 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). |
| `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/icons.ts`); node `id`s must be globally unique. |
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). |
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
## Routes & handlers
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
mount path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
runs the `permission` gate (a coarse JWT-claim check — see the README), and only then calls the
handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor
is redirected to `/login` to sign in (same as the built-in admin screens); the requested page is
preserved as `return_to`, so after signing in they land **back on the page they asked for**, not the
dashboard. A **signed-in** user who simply lacks the role gets the **403** page. A route marked
**`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
items](#public-pages--menu-items)).
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it into the HTTP
response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself.
```ts
type RouteResult =
| { view: string; data?: Record<string, unknown>; status?: number; headers?: Record<string, string> }
| { html: string; status?: number; headers?: Record<string, string> }
| { json: unknown; status?: number; headers?: Record<string, string> } // opt-in JS enhancement
| { redirect: string; status?: number }; // 303 unless status set
```
```ts
// shifts.ts
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
export async function listShifts(ctx: RequestContext) {
const q = parseListQuery(ctx.url);
const rows = await fetch(`${upstream}/shifts?${ctx.url.searchParams}`).then((r) => r.json());
return { view: "shifts", data: { rows, q } }; // renders plugins/scheduling/views/shifts.ejs
}
```
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
the core building-block partials (app shell, nav tree, data table, …) and its own
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`:
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
to render the 403 page.
- The handler **fetches its own data** from upstream and renders it; plugins hold no state
(see the README's *Stateless* section). The partials only need rows.
- `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`.
### Escaping & the trust boundary
The host does not sandbox plugin output (crash-isolation is a non-goal), so a handler **owns the
safety of the data it renders**:
- **Raw HTML is raw.** An `{ html }` result and the `*.html` partial fields (`cell.html`,
`error.html`, a menu `trigger.html`) are emitted **unescaped** — that's their purpose (slot
composition). Escape any untrusted content yourself before putting it there.
- **Text is auto-escaped; URLs are not scheme-checked.** Partials escape text fields (labels,
names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu
item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or
`data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't
control, pass it through **`safeUrl()`** from `src/plugin-api.ts` first — it returns the URL when
it's relative or `http(s):` and collapses anything else to `"#"`:
```ts
import { safeUrl } from "../../src/plugin-api.ts";
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
```
## The landing pages (`home` & `dashboard`)
The host has two replaceable landing slots, and a plugin may own either or both:
| Slot | Path | Gate | Default |
| --- | --- | --- | --- |
| `home` | `/` | **public** — anyone | An intro page with prominent sign-in / register links. |
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { landing, board } from "./pages.ts";
export default definePlugin({
apiVersion: "1.0.0",
home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home
});
```
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler
renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home`
handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
points there.
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
403. (Both slots answer `GET` and `HEAD`.)
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
boot-stopping conflict ([below](#conflict-rules)), never last-write-wins. Neither needs a `routes`
entry — the host mounts them above the `/<id>` route namespace, and `/` can't be shadowed by a plugin
route at all (route paths always carry the `/<id>` prefix).
## RequestContext
Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per
request:
```ts
interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
log: Log; // request-scoped logger, in this request's trace (§9)
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
url: URL;
user: User | null; // { id, email, roles } from the verified session JWT, or null
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
}
```
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref,
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the
global menu — your plugin's nav fragment plus the others and the admin section — already composed,
role-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
`brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the
reference `plugins/scheduling/views/overview.ejs` does; a value you forget simply falls back to its
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.)
**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log),
§9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream.
The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log)
to default an upstream client's `fetch` to — the reference plugin's `createUpstream` does exactly this,
so its calls are traced with no per-handler wiring. Lines are correlated by a `requestId` and carry
`service.name`; output/level/OTLP export are the host's config (it logs to console always, and to an
OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
from the §4 JWT middleware and are `null`/`[]` until a session exists.
## Nav & permissions
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which
applies the central override and then **filters per user** by the roles in the session JWT — a
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
are `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
### Public pages & menu items
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
both is contradictory and discovery refuses the plugin at boot.
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
while the actual shifts list stays behind `scheduling:read`.
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
include the token; those roles come from Keto at login, so an operator grants a token by writing the
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see the README's *Three
tiers of "may I?"*; it is not what a route `permission` checks.)
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so
a dropped-in plugin works out of the box without editing host config.
## Contract versioning
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
against — and the host exposes the current `HOST_API_VERSION` (e.g. `"1.0.0"`). The host bumps
**major** on a breaking manifest/handler change and **minor** on an additive one. At discovery
the host parses both with `parseSemver` (the official semver core regex — strict: no ranges,
`v` prefixes, or leading zeros) and applies provider/consumer semantics in `checkApiVersion`:
| Plugin `apiVersion` vs host | Result | Host action |
| --- | --- | --- |
| same major, same minor (patch ignored) | `ok` | load |
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
| different major | `refuse` | **abort boot** — incompatible contract |
| missing / not a valid semver | `refuse` | **abort boot** — must be declared |
The plugin pins one exact version (no ranges — in keeping with the project's pinning rules); the
*host* supplies the caret-style compatibility. `parseSemver`/`checkApiVersion` are tight,
dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
than the contract needs).
**Write a literal, never `HOST_API_VERSION`.** `apiVersion` records the version the plugin was
*built against*. Importing the host's current constant would make every plugin always equal the
host — the check could never fire, and a future breaking change would slip through silently.
## Conflict rules
Plugins are independent folders, so the host detects collisions across all discovered plugins
with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot;
`warn` logs and continues.
| Kind | Level | Rule |
| --- | --- | --- |
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
rather than aborts; everything else is an error an author fixes before the host will start.
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
## Hooks
Optional, for reacting to system actions. A plugin's `hooks` may implement:
| Hook | When | May |
| --- | --- | --- |
| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config |
| `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 |
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` wins and
short-circuits — later `onRequest` hooks and the route handler are skipped, and that result renders
against its own plugin's views. `onResponse` runs for a matched route after its handler, with the
handler's result; its return value is ignored. Hooks run with no sandbox — a throwing hook fails
loud (boot for `onBoot`, the request for the others). Keep them cheap; `onRequest` is on the hot
path (the host skips the pipeline entirely when no plugin declares a hook). This surface is
intentionally small and may grow additively within the major version.
## Local dev & test story
A plugin is a normal folder of TypeScript, so an author tests it the same way the core is tested
— everything in Docker, no host tooling. The shipped reference (`plugins/scheduling/`) is the
worked example: thin handlers bound to an injectable upstream client, unit-tested in
`shifts.test.ts` with a mocked `fetch` and a hand-built `ctx` (no host).
1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream,
return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with
`node --test`, exactly like `src/dashboard.test.ts` tests the dashboard model. No host needed.
```bash
docker compose run --rm web npm test
```
2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/<id>`
— either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external
folder (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For
an isolated harness, the §2 host exposes plugin injection (`createApp({ plugins: [myPlugin] })`)
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
of the stack.
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
+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
+18
View File
@@ -0,0 +1,18 @@
# 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.
FROM mcr.microsoft.com/playwright:v1.64.1-noble
WORKDIR /e2e-tests
COPY e2e-tests/package.json e2e-tests/package-lock.json ./
RUN npm ci
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
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
ENV HOME=/tmp
CMD ["npx", "playwright", "test"]
View File
@@ -1,15 +1,15 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./console-guard.ts";
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in", §4). Runs against the
// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the
// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos
// and web sit on different hosts here; web's own server-side cookie relay is what we exercise.
// The browser-UI login is owned by §8; this proves the timeout/refresh server behaviour end-to-end.
// The browser-UI login is owned by the full-flow E2E; this proves the timeout/refresh server behaviour end-to-end.
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
const ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
.filter((kv) => kv.split("=")[1] !== "")
.join("; ");
}
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
}
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
const session = await kratosLogin();
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(complete.status, "auth/complete redirects home").toBe(303);
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
expect(claims1.permissions, "permissions are projected from Keto").toContain("users:read");
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("users:read");
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
@@ -1,9 +1,9 @@
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in", §4). The Ory-free
# visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart:
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in"). The Ory-free
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
# docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
services:
web:
# This suite exercises only the Kratos session → JWT re-mint; it needs Kratos + Keto + bootstrap,
@@ -19,6 +19,7 @@ services:
# Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as
# expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly.
environment:
APP_URL: http://web:3000 # the runner calls web on this host → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
JWT_CLOCK_SKEW_SEC: "0"
REQUIRE_SECURE_SECRETS: "false"
@@ -29,6 +30,18 @@ services:
timeout: 4s
retries: 30
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
# something to project.
#
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
bootstrap:
environment:
ADMIN_PERMISSIONS: admin,users:read
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
# merged after the base config.
kratos:
@@ -37,7 +50,7 @@ services:
e2e:
build:
context: .
dockerfile: Dockerfile.e2e
dockerfile: e2e-tests/Dockerfile
depends_on:
web:
condition: service_healthy
@@ -47,4 +60,4 @@ services:
KRATOS_PUBLIC_URL: http://kratos:4433
command: ["npx", "playwright", "test", "auth-refresh.spec.ts"]
volumes:
- ./e2e/artifacts:/e2e/artifacts
- ./e2e-tests/artifacts:/e2e-tests/artifacts
+48
View File
@@ -0,0 +1,48 @@
# Dev-stack login regression — guards the from-scratch experience the banner advertises (login from
# http://localhost:3000 works, and entering on 127.0.0.1 is canonicalised). Unlike the proxied
# full-flow suite (which fronts web + Kratos on ONE origin and so can't see this class of bug), this
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
services:
web:
# Pin APP_URL so the regression is deterministic regardless of any APP_URL exported in the shell
# running ci.sh (overrides the dev override's ${APP_URL:-…}); the runner's BASE_URL matches it.
environment:
APP_URL: http://localhost:3000
# Base web has no healthcheck; add one so the runner waits for a ready app (deps come via base).
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 30
# Pin Kratos' browser URLs to localhost too (literal, not ${APP_URL}) so the whole suite is
# hermetic — the 127.0.0.1 sub-test asserts canonicalisation onto localhost, which only holds if
# web AND Kratos agree on localhost regardless of the ambient shell env.
kratos:
environment:
SERVE_PUBLIC_BASE_URL: http://localhost:4433/
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/
SELFSERVICE_ALLOWED_RETURN_URLS: http://localhost:3000
SELFSERVICE_FLOWS_ERROR_UI_URL: http://localhost:3000/error
SELFSERVICE_FLOWS_LOGIN_UI_URL: http://localhost:3000/login
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/auth/complete
e2e:
build:
context: .
dockerfile: e2e-tests/Dockerfile
command: ["npx", "playwright", "test", "devstack-login.spec.ts"]
# Host network: reach the host-published ports (web 3000, Kratos public 4433) at the very
# hostnames a user types — localhost / 127.0.0.1 — so the cross-host CSRF-cookie split reproduces.
network_mode: "host"
depends_on:
web:
condition: service_healthy
environment:
BASE_URL: http://localhost:3000
volumes:
- ./e2e-tests/artifacts:/e2e-tests/artifacts
+128
View File
@@ -0,0 +1,128 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# 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
# 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 down -v # tear down after
services:
web:
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
# Kratos offers, mapped through the committed claims jsonnet.
depends_on:
shifts-upstream:
condition: service_healthy
environment:
APP_URL: http://proxy # the browser reaches web through the same-origin gateway → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 30
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap:
volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
# non-loopback host like `proxy` — dev mode drops that so the session/CSRF cookies are stored.
kratos:
command: serve --dev -c /etc/config/kratos/kratos.yml -c /etc/config/kratos/e2e-proxy.yml --watch-courier
environment:
SELFSERVICE_METHODS_OIDC_ENABLED: "true"
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
# --dev permits the http issuer (the base file drops it for an https prod issuer).
hydra:
command: serve all --dev -c /etc/config/hydra/hydra.yml
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:26.13.0-alpine3.24
command: ["node", "/server.ts"]
volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/shifts"]
interval: 2s
timeout: 4s
retries: 15
# 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.
mock-oidc:
image: node:26.13.0-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
SSO_EMAIL: sso-user@plainpages.local
volumes:
- ./e2e-tests/mock-oidc.ts:/mock-oidc.ts:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9000/.well-known/openid-configuration"]
interval: 2s
timeout: 4s
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).
proxy:
image: node:26.13.0-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
condition: service_healthy
environment:
KRATOS_URL: http://kratos:4433
WEB_URL: http://web:3000
volumes:
- ./e2e-tests/proxy.ts:/proxy.ts:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 30
e2e:
build:
context: .
dockerfile: e2e-tests/Dockerfile
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
depends_on:
mailpit:
condition: service_healthy
mock-oidc:
condition: service_healthy
proxy:
condition: service_healthy
environment:
BASE_URL: http://proxy
KRATOS_ADMIN_URL: http://kratos:4434
MAILPIT_URL: http://mailpit:8025
volumes:
- ./e2e-tests/artifacts:/e2e-tests/artifacts
@@ -1,14 +1,15 @@
# Full-stack OAuth2 E2E — the §6 login-challenge handler. Another app logs in *through* us:
# Full-stack OAuth2 E2E — the login-challenge handler. Another app logs in *through* us:
# Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
# reaches the Ory services by their compose-network names.
# docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.e2e-oauth.yml down -v # tear down after
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
services:
web:
# Dev throwaways are fine for the test stack; the runner hits web over http.
environment:
APP_URL: http://web:3000 # the runner/browser reach web on this host → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false"
@@ -31,7 +32,7 @@ services:
e2e:
build:
context: .
dockerfile: Dockerfile.e2e
dockerfile: e2e-tests/Dockerfile
depends_on:
web:
condition: service_healthy
@@ -42,4 +43,4 @@ services:
KRATOS_PUBLIC_URL: http://kratos:4433
command: ["npx", "playwright", "test", "oauth-login.spec.ts"]
volumes:
- ./e2e/artifacts:/e2e/artifacts
- ./e2e-tests/artifacts:/e2e-tests/artifacts
+45
View File
@@ -0,0 +1,45 @@
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) —
# Ory-free, so it's fast.
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
services:
web:
# The dashboard renders mock data — no Ory needed. Drop the base file's kratos/keto
# dependency so the visual suite stays fast and doesn't boot Postgres + the Ory stack.
depends_on: !reset []
# Dev throwaways are fine for tests; cache templates for production-like rendering.
environment:
APP_URL: http://web:3000 # the suite reaches web on this host → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 15
# plugins/ is empty in the image; bind the reference example in as the `scheduling` plugin so the
# nav-gating spec has a drop-in plugin to assert against.
volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
e2e:
build:
context: .
dockerfile: e2e-tests/Dockerfile
# The Ory-free suites (design system + language switching); the full-stack auth spec runs via
# e2e-tests/compose.auth.yml.
command: ["npx", "playwright", "test", "visual.spec.ts", "language.spec.ts"]
depends_on:
web:
condition: service_healthy
environment:
BASE_URL: http://web:3000
volumes:
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated
# dashboard renders; web verifies it with the same key (the file it mounts read-only).
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
- ./e2e-tests/artifacts:/e2e-tests/artifacts
+58
View File
@@ -0,0 +1,58 @@
import { expect, test as base, type BrowserContext, type Page } from "@playwright/test";
// The `test` every spec imports: it fails a test whose browser logged a console error or warning,
// or threw, at any step — in whichever engine ran it. A zero-JS app has nothing to say in the
// console, so anything there is a defect (a broken sub-resource, a rejected attribute, an engine
// refusing a feature) that no assertion looks for.
//
// One module-level buffer is enough: a Playwright worker runs one test at a time. It is cleared at
// teardown, not at setup, so what a `beforeAll` provoked — full-flow's whole login runs in one —
// still lands on the first test rather than being wiped before it. The cost of the same choice: a
// page that outlives its test (a serial describe's) can log late and fail the next test instead.
const problems: string[] = [];
const allowed: RegExp[] = [];
// The one message the stack itself provokes: the runner reaches `web`/`proxy` by container name over
// plain http, and only a `localhost` origin is trustworthy without TLS — so Chromium drops the COOP
// header the app sends and says so on every page. Over https, where a deployment serves, it applies.
const EXPECTED = [/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/];
// Allow a message for the current test only, when the page under test provokes it on purpose.
export function allowConsole(...patterns: RegExp[]): void {
allowed.push(...patterns);
}
function watch(page: Page): void {
page.on("console", (msg) => {
const type = msg.type();
// The origin is part of the record: a 404 reads the same whether it was the page or its
// stylesheet, and a failure nobody can locate is half a failure.
if (type === "error" || type === "warning") problems.push(`console.${type}: ${msg.text()} @ ${msg.location().url}`);
});
page.on("pageerror", (err) => problems.push(`pageerror: ${err.message}`));
}
// Every page of the context, however it is opened — `context.newPage()` fires this event too, so
// watching the context is the whole job and a page must never be watched a second time on top.
function watchContext(context: BrowserContext): BrowserContext {
context.on("page", watch);
return context;
}
// A spec that opens its own context — a page shared across a serial describe — goes through this.
export function watchedPage(context: BrowserContext): Promise<Page> {
return watchContext(context).newPage();
}
export const test = base.extend<{ consoleGuard: void }>({
context: async ({ context }, use) => { await use(watchContext(context)); },
consoleGuard: [async ({}, use) => {
await use();
const unexpected = problems.filter((p) => ![...EXPECTED, ...allowed].some((re) => re.test(p)));
problems.length = 0;
allowed.length = 0;
expect(unexpected, "the browser logged nothing while this test ran").toEqual([]);
}, { auto: true }],
});
export { expect };
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "./console-guard.ts";
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
//
// The runner is on the host network against the plain `docker compose up` topology, so it sees
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin";
async function signIn(page: import("@playwright/test").Page): Promise<void> {
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();
}
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
test.setTimeout(90_000);
// Open the app at the URL the first-run banner prints, then follow the landing's "Sign in" action.
await page.goto("/");
await page.locator("#main-content").getByRole("link", { name: "Sign in" }).click();
await signIn(page);
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
await expect(page).not.toHaveURL(/\/error(\?|$)/);
await expect(page.locator("h1"), 'must not land on the "Page not found" 404 view').not.toHaveText("Page not found");
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
});
test("entering on the wrong host (http://127.0.0.1:3000) is canonicalised to APP_URL and login still works", async ({ page }) => {
test.setTimeout(90_000);
// The exact trigger from the bug report: a user types 127.0.0.1 instead of the advertised localhost.
// The canonical-host redirect sends them to localhost before the flow starts, so the CSRF cookie
// and the cross-origin Kratos POST share one host and login succeeds.
await page.goto("http://127.0.0.1:3000/login");
await expect(page).toHaveURL(/^http:\/\/localhost:3000\//); // 308'd onto the canonical host
await signIn(page);
await expect(page).not.toHaveURL(/\/error(\?|$)/);
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
});
+349
View File
@@ -0,0 +1,349 @@
import type { Browser, Page } from "@playwright/test";
import { allowConsole, expect, test, watchedPage } from "./console-guard.ts";
import { randomUUID } from "node:crypto";
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
// gateway (e2e-tests/compose.full.yml) — the browser-UI login the earlier full-stack suites deferred here.
// Coverage is the test titles below, plus the standalone SSO test.
//
// Runs on a fresh stack (`down -v` after, like the other full-stack suites). The serial admin
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
const ADMIN_PASSWORD = "admin";
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 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.
async function loginPassword(page: Page): Promise<void> {
await page.goto("/login");
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset
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.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
}
// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and
// the card's own links keeping the choice (they are rendered by the flow body, not by the menu).
test("the login page speaks the visitor's language, links included", async ({ browser }) => {
const page = await watchedPage(await browser.newContext());
await page.goto("/login?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible();
await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password
await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/);
await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/);
await page.context().close();
});
test.describe.serial("authenticated admin journey", () => {
let browser: Browser;
let page: Page;
test.beforeAll(async ({ browser: b }) => {
browser = b;
page = await watchedPage(await browser.newContext());
test.setTimeout(90_000);
await loginPassword(page);
});
test.afterAll(async () => { await page.context().close(); });
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
// chosen language is most easily dropped; the core building blocks carry it through.
test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
});
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
// every page — must point somewhere that answers GET rather than at the POST-only URL.
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
await page.goto("/admin/users?locale=sv-SE");
await page.getByRole("link", { name: "Ny användare" }).click();
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
await page.getByRole("button", { name: "Skapa användare" }).click();
await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}`);
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
// The picker is here too, and following it lands on a real page in the other language.
await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click();
expect(page.url()).toContain("locale=en-US");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); // not a 405
});
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds every permission the two mounted plugins declare (the bootstrap
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
});
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
const email = `e2e-${suffix}@plainpages.local`;
await page.goto("/admin/users/new");
await page.fill('input[name="email"]', email);
await page.fill('input[name="first"]', "E2E");
await page.fill('input[name="last"]', "User");
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible();
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
// scrolling table around the row cannot clip the panel.
await row.locator("button.kebab").click();
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`);
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
// 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
// enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`;
await page.goto("/admin/groups/new");
await page.fill('input[name="name"]', group);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group);
// 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.
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"]');
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked();
await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
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();
// 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 () => {
const name = `e2e-client-${suffix}`;
await page.goto("/admin/clients");
await page.getByRole("link", { name: "Register client" }).click();
await page.fill('input[name="name"]', name);
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
await page.locator('.form-card button[type="submit"]').click();
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
await expect(page.locator("h1")).toHaveText("Client registered");
const clientId = await page.locator("#cid").inputValue();
expect(clientId).toBeTruthy();
await expect(page.locator("#csecret")).toHaveValue(/.+/);
// Listed; the row header links to the plain detail, which never shows the secret again.
await page.goto("/admin/clients");
const row = page.locator("tr", { hasText: name });
await expect(row).toBeVisible();
await row.getByRole("link", { name }).click();
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
await expect(page.locator("#csecret")).toHaveCount(0);
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
await page.getByRole("link", { name: "Delete client" }).click();
await page.getByRole("button", { name: "Delete client" }).click();
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
});
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts");
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 () => {
await page.goto("/dashboard");
await page.locator("button.profile").click(); // open the profile dropdown
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/);
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login(\?|$)/);
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
});
test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL).
await page.goto("/admin/users");
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();
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
await expect(page).toHaveURL(/\/admin\/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.setTimeout(90_000);
await page.goto("/login");
await expect(page.locator(".sso-btn")).toBeVisible(); // the configured provider renders a button
await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// 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);
});
// 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");
});
+81
View File
@@ -0,0 +1,81 @@
import { readFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto";
import { expect, test, watchedPage } from "./console-guard.ts";
// Language switching in a real browser, Ory-free (the visual stack). Proves the whole path a
// visitor takes: pick a language, read the page in it, and stay in it while clicking around —
// including into a plugin, whose words come from its own catalog (plugins/scheduling/i18n/).
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt";
// Same trick as visual.spec.ts: sign a session JWT with the committed dev tokenizer key so the
// gated pages render without standing up Ory.
function devSession(permissions: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "lang-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test("the switcher changes language, and the choice survives clicking through the app", async ({ page, context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession(["scheduling:read"]) }]);
await page.goto("/dashboard");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
// The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to
// this same page in that language (zero-JS).
await page.locator('button[aria-label="Language"]').click();
await page.getByRole("link", { name: /svenska/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish
await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too
await page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/live-05-swedish.png` });
// Clicking a menu item keeps Swedish — the host carries the choice onto the links it renders,
// and the plugin's own page is translated from its own catalog. The section's own label comes
// from the plugin's catalog too, so opening it proves the nav fragment was translated.
await page.locator('summary[aria-label="Visa eller dölj Schemaläggning"]').click();
await page.getByRole("link", { name: "Pass", exact: true }).click();
await expect(page).toHaveURL(/\/scheduling\/shifts\?locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible();
await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish
// The filter bar is a GET form: submitting it replaces the whole query string, so the choice
// survives only because the form carries it as a hidden field.
await page.getByRole("button", { name: "Sök" }).click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// …and back to English the same way.
await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible();
});
test("a browser that asks for Swedish gets it without touching the URL", async ({ browser }) => {
const context = await browser.newContext({ locale: "sv" }); // a browser set to Swedish, no region
const page = await watchedPage(context);
await page.goto(`${BASE_URL}/`);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
const signIn = page.locator("#main-content").getByRole("link", { name: "Logga in" });
await expect(signIn).toBeVisible();
// Nothing was chosen in the URL, so the links stay plain — the browser asks again on the next hit.
await expect(signIn).toHaveAttribute("href", "/login");
await context.close();
});
test("an uninstalled language falls back to English rather than failing", async ({ page }) => {
const response = await page.goto("/?locale=sv-FI"); // sv-SE is installed; sv-FI is not
expect(response?.status()).toBe(200);
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
});
+6 -6
View File
@@ -1,8 +1,8 @@
// Mock OIDC provider for the SSO browser E2E (todo §8) — a stand-in for Google/etc. so the test
// Mock OIDC provider for the SSO browser E2E — a stand-in for Google/etc. so the test
// 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
// 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";
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 jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce) {
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce: string): string {
const now = Math.floor(Date.now() / 1000);
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
const payload = b64({
@@ -27,8 +27,8 @@ function idToken(nonce) {
return `${header}.${payload}.${sig}`;
}
const codes = new Map(); // 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 codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
createServer((req, res) => {
const url = new URL(req.url ?? "/", ISSUER);
@@ -1,16 +1,16 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./console-guard.ts";
// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
// exercises web's server-side challenge handling. The browser-UI login is owned by §8.
// exercises web's server-side challenge handling. The browser-UI login is owned by the full-flow E2E (full-flow.spec.ts).
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin";
function setCookieLine(res: Response, name: string): string | undefined {
+452
View File
@@ -0,0 +1,452 @@
{
"name": "plainpages-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plainpages-e2e",
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "26.6.6",
"typescript": "7.0.2"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"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,
"os": [
"darwin"
],
"engines": {
"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": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"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": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
}
}
}
+5 -3
View File
@@ -1,13 +1,15 @@
{
"name": "plainpages-e2e",
"version": "0.1.0",
"private": true,
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module",
"scripts": {
"test": "playwright test"
"test": "playwright test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@playwright/test": "1.49.1"
"@playwright/test": "1.64.1",
"@types/node": "26.6.6",
"typescript": "7.0.2"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from "@playwright/test";
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
// so the rendered design is stable across runs.
const ORY_FREE = /\/(visual|language)\.spec\.ts$/;
export default defineConfig({
testDir: ".",
outputDir: "artifacts/test-output",
fullyParallel: true,
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
colorScheme: "light",
screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 },
},
// The Ory-free suites run in all three engines: the console guard (console-guard.ts) only sees an
// engine's warnings when that engine renders the page, and the newest platform features in the app
// (popover, CSS anchor positioning, `:has()`) are exactly where engines disagree. They stay
// side-effect-free, so three parallel runs of them don't collide. The Ory-backed suites write to
// one shared backend and stay on chromium.
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", testMatch: ORY_FREE, use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", testMatch: ORY_FREE, use: { ...devices["Desktop Safari"] } },
],
});
+2 -2
View File
@@ -1,4 +1,4 @@
// Same-origin gateway for the browser E2E (todo §8). The themed login form posts straight to
// Same-origin gateway for the browser E2E. The themed login form posts straight to
// Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real
// browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
@@ -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
// 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) => {
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "lib": ["ESNext", "DOM"] },
"include": ["*.ts"]
}
+321
View File
@@ -0,0 +1,321 @@
import { createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import type { Page } from "@playwright/test";
import { allowConsole, expect, test } from "./console-guard.ts";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS
// Per engine: the three projects run this suite in parallel and would otherwise write one file.
const shot = (page: Page, name: string): Promise<Buffer> =>
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
// 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.
function devSession(permissions: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no permissions) so the gated scheduling nav stays filtered out.
test.beforeEach(async ({ context }) => {
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 }) => {
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible();
// the default /dashboard is the instructional starter, not a mock-data list.
await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible();
await shot(page, "live-01-dashboard");
await page.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
await shot(page, "live-03-dark");
});
// 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 }) => {
await page.goto("/dashboard");
const missing = await page.evaluate(() => {
const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id));
return [...document.querySelectorAll("use")]
.map((u) => (u.getAttribute("href") ?? "").replace(/^#/, ""))
.filter((id) => id && !ids.has(id));
});
expect(missing).toEqual([]);
});
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
await page.locator("#theme-dark").check({ force: true });
const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
expect(dark).not.toBe(light);
});
// The menus are <button popovertarget> + [popover], so the browser dismisses them: the visitor no
// longer has to click the trigger again to get rid of one. Driven through the language picker; the
// profile menu is the same block. Anchoring is asserted too — without `position-anchor` the panel
// silently detaches and lands in the middle of the viewport.
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const trigger = page.locator('button[aria-label="Language"]');
const panel = page.locator('button[aria-label="Language"] + .menu-pop');
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
// Anchored to the button that opened it: directly above (.up), right edges flush.
const t = (await trigger.boundingBox())!;
const p = (await panel.boundingBox())!;
expect(Math.abs(p.x + p.width - (t.x + t.width))).toBeLessThan(2);
expect(t.y - (p.y + p.height)).toBeGreaterThan(-1); // above the trigger, subpixel-tolerant
expect(t.y - (p.y + p.height)).toBeLessThan(12);
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
await page.keyboard.press("Escape");
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 }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await expect(page.locator(".hamburger")).toBeVisible();
const offCanvas = await page.locator(".sidebar").evaluate((el) => {
const r = el.getBoundingClientRect();
return r.right <= 1 || r.left >= window.innerWidth;
});
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 }) => {
await page.goto("/dashboard");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf");
expect(cookie?.value, "GET /dashboard issues a plainpages_csrf cookie").toBeTruthy();
const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value");
expect(field).toBe(cookie!.value);
// A POST carrying the cookie but no form token is rejected before any Kratos call.
const res = await page.request.post("/logout", { form: {}, maxRedirects: 0 });
expect(res.status()).toBe(403);
});
test("the public landing at / is ungated and links to sign in + register", async ({ page, context }) => {
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/");
await expect(page.locator(".landing")).toBeVisible();
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
// Scoped to the landing itself: the anonymous sidebar offers a "Sign in" link of its own.
await expect(page.locator("#main-content").getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login");
await expect(page.locator("#main-content").getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
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 }) => {
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");
expect(res?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
});
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
// Side-effect-free.
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
// probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in.
const pub = await request.get("/scheduling", { maxRedirects: 0 });
expect(pub.status()).toBe(200);
const body = await pub.text();
expect(body).toContain("Scheduling");
// Anonymous in the native shell: the gated Dashboard link is hidden (it would only dead-end at
// /login), and the shell's Sign-in link carries the current page as return_to.
expect(body).not.toContain('href="/dashboard"');
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
// assert the gate's 303 with the requested page preserved as return_to.
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
expect(res.status()).toBe(303);
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,
// but the gated Shifts leaf is filtered out.
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="/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/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);
});
-129
View File
@@ -1,129 +0,0 @@
import { type Browser, type Page, expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin
// gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here.
// Coverage is the test titles below, plus the standalone SSO test.
//
// Runs on a fresh stack (`down -v` after, like the other full-stack suites). The serial admin
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto
const ADMIN_PASSWORD = "admin";
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
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
async function loginPassword(page: Page): Promise<void> {
await page.goto("/login");
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset
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.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
}
test.describe.serial("authenticated admin journey", () => {
let browser: Browser;
let page: Page;
test.beforeAll(async ({ browser: b }) => {
browser = b;
page = await (await browser.newContext()).newPage();
test.setTimeout(90_000);
await loginPassword(page);
});
test.afterAll(async () => { await page.context().close(); });
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
});
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
const email = `e2e-${suffix}@plainpages.local`;
await page.goto("/admin/users/new");
await page.fill('input[name="email"]', email);
await page.fill('input[name="first"]', "E2E");
await page.fill('input[name="last"]', "User");
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`);
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
// 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.
const group = `e2e-grp-${suffix}`;
await page.goto("/admin/groups/new");
await page.fill('input[name="name"]', group);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group);
const role = `e2e-role-${suffix}`;
await page.goto("/admin/roles/new");
await page.fill('input[name="name"]', role);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
await expect(page.locator("main")).toContainText(role);
});
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
});
test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/dashboard");
await page.locator("summary.profile").click(); // open the profile dropdown
await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/);
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login(\?|$)/);
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
});
test("return_to: a deep link while logged out returns to that page after login (§9)", async ({ page }) => {
test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL).
await page.goto("/admin/users");
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();
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("h1")).toHaveText("Users");
});
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/login");
await expect(page.locator(".sso-btn")).toBeVisible(); // the configured provider renders a button
await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
-78
View File
@@ -1,78 +0,0 @@
{
"name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "1.49.1"
}
},
"node_modules/@playwright/test": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the
// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel
// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the
// computed-style parity vs the reference design is stable.
export default defineConfig({
testDir: ".",
outputDir: "artifacts/test-output",
fullyParallel: true,
forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
colorScheme: "light",
screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 },
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
-186
View File
@@ -1,186 +0,0 @@
import { createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
// The mockups are bind-mounted at /repo (sibling to /repo/public so their ../public/css/ resolves).
const MOCKUP = "file:///repo/html-css-foundation";
const APP_SHELL = `${MOCKUP}/App%20Shell.html`;
const AUTH = `${MOCKUP}/Auth.html`;
const SHOTS = "artifacts/screenshots";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS
const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard (§10) 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.
function devSession(roles: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated (§10): a page navigation needs a session. Plant one per test — a plain
// member (no roles) so the gated scheduling/admin nav stays filtered out, matching the mockup.
test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
});
test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.locator("table.table tbody tr").first()).toBeVisible();
await shot(page, "live-01-dashboard");
await page.goto("/dashboard?sort=-name&status=active");
await shot(page, "live-02-sorted-filtered");
await page.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
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 });
await page.goto(APP_SHELL);
await shot(page, "mockup-01-app-shell");
await page.goto(AUTH);
await shot(page, "mockup-02-auth");
});
// The live DOM reuses the foundation's classes, so the same styles.css must compute identically
// on both — proof we render the intended graphics, independent of the (different) row data.
const PROPS = ["backgroundColor", "borderRadius", "borderTopColor", "color", "fontSize", "fontWeight"] as const;
const styleOf = (page: Page, selector: string): Promise<Record<string, string>> =>
page.locator(selector).first().evaluate((el, props) => {
const cs = getComputedStyle(el as Element);
return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || (cs as unknown as Record<string, string>)[p]]));
}, PROPS as unknown as string[]);
test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => {
await page.goto("/dashboard");
const ref = await context.newPage();
await ref.goto(APP_SHELL);
for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch", ".filters", ".pager"]) {
expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector));
}
await ref.close();
});
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
await page.goto("/dashboard");
const missing = await page.evaluate(() => {
const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id));
return [...document.querySelectorAll("use")]
.map((u) => (u.getAttribute("href") ?? "").replace(/^#/, ""))
.filter((id) => id && !ids.has(id));
});
expect(missing).toEqual([]);
});
test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => {
await page.goto("/dashboard");
const total = await page.locator("tbody tr").count();
await page.getByRole("link", { name: /Name/ }).first().click();
await expect(page).toHaveURL(/sort=name/);
await expect(page.locator("thead th").filter({ hasText: "Name" })).toHaveAttribute("aria-sort", "ascending");
await page.goto("/dashboard");
await page.locator('input[name="q"]').fill("Avery");
await page.getByRole("button", { name: /Apply filters/ }).click();
await expect(page).toHaveURL(/q=Avery/);
expect(await page.locator("tbody tr").count()).toBeLessThan(total);
});
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
await page.locator("#theme-dark").check({ force: true });
const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
expect(dark).not.toBe(light);
});
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await expect(page.locator(".hamburger")).toBeVisible();
const offCanvas = await page.locator(".sidebar").evaluate((el) => {
const r = el.getBoundingClientRect();
return r.right <= 1 || r.left >= window.innerWidth;
});
expect(offCanvas).toBe(true);
});
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");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf");
expect(cookie?.value, "GET /dashboard issues a plainpages_csrf cookie").toBeTruthy();
const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value");
expect(field).toBe(cookie!.value);
// A POST carrying the cookie but no form token is rejected before any Kratos call.
const res = await page.request.post("/logout", { form: {}, maxRedirects: 0 });
expect(res.status()).toBe(403);
});
test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => {
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/");
await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell
await expect(page.locator(".sidebar")).toHaveCount(0);
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await shot(page, "live-05-public-landing");
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
const res = await page.goto("/no-such-page");
expect(res?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
});
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the §8 full
// E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login (§10)", async ({ page, request }) => {
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in.
const pub = await request.get("/scheduling", { maxRedirects: 0 });
expect(pub.status()).toBe(200);
const body = await pub.text();
expect(body).toContain("Scheduling");
// Anonymous in the native shell (§10): the gated Dashboard link is hidden (it would only dead-end at
// /login), and the shell's Sign-in link carries the current page as return_to.
expect(body).not.toContain('href="/dashboard"');
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
// assert the gate's 303 with the requested page preserved as return_to (§9).
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out.
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders
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
});
+11
View File
@@ -0,0 +1,11 @@
# examples/
Copy-in reference material. Each subfolder mirrors a **drop-in mount dir** at the repo root — copy it
across (or bind-mount your own) and restart.
| Path | Copy into | Example of |
| --- | --- | --- |
| [`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). |
| [`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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
+11 -8
View File
@@ -1,22 +1,25 @@
// Central menu override + branding (todo §2). Brand the app and reorder/rename/group/hide nav
// nodes (by their `id`) across all plugins — the override always wins, applied before the
// per-user permission filter. Every field is optional; delete one to fall back to the default.
// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
// cp examples/config/menu.ts config/menu.ts
// Absent config = built-in defaults.
//
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
// override always wins, applied before the per-user permission filter. Every field is optional.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
import { defineMenu } from "../src/menu-config.ts";
import { defineMenu } from "#menu-config";
export default defineMenu({
branding: {
name: "Plainpages", // app name shown in the sidebar
sub: "Console", // optional subtitle under the name
sub: "Console", // optional subtitle under the name — a catalog key here would be translated
// logo: "/public/logo.svg", // optional logo asset (rendered in the sidebar brand)
// theme: "auto", // default color theme: auto | light | dark
},
// Operator override (rename → group → order → hide), keyed by node id.
override: {
// rename: { people: "Staff" }, // node id → new label
// groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }],
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
// order: ["people", "reports"], // top-level order by id
// hide: ["teams"], // remove nodes (any depth)
},
+65
View File
@@ -0,0 +1,65 @@
# Admin — the system-administration plugin
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose up -d
```
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
the section appears in the menu and the screens work immediately. An older copy already in
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
English, so a unit test reads in words rather than keys.
## What it demonstrates — a *system* plugin
Most plugins fetch their data from an upstream service of their own (see the [scheduling
reference](../scheduling/README.md)). The admin screens instead administer **Plainpages' own identity
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
core building blocks in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
## Layout
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
so a GET needs `:read` and a POST `:write`.
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
- `admin-users.ts` · `admin-groups.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
screen's permission gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
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
`src/http/app-admin.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
@@ -1,6 +1,6 @@
// Built-in OAuth2 clients admin screen (§6): 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
// 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 { test } from "node:test";
import {
@@ -62,7 +62,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "OAuth2 clients");
assert.equal(all.title, "OAuth2 clients");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "app-00");
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
@@ -74,7 +74,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
const m = buildClientFormModel({ csrfToken: "tok.sig" });
assert.equal(m.shell.title, "Register client");
assert.equal(m.title, "Register client");
assert.equal(m.form.action, "/admin/clients");
assert.equal(m.form.submitLabel, "Register client");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -93,7 +93,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const client = toClientView({ client_id: "c1", client_name: "Acme", redirect_uris: ["https://a/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" });
const plain = buildClientDetailModel({ client });
assert.equal(plain.shell.title, "Acme");
assert.equal(plain.title, "Acme");
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
assert.equal(plain.created, false);
assert.equal(plain.secret, undefined);
@@ -101,5 +101,5 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" });
assert.equal(fresh.created, true);
assert.equal(fresh.secret, "s3cr3t");
assert.equal(fresh.shell.title, "Client registered");
assert.equal(fresh.title, "Client registered");
});
+323
View File
@@ -0,0 +1,323 @@
// OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
// apps log in *through* us with (Ory Hydra, the login+consent handlers). A client is an Ory Hydra
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
// register POST renders the new client's detail page (with the one-time secret) directly instead of a
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// One Hydra page is fetched and filtered/paged in memory — its list API has no search. Ample for an
// admin tool (the OAuth2 clients of a deployment number in the dozens); raise if one outgrows it.
const LIST_FETCH_SIZE = 250;
const DEFAULT_SCOPE = "openid offline_access";
export interface ClientView {
firstParty: boolean;
id: string; // client_id
name: string;
public: boolean; // public (PKCE, no secret) vs confidential
redirectUris: string[];
scopes: string[];
}
export interface ClientInput {
firstParty: boolean;
name: string;
public: boolean;
redirectUris: string[];
scope: string;
}
export function toClientView(client: OAuth2Client): ClientView {
const id = client.client_id ?? "";
return {
firstParty: (client.metadata as { first_party?: unknown } | undefined)?.first_party === true,
id,
name: client.client_name?.trim() || id || "(unnamed)",
public: client.token_endpoint_auth_method === "none",
redirectUris: client.redirect_uris ?? [],
scopes: (client.scope ?? "").split(/\s+/).filter(Boolean),
};
}
// Split a textarea value into redirect URIs (one per line / whitespace / comma), dropping empties.
export function parseRedirectUris(raw: string): string[] {
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}
// Hydra's create body. We register a standard authorization-code web/native client (+ refresh);
// the type (confidential vs public/PKCE) and auto-consent ride the auth method + metadata.
export function clientPayload(input: ClientInput): Record<string, unknown> {
return {
client_name: input.name,
grant_types: ["authorization_code", "refresh_token"],
metadata: { first_party: input.firstParty },
redirect_uris: input.redirectUris,
response_types: ["code"],
scope: input.scope,
token_endpoint_auth_method: input.public ? "none" : "client_secret_basic",
};
}
export function validateClientInput(input: ClientInput, t: Translate = ADMIN_EN): string | null {
if (!input.name) return t("admin.clients.validation.name");
if (!input.redirectUris.length) return t("admin.clients.validation.redirectUris");
for (const uri of input.redirectUris) {
try {
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
} catch {
return t("admin.clients.validation.redirectUri", { uri });
}
}
return null;
}
// ---- list view model ----
interface ListState {
page: number;
pageSize: number;
q: string;
}
function detailHref(id: string): string {
return `${ADMIN_CLIENTS_BASE}/${encodeURIComponent(id)}`;
}
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_CLIENTS_BASE}?${qs}` : ADMIN_CLIENTS_BASE;
}
export function buildClientsListModel(opts: {
canWrite?: boolean;
clients: OAuth2Client[];
csrfToken?: string;
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const needle = query.q.toLowerCase();
const all = opts.clients.map(toClientView);
const list = all.filter((c) => !needle || c.name.toLowerCase().includes(needle) || c.id.toLowerCase().includes(needle));
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, t),
title: t("admin.clients.title"),
};
}
function listTable(rows: ClientView[], t: Translate) {
return {
caption: t("admin.clients.title"),
columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }],
rows: rows.map((c) => ({
cells: [
{ rowHeader: { href: detailHref(c.id), text: c.name } },
{ className: "cell-muted", text: c.id },
{ badge: { label: c.public ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } },
],
name: c.name,
})),
};
}
function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
clearHref: ADMIN_CLIENTS_BASE,
label: t("admin.clients.filter"),
pills,
rows: [[
{ label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
return {
label: t("admin.clients.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- register form + detail view models ----
export function buildClientFormModel(opts: {
csrfToken?: string;
error?: string;
t?: Translate;
values?: Partial<ClientInput>;
}) {
const t = opts.t ?? ADMIN_EN;
const v = opts.values;
const nameField: FieldConfig = {
autocomplete: "off", icon: "i-box", id: "name", label: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "",
};
const scopeField: FieldConfig = {
hint: t("admin.clients.field.scopesHint"), id: "scope", label: t("admin.clients.field.scopes"), name: "scope",
value: v?.scope ?? DEFAULT_SCOPE,
};
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: t("admin.clients.register") }],
error: opts.error,
form: {
action: ADMIN_CLIENTS_BASE,
cancelHref: ADMIN_CLIENTS_BASE,
csrfToken: opts.csrfToken ?? "",
firstParty: v?.firstParty ?? false,
nameField,
public: v?.public ?? false,
redirectUris: (v?.redirectUris ?? []).join("\n"),
scopeField,
submitLabel: t("admin.clients.registerClient"),
},
title: t("admin.clients.registerTitle"),
};
}
export function buildClientDetailModel(opts: {
canWrite?: boolean;
client: ClientView;
created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string;
secret?: string; // one-time client_secret (confidential clients), shown once right after create
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const base = detailHref(opts.client.id);
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }],
canWrite: opts.canWrite !== false,
client: opts.client,
created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
secret: opts.secret,
title: opts.created ? t("admin.clients.created") : opts.client.name,
};
}
// ---- request handler (imperative shell) ----
function readClientInput(form: URLSearchParams): ClientInput {
return {
firstParty: form.get("firstParty") === "on",
name: (form.get("name") ?? "").trim(),
public: form.get("public") === "on",
redirectUris: parseRedirectUris(form.get("redirectUris") ?? ""),
scope: (form.get("scope") ?? "").trim(),
};
}
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "oauth2-clients", action);
const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
return inner({ ctx, hydra, user });
};
}
// Same, plus the target client from ctx.params.id (unknown → themed 404).
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withClients(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const client = await deps.hydra.getClient(id);
if (!client) return notFound(deps.ctx);
return inner(deps, client, id);
}, action);
}
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" });
const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write"));
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
// GET /admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
});
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
const input = readClientInput((await guardedForm(ctx))!);
const error = validateClientInput(input, ctx.t);
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
let created: OAuth2Client;
try {
created = await hydra.createClient(clientPayload(input));
} catch (err) {
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: ctx.t("admin.clients.error.rejected"), values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
});
// GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
// GET /admin/clients/:id/delete — the deliberate confirm step.
export const clientsDeleteConfirm = withClient((deps, client, id) => {
const base = detailHref(id);
const name = toClientView(client).name;
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
}) }, view: "confirm" });
}, "write");
// POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
await guardedForm(ctx); // CSRF-verify the POST
await hydra.deleteClient(id);
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
return { redirect: ADMIN_CLIENTS_BASE };
});
+129
View File
@@ -0,0 +1,129 @@
// 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 src/http/app-admin.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api";
import type { KetoClient } from "@plainpages/plugin-api";
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
{ description: "Edit users", name: "users:write" },
{ name: "groups:read" },
];
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
namespace: "Permission", object: "users:read", relation: "granted",
subject_set: { namespace: "Group", object: "eng", relation: "members" },
});
});
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
});
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
// A crafted POST can't grant a name no plugin gates on…
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
});
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/admin/users/u1/permissions");
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users");
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
assert.equal(picker.empty, undefined);
assert.equal(picker.readOnly, false);
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
});
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
// disabled, and never posted.
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
["users:read", true, true], // effective but not direct → shown as held, not editable here
["users:write", true, false], // direct → editable
["groups:read", false, false],
]);
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
});
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
assert.equal(picker.readOnly, true);
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
assert.equal(picker.inheritedNote, undefined);
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
});
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
// change revokes their live tokens, so there is nothing to warn about.
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
});
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
assert.deepEqual(picker.choices, []);
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
});
+157
View File
@@ -0,0 +1,157 @@
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
// resolves a group's grant transitively at login.
//
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// 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.
import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
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 const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
}
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
}
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
// the catalog is small and this is an admin screen (login does the same walk).
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[]): Promise<string[]> {
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
}
export interface PermissionChoice {
checked: boolean; // held directly — the only state this form can change
description: string;
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
// (it reaches the JWT), and it is removed by editing the group, not this subject.
inherited: boolean;
name: string;
}
export interface PermissionPicker {
action: string;
baseline: string; // baselineToken of the direct grants this form was rendered from
choices: PermissionChoice[];
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
field: string;
hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
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
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined;
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
submit: string;
}
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
export function buildPermissionPicker(opts: {
action: string;
declared: readonly PermissionDecl[];
direct: string[];
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
readOnly?: boolean;
t?: Translate;
transitive?: boolean; // a group: its members inherit, so the change lands at their next re-mint
}): PermissionPicker {
const t = opts.t ?? ((k: string) => k);
const directSet = new Set(opts.direct);
const effectiveSet = new Set(opts.effective ?? opts.direct);
const choices = opts.declared.map((decl) => ({
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
description: decl.description ?? "",
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
name: decl.name,
}));
return {
action: opts.action,
baseline: baselineToken(opts.declared, opts.direct),
choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
// A reader sees every row disabled, so "tick to grant" is false and "greyed-out means group-held"
// is worse than false — it would misattribute a *direct* grant to a group that doesn't hold it.
hint: t(opts.readOnly === true ? "admin.grants.hintReadOnly" : "admin.grants.hint"),
inheritedNote: opts.readOnly !== true && choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
legend: t("admin.grants.legend"),
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
readOnly: opts.readOnly === true,
submit: t("admin.grants.save"),
};
}
// 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
// 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[]): GrantChange {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
return {
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
};
}
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
// 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,7 +1,7 @@
// Built-in Groups admin screen (§5): 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
// 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 { test } from "node:test";
import {
@@ -14,7 +14,7 @@ import {
memberView,
parseSubject,
} from "./admin-groups.ts";
import type { RelationTuple } from "./keto-client.ts";
import type { RelationTuple } from "@plainpages/plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple =>
@@ -59,7 +59,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "Groups");
assert.equal(all.title, "Groups");
// The group name is the row header, linking to its detail page.
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "team-00");
@@ -78,7 +78,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.shell.title, "New group");
assert.equal(m.title, "New group");
assert.equal(m.form.action, "/admin/groups");
assert.equal(m.form.submitLabel, "Create group");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -103,7 +103,7 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
{ label: "ops (group)", value: "group:ops" },
];
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
assert.equal(m.shell.title, "eng");
assert.equal(m.title, "eng");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
assert.equal(m.add.action, "/admin/groups/eng/members");
+476
View File
@@ -0,0 +1,476 @@
// Groups admin screen: list / create / delete Keto groups and manage membership.
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// 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 { 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, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group";
const MEMBERS = "members";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// One Keto page of candidate users is fetched for the member pickers (mirrors admin-users).
const LIST_FETCH_SIZE = 250;
const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id
export interface GroupView {
memberCount: number;
name: string;
}
// A member's view model: a user (label = email) or a nested group (label = group name). `subject`
// is the form value that round-trips it — `user:<id>` or `group:<name>` (see parseSubject).
export interface MemberView {
kind: "group" | "user";
label: string;
subject: string;
}
// One option in a member <select>.
export interface MemberOption {
label: string;
value: string; // `user:<id>` | `group:<name>`
}
export function isValidGroupName(name: string): boolean {
return name.length <= 64 && GROUP_NAME.test(name);
}
// Map a member-picker value → the Keto subject (subject_id for a user, subject_set for a nested
// group). Returns null for anything unrecognised (a crafted/empty POST).
export function parseSubject(value: string): { subject_id: string } | { subject_set: SubjectSet } | null {
const sep = value.indexOf(":");
if (sep <= 0) return null;
const rest = value.slice(sep + 1);
if (!rest) return null;
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
return null;
}
// The full membership tuple for adding/removing `value` to/from `group` (null if value is invalid).
export function memberTuple(group: string, value: string): RelationTuple | null {
const subject = parseSubject(value);
return subject ? { namespace: GROUP_NS, object: group, relation: MEMBERS, ...subject } : null;
}
// Collapse the namespace's membership tuples → distinct groups + member counts, sorted by name.
export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
const counts = new Map<string, number>();
for (const t of tuples) counts.set(t.object, (counts.get(t.object) ?? 0) + 1);
return [...counts].map(([name, memberCount]) => ({ memberCount, name })).sort((a, b) => a.name.localeCompare(b.name));
}
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
const subjectId = tuple.subject_id ?? "";
const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
}
// ---- list view model ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
}
const SORT: Record<string, (g: GroupView) => number | string> = {
members: (g) => g.memberCount,
name: (g) => g.name,
};
const COLUMNS = [
{ key: "name", label: "admin.groups.column.name" },
{ key: "members", label: "admin.groups.column.members" },
];
function detailHref(name: string): string {
return `${ADMIN_GROUPS_BASE}/${encodeURIComponent(name)}`;
}
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_GROUPS_BASE}?${qs}` : ADMIN_GROUPS_BASE;
}
export function buildGroupsListModel(opts: {
canWrite?: boolean;
csrfToken?: string;
groups: GroupView[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = opts.groups.filter((g) => !needle || g.name.toLowerCase().includes(needle));
if (sort) {
const get = SORT[sort.field]!;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => {
const av = get(a), bv = get(b);
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
return cmp * dir;
});
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.groups.title"),
};
}
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
return {
caption: t("admin.groups.title"),
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}),
rows: rows.map((g) => ({
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
name: g.name,
})),
};
}
function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
clearHref: ADMIN_GROUPS_BASE,
label: t("admin.groups.filter"),
pills,
rows: [[
{ label: t("admin.groups.searchLabel"), name: "q", placeholder: t("admin.groups.searchPlaceholder"), type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.groups.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ----
export function buildGroupFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
t?: Translate;
values?: { member?: string; name?: string };
}) {
const t = opts.t ?? ADMIN_EN;
const nameField: FieldConfig = {
autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers",
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }],
error: opts.error,
form: {
action: ADMIN_GROUPS_BASE,
cancelHref: ADMIN_GROUPS_BASE,
csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: t("admin.groups.create"),
},
title: t("admin.groups.new"),
};
}
export function buildGroupDetailModel(opts: {
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
candidates: MemberOption[];
csrfToken?: string;
error?: string;
group: { name: string };
members: MemberView[];
permissions?: PermissionPicker;
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const name = opts.group.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const self = `group:${name}`; // a group can't be a member of itself
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
const canWrite = opts.canWrite !== false;
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
error: opts.error,
group: { name },
members: { action: `${base}/members/delete`, rows: opts.members },
permissions: opts.permissions,
title: name,
};
}
// ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
// Build the member-picker options (every user by email + every existing group) and the id→email map
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
const emailById = new Map<string, string>();
const userOptions: MemberOption[] = [];
for (const it of identities) {
const trait = it.traits?.["email"];
const email = typeof trait === "string" ? trait : it.id;
emailById.set(it.id, email);
userOptions.push({ label: email, value: `user:${it.id}` });
}
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
}
// A group exists exactly while it has ≥1 member.
async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
const page = await keto.listRelations({ namespace: GROUP_NS, object: name, relation: MEMBERS, pageSize: 1 });
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
// 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.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "groups", action);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
return inner({ ctx, keto, kratosAdmin, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withGroups((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
}, action);
}
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
};
// GET /admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
});
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
export const groupsCreate = withGroups(async (deps) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = memberTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name });
return { redirect: detailHref(name) };
});
// GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
// 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 members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions: PermissionPicker = {
...buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
}),
...(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
// 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.
export const groupsPermissions = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
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(",") });
}
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).
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
return { redirect: detailHref(name) };
});
// GET /admin/groups/:name/delete — the deliberate confirm step.
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 tt = deps.ctx.t;
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") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
}) }, view: "confirm" };
}, "write");
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
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
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE };
});
// POST /admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
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) };
});
@@ -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" };
};
+102
View File
@@ -0,0 +1,102 @@
// 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
// 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 assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
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,
verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
// ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin");
// 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 them hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined);
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", "/admin/plugin-settings"]);
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
// 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", "admin.nav.pluginSettings"]);
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));
});
// ---- permission naming ----
test("permissionName builds <resource>:<action>, and the host agrees the result is well-formed", () => {
assert.equal(permissionName("users", "read"), "users:read");
assert.equal(permissionName("oauth2-clients", "write"), "oauth2-clients:write");
assert.ok(isValidPermissionName(permissionName("oauth2-clients", "write"))); // the rule discovery enforces
});
test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
assert.equal(actionForMethod("GET"), "read");
assert.equal(actionForMethod("HEAD"), "read"); // a GET route also answers HEAD
assert.equal(actionForMethod("POST"), "write");
assert.equal(actionForMethod("DELETE"), "write"); // anything that isn't a read is a write
assert.equal(actionForMethod("get"), "read"); // method case is the caller's
});
// ---- auth gates ----
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
// Resources don't leak into each other: a users holder is not a groups holder.
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
});
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
const post = (over: { body?: string; verifyCsrf?: (s: string | null | undefined) => boolean }) => fakeCtx({ method: "POST", ...over });
const ok = await guardedForm(post({ body: "_csrf=tok&name=Bo", verifyCsrf: () => true }));
assert.equal(ok?.get("name"), "Bo");
await assert.rejects(guardedForm(post({ body: "_csrf=nope&name=Bo", verifyCsrf: () => false })), // ctx.verifyCsrf rejects
(e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(await guardedForm(fakeCtx({ method: "GET" })), undefined); // not a mutation → no gate, no body read
});
// ---- confirm-page model ----
test("buildConfirmModel wires the danger action, message, breadcrumbs and title (shell comes from ctx.chrome)", () => {
const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
message: "Delete ada@x.io?", title: "Delete user",
});
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
});
+113
View File
@@ -0,0 +1,113 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
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.
// `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
// 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" | "plugin-settings" | "users";
export type AdminAction = "read" | "write";
// `<resource>:<action>` (README → Naming a permission).
export function permissionName(resource: AdminResource, action: AdminAction): string {
return `${resource}:${action}`;
}
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
}
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
// permission. composeNav drops a header left with no visible children, so a user holding none of
// them never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = {
children: [
{ 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_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",
id: "admin",
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
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
// would 403. The route table declares the same override, so the two cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
return user;
}
// Read + CSRF-verify a mutation's form body once (double-submit via ctx.verifyCsrf); non-POST ⇒
// undefined. A POST without a valid token is refused (GuardError → 403).
export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
return form;
}
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
export function notFound(ctx: RequestContext): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.notFound.message"), title: ctx.t("admin.notFound.title") }, status: 404, view: "notice" };
}
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
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" };
}
// 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
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
message: string;
title: string;
}) {
return {
breadcrumbs: opts.breadcrumbs,
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
title: opts.title,
};
}
@@ -1,7 +1,8 @@
// Built-in Users admin screen (§5): the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts.
// 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-admin.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Identity } from "@plainpages/plugin-api";
import {
buildUserFormModel,
buildUsersListModel,
@@ -10,7 +11,6 @@ import {
toUserView,
updateIdentityPayload,
} from "./admin-users.ts";
import type { Identity } from "./kratos-admin.ts";
const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
@@ -41,7 +41,7 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "Users");
assert.equal(all.title, "Users");
// Search narrows to one and shows a pill.
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
@@ -60,11 +60,19 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
// 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.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", () => {
const m = buildUserFormModel({ csrfToken: "tok.sig" });
assert.equal(m.shell.title, "New user");
assert.equal(m.title, "New user");
assert.equal(m.form.action, "/admin/users");
assert.equal(m.form.submitLabel, "Create user");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -76,7 +84,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
const m = buildUserFormModel({ identity: identity(3) });
assert.equal(m.shell.title, "Edit user");
assert.equal(m.title, "Edit user");
assert.equal(m.form.action, `/admin/users/${id(3)}`);
assert.equal(m.form.submitLabel, "Save changes");
const email = m.form.fields.find((f) => f.name === "email")!;
@@ -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");
});
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", () => {
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
schema_id: "default",
+464
View File
@@ -0,0 +1,464 @@
// Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// 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 { 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, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// One Kratos page is fetched and filtered/sorted/paged in memory — the admin API offers no
// full-text search or sort. Ample for an admin tool; raise if a deployment outgrows it.
const LIST_FETCH_SIZE = 250;
const STATE_TONE: Record<string, string> = { active: "pos", inactive: "warn" };
export interface UserView {
email: string;
id: string;
initials: string;
name: string;
state: string; // Kratos identity state: "active" | "inactive"
}
export interface UserInput {
email: string;
first: string;
last: string;
password: string;
}
function nameParts(identity: Identity): { first: string; last: string } {
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
return {
first: typeof nm.first === "string" ? nm.first.trim() : "",
last: typeof nm.last === "string" ? nm.last.trim() : "",
};
}
export function toUserView(identity: Identity): UserView {
const email = typeof identity.traits?.email === "string" ? (identity.traits.email as string) : "";
const { first, last } = nameParts(identity);
const full = `${first} ${last}`.trim();
const name = full || email.split("@")[0] || email;
const initials = (first && last ? first[0]! + last[0]! : name.slice(0, 2) || "U").toUpperCase();
return { email, id: identity.id, initials, name, state: identity.state ?? "active" };
}
// ---- Kratos payloads ----
export function createIdentityPayload(input: UserInput): Record<string, unknown> {
const traits: Record<string, unknown> = { email: input.email };
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
const payload: Record<string, unknown> = { schema_id: SCHEMA_ID, state: "active", traits };
if (input.password) payload.credentials = { password: { config: { password: input.password } } };
return payload;
}
// A full-identity PUT must carry schema/state/traits. Keep the existing email (the form's email is
// read-only) and other traits; rewrite name from the input (cleared ⇒ drop it).
export function updateIdentityPayload(identity: Identity, input: UserInput): Record<string, unknown> {
const traits: Record<string, unknown> = { ...(identity.traits ?? {}) };
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
else delete traits.name;
return { schema_id: identity.schema_id ?? SCHEMA_ID, state: identity.state ?? "active", traits };
}
export function setStatePayload(identity: Identity, state: "active" | "inactive"): Record<string, unknown> {
return { schema_id: identity.schema_id ?? SCHEMA_ID, state, traits: { ...(identity.traits ?? {}) } };
}
// ---- view models ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
status: string;
}
const SORT: Record<string, (u: UserView) => string> = {
email: (u) => u.email,
name: (u) => u.name,
status: (u) => u.state,
};
const COLUMNS = [
{ key: "name", label: "admin.users.column.name" },
{ key: "email", label: "admin.users.column.email" },
{ key: "status", label: "admin.users.column.status" },
];
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.status && s.status !== "all") p.set("status", s.status);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_USERS_BASE}?${qs}` : ADMIN_USERS_BASE;
}
export function buildUsersListModel(opts: {
canWrite?: boolean;
csrfToken?: string;
identities: Identity[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
const all = opts.identities.map(toUserView);
let list = all.filter((u) =>
(!needle || u.name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)) &&
(status === "all" || u.state === status));
if (sort) {
const get = SORT[sort.field] as (u: UserView) => string;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * dir);
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t, opts.canWrite !== false),
title: t("admin.users.title"),
};
}
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
return {
actions: true,
caption: t("admin.users.title"),
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}),
rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }],
cells: [
{ user: { initials: u.initials, name: u.name } },
u.email,
{ badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
],
name: u.name,
})),
};
}
function listFilterBar(state: ListState, total: number, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) });
return {
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
clearHref: ADMIN_USERS_BASE,
label: t("admin.users.filter"),
pills,
rows: [[
{ label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q },
{ legend: t("admin.users.status.label"), name: "status", options: [
{ count: total, label: t("admin.users.status.all"), value: "all" },
{ label: t("admin.users.status.active"), value: "active" },
{ label: t("admin.users.status.inactive"), value: "inactive" },
], type: "segmented", value: state.status },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.users.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string;
error?: string;
identity?: Identity | null;
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
recovery?: RecoveryCode;
t?: Translate;
values?: Partial<UserInput>;
}) {
const t = opts.t ?? ADMIN_EN;
const editing = opts.identity != null;
const view = editing ? toUserView(opts.identity!) : null;
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
const email = editing ? view!.email : (opts.values?.email ?? "");
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
const fields: FieldConfig[] = [
{ autocomplete: "email", icon: "i-mail", id: "email", label: t("admin.users.field.email"), name: "email", required: !editing, type: "email", value: email,
...(editing ? { hint: t("admin.users.field.emailHint"), readonly: true } : {}) },
{ id: "first", label: t("admin.users.field.first"), name: "first", optional: true, value: np.first },
{ id: "last", label: t("admin.users.field.last"), name: "last", optional: true, value: np.last },
];
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 mode = !editing ? "new" : canWrite ? "edit" : "view";
return {
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
edit: editing ? {
deleteAction: `${idPath}/delete`,
id: view!.id,
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"),
recoveryAction: `${idPath}/recovery`,
state: view!.state,
stateAction: `${idPath}/state`,
} : undefined,
error: opts.error,
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,
recovery: opts.recovery,
title: t({ edit: "admin.users.edit", new: "admin.users.new", view: "admin.users.viewTitle" }[mode]),
};
}
// ---- request handler (imperative shell) ----
function readUserInput(form: URLSearchParams): UserInput {
return {
email: (form.get("email") ?? "").trim(),
first: (form.get("first") ?? "").trim(),
last: (form.get("last") ?? "").trim(),
password: form.get("password") ?? "",
};
}
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
// thin handler over these.
// `keto` is optional the way every other capability here is: without it the page still lists and
// edits users, it just can't show the permission picker.
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "users", action);
const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withUser(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const identity = await deps.kratosAdmin.getIdentity(id);
if (!identity) return notFound(deps.ctx);
return inner(deps, identity, id);
}, action);
}
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
// GET /admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
});
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err, ctx.t), values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
return { redirect: ADMIN_USERS_BASE };
});
// GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
// GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, 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 } : {}) });
});
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
// 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 rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const subject = userSubject(id);
const [direct, effective] = await Promise.all([
heldPermissions(deps.keto, subject),
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
]);
return {
...buildPermissionPicker({
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
declared: deps.ctx.declaredPermissions,
direct,
effective,
readOnly: !canWriteUsers(deps.ctx),
t: deps.ctx.t,
}),
...(error ? { error } : {}),
...(notice ? { notice } : {}),
};
}
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
// revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id);
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
// 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.
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(",") });
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
}
const { applied, error } = await applyGrants(keto, subject, diff);
if (applied.grant.length > 0 || applied.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: 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).
export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
throw err;
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id);
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: id });
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
}) }, view: "confirm" });
}, "write");
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
await kratosAdmin.deleteIdentity(id);
revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
return { redirect: ADMIN_USERS_BASE };
});
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
await guardedForm(ctx); // CSRF-verify the POST
const recovery = await kratosAdmin.createRecoveryCode(id);
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
});
// The picker as a spreadable fragment, so a re-render never silently drops the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
}
function createError(err: KratosError, t: Translate): string {
return err.status === 409
? t("admin.users.error.duplicate")
: t("admin.users.error.create");
}
+162
View File
@@ -0,0 +1,162 @@
// The admin plugin's own catalog — the baseline its other locales are written against. Its keys
// are looked up before the host's, so this plugin owns its words without prefixing them.
const messages = {
"admin.capability.hydra": "Hydra OAuth2 admin",
"admin.capability.keto": "Keto and Kratos identity admin",
"admin.capability.kratos": "Kratos identity admin",
"admin.clients.column.id": "Client ID",
"admin.clients.column.name": "Name",
"admin.clients.column.type": "Type",
"admin.clients.confidential": "Confidential",
"admin.clients.consent.firstParty": "First-party (auto-granted)",
"admin.clients.consent.label": "Consent",
"admin.clients.consent.screen": "Shows the consent screen",
"admin.clients.created": "Client registered",
"admin.clients.createdNotice": "Client registered.",
"admin.clients.delete": "Delete client",
"admin.clients.deleteMessage": "Delete client {{name}}? Apps using it can no longer sign in through Plainpages.",
"admin.clients.error.rejected": "Hydra rejected the client — check the redirect URIs and scopes.",
"admin.clients.field.name": "Name",
"admin.clients.field.redirectUris": "Redirect URIs",
"admin.clients.field.redirectUrisHint": "One per line — where the app is sent back after sign-in.",
"admin.clients.field.scopes": "Scopes",
"admin.clients.field.scopesHint": "Space-separated scopes the client may request.",
"admin.clients.field.typeHint":
"Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.",
"admin.clients.filter": "Filter clients",
"admin.clients.pagination": "Clients pagination",
"admin.clients.public": "Public",
"admin.clients.publicPkce": "Public (PKCE)",
"admin.clients.register": "Register",
"admin.clients.registerClient": "Register client",
"admin.clients.registerTitle": "Register client",
"admin.clients.rereg": "To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.",
"admin.clients.searchLabel": "Search clients",
"admin.clients.searchPlaceholder": "Search name or client ID…",
"admin.clients.secret": "Client secret",
"admin.clients.secretHint": "Copy these now — the secret can't be shown again. Store them where the app reads its credentials.",
"admin.clients.title": "OAuth2 clients",
"admin.clients.validation.name": "Enter a name for the client.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.",
"admin.clients.validation.redirectUris": "Add at least one redirect URI.",
"admin.common.chooseMember": "Choose a user or group…",
"admin.common.group": "Group",
"admin.common.member": "Member",
"admin.common.type": "Type",
"admin.common.user": "User",
"admin.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
"admin.grants.hintReadOnly": "Which permissions exist is set by the plugins installed on this system. You can see these, but not change them.",
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions",
"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.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.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.addMember": "Add a member",
"admin.groups.allMembers": "All users and groups are already members.",
"admin.groups.column.members": "Members",
"admin.groups.column.name": "Group",
"admin.groups.create": "Create group",
"admin.groups.delete": "Delete group",
"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.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.groups.filter": "Filter groups",
"admin.groups.firstMember": "First member",
"admin.groups.firstMemberHint": "A group exists once it has a member; add more after creating it.",
"admin.groups.members": "Members",
"admin.groups.membersOf": "Members of {{name}}",
"admin.groups.new": "New group",
"admin.groups.noMembers": "No members yet.",
"admin.groups.pagination": "Groups pagination",
"admin.groups.searchLabel": "Search groups",
"admin.groups.searchPlaceholder": "Search group name…",
"admin.groups.title": "Groups",
"admin.groups.validation.member": "Pick a member to add as the group's first member.",
"admin.groups.validation.name": "Group names use lowercase letters, digits, dashes and underscores.",
"admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups",
"admin.nav.pluginSettings": "Plugin settings",
"admin.nav.section": "Admin",
"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.title": "Not found",
"admin.unavailable.message": "{{what}} is not configured on this deployment.",
"admin.unavailable.title": "Admin unavailable",
"admin.users.actions": "Account actions",
"admin.users.column.email": "Email",
"admin.users.column.name": "Name",
"admin.users.column.status": "Status",
"admin.users.confirm": "Confirm action",
"admin.users.create": "Create user",
"admin.users.deactivate": "Deactivate",
"admin.users.delete": "Delete user",
"admin.users.deleteMessage": "Delete {{email}}? This permanently removes the account and can't be undone.",
"admin.users.edit": "Edit user",
"admin.users.error.create": "Could not create the user — check the email and try again.",
"admin.users.error.duplicate": "A user with that email already exists.",
"admin.users.error.save": "Could not save changes — check the fields and try again.",
"admin.users.error.selfDeactivate": "You can't deactivate your own account.",
"admin.users.error.selfDelete": "You can't delete your own account.",
"admin.users.field.email": "Email",
"admin.users.field.emailHint": "The sign-in identifier — can't be changed here.",
"admin.users.field.first": "First name",
"admin.users.field.last": "Last name",
"admin.users.field.password": "Password",
"admin.users.field.passwordHint": "Optional — leave blank to have the user set one via a recovery code.",
"admin.users.filter": "Filter users",
"admin.users.new": "New user",
"admin.users.pagination": "Users pagination",
"admin.users.reactivate": "Reactivate",
"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": "password-reset link",
"admin.users.recovery.generate": "Generate recovery code",
"admin.users.recovery.title": "Recovery code generated",
"admin.users.save": "Save changes",
"admin.users.searchLabel": "Search users",
"admin.users.searchPlaceholder": "Search name or email…",
"admin.users.status.active": "Active",
"admin.users.status.all": "All",
"admin.users.status.inactive": "Inactive",
"admin.users.status.label": "Status",
"admin.users.title": "Users",
"admin.users.view": "View",
"admin.users.viewTitle": "View user",
};
export type AdminMessages = typeof messages;
export default messages;
+160
View File
@@ -0,0 +1,160 @@
import type { AdminMessages } from "./en-US.ts";
const messages: AdminMessages = {
"admin.capability.hydra": "Hydra OAuth2-administration",
"admin.capability.keto": "Keto- och Kratos-identitetsadministration",
"admin.capability.kratos": "Kratos identitetsadministration",
"admin.clients.column.id": "Klient-ID",
"admin.clients.column.name": "Namn",
"admin.clients.column.type": "Typ",
"admin.clients.confidential": "Konfidentiell",
"admin.clients.consent.firstParty": "Förstapart (godkänns automatiskt)",
"admin.clients.consent.label": "Godkännande",
"admin.clients.consent.screen": "Visar godkännandesidan",
"admin.clients.created": "Klienten är registrerad",
"admin.clients.createdNotice": "Klienten är registrerad.",
"admin.clients.delete": "Radera klient",
"admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.",
"admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.",
"admin.clients.field.name": "Namn",
"admin.clients.field.redirectUris": "Omdirigerings-URI:er",
"admin.clients.field.redirectUrisHint": "En per rad — dit appen skickas tillbaka efter inloggning.",
"admin.clients.field.scopes": "Scope",
"admin.clients.field.scopesHint": "Mellanslagsseparerade scope som klienten får begära.",
"admin.clients.field.typeHint":
"Webbläsar- och mobilappar kan inte hålla en hemlighet — välj Publik. Serverappar som kan lagra en — låt stå som Konfidentiell.",
"admin.clients.filter": "Filtrera klienter",
"admin.clients.pagination": "Sidnavigering för klienter",
"admin.clients.public": "Publik",
"admin.clients.publicPkce": "Publik (PKCE)",
"admin.clients.register": "Registrera",
"admin.clients.registerClient": "Registrera klient",
"admin.clients.registerTitle": "Registrera klient",
"admin.clients.rereg":
"För att ändra en klient: ta bort den och registrera på nytt — det ger ett nytt klient-ID och en ny hemlighet. Hemligheten visas bara en gång, vid registreringen.",
"admin.clients.searchLabel": "Sök klienter",
"admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…",
"admin.clients.secret": "Klienthemlighet",
"admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.",
"admin.clients.title": "OAuth2-klienter",
"admin.clients.validation.name": "Ange ett namn för klienten.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.",
"admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.",
"admin.common.chooseMember": "Välj en användare eller grupp…",
"admin.common.group": "Grupp",
"admin.common.member": "Medlem",
"admin.common.type": "Typ",
"admin.common.user": "Användare",
"admin.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
"admin.grants.hintReadOnly": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Du kan se dem, men inte ändra dem.",
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter",
"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.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.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.addMember": "Lägg till en medlem",
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
"admin.groups.column.members": "Medlemmar",
"admin.groups.column.name": "Grupp",
"admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp",
"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.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.groups.filter": "Filtrera grupper",
"admin.groups.firstMember": "Första medlem",
"admin.groups.firstMemberHint": "En grupp finns så snart den har en medlem; lägg till fler efteråt.",
"admin.groups.members": "Medlemmar",
"admin.groups.membersOf": "Medlemmar i {{name}}",
"admin.groups.new": "Ny grupp",
"admin.groups.noMembers": "Inga medlemmar ännu.",
"admin.groups.pagination": "Sidnavigering för grupper",
"admin.groups.searchLabel": "Sök grupper",
"admin.groups.searchPlaceholder": "Sök på gruppnamn…",
"admin.groups.title": "Grupper",
"admin.groups.validation.member": "Välj en medlem som gruppens första medlem.",
"admin.groups.validation.name": "Gruppnamn använder små bokstäver, siffror, bindestreck och understreck.",
"admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper",
"admin.nav.pluginSettings": "Tilläggsinställningar",
"admin.nav.section": "Administration",
"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.title": "Hittades inte",
"admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.",
"admin.unavailable.title": "Administrationen är otillgänglig",
"admin.users.actions": "Kontoåtgärder",
"admin.users.column.email": "E-postadress",
"admin.users.column.name": "Namn",
"admin.users.column.status": "Status",
"admin.users.confirm": "Bekräfta åtgärden",
"admin.users.create": "Skapa användare",
"admin.users.deactivate": "Inaktivera",
"admin.users.delete": "Radera användare",
"admin.users.deleteMessage": "Ta bort {{email}}? Kontot tas bort permanent och det går inte att ångra.",
"admin.users.edit": "Redigera användare",
"admin.users.error.create": "Användaren kunde inte skapas — kontrollera e-postadressen och försök igen.",
"admin.users.error.duplicate": "Det finns redan en användare med den e-postadressen.",
"admin.users.error.save": "Ändringarna kunde inte sparas — kontrollera fälten och försök igen.",
"admin.users.error.selfDeactivate": "Du kan inte inaktivera ditt eget konto.",
"admin.users.error.selfDelete": "Du kan inte ta bort ditt eget konto.",
"admin.users.field.email": "E-postadress",
"admin.users.field.emailHint": "Inloggningsidentiteten — den kan inte ändras här.",
"admin.users.field.first": "Förnamn",
"admin.users.field.last": "Efternamn",
"admin.users.field.password": "Lösenord",
"admin.users.field.passwordHint": "Frivilligt — lämna tomt så får användaren sätta det själv via en återställningskod.",
"admin.users.filter": "Filtrera användare",
"admin.users.new": "Ny användare",
"admin.users.pagination": "Sidnavigering för användare",
"admin.users.reactivate": "Aktivera igen",
"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": "länk för lösenordsåterställning",
"admin.users.recovery.generate": "Skapa återställningskod",
"admin.users.recovery.title": "Återställningskod skapad",
"admin.users.save": "Spara ändringar",
"admin.users.searchLabel": "Sök användare",
"admin.users.searchPlaceholder": "Sök på namn eller e-postadress…",
"admin.users.status.active": "Aktiv",
"admin.users.status.all": "Alla",
"admin.users.status.inactive": "Inaktiv",
"admin.users.status.label": "Status",
"admin.users.title": "Användare",
"admin.users.view": "Visa",
"admin.users.viewTitle": "Visa användare",
};
export default messages;
+65
View File
@@ -0,0 +1,65 @@
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
// with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict";
import { test } from "node:test";
import { isValidPermissionName } from "@plainpages/plugin-api";
import manifest from "./plugin.ts";
const routes = manifest.routes ?? [];
const declared = (manifest.permissions ?? []).map((p) => p.name);
test("every route is gated, and gates on a permission the manifest declares", () => {
assert.ok(routes.length > 0);
for (const route of routes) {
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
}
});
test("the manifest declares no permission it never gates on", () => {
const gated = new Set(routes.map((r) => r.permission));
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
});
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
// class the route checks above close, so close it on the nav side too.
test("every nav permission is one the manifest declares", () => {
const navPermissions: string[] = [];
const walk = (nodes: typeof manifest.nav): void => {
for (const node of nodes ?? []) {
if (node.permission != null) navPermissions.push(node.permission);
walk(node.children);
}
};
walk(manifest.nav);
assert.equal(navPermissions.length, 4);
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", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
// 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(), [
"groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write",
"plugin-settings:read",
"users:read", "users:write",
]);
});
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
// …except a write-intent GET — a create form or a delete-confirm page, which exists only to start a
// write. Those gate on `:write` so a reader is refused there rather than at the submit.
const writeIntent = (path: string): boolean => path.endsWith("/new") || path.endsWith("/delete");
for (const route of routes) {
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
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 CRUD screen; plugin settings has none
});
+77
View File
@@ -0,0 +1,77 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
//
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
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 { pluginSettingsList } from "./admin-plugin-settings.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";
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
// derived through the same two helpers the in-handler guard uses, so the table below cannot drift
// from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
// permission the 403 page, and filters the nav the same way. Handlers are thin and keyed on
// ctx.params (the host extracts :id / :name), the idiomatic per-route style.
// `action` overrides the method's default for a *write-intent GET* — a create form or a
// delete-confirm page, which exists only to start a write and so refuses a reader rather than
// rendering a form whose submit would 403. The handler's own guard takes the same override.
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler, action?: AdminAction): Route =>
({ handler, method, path, permission: permissionName(resource, action ?? actionForMethod(method)) });
const users = on("users");
const groups = on("groups");
const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
permissions: [
{ description: "View users and the permissions they hold", name: "users:read" },
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ 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: [
// Users
users("GET", "/users", usersList),
users("POST", "/users", usersCreate),
users("GET", "/users/new", usersNewForm, "write"),
users("GET", "/users/:id", usersEditForm),
users("POST", "/users/:id", usersUpdate),
users("POST", "/users/:id/state", usersState),
users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
users("POST", "/users/:id/delete", usersDelete),
users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions),
// Groups
groups("GET", "/groups", groupsList),
groups("POST", "/groups", groupsCreate),
groups("GET", "/groups/new", groupsNewForm, "write"),
groups("GET", "/groups/:name", groupsDetail),
groups("POST", "/groups/:name/members", groupsAddMember),
groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
groups("POST", "/groups/:name/delete", groupsDelete),
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
groups("POST", "/groups/:name/permissions", groupsPermissions),
// OAuth2 clients
clients("GET", "/clients", clientsList),
clients("POST", "/clients", clientsCreate),
clients("GET", "/clients/new", clientsNewForm, "write"),
clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
],
});
@@ -0,0 +1,17 @@
<%#
OAuth2 client detail page: the client-detail body (info · one-time secret · delete) in the
shell. Doubles as the post-register page when `created`/`secret` are set.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-detail-body", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
OAuth2 client register page: the client-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+22
View File
@@ -0,0 +1,22 @@
<%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Groups screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+17
View File
@@ -0,0 +1,17 @@
<%#
Admin destructive-action confirmation page: the confirm body in the app shell. Model
from buildConfirmModel: { message, confirm:{action,label}, cancelHref, nav, shell }.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/confirm-body", { cancelHref: model.cancelHref, confirm: model.confirm, csrfToken: chrome.csrfToken, message: model.message });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
Group admin detail / membership page: the group-detail body in the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
Group admin create page: the group-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+22
View File
@@ -0,0 +1,22 @@
<%#
Groups admin list: the same building blocks as the Users screen, around the shell, but
backed by live Keto subject sets (admin-groups.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+17
View File
@@ -0,0 +1,17 @@
<%#
Admin notice page — a single message in the app shell, reused for not-found (404) and
capability-unavailable (503). The shell renders `title` as the page <h1>; the body is one line.
Data: chrome, title, message.
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/notice-body", { message });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
csrfToken: chrome.csrfToken,
nav: navHtml,
theme: chrome.theme,
title,
user: chrome.user,
}) %>
@@ -0,0 +1,40 @@
<%#
Admin OAuth2 client detail body, captured into the shell content slot. Config:
client { firstParty, id, name, public, redirectUris[], scopes[] }
created bool just registered → success banner
secret? string one-time client secret (confidential clients), shown once right after create
del { action } delete the client
csrfToken
%><%
const c = locals.client;
const del = locals.del;
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %>
<% } -%>
<% if (locals.secret) { -%>
<section class="form-card" aria-labelledby="secret-h">
<h2 class="card-title" id="secret-h"><%= t("admin.clients.secret") %></h2>
<p class="field-hint"><%= t("admin.clients.secretHint") %></p>
<div class="field"><label for="cid"><%= t("admin.clients.column.id") %></label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret"><%= t("admin.clients.secret") %></label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
</section>
<% } -%>
<section class="form-card" aria-labelledby="client-h">
<h2 class="card-title" id="client-h"><%= c.name %></h2>
<dl class="detail-list">
<dt><%= t("admin.clients.column.id") %></dt><dd><%= c.id %></dd>
<dt><%= t("admin.clients.column.type") %></dt><dd><%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %></dd>
<dt><%= t("admin.clients.consent.label") %></dt><dd><%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %></dd>
<dt><%= t("admin.clients.field.scopes") %></dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
</dl>
</section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
</section>
<% } -%>
</div>
@@ -1,5 +1,5 @@
<%#
Admin OAuth2 client register form body (todo §6), captured into the shell content slot. Config:
Admin OAuth2 client register form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField, scopeField (field.ejs configs),
redirectUris: string (newline-separated), public: bool, firstParty: bool }
error? string shown when a write was rejected
@@ -8,22 +8,22 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("field", form.nameField) %>
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="redirectUris">Redirect URIs</label>
<label for="redirectUris"><%= t("admin.clients.field.redirectUris") %></label>
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
<span class="field-hint"><%= t("admin.clients.field.redirectUrisHint") %></span>
</div>
<%- include("field", form.scopeField) %>
<%- include("partials/field", form.scopeField) %>
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
<span class="field-hint">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
<span class="field-hint"><%= t("admin.clients.field.typeHint") %></span>
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -0,0 +1,17 @@
<%#
Destructive-action confirm body, captured into the shell content slot. Zero-JS: the
delete is a deliberate second step (a POST form), with a cancel link back. Config:
message string
confirm { action, label } the danger POST endpoint + button label
cancelHref string
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
<p><%= locals.message %></p>
<div class="form-actions">
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
<form method="post" action="<%= localeHref(locals.confirm.action) %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
</div>
</section>
</div>
@@ -0,0 +1,49 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
%><%
const group = locals.group;
const members = locals.members;
const add = locals.add;
const del = locals.del;
const csrf = locals.csrfToken;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><% if (locals.canWrite !== false) { %><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form><% } %></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
<% } -%>
</section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%>
</section>
<% } -%>
<% if (locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
<% } -%>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section>
<% } -%>
</div>
@@ -0,0 +1,26 @@
<%#
Admin group create form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected
%><%
const form = locals.form;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member"><%= t("admin.groups.firstMember") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
</div>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>
@@ -0,0 +1,2 @@
<%# One-line notice body (not-found / unavailable). Data: message. %>
<section class="notice"><p><%= message %></p></section>
@@ -0,0 +1,54 @@
<%#
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
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.
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
%>
<section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%>
<% if (permissions.notice) { -%>
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
<% } -%>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
<p class="cell-muted"><%= permissions.hint %></p>
<% if (permissions.readOnly) { -%>
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<% } else { -%>
<%# 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="baseline" value="<%= permissions.baseline %>">
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
</div>
</form>
<% } -%>
<% if (permissions.inheritedNote) { -%>
<p class="cell-muted"><%= permissions.inheritedNote %></p>
<% } -%>
<% if (permissions.pending) { -%>
<p class="cell-muted"><%= permissions.pending %></p>
<% } -%>
<% } -%>
</section>
@@ -0,0 +1,41 @@
<%#
Admin user create/edit form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
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
%><%
const form = locals.form;
const edit = locals.edit;
const recovery = locals.recovery;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<% 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="<%= 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) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<% if (locals.canWrite !== false) { -%>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
<% } -%>
</div>
</form>
<% if (edit && locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
<% } -%>
<% if (edit && locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
</section>
<% } -%>
</div>
@@ -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,
}) %>
@@ -0,0 +1,16 @@
<%#
Users admin create/edit page: the user-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+22
View File
@@ -0,0 +1,22 @@
<%#
Users admin list: the same building blocks as the dashboard, around the shell, but
backed by live Kratos identities (admin-users.ts). Filter/sort/page all round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+66
View File
@@ -0,0 +1,66 @@
# Scheduling — the reference plugin
A worked example of the [plugin contract](../../../README.md#building-plugins). Copy this folder into
`plugins/` (it keeps the id and mount path `scheduling`) and point it at your own backend — the folder
name *is* the plugin id and mount path, so rename it only if you want a different one.
What it demonstrates:
- **A list page that fetches upstream data** — `GET /scheduling/shifts` calls the upstream REST
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
`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`,
reusing the core `field` partial.
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
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
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()`.
(README → [Languages](../../../README.md#languages-i18n).)
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
## Upstream
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.
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
### Upstream contract
Your backend must expose two routes; the plugin treats any non-2xx as a recoverable failure
(the list degrades to a "try again" alert, the create re-renders the form keeping the input).
| Route | Request | Success | Response body |
| --- | --- | --- | --- |
| `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` 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
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
`start`/`end` come from the form's `datetime-local` inputs as `YYYY-MM-DDTHH:mm` and are stored and
shown verbatim (the dev mock seeds a space-separated style, so created vs seeded rows differ only
cosmetically) — normalise to your backend's format there if it matters.
## Granting access
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
`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.
+44
View File
@@ -0,0 +1,44 @@
// This plugin's own catalog, and the baseline its other locales are written against. Keys are
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "@plainpages/plugin-api";
const messages = {
"scheduling.field.assignee": "Assignee",
"scheduling.field.end": "End",
"scheduling.field.start": "Start",
"scheduling.field.title": "Shift title",
"scheduling.filter.label": "Filter shifts",
"scheduling.filter.searchLabel": "Search shifts",
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
"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.section": "Scheduling",
"scheduling.nav.shifts": "Shifts",
"scheduling.new.title": "New shift",
"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.overview.mine": "See my shifts",
"scheduling.overview.signIn": "Sign in to view shifts",
"scheduling.overview.title": "Scheduling",
"scheduling.overview.view": "View shifts",
"scheduling.shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } as PluralMessage,
"scheduling.shifts.new": "New shift",
"scheduling.shifts.title": "Shifts",
"scheduling.table.assignee": "Assignee",
"scheduling.table.end": "End",
"scheduling.table.shift": "Shift",
"scheduling.table.start": "Start",
"scheduling.upstream.create": "Couldn't save the shift — the scheduling service is unavailable.",
"scheduling.upstream.list": "Couldn't reach the scheduling service — try again shortly.",
"scheduling.validation.assignee": "Assign the shift to someone.",
"scheduling.validation.title": "A shift needs a title.",
};
export type SchedulingMessages = typeof messages;
export default messages;
+38
View File
@@ -0,0 +1,38 @@
import type { SchedulingMessages } from "./en-US.ts";
const messages: SchedulingMessages = {
"scheduling.field.assignee": "Tilldelad",
"scheduling.field.end": "Slut",
"scheduling.field.start": "Start",
"scheduling.field.title": "Passets namn",
"scheduling.filter.label": "Filtrera pass",
"scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"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.section": "Schemaläggning",
"scheduling.nav.shifts": "Pass",
"scheduling.new.title": "Nytt pass",
"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>.",
"scheduling.overview.mine": "Visa mina pass",
"scheduling.overview.signIn": "Logga in för att se passen",
"scheduling.overview.title": "Schemaläggning",
"scheduling.overview.view": "Visa pass",
"scheduling.shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
"scheduling.shifts.new": "Nytt pass",
"scheduling.shifts.title": "Pass",
"scheduling.table.assignee": "Tilldelad",
"scheduling.table.end": "Slut",
"scheduling.table.shift": "Pass",
"scheduling.table.start": "Start",
"scheduling.upstream.create": "Passet kunde inte sparas — schemaläggningstjänsten är otillgänglig.",
"scheduling.upstream.list": "Vi når inte schemaläggningstjänsten — försök igen om en stund.",
"scheduling.validation.assignee": "Passet måste tilldelas någon.",
"scheduling.validation.title": "Passet behöver ett namn.",
};
export default messages;
+61
View File
@@ -0,0 +1,61 @@
// Reference plugin: a worked example of the contract — a list page that fetches upstream
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "@plainpages/plugin-api";
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
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
// over — which is after this manifest is built, hence the getter.
let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({
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 — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
// 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"
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
nav: [{
children: [
{ 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 },
],
icon: "i-cal",
id: "scheduling",
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>`.
permissions: [
{ description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE },
],
routes: [
{ 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: newShiftForm(), method: "GET", path: "/shifts/new", 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 {
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);
}
+215
View File
@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import test from "node:test";
// 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.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
import {
buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts";
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" } };
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 req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
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,
verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ 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 asView = (r: RouteResult | void) => {
assert.ok(r && "view" in r, "expected a view result");
return r as { data: Record<string, unknown>; status?: number; view: string };
};
// ---- the upstream URL as a declared setting ----
test("the manifest declares its upstream as a URL setting the host validates", async () => {
const manifest = (await import("./plugin.ts")).default;
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
});
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
let baseUrl = "http://first:4000";
const seen: string[] = [];
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
const upstream = createUpstream(() => baseUrl, http);
await upstream.list();
baseUrl = "http://second:4000";
await upstream.list();
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
});
// ---- upstream client (fetch injected) ----
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {
let seen = "";
const http = (async (url, init) => {
seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
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;
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts");
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 () => {
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);
});
test("createUpstream.create POSTs the input as JSON", async () => {
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 input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream(() => "http://up:4000", http).create(input);
assert.equal(method, "POST");
assert.deepEqual(body, input);
});
// ---- input + validation ----
test("readInput trims; validate requires title + assignee", () => {
assert.deepEqual(readInput(new URLSearchParams("title=%20Shift%20&assignee=Bo&start=1&end=2")), { assignee: "Bo", end: "2", start: "1", title: "Shift" });
assert.equal(validate({ assignee: "Bo", end: "", start: "", title: "Shift" }), null);
assert.deepEqual(Object.keys(validate({ assignee: "", end: "", start: "", title: "" }) ?? {}), ["title", "assignee"]);
});
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
assert.equal(r.data["canWrite"], true);
assert.equal(r.data["chrome"], CHROME);
const filtered = asView(await listShifts(fakeUpstream())(fakeCtx({ url: "http://localhost/scheduling/shifts?q=afternoon" })));
assert.deepEqual((filtered.data["table"] as { rows: { name: string }[] }).rows.map((x) => x.name), ["Afternoon support"]);
assert.equal(filtered.data["canWrite"], false); // no scheduling:write
});
test("listShifts degrades to a recoverable error page when the upstream is down (no throw)", async () => {
const r = asView(await listShifts(fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } }))(fakeCtx()));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
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
assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME);
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"] })));
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 ----
test("newShiftForm renders the empty form", async () => {
const r = asView(await newShiftForm()(fakeCtx()));
assert.equal(r.view, "shift-new");
assert.equal((r.data["form"] as { csrfToken: string }).csrfToken, "tok");
});
test("createShift rejects a bad CSRF token with a 403 GuardError", async () => {
await assert.rejects(
async () => { await createShift(fakeUpstream())(fakeCtx({ body: "title=T&assignee=A", verifyCsrf: () => false })); },
(e: unknown) => e instanceof GuardError && e.status === 403,
);
});
test("createShift re-renders the form (400) on a validation error, never touching the upstream", async () => {
let created = false;
const r = asView(await createShift(fakeUpstream({ create: async () => { created = true; } }))(fakeCtx({ body: "title=&assignee=" })));
assert.equal(r.status, 400);
assert.equal(r.view, "shift-new");
assert.equal(created, false);
});
test("createShift forwards a valid write upstream then POST-redirect-GETs", async () => {
let got: ShiftInput | undefined;
const r = await createShift(fakeUpstream({ create: async (i) => { got = i; } }))(fakeCtx({ body: "title=Night&assignee=Casey&start=22%3A00&end=06%3A00" }));
assert.deepEqual(got, { assignee: "Casey", end: "06:00", start: "22:00", title: "Night" });
assert.deepEqual(r, { redirect: SHIFTS_PATH });
});
test("createShift surfaces an upstream failure as a recoverable 502 form, keeping the input", async () => {
const r = asView(await createShift(fakeUpstream({ create: async () => { throw new UpstreamError("boom", 500); } }))(fakeCtx({ body: "title=Night&assignee=Casey" })));
assert.equal(r.status, 502);
assert.match(String(r.data["formError"]), /unavailable/i);
const fields = (r.data["form"] as { fields: { name: string; value: string }[] }).fields;
assert.equal(fields.find((f) => f.name === "title")?.value, "Night"); // input preserved for retry
});
test("buildFormModel marks title/assignee required and attaches field errors", () => {
const model = buildFormModel({ chrome: CHROME, errors: { title: "needed" }, values: { title: "x" } });
const fields = model.form.fields as { error?: string; name: string; required?: boolean; value: string }[];
const title = fields.find((f) => f.name === "title")!;
assert.equal(title.required, true);
assert.equal(title.error, "needed");
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
});
+266
View File
@@ -0,0 +1,266 @@
// Reference plugin — Scheduling/Shifts handlers + the upstream client. Shows the blessed
// shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the
// host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream.
//
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// 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).
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";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
// locale first, then the host's.
const EN: Translate = englishTranslator(enUS);
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
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 WRITE = "scheduling:write"; // the permission gating create
export interface Shift {
id: 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;
start: string;
title: string;
}
export interface ShiftInput {
assignee: string;
end: string;
start: string;
title: string;
}
// Thrown when the upstream errors; the handler degrades to a recoverable page, never a host 500.
export class UpstreamError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "UpstreamError";
this.status = status;
}
}
export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>;
// `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[]>;
}
// 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
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
// these handlers has already been built.
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = (): string => baseUrl().replace(/\/+$/, "");
return {
async create(input) {
const res = await fetchImpl(`${base()}/shifts`, {
body: JSON.stringify(input),
headers: { "content-type": "application/json" },
method: "POST",
});
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
},
async list(opts = {}) {
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);
const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : [];
},
};
}
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
function toShift(raw: unknown): Shift {
const r = (raw ?? {}) as Record<string, unknown>;
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) -----------------------------------
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) {
const t = opts.t ?? EN;
return {
breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it
canWrite: opts.canWrite,
chrome: opts.chrome,
// A plural message: one catalog key, the right form per locale and count (Intl.PluralRules).
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}),
filterBar: {
applyLabel: t("filter.search"),
clearHref: SHIFTS_PATH,
label: t("scheduling.filter.label"),
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
rows: [[
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
{ type: "spacer" },
]],
},
newHref: `${SHIFTS_PATH}/new`,
table: {
caption: t("scheduling.shifts.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
rows: opts.shifts.map((s) => ({
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
name: s.title,
})),
},
title: t("scheduling.shifts.title"),
};
}
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; t?: Translate; values?: Partial<ShiftInput> }) {
const t = opts.t ?? EN;
const v = opts.values ?? {};
const e = opts.errors ?? {};
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
});
return {
breadcrumbs: [{ href: SHIFTS_PATH, label: t("scheduling.shifts.title") }, { label: t("scheduling.new.title") }],
chrome: opts.chrome,
...(opts.formError ? { formError: opts.formError } : {}),
form: {
action: SHIFTS_PATH,
cancelHref: SHIFTS_PATH,
csrfToken: opts.chrome.csrfToken,
cancelLabel: t("common.cancel"),
fields: [
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }),
],
submitLabel: t("scheduling.form.submit"),
},
title: t("scheduling.new.title"),
};
}
// ---- input + validation -------------------------------------------------------------
export function readInput(form: URLSearchParams): ShiftInput {
return {
assignee: (form.get("assignee") ?? "").trim(),
end: (form.get("end") ?? "").trim(),
start: (form.get("start") ?? "").trim(),
title: (form.get("title") ?? "").trim(),
};
}
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
export function validate(input: ShiftInput, t: Translate = EN): Record<string, string> | null {
const errors: Record<string, string> = {};
if (!input.title) errors["title"] = t("scheduling.validation.title");
if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee");
return Object.keys(errors).length ? errors : null;
}
// ---- handlers (factories bound to the upstream) -------------------------------------
export function listShifts(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const q = parseListQuery(ctx.url).q;
let shifts: Shift[] = [];
let error: string | undefined;
try {
shifts = await upstream.list();
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
error = ctx.t("scheduling.upstream.list");
}
const needle = q.toLowerCase();
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" };
};
}
export function newShiftForm(): RouteHandler {
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
// 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
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: {
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
canRead: can(ctx, READ),
chrome: ctx.chrome,
mineHref: ctx.localeHref(MINE_PATH),
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))}`),
title: ctx.t("scheduling.overview.title"),
},
view: "overview",
});
}
export function createShift(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const form = await readFormBody(ctx.req);
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
const input = readInput(form);
const errors = validate(input, ctx.t);
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
try {
await upstream.create(input);
} catch (err) {
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" };
}
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
};
}
@@ -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,
}) %>
@@ -0,0 +1,29 @@
<%#
Scheduling · public overview (reference plugin). A page ANYONE may reach — the route and its
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
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
%><%
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
? '<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>';
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
signInHref: chrome.signInHref,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme,
title,
user: chrome.user,
}) %>
@@ -1,7 +1,7 @@
<%#
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
fields: field.ejs config[] }, formError?
cancelLabel, fields: field.ejs config[] }, formError?
%><%
const form = locals.form;
-%>
@@ -9,13 +9,13 @@
<% if (locals.formError) { -%>
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= form.cancelLabel %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -2,20 +2,20 @@
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
(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.
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
alert) — see the README. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar);
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
: "";
-%>
<%- include("partials/shell", {
actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
-44
View File
@@ -1,44 +0,0 @@
// Dev-only mock upstream for the reference plugin (plugins/scheduling) — a stand-in for the
// customer's real backend so `docker compose up` shows the plugin working out of the box. NOT part
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
// at your real service in production.
//
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 4000);
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: "Afternoon — Support", 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" },
];
const json = (res, status, body) => {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
};
const readBody = (req) =>
new Promise((resolve) => {
let data = "";
req.on("data", (c) => (data += c));
req.on("end", () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
});
});
createServer(async (req, res) => {
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 === "POST") {
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 ?? "") };
shifts.push(shift);
return json(res, 201, shift);
}
json(res, 404, { error: "not found" });
}).listen(PORT, () => console.log(`shifts-upstream listening on :${PORT}`));
+50
View File
@@ -0,0 +1,50 @@
// 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
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
// at your real service in production.
//
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
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 = [
{ 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", 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", 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) => {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
};
const readBody = (req) =>
new Promise((resolve) => {
let data = "";
req.on("data", (c) => (data += c));
req.on("end", () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
});
});
createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
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") {
const b = await readBody(req);
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
shifts.push(shift);
return json(res, 201, shift);
}
json(res, 404, { error: "not found" });
}).listen(PORT, () => console.log(`shifts-upstream listening on :${PORT}`));
-733
View File
@@ -1,733 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App Shell — Template</title>
<link rel="stylesheet" href="../public/css/styles.css">
</head>
<body>
<!-- ============ ICON SPRITE — Lucide (https://lucide.dev, ISC license) ============
Official Lucide paths, inlined as <symbol> so usage stays zero-JS:
<svg class="ico"><use href="#i-name"/></svg>. Stroke + currentColor are
applied via the .ico class, matching Lucide's 24-grid / round-cap style. -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></symbol>
<symbol id="i-kebab" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></symbol>
<symbol id="i-sort" viewBox="0 0 24 24"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></symbol>
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></symbol>
<symbol id="i-sliders" viewBox="0 0 24 24"><line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/></symbol>
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></symbol>
<symbol id="i-logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></symbol>
</svg>
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1">
<div class="app">
<!-- =================== SIDEBAR =================== -->
<aside class="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box"/></svg></span>
<span class="brand-name">Console</span>
<span class="brand-sub">v0.1</span>
</div>
<!-- ====================================================
UNIFIED NAV TREE — every item uses the SAME node markup.
HEADER → node has a <details class="nav-disc"> toggle + <ul class="nav-children">
LEAF → node has a <span class="nav-spacer"> instead of a toggle
CLICKABLE → label is <a class="nav-self" href>
STATIC → label is <span class="nav-self">
Mix freely: a node can be header+clickable, header+static,
leaf+clickable, or leaf+static. Workspace / Insights below are
simply header + static nodes — nothing special about them.
==================================================== -->
<nav class="nav" aria-label="Main navigation">
<ul class="nav-tree">
<!-- leaf + clickable -->
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-grid"/></svg><span class="nav-label">Overview</span></a>
</div>
</li>
<!-- header + STATIC (was the "Workspace" group label) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Workspace"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Workspace</span></span>
</div>
<ul class="nav-children">
<!-- header + CLICKABLE -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Directory"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-users"/></svg><span class="nav-label">Directory</span><span class="nav-count">4</span></a>
</div>
<ul class="nav-children">
<!-- leaf + clickable (active) -->
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#" aria-current="page"><span class="nav-label">People</span><span class="nav-count">1,284</span></a>
</div>
</li>
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#"><span class="nav-label">Teams</span></a>
</div>
</li>
<!-- header + CLICKABLE -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Roles &amp; Access"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><span class="nav-label">Roles &amp; Access</span></a>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Roles</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Permission sets</span></a></div></li>
<!-- header + CLICKABLE (level 4) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Policies"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><span class="nav-label">Policies</span></a>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Password policy</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Session limits</span></a></div></li>
</ul>
</li>
<!-- header + STATIC (level 4) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Scopes"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Scopes</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Read scopes</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Write scopes</span></a></div></li>
</ul>
</li>
</ul>
</li>
<!-- header + STATIC -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Segments"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Segments</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Active users</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Invited</span></a></div></li>
</ul>
</li>
</ul>
</li>
<!-- header + STATIC -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Resources"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><svg class="ico"><use href="#i-box"/></svg><span class="nav-label">Resources</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Projects</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Environments</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">API keys</span></a></div></li>
<!-- leaf + STATIC (no link → not a navigation target) -->
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><span class="nav-self"><span class="nav-label">Webhooks (soon)</span></span></div></li>
</ul>
</li>
</ul>
</li>
<!-- header + STATIC (was the "Insights" group label) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Insights"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Insights</span></span>
</div>
<ul class="nav-children">
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-chart"/></svg><span class="nav-label">Reports</span></a>
</div>
</li>
<!-- header + CLICKABLE -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Activity"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-bell"/></svg><span class="nav-label">Activity</span></a>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Audit log</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Notifications</span></a></div></li>
</ul>
</li>
<!-- header + STATIC -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Catalog"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><svg class="ico"><use href="#i-layers"/></svg><span class="nav-label">Catalog</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Items</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Categories</span></a></div></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
<!-- ---- sidebar footer: theme + profile + settings ---- -->
<div class="side-footer">
<!-- theme switcher: Light / Auto / Dark (Auto = follow system) -->
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
<label>
<input type="radio" name="theme" id="theme-light">
<span>Light</span>
</label>
<label>
<input type="radio" name="theme" id="theme-auto" checked>
<span>Auto</span>
</label>
<label>
<input type="radio" name="theme" id="theme-dark">
<span>Dark</span>
</label>
</div>
<div class="footer-actions">
<!-- profile (opens a menu) -->
<details class="menu" style="flex:1 1 auto">
<summary class="profile">
<span class="avatar" aria-hidden="true">AK</span>
<span class="profile-meta">
<span class="profile-name">Avery Kline</span>
<span class="profile-mail"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="1f7e697a6d665f7e7c727a317670">[email&#160;protected]</a></span>
</span>
</summary>
<div class="menu-pop left" style="bottom:calc(100% + 6px); top:auto; min-width:220px">
<div class="menu-head">Signed in as Avery</div>
<button class="menu-item"><svg class="ico"><use href="#i-user"/></svg>Profile</button>
<button class="menu-item"><svg class="ico"><use href="#i-globe"/></svg>Language — English</button>
<button class="menu-item"><svg class="ico"><use href="#i-bell"/></svg>Notifications</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-logout"/></svg>Sign out</button>
</div>
</details>
<!-- settings -->
<details class="menu">
<summary class="btn icon-btn" aria-label="Settings">
<svg class="ico"><use href="#i-gear"/></svg>
</summary>
<div class="menu-pop" style="bottom:calc(100% + 6px); top:auto">
<div class="menu-head">Settings</div>
<button class="menu-item"><svg class="ico"><use href="#i-gear"/></svg>Preferences</button>
<button class="menu-item"><svg class="ico"><use href="#i-users"/></svg>Members</button>
<button class="menu-item"><svg class="ico"><use href="#i-globe"/></svg>Region &amp; language</button>
</div>
</details>
</div>
</div>
</aside>
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<!-- =================== CONTENT =================== -->
<main class="content">
<!-- topbar -->
<header class="topbar">
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu">
<svg class="ico"><use href="#i-menu"/></svg>
</label>
<h1 class="page-title">People</h1>
<nav class="crumbs" aria-label="Breadcrumb">
<a href="#">Directory</a><span class="sep">/</span><span>People</span>
</nav>
<div class="topbar-spacer"></div>
<button class="btn"><svg class="ico ico-sm"><use href="#i-download"/></svg>Export</button>
<button class="btn btn-primary"><svg class="ico ico-sm"><use href="#i-plus"/></svg>Add person</button>
</header>
<!-- ============ FILTER BAR ============
A real GET form: selections submit as query params (?q=…&status=…)
so filtering is server-side and works with zero JavaScript.
Every control has a name + associated label; related controls are
grouped in <fieldset>/<legend>; Apply submits, Reset clears. -->
<form class="filters" method="get" aria-label="Filter people">
<!-- row 1: search + status + team + column/extra menus -->
<div class="filter-row">
<label class="search">
<span class="sr-only">Search people</span>
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg>
<input type="search" name="q" placeholder="Search people…">
</label>
<fieldset class="filter-field">
<legend class="sr-only">Status</legend>
<div class="segmented">
<label><input type="radio" name="status" value="all" checked><span>All</span><span class="seg-count">1,284</span></label>
<label><input type="radio" name="status" value="active"><span>Active</span></label>
<label><input type="radio" name="status" value="archived"><span>Archived</span></label>
</div>
</fieldset>
<span class="filter">
<label class="sr-only" for="f-team">Team</label>
<span class="select">
<select id="f-team" name="team">
<option value="">All teams</option>
<option value="engineering">Engineering</option>
<option value="design">Design</option>
<option value="operations">Operations</option>
<option value="sales">Sales</option>
</select>
</span>
</span>
<div class="spacer"></div>
<!-- column visibility (display preference, also persisted via the form) -->
<details class="menu">
<summary class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cols"/></svg>Columns</summary>
<div class="menu-pop">
<fieldset class="menu-field">
<legend class="menu-head">Visible columns</legend>
<label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name</label>
<label class="menu-check"><input type="checkbox" name="col" value="email" checked>Email</label>
<label class="menu-check"><input type="checkbox" name="col" value="role" checked>Role</label>
<label class="menu-check"><input type="checkbox" name="col" value="team" checked>Team</label>
<label class="menu-check"><input type="checkbox" name="col" value="status" checked>Status</label>
<label class="menu-check"><input type="checkbox" name="col" value="last_active" checked>Last active</label>
<label class="menu-check"><input type="checkbox" name="col" value="created">Created</label>
</fieldset>
</div>
</details>
<details class="menu">
<summary class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-sliders"/></svg>More filters</summary>
<div class="menu-pop" style="min-width:240px">
<fieldset class="menu-field">
<legend class="menu-head">Role</legend>
<label class="menu-check"><input type="radio" name="role" value="" checked>Any role</label>
<label class="menu-check"><input type="radio" name="role" value="admin">Admin</label>
<label class="menu-check"><input type="radio" name="role" value="member">Member</label>
<label class="menu-check"><input type="radio" name="role" value="viewer">Viewer</label>
</fieldset>
<div class="menu-sep"></div>
<fieldset class="menu-field">
<legend class="menu-head">Flags</legend>
<label class="menu-check"><input type="checkbox" name="flag" value="2fa">2FA enabled</label>
<label class="menu-check"><input type="checkbox" name="flag" value="pending">Pending invite</label>
</fieldset>
</div>
</details>
</div>
<!-- row 2: tags + joined date range -->
<div class="filter-row">
<fieldset class="filter-field">
<legend class="sr-only">Tags</legend>
<span class="filter-legend" aria-hidden="true">Tags</span>
<div class="chips">
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="engineering" checked>Engineering</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="design">Design</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="oncall" checked>On-call</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="contractor">Contractor</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="remote">Remote</label>
</div>
</fieldset>
<div class="spacer"></div>
<fieldset class="filter-field">
<legend class="sr-only">Joined</legend>
<span class="filter-legend" aria-hidden="true">Joined</span>
<div class="daterange">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg>
<label class="sr-only" for="f-from">Joined from</label>
<input type="date" id="f-from" name="joined_from" value="2026-01-01">
<span class="to" aria-hidden="true">to</span>
<label class="sr-only" for="f-to">Joined to</label>
<input type="date" id="f-to" name="joined_to" value="2026-06-14">
</div>
</fieldset>
</div>
<!-- row 3: applied filters (server-rendered) + form actions -->
<div class="filter-row filter-foot">
<div class="active-pills" aria-label="Applied filters">
<span class="filter-legend">Applied</span>
<span class="pill"><b>Team:</b> Engineering <a class="pill-x" href="?tag=oncall&amp;joined_from=2026-01-01" aria-label="Remove Team filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
<span class="pill"><b>Tag:</b> On-call <a class="pill-x" href="?team=engineering&amp;joined_from=2026-01-01" aria-label="Remove On-call filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
<span class="pill"><b>Joined:</b> 2026 <a class="pill-x" href="?team=engineering&amp;tag=oncall" aria-label="Remove Joined filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
<a class="pill-clear" href="?">Clear all</a>
</div>
<div class="spacer"></div>
<div class="filter-actions">
<button type="reset" class="btn">Reset</button>
<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg>Apply filters</button>
</div>
</div>
</form>
<!-- ============ TABLE ============ -->
<div class="table-wrap">
<table class="table">
<caption class="sr-only">People in the directory</caption>
<thead>
<tr>
<th class="col-check" scope="col">
<input type="checkbox" aria-label="Select all rows">
</th>
<th scope="col" aria-sort="ascending">
<button class="th-sort">Name <svg class="ico ico-sm sort-ico"><use href="#i-up"/></svg></button>
</th>
<th scope="col">
<button class="th-sort">Email <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
</th>
<th scope="col">
<button class="th-sort">Role <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
</th>
<th scope="col">Team</th>
<th scope="col">Status</th>
<th scope="col">
<button class="th-sort">Last active <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
</th>
<th class="col-actions" scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<!-- row template, repeated -->
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">MD</span><span class="cell-strong">Mara Delgado</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="80ede1f2e1aee4e5ece7e1e4efc0e1e3ede5aee9ef">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Engineering</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">2 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Soren Vance"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">SV</span><span class="cell-strong">Soren Vance</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4b3824392e25653d2a25282e0b2a28262e652224">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Design</td>
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
<td class="cell-muted">3 hours ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Soren Vance"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Priya Nair"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">PN</span><span class="cell-strong">Priya Nair</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="5f2f2d36263e71313e362d1f3e3c323a713630">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Operations</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">just now</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Priya Nair"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Eli Brandt"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">EB</span><span class="cell-strong">Eli Brandt</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="b0d5dcd99ed2c2d1ded4c4f0d1d3ddd59ed9df">[email&#160;protected]</a></td>
<td>Viewer</td>
<td class="cell-muted">Sales</td>
<td><span class="badge neg"><span class="dot"></span>Suspended</span></td>
<td class="cell-muted">6 days ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Eli Brandt"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Tomas Lindqvist"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">TL</span><span class="cell-strong">Tomas Lindqvist</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="d9adb6b4b8aaf7b599b8bab4bcf7b0b6">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Engineering</td>
<td><span class="badge info"><span class="dot"></span>Invited</span></td>
<td class="cell-muted">—</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Tomas Lindqvist"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Hana Osei"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">HO</span><span class="cell-strong">Hana Osei</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="e28a838c83cc8d91878ba283818f87cc8b8d">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Design</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">21 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Hana Osei"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Rafael Costa"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">RC</span><span class="cell-strong">Rafael Costa</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="fd8f9c9b9c9891d39e928e899cbd9c9e9098d39492">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Operations</td>
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
<td class="cell-muted">1 hour ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Rafael Costa"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Wen Li"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">WL</span><span class="cell-strong">Wen Li</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dfa8bab1f1b3b69fbebcb2baf1b6b0">[email&#160;protected]</a></td>
<td>Viewer</td>
<td class="cell-muted">Sales</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">44 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Wen Li"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Nadia Farouk"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">NF</span><span class="cell-strong">Nadia Farouk</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4826292c2129662e293a273d2308292b252d662127">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Engineering</td>
<td><span class="badge neg"><span class="dot"></span>Suspended</span></td>
<td class="cell-muted">12 days ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Nadia Farouk"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Otto Berg"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">OB</span><span class="cell-strong">Otto Berg</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4a253e3e2564282f382d0a2b29272f642325">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Design</td>
<td><span class="badge info"><span class="dot"></span>Invited</span></td>
<td class="cell-muted">—</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Otto Berg"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Greta Holm"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">GH</span><span class="cell-strong">Greta Holm</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2047524554410e484f4c4d6041434d450e494f">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Operations</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">8 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Greta Holm"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Yusuf Demir"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">YD</span><span class="cell-strong">Yusuf Demir</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2059555355460e44454d49526041434d450e494f">[email&#160;protected]</a></td>
<td>Viewer</td>
<td class="cell-muted">Sales</td>
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
<td class="cell-muted">5 hours ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Yusuf Demir"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ============ PAGINATION ============ -->
<footer class="pager">
<span>1–12 of <b>1,284</b></span>
<div class="pager-rows">
<label for="rows">Rows</label>
<div class="select">
<select id="rows" aria-label="Rows per page">
<option>12</option><option>25</option><option>50</option><option>100</option>
</select>
</div>
</div>
<div class="spacer"></div>
<nav class="page-nums" aria-label="Pagination">
<button class="page-btn" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<button class="page-btn" aria-current="page">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<button class="page-btn">…</button>
<button class="page-btn">107</button>
<button class="page-btn" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
</nav>
</footer>
</main>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script></body>
</html>
-216
View File
@@ -1,216 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Console</title>
<link rel="stylesheet" href="../public/css/styles.css">
<link rel="stylesheet" href="../public/css/auth.css">
</head>
<body>
<!-- ============ ICON SPRITE — Lucide (ISC) ============ -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-box" viewBox="0 0 24 24"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
<symbol id="i-mail" viewBox="0 0 24 24"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></symbol>
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
</svg>
<main class="auth-stage">
<div class="auth">
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box"/></svg></span>
<span class="brand-name">Console</span>
</div>
<!-- =================== LOGIN =================== -->
<section id="login" class="auth-view" aria-labelledby="login-title">
<form class="auth-card" method="post" action="#">
<div class="auth-head">
<h1 id="login-title">Sign in</h1>
<p class="auth-sub">Welcome back. Enter your details to continue.</p>
</div>
<!-- SSO section (toggle on/off) -->
<div class="sso" aria-label="Single sign-on options">
<ul class="sso-list">
<!-- add a provider: copy one <li> and change the logo + label -->
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G</span><span class="sso-label">Continue with Google</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">M</span><span class="sso-label">Continue with Microsoft</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"/></svg></span><span class="sso-label">Continue with SAML SSO</span></button></li>
</ul>
<div class="auth-divider">or</div>
</div>
<div class="auth-form">
<div class="field">
<label for="login-email">Email</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
<input class="input has-ico" id="login-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" required>
</div>
</div>
<div class="field">
<div class="field-top">
<label for="login-password">Password</label>
<a class="field-link" href="#forgot">Forgot password?</a>
</div>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-lock"/></svg>
<input class="input has-ico" id="login-password" name="password" type="password" autocomplete="current-password" placeholder="••••••••" required>
</div>
</div>
<label class="check remember"><input type="checkbox" name="remember" value="1"> Keep me signed in</label>
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
</div>
<p class="auth-alt">Don't have an account? <a href="#register">Create one</a></p>
</form>
</section>
<!-- =================== REGISTER =================== -->
<section id="register" class="auth-view" aria-labelledby="register-title">
<form class="auth-card" method="post" action="#">
<div class="auth-head">
<h1 id="register-title">Create account</h1>
<p class="auth-sub">Get started — it only takes a minute.</p>
</div>
<div class="sso" aria-label="Single sign-on options">
<ul class="sso-list">
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G</span><span class="sso-label">Sign up with Google</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">M</span><span class="sso-label">Sign up with Microsoft</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"/></svg></span><span class="sso-label">Sign up with SAML SSO</span></button></li>
</ul>
<div class="auth-divider">or</div>
</div>
<div class="auth-form">
<div class="register-alert alert alert-neg" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<div class="alert-body"><strong>Please fix the highlighted fields</strong><span>A couple of details need your attention before we can create your account.</span></div>
</div>
<div class="field">
<div class="field-top">
<label for="reg-name">Name</label>
<span class="optional">Optional</span>
</div>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-user"/></svg>
<input class="input has-ico" id="reg-name" name="name" type="text" autocomplete="name" placeholder="Avery Kline">
</div>
</div>
<div class="field">
<label for="reg-email">Email</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
<input class="input has-ico" id="reg-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" aria-describedby="reg-email-err" required>
</div>
<p class="field-error err-email" id="reg-email-err" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<span>This email is already used by another account. <a href="#login">Sign in instead</a>.</span>
</p>
</div>
<div class="field">
<label for="reg-password">Password</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-lock"/></svg>
<input class="input has-ico" id="reg-password" name="password" type="password" autocomplete="new-password" placeholder="At least 8 characters" minlength="8" aria-describedby="reg-password-err" required>
</div>
<span class="field-hint">Use 8 or more characters.</span>
<p class="field-error err-password" id="reg-password-err" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<span>Password must be at least 8 characters.</span>
</p>
</div>
<button type="submit" class="btn btn-primary btn-block">Create account</button>
</div>
<p class="auth-alt">Already have an account? <a href="#login">Sign in</a></p>
</form>
</section>
<!-- =================== FORGOT PASSWORD =================== -->
<section id="forgot" class="auth-view" aria-labelledby="forgot-title">
<form class="auth-card" method="post">
<div class="auth-head">
<a class="auth-back" href="#login"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg>Back to sign in</a>
<h1 id="forgot-title">Reset password</h1>
<p class="auth-sub">Enter your email and we'll send you a reset link.</p>
</div>
<!-- feedback (shown by server via .state-sent / .state-error on #forgot) -->
<div class="forgot-alert is-sent alert alert-pos" role="status">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg>
<div class="alert-body"><strong>Check your email</strong><span>If an account exists for that address, a reset link is on its way.</span></div>
</div>
<div class="forgot-alert is-error alert alert-neg" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<div class="alert-body"><strong>Couldn't send the link</strong><span>Something went wrong on our end. Please try again.</span></div>
</div>
<div class="auth-form">
<div class="field">
<label for="forgot-email">Email</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
<input class="input has-ico" id="forgot-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Send reset link</button>
</div>
<p class="auth-alt">Remembered it? <a href="#login">Sign in</a></p>
</form>
</section>
</div>
</main>
<!-- ============ TEMPLATE PREVIEW CONTROLS (remove for production) ============ -->
<div class="tpl-controls" role="group" aria-label="Template preview controls">
<span class="tpl-label">Preview</span>
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
<label><input type="radio" name="theme" id="theme-light"><span>Light</span></label>
<label><input type="radio" name="theme" id="theme-auto" checked><span>Auto</span></label>
<label><input type="radio" name="theme" id="theme-dark"><span>Dark</span></label>
</div>
<span class="tpl-sep" aria-hidden="true"></span>
<label class="tpl-toggle">
<input type="checkbox" id="sso-toggle" checked>
<span class="tpl-track" aria-hidden="true"></span>
SSO
</label>
<span class="tpl-sep tpl-forgot" aria-hidden="true"></span>
<div class="tpl-forgot">
<div class="segmented" role="radiogroup" aria-label="Forgot-password state (preview)">
<label><input type="radio" name="fstate" id="fstate-default" checked><span>Default</span></label>
<label><input type="radio" name="fstate" id="fstate-sent"><span>Sent</span></label>
<label><input type="radio" name="fstate" id="fstate-error"><span>Error</span></label>
</div>
</div>
<span class="tpl-sep tpl-register" aria-hidden="true"></span>
<div class="tpl-register">
<div class="segmented" role="radiogroup" aria-label="Register state (preview)">
<label><input type="radio" name="rstate" id="rstate-default" checked><span>Default</span></label>
<label><input type="radio" name="rstate" id="rstate-taken"><span>Email taken</span></label>
<label><input type="radio" name="rstate" id="rstate-combined"><span>Multiple</span></label>
</div>
</div>
</div>
</body>
</html>

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