Compare commits

...

14 Commits

Author SHA1 Message Date
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
13 changed files with 305 additions and 95 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: | run: |
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.22.1-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 }} GIT_TAG: ${{ github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-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 node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest - name: Promote the commit-hash image to semver + latest
env: env:
@@ -98,9 +98,9 @@ jobs:
VERSION=${INPUT_VERSION:-${GIT_TAG#v}} VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v} VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag. # An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-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 node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.22.1-alpine3.24 \ node:24.22.2-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION" node release-tooling/dockerhub-overview.ts "$VERSION"
+2 -2
View File
@@ -58,11 +58,11 @@ jobs:
if [ -z "$BUMPS" ]; then if [ -z "$BUMPS" ]; then
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-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) node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made. # 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 \ git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \ | docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" - node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
+1 -1
View File
@@ -367,7 +367,7 @@ docker compose -f compose.yml up --build -d # production
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
copy-pasteable; deeper detail lives in its own section, linked. copy-pasteable; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered 2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Users, groups
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
map → Extending. Place a new section by how early an adopter needs it. **Users, groups & map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
+8
View File
@@ -4,6 +4,14 @@ The release version **is** the plugin contract version (`HOST_API_VERSION`), so
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it 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. 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.
## 0.4.0 ## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page **Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.22.1-alpine3.24 FROM node:24.22.2-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules # Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root). # instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+70 -5
View File
@@ -70,6 +70,8 @@ From here, render real pages against the app shell and fetch upstream data — s
## Contents ## Contents
- [Goals](#goals)
- [Audience](#audience)
- [Overview](#overview) - [Overview](#overview)
- [Users, groups & permissions](#users-groups--permissions) - [Users, groups & permissions](#users-groups--permissions)
- [naming a permission](#naming-a-permission) - [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) - [Project layout](#project-layout)
- [Extending the core](#extending-the-core) - [Extending the core](#extending-the-core)
## Goals
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
its own domain, not at sign-in, a menu or a table.
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
forms built only from the core building blocks.
- Counter-signal: the core grows features only one project needs.
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
2. **Achieve:** Every building block works for the person using it: a non-technical user
gets what they meant on the first try, and can't hurt themselves by accident.
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
code being refused, a double click acting twice, or an admin locking themselves out.
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
know about.
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
plugin author.
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
else is a plugin.
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
on old and embedded browsers too. A newer browser only makes it nicer.
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
database, never in local storage or JavaScript. A cookie carries only what must be bound
to the browser: the session and its CSRF token.
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
styles meaning, not looks. That keeps every page accessible.
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
host, and fewer plugins need a host change to get unblocked.
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
shows up at runtime instead of at boot.
- Why: freedom that breaks silently costs more than the boilerplate it saved.
8. **Achieve:** Speed: pages render fast and stay small.
- Signal: server time per page and bytes sent per page fall.
- Counter-signal: a building block loses a feature or accessibility to save bytes.
- Why: a fast page that does less only moves the work back into each plugin.
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
examples alone.
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
test on the first try.
- Counter-signal: the README grows to restate what the code already shows.
- Why: docs that pass the test by growing cost every later reader, human or LLM.
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
environment.
## Audience
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
configuration through environment variables and the drop-in mounts (`plugins/`,
`config/`, `locales/`), and on upgrades within a minor version that need no change on
their side.
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
work without JavaScript, with a keyboard and a screen reader.
## Overview ## Overview
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu, Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
@@ -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 - **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the admin plugin uses. blocks the admin plugin uses.
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only, Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity, services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
same I/O-free JWT check, and there is no app database.
**Plugins are the extension model.** The plugin API is the product's main surface: powerful, **Plugins are the extension model.** The plugin API is the product's main surface: powerful,
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
@@ -961,6 +1019,13 @@ underneath it — cramped, but nothing is unreachable. List state
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
URL is the only state the UI keeps. URL is the only state the UI keeps.
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
positioning, only enhances. A feature the floor engines lack, such as `:has()` 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 **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. 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 Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
+1 -1
View File
@@ -49,7 +49,7 @@ services:
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.22.1-alpine3.24 image: node:24.22.2-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+3 -3
View File
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream: shifts-upstream:
image: node:24.22.1-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/server.ts"] command: ["node", "/server.ts"]
volumes: volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro - ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc: mock-oidc:
image: node:24.22.1-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:24.22.1-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
+4 -4
View File
@@ -8,7 +8,7 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.62.0", "lucide-static": "1.64.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
@@ -399,9 +399,9 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.62.0", "version": "1.64.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.62.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
"integrity": "sha512-WBZA/m2PvkFpMGHDtsBck5P2A9G4ehLlYu4GwwICcmEm1BvYVgj00lCDxKRWLlbMlOibLaiwdQ88G5pHkSWgwA==", "integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": { "node_modules/postgres": {
+1 -1
View File
@@ -18,7 +18,7 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.62.0", "lucide-static": "1.64.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
+6 -6
View File
@@ -58,7 +58,7 @@
--radius: 5px; --radius: 5px;
--nav-w: 264px; --nav-w: 264px;
--row-h: 34px; --row-h: 34px;
--pad-x: 12px; --pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
--fz: 13px; --fz: 13px;
--fz-sm: 12px; --fz-sm: 12px;
--fz-xs: 11px; --fz-xs: 11px;
@@ -336,7 +336,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.topbar { .topbar {
flex: 0 0 auto; height: 48px; flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 0 16px; border-bottom: 1px solid var(--border); padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
} }
.hamburger { display: none; } /* shown only on narrow */ .hamburger { display: none; } /* shown only on narrow */
@@ -352,7 +352,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.filters { .filters {
flex: 0 0 auto; background: var(--surface); flex: 0 0 auto; background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 10px 16px; padding: 10px var(--pad-x);
display: flex; flex-direction: column; gap: 9px; display: flex; flex-direction: column; gap: 9px;
} }
.filter-row { .filter-row {
@@ -584,7 +584,7 @@ table.table {
.col-num { text-align: right; } .col-num { text-align: right; }
.cell-strong { font-weight: 550; } .cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); } .cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; } .table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
.cell-mono { font-variant-numeric: tabular-nums; .cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); } font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
@@ -648,7 +648,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.pager { .pager {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 8px 16px; border-top: 1px solid var(--border); padding: 8px var(--pad-x); border-top: 1px solid var(--border);
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted); background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
} }
.pager .spacer { flex: 1 1 auto; } .pager .spacer { flex: 1 1 auto; }
@@ -696,7 +696,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; } #nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */ /* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } .form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card { .form-card {
display: flex; flex-direction: column; gap: 14px; display: flex; flex-direction: column; gap: 14px;
padding: 18px; background: var(--surface); padding: 18px; background: var(--surface);
+204 -67
View File
@@ -1,74 +1,211 @@
# Primary todo # todo
## Unfinnished work ## Scoring
- [ ] **Publish every release to Docker Hub, and give the Hub page its short description.** Today Hub's newest tag is 0.4.0 (2026-09-10), while git has v0.4.1–v0.4.12, and the overview still says 0.4.0. No `release.yml` run exists for the tags auto-release pushes as `renovate-bot`, so neither the images nor the overview reach Hub; find out why Gitea doesn't fire `push: tags` for them, fix it, and backfill the missing tags. The short description is empty. fejkdata already did this (`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a `dockerhub/description.txt`-style one-liner, which here can ride 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` that changes it, with its own Read/Write/Delete token in an environment only `main` may use, so the image-push token needs no Delete scope. Decide whether that fits here, where `{{VERSION}}` must render a released tag. `Score = 100 * V / (2**S + 8)`
- [ ] 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.
### Architectural review findings (2026-07-02) `Bar = 1`
Prioritized. Overall verdict: architecture is sound; these are refinements. `Next ID = 33`
- [ ] **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. ## Items
- [ ] **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).
## Finnished work | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 15 | 0.4.21 | | security | **Set `Cache-Control` on every rendered page from the host: `private, no-store` on a gated or signed-in page, a cacheable value on a public one.** | 2 | 2 | 2 | 4 | 3 | 8 by 3 | 3.0 |
| 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 |
| 10 | 0.4.21 | | | **Pin Playwright's `workers` to a fixed number.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 7.2 |
| 18 | 0.4.21 | | | **Record in AGENTS.md and README → Security model that the CSRF token is deliberately unbound to the session, and why.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 7.2 |
| 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 |
| 25 | 0.4.21 | | | **Point the views' references to `docs/plugin-contract.md` at the README.** | 1 | 2 | 1 | 2 | 1 | 9 by 3 | 4.6 |
| 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 |
| 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`. ## Details
- [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. ### 15. Set `Cache-Control` on every rendered page from the host: `private, no-store` on a gated or signed-in page, a cacheable value on a public one.
- [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. The host owns it, not the reverse proxy. Today responses carry `Vary: Accept-Language` but no
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned. `Cache-Control`, so a shared cache in front of `web` may store a signed-in page, with that user's
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout. data, and serve it to the next visitor.
- [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. ### 30. Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.
- [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. The accent, focus and status tokens in `public/css/styles.css` are `oklch()`, which needs Chromium
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them. 111. Below it (Tizen 2024, webOS TV 24) `--accent` is invalid, so `.btn-primary` is white text on
- [x] Organize the files in src into folders. no background: the sign-in button reads as blank.
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] Move the scheduling example out of `plugins/` into `examples/`. ### 29. Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.
- [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. README → "Two browser floors": Chromium 87 must reach every page. `public/css/styles.css` hides
- [x] CI/CD — test on push to any branch except main. `.nav-children` and only a `:has()` rule reveals them, so below Chromium 105 (webOS TV 22 and 23)
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only. no nested entry can be reached. Hide the children only inside `@supports selector(:has(a))`. The
- [x] CI/CD — force-push mirror to GitHub after every merge to main. theme switch is inert there too, since every theme rule is a `:has()`: hide it under the same guard.
- [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. ### 31. Delete the design-preview rules in `public/css/auth.css` that no view uses.
- [x] CI/CD — sync released tags to Docker Hub.
- [x] Write README-dockerhub.md for the Docker Hub overview. `.auth-view`, the `:target` screen switch, `.tpl-*`, the `fstate`/`rstate` radios and the
- [x] CI/CD — set up the Renovate bot. forgot/register alerts match nothing in `views/` or `examples/`.
- [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. ### 26. Add a domain glossary and the project's intended horizon to the README.
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
- [x] Document the auth security model in the README. Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
- [x] Add i18n support. expected plugin count, since per-plugin dependency isolation prices N dependency trees on disk and
- [x] Settle the identity-vs-user vocabulary. in RSS.
- [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. ### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
- [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. `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.
### 18. Record in AGENTS.md and README → Security model that the CSRF token is deliberately unbound to the session, and why.
`src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so an attacker who
can write cookies on the origin can fix a token they know. Accepted: it is standard for unbound
signed double-submit, and `SameSite=Lax` plus HSTS cover it. Valid while the session cookie stays
`SameSite=Lax` or stricter.
### 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.
### 25. Point the views' references to `docs/plugin-contract.md` at the README.
`examples/plugins/scheduling/views/shifts.ejs` still cites it.
### 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.
### 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.