Compare commits

...

90 Commits

Author SHA1 Message Date
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-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 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-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 4s
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-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-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 4s
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 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 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 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 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 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 4s
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-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 4s
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 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 2s
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-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 2s
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-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
23 changed files with 552 additions and 152 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.20.0-alpine3.24 node registry-cleanup/cleanup.ts
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest
env:
@@ -98,9 +98,9 @@ jobs:
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.20.0-alpine3.24 \
node:24.22.2-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.59.3
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
@@ -58,11 +58,11 @@ jobs:
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:24.20.0-alpine3.24 \
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main
+23 -4
View File
@@ -36,6 +36,15 @@ branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode)
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
@@ -212,6 +221,9 @@ Revisit only if the stated reason stops holding.
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.
@@ -278,6 +290,10 @@ Revisit only if the stated reason stops holding.
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
@@ -354,7 +370,7 @@ docker compose -f compose.yml up --build -d # production
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
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: Overview → Users, groups
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → 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 &
@@ -434,9 +450,12 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
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. Prefer state in the URL over POSTing it, for
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
`ids=x,y`.
- 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
+103
View File
@@ -0,0 +1,103 @@
# 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
### One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together.
### Pages are not kept by a shared cache
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
whose page is the same for everyone can still set its own `cache-control` through
`RouteResult.headers`; assets are unchanged.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
### The document scrolls, and the chrome scrolls with it
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
Either let it, or give the region its own height in the plugin's stylesheet.
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
**Sign out** — sits at the end of that page rather than the bottom of the screen.
## 0.3.0
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
### A session is a gate of its own
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
fails the boot instead.
- Naming **two** is refused, as before.
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
while reading as if they set one.
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
host still drops a header whose children all filtered out.
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
### Filter bars take a multi-select
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
### Fixed
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
and no way to tell why.
### Dependencies
- Node 24.20.0.
### Upgrading a plugin
1. Set `apiVersion: "0.3.0"`.
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
`public: true` outright.
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.20.0-alpine3.24
FROM node:24.22.2-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+97 -5
View File
@@ -70,6 +70,8 @@ From here, render real pages against the app shell and fetch upstream data — s
## Contents
- [Goals](#goals)
- [Audience](#audience)
- [Overview](#overview)
- [Users, groups & permissions](#users-groups--permissions)
- [naming a permission](#naming-a-permission)
@@ -121,6 +123,64 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Project layout](#project-layout)
- [Extending the core](#extending-the-core)
## Goals
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
its own domain, not at sign-in, a menu or a table.
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
forms built only from the core building blocks.
- Counter-signal: the core grows features only one project needs.
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
2. **Achieve:** Every building block works for the person using it: a non-technical user
gets what they meant on the first try, and can't hurt themselves by accident.
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
code being refused, a double click acting twice, or an admin locking themselves out.
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
know about.
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
plugin author.
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
else is a plugin.
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
on old and embedded browsers too. A newer browser only makes it nicer.
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
database, never in local storage or JavaScript. A cookie carries only what must be bound
to the browser: the session and its CSRF token.
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
styles meaning, not looks. That keeps every page accessible.
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
host, and fewer plugins need a host change to get unblocked.
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
shows up at runtime instead of at boot.
- Why: freedom that breaks silently costs more than the boilerplate it saved.
8. **Achieve:** Speed: pages render fast and stay small.
- Signal: server time per page and bytes sent per page fall.
- Counter-signal: a building block loses a feature or accessibility to save bytes.
- Why: a fast page that does less only moves the work back into each plugin.
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
examples alone.
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
test on the first try.
- Counter-signal: the README grows to restate what the code already shows.
- Why: docs that pass the test by growing cost every later reader, human or LLM.
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
environment.
## Audience
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
configuration through environment variables and the drop-in mounts (`plugins/`,
`config/`, `locales/`), and on upgrades within a minor version that need no change on
their side.
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
work without JavaScript, with a keyboard and a screen reader.
## Overview
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
@@ -136,11 +196,9 @@ public front with an authenticated area behind it.
- **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the admin plugin uses.
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
same I/O-free JWT check, and there is no app database.
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
@@ -626,6 +684,7 @@ provider/consumer semantics in `checkApiVersion`:
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
contract changed or a dependency moved far enough to warrant one.
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
### Conflict rules
@@ -932,6 +991,11 @@ recovery / front pages — so it looks identical signed in or out and just shows
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
chrome-free layout opts out with the shell's `menu: false`.
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
pane owns that in its own stylesheet.
## Building blocks
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
@@ -955,6 +1019,23 @@ underneath it — cramped, but nothing is unreachable. List state
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
URL is the only state the UI keeps.
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
positioning, only enhances. A feature the floor engines lack, such as `:has()` or `oklch()`, may
decorate a page but never decide whether something shows or can be read.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
to the URL without it) above the next step's form, which carries the earlier choices as hidden inputs,
plus `locale` from `localeParam`. The form that writes comes last and posts to the URL naming every
choice, so its handler reads them from `ctx.query`, never from the body; a value the query names that
the step does not offer is that step's error, never a silent fallback. Don't redraw a half-filled form
through a submit that writes nothing instead: a `required` field blocks it, and the choice never
reaches the URL.
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
The baseline never depends on it.
@@ -1303,6 +1384,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
still set cookies, so host nothing untrusted on one.
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
@@ -1564,6 +1650,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
header per-response via `RouteResult.headers`.
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
never keeps one user's page for the next. A route may override it only for a page that is the same for
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
every plugin route.
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
@@ -1730,6 +1821,7 @@ e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,aut
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
.gitea/workflows/ Gitea Actions — see CI/CD
```
+2 -2
View File
@@ -49,7 +49,7 @@ services:
# 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.20.0-alpine3.24
image: node:24.22.2-alpine3.24
command: node /srv/server.ts
restart: unless-stopped
volumes:
@@ -58,7 +58,7 @@ 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.31.0
image: axllent/mailpit:v1.32.0
ports:
- "8025:8025"
restart: unless-stopped
+1 -1
View File
@@ -1,6 +1,6 @@
# 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.62.1-noble
FROM mcr.microsoft.com/playwright:v1.64.1-noble
WORKDIR /e2e-tests
+3 -3
View File
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.20.0-alpine3.24
image: node:24.22.2-alpine3.24
command: ["node", "/server.ts"]
volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc:
image: node:24.20.0-alpine3.24
image: node:24.22.2-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy:
image: node:24.20.0-alpine3.24
image: node:24.22.2-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
+12 -30
View File
@@ -6,17 +6,17 @@
"": {
"name": "plainpages-e2e",
"devDependencies": {
"@playwright/test": "1.62.1"
"@playwright/test": "1.64.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"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.62.1"
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -25,44 +25,26 @@
"node": ">=20"
}
},
"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.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"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.62.1"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"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": {
+1 -1
View File
@@ -7,6 +7,6 @@
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.62.1"
"@playwright/test": "1.64.1"
}
}
+1
View File
@@ -10,6 +10,7 @@ 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: {
+39
View File
@@ -28,6 +28,45 @@ 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 [1280, 390]) {
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();
+1 -1
View File
@@ -2,7 +2,7 @@
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.
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 });
+12 -12
View File
@@ -8,12 +8,12 @@
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"lucide-static": "1.34.0",
"lucide-static": "1.64.0",
"postgres": "3.4.9"
},
"devDependencies": {
"@types/ejs": "3.1.5",
"@types/node": "24.13.3",
"@types/node": "24.19.2",
"typescript": "7.0.2"
},
"engines": {
@@ -37,13 +37,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
@@ -399,9 +399,9 @@
}
},
"node_modules/lucide-static": {
"version": "1.34.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.34.0.tgz",
"integrity": "sha512-pSUvFhfhvDnhXN1fXerZEMgKLQQ3DneKwFYlqB5ji8OEAN0iPi/qgwQvCkcL519QgMeR66IpS/pT342VyT/g4g==",
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
"license": "ISC"
},
"node_modules/postgres": {
@@ -453,9 +453,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
+2 -2
View File
@@ -18,12 +18,12 @@
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"lucide-static": "1.34.0",
"lucide-static": "1.64.0",
"postgres": "3.4.9"
},
"devDependencies": {
"@types/ejs": "3.1.5",
"@types/node": "24.13.3",
"@types/node": "24.19.2",
"typescript": "7.0.2"
}
}
+12 -15
View File
@@ -58,7 +58,7 @@
--radius: 5px;
--nav-w: 264px;
--row-h: 34px;
--pad-x: 12px;
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
--fz: 13px;
--fz-sm: 12px;
--fz-xs: 11px;
@@ -111,7 +111,6 @@ html:has(#theme-light:checked) {
/* ---------- 2. RESET ---------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text);
-webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; }
@@ -151,8 +150,7 @@ summary { list-style: none; cursor: pointer; }
.app {
display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr);
height: 100dvh;
overflow: hidden;
min-height: 100dvh;
}
/* ---------- 4. SIDEBAR -------------------------------------- */
@@ -160,7 +158,6 @@ summary { list-style: none; cursor: pointer; }
grid-column: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface);
border-right: 1px solid var(--border);
}
@@ -331,7 +328,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.content {
grid-column: 2;
display: flex; flex-direction: column;
min-width: 0; min-height: 0;
min-width: 0;
background: var(--bg);
}
@@ -339,7 +336,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.topbar {
flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px; border-bottom: 1px solid var(--border);
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
background: var(--surface);
}
.hamburger { display: none; } /* shown only on narrow */
@@ -355,7 +352,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.filters {
flex: 0 0 auto; background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
padding: 10px var(--pad-x);
display: flex; flex-direction: column; gap: 9px;
}
.filter-row {
@@ -559,13 +556,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.pill-clear:hover { text-decoration: underline; }
/* ---------- 9. TABLE --------------------------------------- */
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
.table-wrap { overflow-x: auto; }
table.table {
width: 100%; border-collapse: separate; border-spacing: 0;
font-size: var(--fz); font-variant-numeric: tabular-nums;
}
.table thead th {
position: sticky; top: 0; z-index: 10;
background: var(--surface-3);
border-bottom: 1px solid var(--border-2);
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
@@ -588,7 +584,7 @@ table.table {
.col-num { text-align: right; }
.cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
.cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
@@ -652,7 +648,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.pager {
flex: 0 0 auto;
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; border-top: 1px solid var(--border);
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
}
.pager .spacer { flex: 1 1 auto; }
@@ -697,10 +693,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
}
/* the nav-toggle checkbox itself is visually hidden but focusable */
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card {
display: flex; flex-direction: column; gap: 14px;
padding: 18px; background: var(--surface);
@@ -722,5 +718,6 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
.app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; }
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
+1 -1
View File
@@ -131,7 +131,7 @@ services:
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
mailpit:
image: axllent/mailpit:v1.31.0
image: axllent/mailpit:v1.32.0
ports:
- "8025:8025"
restart: unless-stopped
+33
View File
@@ -284,6 +284,39 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
});
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => {
const NO_STORE = "private, no-store";
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
// The signed-out landing is no-store as well: it and the signed-in page share one URL.
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: `${csrf}; ${session()}` } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
const demo: Plugin = {
apiVersion: "1.0.0",
home: () => ({ html: "<p>plugin home</p>" }),
id: "demo",
routes: [
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
{ handler: () => ({ headers: { "Cache-Control": "public, max-age=600" }, html: "same" }), method: "GET", path: "/same", public: true },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true },
],
};
const url = await startApp(t, [demo]);
for (const path of ["/demo/open", "/demo/data", "/"]) {
assert.equal((await fetch(url + path)).headers.get("cache-control"), NO_STORE, path);
}
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
});
// Production caches compiled templates; rendering must stay correct across repeated requests.
test("renders correctly with template caching enabled", async () => {
const app = createApp({ cache: true });
+7
View File
@@ -36,6 +36,8 @@ import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts";
const NO_STORE = "private, no-store";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions {
@@ -203,6 +205,11 @@ export function createApp(options: AppOptions = {}): Server {
// A cache in front of us must key on the language. Set after the static branch: an asset is
// the same bytes in every language, and a Vary there fragments its entry per raw header.
res.setHeader("vary", "accept-language");
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
res.setHeader("cache-control", NO_STORE);
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
+193 -66
View File
@@ -1,73 +1,200 @@
# Primary todo
# todo
## Unfinnished work
## Scoring
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
- [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
`Score = 100 * V / (2**S + 8)`
### Architectural review findings (2026-07-02)
`Bar = 1`
Prioritized. Overall verdict: architecture is sound; these are refinements.
`Next ID = 34`
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
## Items
## Finnished work
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 30 | 0.4.21 | | defect | **Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.** | 1 | 2 | 2 | 3 | 2 | 4 by 5 | 29.9 |
| 29 | 0.4.21 | | defect | **Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.** | 2 | 2 | 2 | 4 | 3 | 4 by 6 | 26.9 |
| 31 | 0.4.21 | | | **Delete the design-preview rules in `public/css/auth.css` that no view uses.** | 1 | 1 | 1 | 1 | 2 | code health by 2, 8 by 1 | 13.3 |
| 26 | 0.4.21 | | | **Add a domain glossary and the project's intended horizon to the README.** | 1 | 2 | 1 | 2 | 3 | 9 by 3, code health by 2 | 11.9 |
| 8 | 0.4.21 | | | **Run the visual E2E specs at viewports from a large desktop down to a small phone.** | 2 | 1 | 1 | 2 | 4 | code health by 3 | 9.0 |
| 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 |
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
| 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 |
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
| 32 | 0.4.21 | 29, 30 | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
| 27 | 0.4.21 | | | **Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.** | 1 | 2 | 1 | 2 | 1 | 9 by 2, 2 by 1 | 4.8 |
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 22 | 0.4.21 | | | **Offer a `users:read` holder a View row action on the users list, not Edit.** | 2 | 2 | 1 | 3 | 3 | 2 by 2 | 2.1 |
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 33 | 0.5.0 | | question | **Decide whether any public page gets a shared-cache `Cache-Control`, now that the host sends `private, no-store` on every page.** | 2 | 3 | 4 | 7 | 3 | 8 by 2 | 2.0 |
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 |
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
| 14 | 0.5.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
- [x] Skip the CI gate when only markdown changed.
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
- [x] Organize the files in src into folders.
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] Move the scheduling example out of `plugins/` into `examples/`.
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
- [x] Turn the built-in admin pages into a drop-in example plugin.
- [x] CI/CD — test on push to any branch except main.
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
- [x] CI/CD — sync released tags to Docker Hub.
- [x] Write README-dockerhub.md for the Docker Hub overview.
- [x] CI/CD — set up the Renovate bot.
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
- [x] Document the auth security model in the README.
- [x] Add i18n support.
- [x] Settle the identity-vs-user vocabulary.
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
- [x] Remove the dead "Profile" link from the sidebar profile menu.
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
## Details
### 30. Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.
The accent, focus and status tokens in `public/css/styles.css` are `oklch()`, which needs Chromium
111. Below it (Tizen 2024, webOS TV 24) `--accent` is invalid, so `.btn-primary` is white text on
no background: the sign-in button reads as blank.
### 29. Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.
README → "Two browser floors": Chromium 87 must reach every page. `public/css/styles.css` hides
`.nav-children` and only a `:has()` rule reveals them, so below Chromium 105 (webOS TV 22 and 23)
no nested entry can be reached. Hide the children only inside `@supports selector(:has(a))`. The
theme switch is inert there too, since every theme rule is a `:has()`: hide it under the same guard.
### 31. Delete the design-preview rules in `public/css/auth.css` that no view uses.
`.auth-view`, the `:target` screen switch, `.tpl-*`, the `fstate`/`rstate` radios and the
forgot/register alerts match nothing in `views/` or `examples/`.
### 26. Add a domain glossary and the project's intended horizon to the README.
Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
expected plugin count, since per-plugin dependency isolation prices N dependency trees on disk and
in RSS.
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
almost every field. Keep `shellUser` as the shared primitive.
### 17. Remove the icons no caller uses from `ICON_NAMES` and the sprite.
`i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page.
AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup.
### 21. Fail the gate on a function that exceeds a size or complexity limit.
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
allow a feature only inside an `@supports` that guards it.
### 27. Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.
They sit flush against the content pane's edge while the filter bar, table and pager sit 12px in.
The fix belongs in `examples/plugins/scheduling/public/scheduling.css`; check each page in a browser.
### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no
session. `/settings` stays `public`, since recovery lands there with a Kratos session but no app JWT;
a 401 should redirect to `/login` with the page as `return_to`. Add the missing E2E.
### 4. Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.
Sketch: post the rendered set as a hidden baseline; when it no longer matches Keto, re-render with
"this changed while you had the page open" rather than applying.
### 7. Confirm a successful permission save, and report a partial failure instead of hiding it.
`applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a
half-applied set behind the generic error page. The `alert alert-pos` pattern of the recovery-code
banner is available.
### 22. Offer a `users:read` holder a View row action on the users list, not Edit.
The pencil opens the read-only detail page, so it is harmless, but the label contradicts what the
reader can do. Needs `canWrite` threaded into `listTable`, a `common.view` core catalog key and an
`i-eye` icon.
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
on a group you belong to, leaving that group, or deleting it can still strip your own access with no
warning. AGENTS.md records this as a known gap. The robust fix is a "last effective holder" check,
which needs a reverse Keto query.
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
`release.yml` run exists for the tags auto-release pushes as `renovate-bot`; find out why Gitea
doesn't fire `push: tags` for them, fix it, and backfill the missing tags. fejkdata did this
(`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a one-line
description riding the existing PATCH as its `description` field, and a unit test that the
description opens a README sentence and every command the overview shows appears in the README.
fejkdata also syncs the page on any push to `main`, with its own token in an environment only `main`
may use; decide whether that fits here, where `{{VERSION}}` must render a released tag.
### 13. Accept a verification code pasted with surrounding whitespace.
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`
cannot transform, so loosening it only lets the untrimmed value through to Kratos. **Check first:**
`kratos-admin.ts` returns a `recovery_link` alongside the code; if the stock courier mail can carry
one, a template change fixes this with no host code. Otherwise proxy the flow POST through the host:
`submitFlow` (`src/auth/kratos-public.ts`) already relays cookies and normalises a 422
`redirect_browser_to`, and has no callers outside tests. CSRF improves, since the POST becomes
same-origin and `form-action 'self'` can join the CSP. Risks: one `flow.ui.action` serves all five
flows, so this rewrites the sign-in path; and Go's nosurf checks Referer only over https, which no
test covers. Code entry has no E2E coverage today.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
`dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM.
### 33. Decide whether any public page gets a shared-cache `Cache-Control`, now that the host sends `private, no-store` on every page.
Every non-asset response is `private, no-store`. The built-in signed-out `/` and its signed-in
variant share one URL, so a shared cache keyed on URL and `Accept-Language` would serve the
signed-out page to a signed-in visitor for the cache lifetime, and a cache that bypasses on cookies
would never store it. Options: leave it, so operators cache only assets; add `Vary: Cookie`; or give
the signed-out page its own URL. A plugin route can already opt in through `RouteResult.headers`.
I is 4 because a wrong rule caches a signed-in page for everyone, exposing PII.
### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.
Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the
project's "no catch-all names" rule applied to namespaces.
### 6. Warn at discovery when a route or nav node gates on a permission no plugin declares.
Declaring stays optional on purpose, but the picker is built from declarations only, so such a route
is ungrantable from the GUI and answers a permanent 403. A warning, not an error.
### 23. Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.
Every list screen bridges `parseListQuery`/`paginate` to the partials by hand. At minimum a
`buildPaginationModel(page, hrefFor)`. Exported through the barrel, so it is contract surface.
### 28. Let a plugin add entries to the profile menu through the plugin API.
The profile dropdown stays a dropdown: Sign out is its only entry today, and it will gain more.
`views/partials/shell.ejs` hand-rolls it (AGENTS.md → UI, the dropdown rule), so the entries need a
manifest field that discovery checks, gated like a nav node.
### 2. Rename the admin example plugin to a name that says what it administers.
Candidates: `auth-admin`, `users-groups-admin`. The id is also the mount path, so the compose files,
E2E stacks and README move with it.
### 5. List grants that no installed plugin declares, with a remove action.
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
cleaned, and reinstalling its plugin silently reactivates access nobody remembers granting.
### 14. Drop a double-clicked duplicate submit, with no client-side JavaScript.
A second identical POST is expected from a non-technical user, and today creates two
users, mints two recovery codes, or registers two OAuth2 clients. It must not break a legitimately
repeatable action. Sketch: a CSS-only affordance so the second click has nothing to hit, plus the
host recognising a duplicate (same session, route and payload within a short window), logging and
dropping it. Open: what identifies "the same submission" (a one-time token minted into each form
beats hashing the payload), the window, where the record lives given the host is stateless, and how
a plugin declares a route repeatable.
+1 -1
View File
@@ -6,7 +6,7 @@
<symbol id="i-box" viewBox="0 0 24 24"><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-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></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-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></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-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>