Files
plainpages/todo.md
lilleman 66ea68a91b §9 whole-project arch+product review pass (todo §9); ran systems-architect + product-owner on the whole project (no Critical/High — a converged scaffold) and addressed the in-scope §9 customer-facing/security findings. (1) return_to deep-link login, open-redirect-safe: a gated request hit while signed out (plugin-route gate, requireSession, requireAdmin) bounces to /login?return_to=<host-relative path> via new loginRedirect(ctx) (GET/HEAD, skips /); /login bakes it into the Kratos flow — a host-relative target is wrapped through <origin>/auth/complete?return_to=<path> so the JWT mints before landing, an absolute target (§6 OAuth2 login challenge) passes to Kratos as-is; /auth/complete redirects to the requested page. (2) safeUrl()+localPath() in new pure src/safe-url.ts: safeUrl sanitises an untrusted href/src to relative-or-http(s) (else "#"), exported via plugin-api.ts (closes the contract's "planned for §9" pointer); localPath is the host-relative redirect-allowlist guard for return_to, re-checked at both /login and /auth/complete. (3) honest 503 on Ory-unreachable sign-in (views/503.ejs) instead of the misattributed catch-all 500; expired-flow 4xx still restarts. Tests-first throughout; stability-reviewer APPROVE (addressed its Medium — scoped the 503 catch so a template bug hits the 500 with a stack, not a 503). typecheck + 339 units + full scripts/ci.sh gate green. Deferred with justification: the app.ts route-table refactor (standalone change + §10 prereq), mock dashboard + public-page blessing (§10 lines 139/140), success-flash (known).
2026-06-20 16:39:09 +02:00

140 lines
143 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plainpages — implementation TODO
Build order is top → bottom; each phase is roughly independent and testable.
Conventions: **write tests first** (node --test for units, Playwright for E2E),
tear down test containers after runs, keep deps minimal, pin all versions, run
everything via Docker.
> **North-star / MVP.** Done = a developer can **clone, run one command, get a
> working register/login, and start hacking on their own plugin** — no manual key
> generation, no hand-edited Ory config, no DB setup. Everything below serves that;
> the one-command bootstrap (§3) and the example plugin (§7) are what make the MVP
> real. Hydra/SSO are explicitly *post-MVP*.
## 0. Housekeeping / primitives
- [x] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding. → `node:crypto` (no new dep); `src/jwt.ts` verifies JWS signatures.
- [x] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite). → `src/cookie.ts` (`parseCookies`/`serializeCookie`); stdlib-only, injection/pollution-safe.
- [x] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`. → `src/context.ts` (`RequestContext` + `buildContext`); `roles` mirror `user.roles`, the §2 router/§4 JWT middleware supply `params`/`user`.
- [x] Error templates: add 403 + 500 (404 exists). → `views/403.ejs` + `views/500.ejs`; 500 wired into `app.ts` error handler (HTML, plain-text fallback).
- [x] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. → `src/config.ts` (`loadConfig`); validated at boot, dev defaults for clean-clone, prod requires real secrets; wired into `server.ts`.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4).
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Tightened comments across `src/*.ts`, Dockerfile, and trimmed verbose/duplicated prose in README; tests + typecheck green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Merged related cases across jwt/cookie/app/context/config tests (59 → 42), every assertion preserved; typecheck + tests green.
### 0.1 Extra input from human
- [x] Remove all usage of NODE_ENV - add a new core principle to the project that the app should at all times be unaware of what environment it is running in. Configuration should be explicit, like "disable email" or "cache templates". → Dropped NODE_ENV everywhere; added **environment-agnostic** principle (AGENTS.md §4 + README). Behaviour is now explicit toggles: `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS` (parsed/validated in `config.ts`, wired via `server.ts`); compose files set them per deployment. `app.ts` no longer reads `process.env`.
## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data)
- [x] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`. → `git mv` from `html-css-foundation/` into `public/css/`; dropped the placeholder `style.css`; views + tests now reference `styles.css`; foundation mockups repointed to `../public/css/`.
- [x] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used. → `src/icons.ts` (id→lucide map + `buildIconSprite`) generates a hidden `<symbol>` sprite of the 31 icons the mockups reference, paths sourced from pinned lucide-static; `icons.test.ts` guards provenance + only-used. Stale image rebuilt (lucide-static was missing). Wiring into the app shell is the next item.
- [x] App-shell partial (sidebar + topbar + content slot). → `views/partials/shell.ejs`: full document wrapping `.app` → sidebar (brand + `nav` slot + theme/profile footer) · `.scrim` · `.content` (`.topbar` + `body` slot); reuses the mockup's classes (styled by `styles.css`), inlines the icon sprite. Slots `nav`/`actions`/`body` are HTML locals, `title`/`brand`/`user`/`breadcrumbs` text; defaults render standalone. `shell.test.ts` covers landmarks, slots, escaping, defaults. Not yet routed (that's "replace placeholder index").
- [x] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. → `views/partials/nav-tree.ejs`: data-driven, self-including. Node `{ label, href?, icon?, count?, current?, open?, children? }`; header (children → `.nav-disc` toggle + sibling `.nav-children`) vs leaf (spacer), clickable (`<a>`) vs static (`<span>`), orthogonal. Renders into the shell's `nav` slot. `nav-tree.test.ts` covers the full matrix + counts/icons/aria-current/escaping/empty.
- [x] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). → `views/partials/filter-bar.ejs`: data-driven `<form method="get">` (server-side, zero-JS). `rows: Control[][]`, `type ∈ search|segmented|select|chips|daterange|spacer`, each reflecting current value (checked/selected); plus applied `pills` (+ remove links, Clear all) and Reset/Apply actions. Columns/“more filters” menus deferred to the menu/popover item. `filter-bar.test.ts` covers every type + value reflection + pills + defaults.
- [x] Data-table partial — sortable headers, row-select, badges, kebab row actions. → `views/partials/data-table.ejs`: data-driven, zero-JS. `columns` ({ label, sortable, sort, href, className }) render sort as `<a class="th-sort">` + `aria-sort` (links, not the mockup's inert buttons); `selectable`/`actions` toggle the check/kebab columns. `rows` carry typed `cells` (string | text+class | user/avatar | badge tone | raw html) + kebab `actions` (link or danger button, separators). `data-table.test.ts` covers the matrix + minimal/empty defaults.
- [x] Pagination partial — rows-per-page + page numbers, query-param driven. → `views/partials/pagination.ejs`: data-driven, zero-JS. `summary {from,to,total}`, rows-per-page GET `<form>` (select + submit, `hidden[]` carries list state), `pages: {label,href?,current?,ellipsis?}[]` (links; current/ellipsis inert), `prev`/`next` (href ⇒ link, omit ⇒ disabled). Reuses the mockup's `.pager` CSS, no changes. `pagination.test.ts` covers the matrix + value reflection + empty defaults.
- [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `<form class="auth-card">` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults.
- [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `<details>` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults.
- [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix.
- [x] Helper `parseListQuery(url)``{ q, filters, sort, page, pageSize }`. → `src/list-query.ts`: pure, never throws; inverse of the filter-bar GET form + sort/pagination links. Accepts `URL`/`URLSearchParams`/string. `q` trimmed; `filters` = every non-reserved param as `string[]` (multi-value chips kept, empties dropped); `sort` = `{field,dir}` with `-field` ⇒ desc (lone `-`/empty ⇒ null); `page` a positive int (else 1); `pageSize` defaults 25, clamped to [1, max 100]. Reserved names + page-size bounds overridable via options. `list-query.test.ts` covers the full/default/clamp/custom-name matrix.
- [x] Helper `paginate(total, page, pageSize)` → page model. → `src/paginate.ts`: pure, URL-free math feeding `pagination.ejs`; caller maps page numbers → hrefs. Returns `{ from, to, page, pageCount, pageSize, prev, next, total, pages }`. Inputs clamped/guarded (page pinned to [1,pageCount], total/pageSize coerced to sane ints, empty list ⇒ 1 page / 00). `pages` = first/last `boundaries` + `siblings`-wide window around current, sorted/deduped, with ellipsis for gaps >1 (a lone hole is shown, not collapsed); `siblings`/`boundaries` overridable. `paginate.test.ts` covers model/clamp/empty/windowing.
- [x] Replace placeholder `index` with the app-shell dashboard. → `/` now renders a real app-shell "People" list. `src/dashboard.ts` (pure `buildDashboardModel(url, roles)`) wires the §1 helpers end-to-end: `parseListQuery` → filter (q/status/team) + sort + `paginate` over a 30-row mock dataset → `composeNav`; builds the filter-bar/data-table/pagination/shell configs with canonical, state-preserving links. `views/index.ejs` composes the partials around the shell by capturing each `include()` (EJS returns the string) into a slot. Filtering/sorting/paging all round-trip the URL, zero-JS. Removed the dead `partials/header.ejs`. `dashboard.test.ts` covers default/search/sort/paginate; `app.test.ts` asserts the live page + URL filtering. Mock data + demo profile stand in until §2/§4.
- [x] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics. → Dockerized Playwright (official image, browsers preinstalled — no host Node/browsers): `e2e/` (config + `visual.spec.ts`), `Dockerfile.e2e`, `compose.e2e.yml` run the suite against the live `web` service. 6 parallel tests: screenshots live (default/sorted+filtered/dark/mobile) **and** the foundation mockups (App Shell + Auth) → `e2e/artifacts/` (git-ignored); asserts the live DOM computes the **same** design-system styles as the mockup for the shared components (`.sidebar/.topbar/.brand/.btn-primary/.theme-switch/.filters/.pager`), every icon `<use>` resolves, sort/search round-trip the URL, the CSS theme switch flips the palette, and mobile hides the sidebar off-canvas. Verified visually: live dashboard matches the mockup design (light + dark); diffs are data only. All green.
- [x] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project. → Added **Semantic, accessible DOM** as core principle (AGENTS.md §5 + README). Fixes: page title is now the page `<h1>` (shell + mockup), a focus-revealed skip link to `#main-content`, data-table identifier cell is `<th scope="row">` (CSS styles tbody `th`), error pages got descriptive headings (code retained). Tests-first: shell/data-table specs assert the new markup; typecheck + 75 units + 6 E2E green.
### 1.1 Extra input from human
- [x] Add to principles that we should have full E2E coverage in the Playwright tests - make sure they can run in parallel to get up some speed. → Added **Full, parallel E2E** core principle (AGENTS.md §6 + README): every user-facing flow gets a Playwright test shipped with it, tests stay side-effect-free so the suite runs `fullyParallel` (already set; verified 7 tests / 7 workers). Led by example: added E2E coverage for the 404 page (the one user-facing gap). Fixed the documented run command to `--build` (the runner bakes in `e2e/`, so spec edits were silently ignored without it).
## 2. Plugin host
- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: authored `PluginManifest` + folder-derived `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `isValidPluginId` (URL-safe folder name: lowercase/digits/dashes), `checkApiVersion` (semver via `parseSemver`/official regex, no dep: same major+minor→ok, older minor→warn, newer minor/major-mismatch/malformed→refuse) and `findConflicts` (id/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). Identity is the folder: id = folder name, mount = `/<id>` — neither is in the manifest, so mount-path uniqueness is structural (no basePath rule). `apiVersion` is a literal a plugin pins (never imports `HOST_API_VERSION`). nav `icon` = Lucide sprite id. `docs/plugin-contract.md` is the prose reference (anatomy/identity, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it. Tests-first (`plugin.test.ts`); typecheck + 82 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP.
- [x] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. → `src/discovery.ts` (`discoverPlugins`): the imperative shell over plugin.ts's pure rules. Scans `plugins/` (sorted, skips dotfiles/non-dirs; missing dir ⇒ `[]` for a clean clone), derives `id` from the folder, dynamically imports each `plugin.ts` default export and validates it — `isValidPluginId`, default-export-is-a-manifest, `checkApiVersion`, array-shape of nav/routes/permissions, then `findConflicts` across the set. Fails loud: every per-plugin problem + every error-level conflict is collected and thrown as one boot-stopping Error naming the plugin(s); warns (older-minor apiVersion, shared permission token) log and load continues. Wired into `server.ts` boot (logs the loaded ids). `discovery.test.ts` covers empty/happy/each failure mode + the warn path (temp-dir fixtures). Router/view-resolver/static are the next §2 items.
- [x] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. → `src/router.ts`: the pure core (`matchRoute`/`allowedMethods`/`isAuthorized`), wired by `app.ts` (the imperative shell). A route mounts at `/<id>` + its path via the now-exported `fullPath` (shared with `findConflicts`, so they can't drift); `:name` segments → `ctx.params.name` (percent-decoded, malformed ⇒ no match). Specificity: a literal segment beats a `:param` (`/users/new` wins over `/users/:id` regardless of declaration order), ties keep discovery order. HEAD answers a GET route; known-path/wrong-method ⇒ 405 + `Allow`. `isAuthorized` = composeNav's gate (no `permission` ⇒ open, else `roles` must include it); fail-closed today since auth (§4) supplies no user yet (gated ⇒ 403). `app.ts` builds the context, gates, calls the handler, and maps `RouteResult` → response (`sendResult`: html/json/redirect/view/void; author headers override; the void escape hatch lets a handler own `ctx.res`); `view` renders the plugin's own `views/<view>.ejs` (the richer resolver — core-partial includes, subfolders — is the next §2 item). Dropped the global non-GET/HEAD 405 (plugins bring other methods). Wired into `server.ts` (`createApp({ plugins })`). Tests-first: `router.test.ts` (match/params/specificity/HEAD/methods/gate) + an `app.test.ts` integration mounting a demo plugin (every RouteResult shape + 403/405/404); typecheck + 98 units green.
- [x] Per-plugin view resolver (`plugins/<id>/views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes. → `src/view-resolver.ts` (`renderPluginView`/`resolveViewPath`), wired into `app.ts` for a `view` RouteResult (replaces the router's minimal stub). `resolveViewPath` (pure) maps a view name → `plugins/<id>/views/<view>.ejs`, supports nested names (`shifts/edit`), defaults the `.ejs` extension, and refuses traversal/control-char names (same guard as `static.ts`). Rendering passes EJS `views: [<plugin>/views, coreViewsDir]`: EJS resolves an `include()` relative to the current file first, then those roots — so a plugin view reaches **every core building-block partial** (shell, nav-tree, data-table, …) *and* its own partials/subfolders, plugin-root first so it can deliberately shadow a core partial. Out-of-bounds name ⇒ reject (fail loud). Tests-first: `view-resolver.test.ts` (resolve/nest/extension/traversal/control-char + a nested view that includes both a core partial and its own) + the `app.test.ts` plugin integration now asserts the live `view` page includes `partials/theme-switch`; typecheck + 102 units green. Per-plugin static serving is the next §2 item.
- [x] Per-plugin static serving: `plugins/<id>/public/``/public/<id>/`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/<rest>` whose leading segment names a discovered plugin serves from `plugins/<id>/public/<rest>`; anything else (e.g. `css/styles.css`) stays on the core `public/`. Disambiguates by the discovered plugin-id set, so only mounted plugins expose assets and core paths are unaffected; plugin ids are URL-safe so the raw segment compares directly (no decode needed). Reuses `serveStatic` unchanged, so the sub-path keeps its decode + traversal/control-char guard (encoded `..` ⇒ 403) and HEAD support; a missing `public/` or file ⇒ 404. Tests-first: a `routePublic` unit (plugin/core split, nested asset, bare `/public/<id>`) + the `app.test.ts` plugin integration now serves a real `demo/public/app.css` (200 + `text/css`) and still 403s a traversal; typecheck + 103 units green. `config/menu.ts` central override is the next §2 item.
- [x] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). → `src/menu-config.ts` (`MenuConfig`/`Branding`/`MenuConfigInput`, `defineMenu()` identity helper, `DEFAULT_MENU`, `loadMenuConfig()`) + the operator file `config/menu.ts`. The override is `composeNav`'s existing `NavOverride` (reorder/rename/group/hide by node id, applied before the per-user filter); branding = `{ name, logo?, sub?, theme? }`. `loadMenuConfig` (imperative shell) dynamically imports `config/menu.ts` if present, validates the authored shape fail-loud (branding field types + `theme` enum, override `hide`/`order` string-arrays / `groups` array / `rename` object), merges branding over defaults; **absent file ⇒ `DEFAULT_MENU`** (clean clone). Wired: `server.ts` loads it at boot → `createApp({ menu })``buildDashboardModel(url, roles, menu)` feeds `menu.override` into `composeNav` and `menu.branding` (name/sub) into the shell brand. `config/menu.ts` ships defaults matching prior behaviour (name "Plainpages"/sub "Console", empty override), so a clean clone is unchanged. Added `config` to tsconfig `include` so the authored file is type-checked (Dockerfile `COPY . .` already bakes it). Tests-first: `menu-config.test.ts` (absent⇒defaults / read+merge / malformed⇒throws) + a `dashboard.test.ts` case asserting rename+hide+branding take effect; typecheck (incl. `config/`) + 107 units green; smoke-loaded the real file at boot. **Rendering branding (logo, default theme) into the app shell is the next §2 item.**
- [x] Wire branding into the app shell. → Completes the §2 branding chain (name/sub already flowed). `shell.ejs` now renders `brand.logo` as `<img class="brand-logo" alt="">` when set, else the default `#i-box` brand-mark; the `theme` local (already forwarded to the theme-switch) is now supplied. `buildDashboardModel` puts `menu.branding.logo` into `shell.brand` and `menu.branding.theme` into `shell.theme` (both omitted when unset, so a clean clone is unchanged → brand-mark + auto theme); `views/index.ejs` forwards `theme` to the shell. Added a `.brand-logo` CSS rule (22px, matches `.brand-mark` sizing). Tests-first: `shell.test.ts` (logo replaces the mark + default theme checked; no-logo ⇒ mark + auto) + extended `dashboard.test.ts` (logo→brand, theme→shell.theme) + an `app.test.ts` integration rendering `createApp({ menu })` end-to-end (logo `<img>` + `theme-dark` checked on `/`). Default-app shell rendering is byte-equivalent, so the visual E2E is unaffected; typecheck + 109 units green. The §2 plugin host is feature-complete (remaining §2 items are the project-wide review + comment/test cleanup).
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`, `views/`, `config/`, Docker/tsconfig. Verdict: architecture sound + disciplined, no crash/security defect in the current path (fail-loud, traversal guards, JWT/cookie defenses all confirmed). **Fixed now:** (1) HIGH — `PluginHooks` was typed+documented but never invoked; wired it (`src/hooks.ts`: `runBootHooks`/`runRequestHooks`/`runResponseHooks`) — `server.ts` runs `onBoot` after discovery before listen, `app.ts` runs `onRequest` (before routing, first non-void short-circuits, renders against its plugin) + `onResponse` (after handler, observer, throw→500); skipped entirely when no plugin declares a hook (hot path free); `hooks.test.ts` + an `app.test.ts` integration. (2) `discovery.ts` `fail` helper retyped `: void`. (3) Documented the template trust boundary in `docs/plugin-contract.md` (raw `html`/`*.html` fields; URL sinks escaped but not scheme-checked) + tightened the Hooks prose to the wired semantics. **Deferred (reviewer-scoped, not §2):** extract a shared `buildShellContext` out of `dashboard.ts` and route the built-in screens through `matchRoute`/`isAuthorized` → §5 (premature at one call site); a `safeUrl()` helper for href sinks → §4 (no untrusted URLs until upstream data flows); doc/type-duplication + non-local `§N` refs → the §2 comment-cleanup item; HEAD-render cost + dev empty-secret fallback → negligible. typecheck + 113 units green; boot smoke-tested.
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §2 accretion (the §0/§1 cleanup at line 21 stands). Tightened the verbose module-header blocks (`plugin.ts`, `discovery.ts`, `router.ts`, `dashboard.ts`) and collapsed the `checkApiVersion` rule comment to a one-liner that points at the contract doc (the if-chain + messages already document it). Removed now-stale forward-refs ("router wiring is the next §2 item", "rendered in the shell — next §2 item"). README: corrected the **Status** note (it undersold — §1 design system + the whole §2 plugin host are built, not just a scaffold), dropped the stale `_(planned)_`/"planned to extract" markers on **Building a plugin** and **Building blocks** (both shipped; auth guards still flagged §4), and named the real helpers. Left the security-rationale comments (jwt/cookie/static/paginate) and the EJS partials' config-doc headers intact — they carry vital info / are the only schema for untyped locals. No anchor links broke; typecheck + 113 units green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Reviewed all 24 test files. The suite already follows the deliberate per-module "matrix + edge" pattern from the §0/§1 merge (line 22), so most files carry no fat and force-merging distinct concerns would only hurt readability. Removed the genuine §2-era overlaps, all in `app.test.ts`: merged the two HTTP static tests into one (GET/HEAD + traversal/NUL→403), and dropped the standalone "renders the 403 error page" `ejs.renderFile` stopgap (its comment even said "403 has no first-party route yet") — the gated plugin route now exercises 403 over HTTP, so the template assertions (status + 403.ejs body + stylesheet link) moved there; also dropped the now-unused `ejs` import. Unified `view-resolver.test.ts`'s two `resolveViewPath` cases (resolve + reject) into one. 113 → 110 tests, zero coverage lost; typecheck + tests green.
## 3. Ory stack — compose + config
- [x] `postgres` service (pinned tag); separate DB/schema per Kratos/Keto/Hydra. → `compose.yml` `postgres` service pinned to `postgres:18.4-alpine3.23` (verified latest stable PG + newest Alpine the official image ships); `ory/postgres/init/init.sql` (mounted at `docker-entrypoint-initdb.d`) creates one DB per service (`kratos`/`keto`/`hydra`) so each owns its schema + migrations. Dev defaults (`ory`/`ory`, env-overridable for prod), named `pgdata` volume mounted at `/var/lib/postgresql` (PG18+ version-subdir layout — not `/data`), `pg_isready` healthcheck. Web app never connects. Verified live: boots healthy, three DBs present, then torn down. `postgres.test.ts` guards the pin + DB-per-service. typecheck + 112 units green.
- [x] `kratos` service (pinned) + `migrate`; identity schema (traits: email, name). → `compose.yml` adds `kratos`/`kratos-migrate` pinned to `oryd/kratos:v26.2.0` (verified latest stable); `kratos-migrate` runs `migrate sql -e --yes` against the per-service `kratos` DB after postgres is healthy, `kratos` waits for it (`service_completed_successfully`). `ory/kratos/identity.schema.json` = email (password identifier, verification/recovery via email) + `name {first,last}`, email required. `ory/kratos/kratos.yml` = bootable baseline: password login, self-service UIs pointing at the web routes (themed in §4), serve URLs, dev-throwaway secrets (prod via env, §3), identity schema wired; DSN via env. Themed flows/SSO/session/tokenizer/JWKS are the next §3/§4 items. Tests-first (`kratos.test.ts`: version pin + migrate-before-serve + DSN→kratos DB + schema traits + schema wiring). Boot-verified: migrate exits 0, kratos serves `/health/ready` 200, serves the identity schema, inits a password login flow; torn down. typecheck + 117 units green.
- [x] Kratos self-service flows (login, registration, recovery, verification, settings) → return URLs at our themed pages. → `ory/kratos/kratos.yml`: all five flows enabled, each `ui_url` (+ after/return URLs) points at our web routes (`/login`, `/registration`, `/recovery`, `/verification`, `/settings`; §4 renders the fields). Recovery + verification run on the email `code` method (login stays password-only — `code.passwordless_enabled` left default-off); registration after-hooks `session` + `show_verification_ui`; settings gets `privileged_session_max_age` + `required_aal: highest_available`. Added a `courier` (SMTP) sending to a pinned dev mail catcher — **mailpit** (`axllent/mailpit:v1.30.1`) in `compose.override.yml`, web UI on `:8025`; prod overrides `COURIER_SMTP_CONNECTION_URI`. Kratos `serve` now runs `--watch-courier` so queued codes actually dispatch (without it they sit "queued"). Tests-first (`kratos.test.ts`: five flow ui_urls → our pages, recovery/verification use `code` + courier + `--watch-courier`, mailpit pin). Boot-verified end-to-end: all four public browser-flows 303 → `127.0.0.1:3000/<flow>?flow=…`, a registration delivered a real "Use code … to verify your account" email to mailpit (queue → `sent`); torn down. typecheck + 120 units green.
- [x] Kratos OIDC/SSO providers (Google/Microsoft/SAML) config (secrets via env). **None enabled by default** — a clean clone runs password-only; a provider activates purely by supplying its env creds. → `ory/kratos/kratos.yml` adds the `oidc` method present-but-disabled with an empty `providers: []` (clean clone = password-only, boots clean). Activation is pure env, no code/rebuild: `SELFSERVICE_METHODS_OIDC_ENABLED=true` + `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[…]` (the whole-array override is the only env-settable form Kratos offers — nested-field env vars aren't supported). Providers (`google`/`microsoft`/OIDC bridges) carry their `client_id`/`client_secret` and reference the committed shared claims mapper `ory/kratos/oidc/claims.jsonnet` (provider claims → `email` + `name{first,last}`). **SAML isn't in OSS Kratos** (Enterprise/Network/Polis only) — documented: front it with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider. README **Social sign-in (SSO)** section documents activation; §4 will derive the buttons from the live provider list. Tests-first (`kratos.test.ts`: oidc disabled + empty by default, mapper maps email/name). Boot-verified both halves: clean stack → login flow has only `default`+`password` groups; a one-off kratos with the SSO env → login flow gains an `oidc` group + a `google` button, no boot errors; torn down. typecheck + 122 units green.
- [x] Kratos session settings (cookie name, lifespan, sliding refresh). → `ory/kratos/kratos.yml` adds a `session` block: branded cookie `name: plainpages_session` (`persistent: true`, `same_site: Lax`), `lifespan: 720h` (30d "stay signed in" backbone the app re-mints the ~10m JWT off, §4), and sliding refresh via `earliest_possible_extend: 24h` (an active session extends back to full lifespan only once within 24h of expiry — no DB write per request). Tests-first (`kratos.test.ts`: cookie name + lifespan + extend window). Boot-verified: kratos serves `/health/ready` 200 with the block; a real browser registration (one-off `--dev` kratos, since Secure cookies don't ride plain http — that's the line-69 split) issued `Set-Cookie: plainpages_session=…; Max-Age=2591999; Expires=…; HttpOnly; SameSite=Lax` — name/persistent/lifespan all as configured; torn down. typecheck + 123 units green.
- [x] Kratos tokenizer template `plainpages`: claims `{ sub, email, roles }`, `ttl ≈ 10m`, `jwks_url` signer, `claims_mapper_url` (Jsonnet reading `metadata_admin.roles`). → `ory/kratos/kratos.yml` adds `session.whoami.tokenizer.templates.plainpages`: `ttl: 10m`, `subject_source: id` (sub = identity id), `claims_mapper_url`/`jwks_url` pointing at the mounted config dir. `ory/kratos/tokenizer/plainpages.jsonnet` is the claims mapper — `email` from `session.identity.traits.email`, `roles` from the `metadata_admin` projection (§4 refreshes it from Keto at login; absent on a fresh identity ⇒ `[]`, defensive `objectHas`). `sub` is fixed to the identity id by Kratos (`subject_source`), not the mapper. The JWKS signing key referenced by `jwks_url` is generated/mounted by the next §3 item — Kratos loads it lazily at tokenize time, so this boots clean. Tests-first (`kratos.test.ts`: template ttl/subject_source/urls + mapper email/roles-from-metadata_admin). Boot-verified: kratos serves `/admin/health/ready` 200 with the tokenizer wired (config schema accepts the block); torn down. typecheck + 125 units green.
- [x] Generate + mount the JWT signing JWKS; document key rotation. → `src/gen-jwks.ts` (`generateJwks()` + CLI) mints an **ES256** EC P-256 signing key as a JWK Set — Ory's recommended alg and the verifier's preferred (`src/jwt.ts`). The committed `ory/kratos/tokenizer/jwks.json` is the **dev throwaway** (like the cookie/cipher secrets in `kratos.yml`), already mounted via `./ory/kratos:/etc/config/kratos:ro` at the `jwks_url` the tokenizer template points to — so a clean clone signs out of the box. Regenerate/rotate: `docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json` (also `npm run gen-jwks`). README documents prod override (mount a real key or `…_JWKS_URL=base64://…`) + zero-downtime rotation (Kratos signs with the first key, app verifies by `kid` (§4) → prepend new, keep old ~one 10m TTL, drop). Tests-first (`gen-jwks.test.ts`: generator shape + unique kid, committed key validity, **round-trip** — a JWS signed with a generated key verifies through `verifyJws`). Boot-verified the full chain end-to-end: live Kratos registered an identity (API flow), `whoami?tokenize_as=plainpages` returned a real JWT signed with our `kid`, `verifyJws` validated it against the committed public half, claims `{sub, email, roles:[]}` + expiat = 600s (10m); torn down. typecheck + 128 units green.
- [x] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). → `compose.yml` adds `keto`/`keto-migrate` pinned to `oryd/keto:v26.2.0` (Ory's unified versioning — same train as kratos; verified latest stable); `keto-migrate` runs `migrate up -y` against the per-service `keto` DB after postgres is healthy, `keto` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/keto/keto.yml` serves read on 4466 + write on 4467 (the ports `config.ts` already targets), DSN via env, loads the OPL from the mounted file. `ory/keto/namespaces.keto.ts` is the OPL model: `User` (subject = Kratos id), `Group`/`Role` as subject sets with `members` (the coarse roles read at login → JWT, README), and a fine-grained `Resource` with `permits` view/edit/delete over owner ⊇ editor ⊇ viewer (README's third "may I?" tier). OPL stays out of tsconfig `include` (Keto-dialect, like the jsonnets). README: Status note + Layout updated, the role tuple example fixed to `#members` to match the OPL. Tests-first (`keto.test.ts`: version pin + migrate-before-serve + DSN→keto DB + read/write ports + OPL namespaces/permits). Fixed a pre-existing kratos test that over-asserted *every* compose DSN was kratos's (now scoped to kratos DSNs). Boot-verified the whole model live: migrate exits 0, read API ready, then over the write/read APIs — `role:admin#members@user:alice` checks allowed; `Resource:doc1` owner→delete/view allowed, viewer→view allowed but delete denied, stranger denied; and a transitive `Group:eng members ⊆ Role:editor` resolved `user:erin`→editor; torn down. typecheck + 135 units green.
- [x] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. → `compose.yml` adds `hydra`/`hydra-migrate` pinned to `oryd/hydra:v26.2.0` (Ory's unified train — same version as kratos/keto; verified latest); `hydra-migrate` runs `migrate sql -e --yes` against the per-service `hydra` DB after postgres is healthy, `hydra` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/hydra/hydra.yml` serves public 4444 + admin 4445, `urls.self.issuer` = the public OAuth2 URL, and `urls.login`/`consent`/`logout` point at our app routes (`/oauth2/login`, `/oauth2/consent`, `/oauth2/logout`; §6 renders the handlers, namespaced under `/oauth2/` so they don't collide with Kratos's first-party `/login`). Dev throwaway `secrets.system` (prod overrides via env). Hydra refuses an http issuer in prod, so `compose.override.yml` adds `serve all --dev` + exposes `4444` for dev (the full dev/prod split + health checks is the next §3 item). Tests-first (`hydra.test.ts`: version pin + migrate-before-serve + DSN→hydra DB + public/admin ports + issuer/login/consent/logout URLs). Boot-verified end-to-end: migrate exits 0, public+admin `/health/ready` 200, OIDC discovery reports `issuer: http://127.0.0.1:4444/`, and a real authorization flow (created an OAuth2 client, hit `/oauth2/auth`) 302-redirected to `http://127.0.0.1:3000/oauth2/login?login_challenge=…` — our app; torn down. typecheck + 140 units green.
- [x] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering. → `compose.yml` (base/prod) adds busybox-`wget` `/health/ready` healthchecks to the long-running Ory services (kratos:4433, keto:4466, hydra:4444) and gates `web` on `kratos`+`keto` `service_healthy` (the services `config.ts` talks to — hydra is post-MVP §6, absent from config, so web doesn't gate on it; ordering is transitive through the migrate gates). Dev/prod split: prod publishes **no** internal Ory ports; `compose.override.yml` exposes only the host-facing ones the browser needs — kratos public 4433 (self-service flows POST to `flow.ui.action`, kratos.yml base_url) alongside the existing hydra 4444 + mailpit 8025. The visual E2E stays Ory-free via `depends_on: !reset []` on `web` in `compose.e2e.yml` (the dashboard is mock data — no Postgres/Ory boot). Tests-first (`compose.test.ts`: Ory healthchecks + web ordering + the port split + the e2e reset). Boot-verified the full dev stack with `--wait`: kratos/keto/hydra/postgres/mailpit all healthy, `web` started **only after** kratos+keto healthy, the host reaches kratos 4433 + hydra 4444 + web 3000 while keto 4466 is refused (internal-only); torn down. README **Development** refreshed (dropped the stale "Ory…planned" note). typecheck + 144 units green.
- [x] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box. → `src/bootstrap.ts` + a one-shot `bootstrap` compose service: runs after kratos+keto are healthy (web gates on its `service_completed_successfully`), idempotent so every `up` re-runs cleanly. (1) `ensureJwks` generates the ES256 signing key (reuses `gen-jwks.ts`) only when the committed dev key is absent — tokenizer dir mounted rw so it can land. (2) `seedAdmin` creates `admin@plainpages.local`/`admin` via the Kratos admin API (a re-run's 409 → look up + reuse the id). (3) grants `Role:admin#members@user:<id>` via the Keto write API (PUT, idempotent) — the source of truth the §4 login flow projects into the JWT. Migrations + default Ory configs already auto-run/committed (§3); OPL/namespaces load from `keto.yml` (§3). The password policy is bypassed by the admin API, so `admin`/`admin` is accepted. Tests-first: `bootstrap.test.ts` (payload builders, seed idempotency via mock fetch, generate-if-absent) + `compose.test.ts` (service wiring). Boot-verified the whole chain on the live stack: `docker compose up --wait` seeds with zero prep, Keto `check``allowed:true`, login with `admin@plainpages.local`/`admin` issues a session + tokenizes a JWT; re-run → "already present"; moving the committed key → "generated a JWKS signing key". JWT `roles` stays `[]` until §4 wires the Keto→`metadata_admin` projection. typecheck + 151 units green. The first-run banner (login URL + creds) and the prod-secret/SSO exception docs are the next §3 items.
- [x] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. → `firstRunBanner()` in `src/bootstrap.ts` (pure, testable) renders a boxed banner — login URL · seeded email/password · "⚠ change before production" — that `main()` prints after seeding. Login URL from `APP_URL` (compose default `http://localhost:3000`, overridable per deployment); creds reuse the seeded `ADMIN_EMAIL`/`ADMIN_PASSWORD`. Tests-first (`bootstrap.test.ts`: asserts URL + creds + warning present); README **Development** notes the banner. Live-verified: rebuilt bootstrap prints the banner after the admin line; typecheck + 152 units green; stack torn down.
- [x] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone. → New README **What you must supply (the only manual prep)** subsection (under Configuration) consolidates the previously-scattered facts into one authoritative list: a clean clone needs nothing; exactly two production-only things can't be auto-generated — (1) production secrets (`COOKIE_SECRET`/`CSRF_SECRET` + the JWT signing key, with `REQUIRE_SECURE_SECRETS=true` refusing throwaways) and (2) optional SSO provider creds (no creds ⇒ no button). States everything else (Ory migrations, dev signing key, demo admin + Keto roles, OPL model) is generated/seeded on first boot. Cross-links the existing SSO + JWT-rotation subsections (no duplication) and adds a pointer from **Production / deployment**. All four anchors verified; docs-only — typecheck + 152 units green.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §3 Ory stack). Verdict: architecture sound + disciplined, no Critical; both independently flagged the *same* top issue. **Fixed now:** (1) HIGH (both agents) — `JWKS_URL` default was `http://kratos:4433/.well-known/jwks.json`, but Kratos does **not** republish the session-tokenizer key there (no OIDC discovery on Kratos — that's Hydra), so the §4 verifier would have fetched the wrong/empty set and *no one* could be authorized. Repointed the default to `file:///etc/config/kratos/tokenizer/jwks.json` — the exact key Kratos signs with (`kratos.yml` `jwks_url`) — and mounted that tokenizer dir **read-only into `web`** (`compose.yml`) so the verifier resolves the live key in dev *and* prod (same file bootstrap regenerates). `config.test.ts` now locks the default to the tokenizer file + asserts the committed key is a real ES256 JWKS carrying a `kid` (the regression the old `/jwks/` match missed). (2) MEDIUM (stability) — `bootstrap` had uncapped `restart: on-failure`; a *permanent* seed error would loop forever and silently hang `web` (gates on `service_completed_successfully`). Capped to `on-failure:5` (seed is idempotent — 409-create + idempotent PUT — so transient Ory blips still recover, permanent ones give up loud). (3) §3's new `web` `depends_on` made the documented `docker compose run --rm web …` typecheck/test/gen-jwks commands drag up the whole Ory stack — added `--no-deps` (README + AGENTS.md). **Deferred (reviewer-scoped, not §3):** extract `buildShellContext` out of `dashboard.ts` + route built-in screens through `matchRoute`/`isAuthorized` → §5 (forcing function arrives with the 2nd/3rd screen); seed the demo admin's `metadata_admin.roles` projection so first login is non-empty → §4 (the login-completion projection owns it); enforce Ory `*.yml` prod secrets + self-service return-URLs via env → §9 (ops). typecheck + 153 units green; both compose files validated.
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §3 Ory accretion. Killed the now-stale "the next §3 item generates/mounts" forward-refs (the JWKS shipped) in `kratos.yml` (×2) + `kratos.test.ts`. Tightened the verbose service/header blocks in `compose.yml` (web depends_on/JWKS-mount, the three Ory headers, the bootstrap block) and the `bootstrap.ts`/`gen-jwks.ts` module headers — dropping prose the README/`src/bootstrap.ts` already carry, keeping the security/stability rationale (read-only mount, bounded retry). Trimmed `config.ts`'s JWKS comment and the `kratos.yml` SSO block (kept the concrete env example), and aligned the `gen-jwks.ts` command with the README's `--no-deps`. Net 12 lines; typecheck + 153 units green. The §3 README sections (Development / What you must supply / SSO / JWT rotation) were already authored concise in §3 (todo lines 7072) and left intact.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §3 Ory-stack tests. The clear overlap: the "image pinned to an exact version" AGENTS.md check was re-implemented 5× (postgres/kratos/keto/hydra + mailpit). Unified into one `compose.test.ts` scan over all three compose files (strictly stronger — auto-covers any future image) + one test asserting each Ory service & its migrate sidecar share one version (subsumes the per-service "both present + same version" halves). Dropped the now-redundant pin tests from `postgres/kratos/keto/hydra.test.ts` (each keeps its config-semantics tests; comments point pinning at `compose.test.ts`). Also trimmed `config.test.ts`'s duplicate re-validation of the committed JWKS key — `gen-jwks.test.ts` already owns key validity (round-trips a signature); the config test keeps the default-path assertion. The migrate-before-server / DSN / port / URL tests stay per-service (distinct config, distinct files — merging would hurt the per-module structure). 153 → 150 tests, zero coverage lost; typecheck + tests green.
## 4. Auth — identity, session JWT, guards
- [x] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. → `src/kratos-public.ts` (`createKratosPublic({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' public API, no SDK dep (built-in `fetch`), `fetchImpl`-injectable like `bootstrap.ts`. `initBrowserFlow(type, {cookie?, returnTo?})` GETs `/self-service/<type>/browser` with `Accept: json` (so Kratos returns the flow + CSRF `Set-Cookie` to relay, not a redirect); `getFlow(type, id, {cookie?})` reads `/self-service/<type>/flows?id=` forwarding the browser cookie; `submitFlow(action, {body, contentType?, cookie?})` POSTs urlencoded to the flow's `ui.action` (manual redirect) → `{ok, status, body, location, setCookie}` (200 success / 400 re-rendered flow-with-errors, no throw / 303 Location or 422 `redirect_browser_to`); `whoami({cookie?, tokenizeAs?})` reads `/sessions/whoami``Session|null` (401⇒null), with `?tokenize_as=plainpages` returning the session's `tokenized` JWT. Fail-loud `KratosError` carries `.status` (so §4 line 81 can re-init on an expired 404/410). Flow `ui.nodes` typed loosely — rendering/field-error mapping is §4's renderer. Tests-first (`kratos-public.test.ts`, mock fetch: URLs/JSON-accept/cookie relay/Set-Cookie/tokenize query + 410/500 errors + 400 validation + redirect targets). Building block — no route/E2E yet (the themed flow pages + login completion are the next §4 items). README **Layout** lists it. typecheck + 159 units green.
- [x] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. → `src/kratos-admin.ts` (`createKratosAdmin({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' admin API (admin port), no SDK, `fetchImpl`-injectable like `kratos-public.ts`; reuses that module's `KratosError` (carries `.status`). `createIdentity` (POST, 201), `getIdentity` (GET, 404⇒`null`), `listIdentities({credentialsIdentifier?, ids?, pageSize?, pageToken?})``{identities, nextPageToken}` (parses the keyset cursor from the `Link` rel="next" header for the §5 users list), `updateIdentity` (full PUT), `deleteIdentity` (DELETE, 204), and `updateMetadataAdmin` — the key login-completion method: `PATCH` JSON-Patch `add /metadata_admin` so it sets the roles projection whether the field is absent/null/set and never clobbers traits/state. Building block — no route/E2E yet (login completion §4 line 83 wires it; the projection feeds the tokenizer's `metadata_admin` mapper, §3). Tests-first (`kratos-admin.test.ts`, mock fetch: URLs/method/JSON-Patch body/query+pagination/Link parsing + 201/200/404/409 mapping). README **Layout** lists it. typecheck + 167 units green.
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>``getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
- [x] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}``buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
- [x] Login completion: read roles from Keto → write `metadata_public` projection → tokenize → set JWT cookie. → `src/login.ts` (`completeLogin`/`readRoles`/`sessionCookie`, `SESSION_COOKIE`), wired into `app.ts` at `GET /auth/complete` — where `kratos.yml` now lands the browser after a successful login (`login.after.default_browser_return_url`). The route: `whoami(cookie)` → identity (id/email; no session ⇒ 303 `/login`); `readRoles` lists `Role:*#members@user:<id>` from Keto (one paged read, sorted/de-duped; group→role transitivity is §5); projects `{roles}` onto the identity; then `whoami(tokenize_as: plainpages)` → the signed JWT, stored as `plainpages_jwt` (HttpOnly + SameSite=Lax + 30d, `secure` deferred to §9). `server.ts` builds the kratos-admin + keto clients and passes all three to `createApp`. **Design bug caught in live boot-verify + fixed:** the projection had to move `metadata_admin``metadata_public` — Kratos *strips admin metadata* from the session the tokenizer reads, so `metadata_admin` yielded `roles:[]`; `metadata_public` is carried (and the user already reads these coarse roles in their own JWT, so nothing leaks). Touched `kratos-admin.ts` (`updateMetadataAdmin``updateMetadataPublic`, `/metadata_public` patch), the tokenizer jsonnet, and the kratos.yml/README rationale. Tests-first: `login.test.ts` (readRoles paging/dedup; completeLogin order whoami→project→tokenize; no-session⇒null; missing email⇒null; no-JWT⇒throw; cookie flags) + `app.test.ts` integration (`/auth/complete` projects roles, sets `plainpages_jwt`, 303→`/`; no session ⇒ 303 `/login`, no cookie) + `kratos.test.ts` (after-login URL + jsonnet metadata_public). Boot-verified the whole chain live: real admin login → `/auth/complete` → JWT `{sub, email, roles:["admin"], expiat=600}`, identity re-projected `metadata_public:{roles:["admin"]}` from Keto (wiped first to prove the write); no-session ⇒ 303 `/login`; torn down. The full-stack login Playwright E2E is owned by §8. typecheck + 189 units green.
- [x] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles). → `src/jwt-middleware.ts` (`authenticate`/`verifyToken`/`validateClaims`/`claimsToUser`) is the per-request hot path that never calls Ory: read the `plainpages_jwt` cookie → `decodeJws` the `kid` → resolve the verify key from the cached JWKS → `verifyJws` (§0 signature/alg-confusion guards) → validate claims → project the `User` (`sub`→id, email, roles). `src/jwks.ts` (`JwksProvider`, `loadJwks`, `staticJwks`) is the key-by-`kid` seam: `loadJwks` reads the mounted `file://` tokenizer key (dev default + prod mount) or a `base64://` inline set; `staticJwks` picks by `kid`, falling back to the sole key when a token carries none — **HTTP fetch + TTL cache + rotation-on-miss is the next §4 item (line 85)**; the interface lets it drop in without touching callers. Claim checks: `exp` required + `nbf` honoured, both with a 60s clock-skew leeway; `iss`/`aud` are **opt-in** — validated only when `JWT_ISSUER`/`JWT_AUDIENCE` are pinned (new optional `config.ts` fields), because the Kratos tokenizer sets neither (a clean clone must still verify). `authenticate` **fails closed**: any bad/expired/malformed token ⇒ `null` (anonymous), so the route renders signed-out and the §2 permission gate denies. Wired into `app.ts` — verify once per request (after the static short-circuit, before routing/hooks), thread `user` into both the base and route `RequestContext`, and feed `ctx.roles` (was `[]`) into the dashboard nav; `server.ts` loads the mounted JWKS at boot + passes the pinned iss/aud. Tests-first: `jwt-middleware.test.ts` (key-by-kid across a rotated set, exp/nbf + skew, iss/aud only-when-configured, bad-sig/unknown-kid, claimsToUser sub/email/roles, authenticate fail-closed matrix), `jwks.test.ts` (kid select/sole-key/miss + file/base64/reject-http), `config.test.ts` (iss/aud optional), `app.test.ts` (a verified cookie authorizes the gated `/demo/secret`; no-cookie/expired ⇒ 403). typecheck + 199 units + 7 E2E green; boot-smoked server.ts loading the mounted key. The live-stack token-refresh/timeout E2E is the §4 line 90 item; the full login E2E is §8.
- [x] JWKS fetch + cache + rotation handling. → `src/jwks.ts`: `cachingJwks(load, opts)` self-refreshing provider behind the existing `JwksProvider.getKey` seam (drop-in, callers untouched) — holds keys for `ttlMs` (5m), reloads on the next lookup past TTL, and on a `kid` miss reloads **once more** (rotation-on-miss → a freshly-prepended key verifies without a restart, README zero-downtime rotation), throttled by `minRefetchMs` (60s) so a stream of bogus kids can't hammer the source. A reload failure keeps the last-good set (transient resilience); only a cold cache propagates the error (→ middleware fails closed). Concurrent loads coalesce on one in-flight promise. `createJwksProvider(jwksUrl)` routes by scheme + primes at boot (fail loud): `base64://` → immutable `staticJwks`; `file://` → re-readable cache (rotation by remount/edit); `http(s)://` → new `fetchJwks` (Accept JSON, non-2xx throws). `server.ts` now `await createJwksProvider(config.jwksUrl)` (top-level await already present) — replaces `staticJwks(loadJwks(...))`. Tests-first (`jwks.test.ts`: TTL cache+expiry, rotation-on-miss + throttle, last-good-on-error vs cold-load-propagates, scheme routing + http prime/cache + fail-loud on non-2xx/missing-file/bad-scheme). README **Layout** line updated; the **JWT signing key & rotation** + flow-diagram cache notes already described this. typecheck + 203 units green; boot-smoked the file:// prime path. Guards/re-mint/logout/CSRF are the next §4 items.
- [x] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). → `src/guards.ts`: in-handler authorization (imperative counterpart to the §2 declarative route `permission` gate; the JWT was already verified once by the §4 middleware → `ctx.user`/`ctx.roles`, so these never call Ory for the coarse tiers). `requireSession(ctx)` asserts a session → returns the `User`, else throws `GuardError(401, location:/login)`; `can(ctx, role)` is the coarse zero-I/O JWT-claim predicate (anonymous ⇒ false); `check(keto, ctx, {namespace, object, relation})` is the one live Keto call (fine-grained relationship tier, README) — subject = `user:<id>`, anonymous ⇒ false fail-closed (no call). New `GuardError {status, location?}`; `app.ts`'s request catch maps it (location ⇒ 303 redirect, else render the 403 page) **before** the 500 path, so a guard thrown anywhere in handling becomes the right response, never a 500. Tests-first: `guards.test.ts` (requireSession return/throw, `can` matrix, `check` subject + fail-closed) + an `app.test.ts` HTTP integration (anonymous → `/login`, `can`/`check` pass → 200 / fail → 403). README **Building blocks** + `docs/plugin-contract.md` Routes document them (dropped the "land with §4" marker). typecheck + 207 units green. Session re-mint / logout / CSRF are the next §4 items.
- [x] Session re-mint on TTL expiry (re-read roles from Keto). → "stay signed in": the ~10m JWT lapses but the 30d Kratos session lives, so the hot path silently re-mints instead of dropping to anonymous. `jwt-middleware.ts` now classifies the cookie via `resolveSession``{user, expired}` (`TokenError.expired` set only on a lapsed-but-intact token); `authenticate` delegates to it. `login.ts` adds `remintSession` (reuses `completeLogin`: whoami → re-read roles from Keto → re-project → re-tokenize → fresh cookie + refreshed user — the one moment authz recomputes) + `clearSessionCookie` (Max-Age=0). `app.ts` hot path: only when the token is *expired* (not absent/garbage) **and** the Ory clients are wired does it re-mint, setting the cookie via `res.setHeader` so it rides whatever response follows; a dead Kratos session clears the stale cookie so later requests fall straight through to anonymous (no per-request Ory hit). Tests-first: `jwt-middleware.test.ts` (resolveSession lapsed-vs-absent/tampered matrix), `login.test.ts` (remintSession live→fresh / dead→clearing), `app.test.ts` (expired+live session → gated route runs + fresh cookie; expired+dead session → 403 + cleared cookie). typecheck + 210 units green. Live-stack token-timeout/refresh Playwright E2E is the §4 line 90 item.
- [x] Logout: revoke Kratos session + clear cookie. → `GET /logout` (`app.ts`): clears our local `plainpages_jwt` (`clearSessionCookie`, Max-Age=0) **and** revokes the Kratos session. Kratos' own cookie lives on its origin, so we can't expire it from here — instead `kratos.createLogoutFlow(cookie)` (new `KratosPublic` method, `GET /self-service/logout/browser``{logoutToken, logoutUrl}`, 401⇒null) and 303 the browser to `logoutUrl`; Kratos revokes the session, clears `plainpages_session`, and lands on `/login` (`kratos.yml` `logout.after`, already configured). No active session ⇒ just clear our cookie + 303 `/login`. Wired the inert shell "Sign out" button → `<a href="/logout">` (zero-JS, matches the menu's existing link items). Tests-first: `kratos-public.test.ts` (logout flow 200→urls / 401→null + cookie forwarded), `app.test.ts` integration (active session → Kratos logout URL + cleared JWT; no session → `/login` + cleared JWT), `shell.test.ts` (sign-out link wired). typecheck + 212 units green. Boot-verified live: admin login → `/logout` 303s to the real `…/self-service/logout?token=ory_lo_…` with `plainpages_jwt` cleared, following it revokes the session (`whoami` 200→401) and redirects to `/login`; no-session `/logout``/login`; torn down.
- [x] Secure cookie flags; CSRF for our own POST forms. → **Secure flag:** new explicit `SECURE_COOKIES` toggle (`config.ts`, default off — dev is http; `compose.yml` sets it `true`, `compose.override.yml`/`compose.e2e.yml` `false`), threaded through every first-party Set-Cookie (session JWT, clear, re-mint, CSRF). **CSRF:** `src/csrf.ts` — stateless **signed double-submit** token `<nonce>.<HMAC-SHA256(CSRF_SECRET, nonce)>` (node:crypto, no dep): `issueCsrfToken`/`verifyCsrfToken` (self-validating, timing-safe), `ensureCsrfToken` (reuse a genuine `plainpages_csrf` cookie, else mint — one token across tabs), `csrfCookie` (HttpOnly+Lax, secure opt-in), `verifyCsrfRequest` (cookie genuine **and** field echoes it). `src/body.ts` `readFormBody` (size-capped urlencoded reader; §5 forms reuse it). Applied to our one first-party form: **logout is now a CSRF-guarded `POST`**`shell.ejs`'s Sign-out is a `<form method=post action=/logout>` with a hidden `_csrf` (semantic win: a state change is a form, not a GET link), `app.ts` issues the token cookie on `GET /` and verifies it on `POST /logout` (bad/missing → 403, before any Kratos call); `dashboard.ts``index.ejs`→shell thread the token. Kratos' own flows keep Kratos' CSRF; the host does **not** auto-gate plugin routes (they own their body/safety per the contract). Switched the cookie-setting sites to `appendHeader` so the CSRF cookie coexists with others. Tests-first: `csrf.test.ts`/`body.test.ts` + extended `config`/`dashboard`/`shell`/`app` tests (logout POST: valid→Kratos logout + cleared JWT, no-session→/login, missing/forged→403) + an Ory-free E2E (GET / issues the cookie + matching form token; tokenless POST→403). typecheck + 217 units + 8 E2E green. Boot-verified live on the full stack: GET / double-submit token matches; admin login → `POST /logout` 303s to the real `…/self-service/logout?token=ory_lo_…` with the JWT cleared; no-session→/login; forged/missing→403; torn down.
- [x] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something). → New full-stack Playwright suite `e2e/auth-refresh.spec.ts` (run via `compose.e2e-auth.yml`): boots the **real** Ory stack (Postgres + Kratos + Keto + bootstrap + web), logs in the seeded admin, completes login on web → session JWT, then proves the §4 "stay signed in" hot path end-to-end — once the token lapses the next request is silently **re-minted** from the live Kratos session (fresh JWT, later `exp`, roles re-read from Keto = `["admin"]`); revoking the Kratos session (admin API) then makes the next lapsed request **clear** the stale cookie (→ anonymous). To make timeout/refresh observable in seconds not ~10m: `ory/kratos/e2e.yml` (merged via a second `-c`) shortens the tokenizer `ttl` to **8s** and points `serve.public.base_url` at `kratos:4433` (so the runner drives self-service over the compose network), and a new explicit **`JWT_CLOCK_SKEW_SEC`** config (default 60, the E2E sets `0`) makes web treat the JWT as expired the instant its ttl lapses instead of +60s. The flow is driven over HTTP (fetch + manual cookie relay) because Kratos/web sit on different hosts here — it exercises web's own server-side relay; the browser-UI login stays §8. Scoped the existing visual suite to `visual.spec.ts` (stays Ory-free/fast) so the two suites don't cross-run. Tests-first for the config knob (`config.test.ts`). Verified live: auth suite green (re-mint + clear), visual suite still 8/8 green; typecheck + 218 units green; both stacks torn down.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §4 auth hot path). Verdict: no Critical/High; both confirmed the auth core (alg-allowlist JWS verify, fail-closed `resolveSession`, key-by-`kid` cache, timing-safe CSRF, traversal guards) is sound, and that a tampered/garbage cookie **can't** drive the Ory re-mint round-trip (only a validly-signed, time-expired token sets `expired`). **Fixed now (tests-first):** (1) MEDIUM (stability) — the re-mint hot path turned an Ory *outage* into a 500 on **every** lapsed request (a dead Kratos session returns `null` and clears cleanly, but a 5xx/refused/timeout *throws* and escaped to the 500 handler). Wrapped the `remintSession` call in `app.ts` in try/catch → degrade to **anonymous** (route renders signed-out / guard bounces to `/login`), and leave the cookie untouched so it re-mints once Ory recovers; `app.test.ts` re-mint test now also asserts outage→403-not-500 + no cleared cookie. (2) MEDIUM (architecture) — a plugin folder named after a host route (`login`/`logout`/`auth`/`public`/`recovery`/`registration`/`settings`/`verification`) would **silently shadow** it (plugin routes resolve first), the one collision `findConflicts` didn't catch. Added `RESERVED_PLUGIN_IDS` (`plugin.ts`) checked in `discovery.ts` → fails boot loud, like every other conflict; documented in `docs/plugin-contract.md` Identity; `discovery.test.ts` covers it. **Deferred (reviewer-scoped, not §4):** extract `buildShellContext` out of `dashboard.ts` + thread the real `ctx.user` into the shell (kills the hardcoded "Sam Rivers" demo profile) **and** give the host its own internal route table via `matchRoute`/`isAuthorized`**§5** (the 2nd/3rd built-in screen is the forcing function; the hardcoded user is the one user-visible §4 gap, so §5 opens with it); `/auth/complete` login-CSRF hardening + the `POST /logout` oversized-body→500 papercut → **§9** (security headers/CSRF/cookies); retarget the stale `safeUrl()` §4 reference in the contract doc → the next §4 comment-cleanup item (line 92), helper itself deferred to §5/§7 when untrusted URL data first flows. No action: forwarding the full cookie header to Kratos on re-mint (works, mild over-coupling), the deliberately-opt-in `iss`/`aud` claim checks, the `serializeCookie` length bound. typecheck + 219 units green.
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §4 auth accretion (the §3 cleanup at line 74 stands). The §4 comments were authored dense, so the wins are targeted: tightened the verbose client module-headers — `kratos-public.ts` (dropped the "themed flow pages build on this" forward-ref, kept the loose-`ui.nodes`-types rationale), `kratos-admin.ts` (folded the admin-port note up, trimmed the `KratosError` restatement), `keto-client.ts` (dropped the caller-listing tail). Retargeted the stale `safeUrl()` ref in `docs/plugin-contract.md` (the §4 reviewer flag at line 91): the helper was deferred to §5/§7, not §4. Left intact: app.ts's per-branch *why* comments (right altitude for scanning the request flow), config.ts's dense field notes, and the §4 README **Auth, sessions & permissions** sections (the canonical design rationale, authored concise in §4). `_(planned)_` markers stay for §9 (line 133 owns dropping them). typecheck + 219 units green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §4 auth tests. The clients (`kratos-public`/`kratos-admin`/`keto-client`) and the focused units (`jwks`/`flow-view`/`guards`/`csrf`/`body`/`login`) already follow the per-module "matrix + edge" pattern, no fat to cut. Removed the two genuine §4-era overlaps: (1) `jwt-middleware.test.ts` re-ran `resolveSession`'s whole classification matrix again under `authenticate` — but `authenticate` is just `resolveSession(...).user`, so merged into one test where `resolveSession` owns the matrix and `authenticate` is asserted as its fail-closed user-projection (kept `authenticate` itself — a documented convenience export, just not double-tested). (2) `app.test.ts` had two `/auth/complete` HTTP tests (live-session vs no-session) for one route → merged into one (happy path + edge), mirroring the project's style. 219 → 217 tests, zero coverage lost; typecheck + tests green.
## 5. Built-in admin screens (writes go only to Keto/Kratos)
- [x] Users: list (Kratos identities) with filter/sort/pagination; create/edit/deactivate/delete; trigger recovery. → `src/admin-users.ts`: pure view-model + Kratos-payload builders (`toUserView`, `buildUsersListModel`, `buildUserFormModel`, `create/updateIdentityPayload`, `setStatePayload`) + `handleAdminUsers` (the imperative shell app.ts dispatches `/admin/users*` to). Routes: `GET /admin/users` (list — filter by q/status, sortable headers, paginate; in-memory over one fetched Kratos page since the admin API has no search/sort), `GET|POST /admin/users/new`+`/` (create), `GET|POST /admin/users/:id` (edit; email is the read-only login identifier, name editable, optional initial password), `POST …/:id/state` (deactivate↔reactivate), `…/delete`, `…/recovery` (mints a code via the new `kratosAdmin.createRecoveryCode` admin endpoint, renders the link). Writes go **only to Kratos** (README "stateless"). Gated **admin-only** (anonymous→/login, non-admin→403 via `GuardError`) and every mutation is **CSRF-guarded** (signed double-submit, like logout); reuses the §1 building blocks (filter-bar/data-table/pagination/field) around the app shell. Reviewer's §5 opener done too: extracted `src/shell-context.ts` (`buildShellContext`/`shellUser`) shared by the dashboard + admin screens — kills the hardcoded "Sam Rivers" demo profile, threads the **real** signed-in user (email/derived initials; anonymous→Guest); `dashboard.ts` + `app.ts` now pass `ctx.user`. Added `readonly` to `field.ejs`, `admin` to `RESERVED_PLUGIN_IDS` (a plugin folder can't shadow the screens), `views:[viewsDir]` to the core renderer (so a subfolder view includes the shared `partials/` by root-relative name). Tests-first: `admin-users.test.ts` (mapping/selection/payload matrix), `app.test.ts` HTTP integration (gate/list-filter/create/edit/state/delete/recovery + CSRF reject), `shell-context.test.ts`, `kratos-admin.test.ts` (recovery endpoint), `discovery.test.ts` (reserved `admin`). typecheck + 228 units + 8 visual E2E green. Boot-verified live on the full Ory stack: seeded-admin login → JWT `roles:["admin"]``/admin/users` lists identities; create→303→listed, recovery→real Kratos code/link, state→inactive, delete→absent, forged CSRF→403; torn down. Groups/roles/menu-wiring are the next §5 items.
- [x] Groups: Keto subject sets — list/create/delete + membership management. → `src/admin-groups.ts`: pure view-model + Keto-tuple builders (`groupsFromTuples`, `parseSubject`/`memberTuple`, `memberView`, `isValidGroupName`, `buildGroups{List,Detail,Form}Model`) + `handleAdminGroups` (the imperative shell app.ts dispatches `/admin/groups*` to). A group is a Keto subject set `Group:<name>#members`; a member is a user (`subject_id=user:<uuid>`) or a nested group (`subject_set=Group:<other>#members`). Keto has no create-object, so a group exists while it has ≥1 member: **create** writes the first-member tuple (requires a member, rejects a duplicate/invalid name), **delete** removes every member tuple (one delete-by-partial-filter), **add/remove member** write/delete one tuple. Routes: `GET /admin/groups` (list — search/sort/paginate over one Keto namespace scan), `GET|POST /admin/groups/new`+`/` (create), `GET /admin/groups/:name` (membership detail — members by email, add a user/nested group, remove, delete-group), `POST …/members` · `…/members/delete` · `…/delete`. Writes go **only to Keto** (README "stateless"); Kratos is read only to label the member pickers by email. Gated **admin-only** (anon→/login, non-admin→403) and every mutation **CSRF-guarded**, same as Users; reuses the §1 building blocks around the shell. Extracted `src/admin-nav.ts` (shared Dashboard·Users·Groups sidebar nav) so the two screens can't drift; added a generic `rowHeader` `<th scope=row>` data-table cell (the group name links to its detail). Tests-first: `admin-groups.test.ts` (builder/validation/subject matrix), `app.test.ts` HTTP integration (gate/list/create/dup-reject/detail/add/remove/delete + CSRF + invalid-name & malformed-`%`→404), `data-table.test.ts` (rowHeader). Stability-reviewer (treated as a local PR): APPROVE; fixed its nits — symmetric subject validation (UUID-check the user id), "already exists" feedback on create, malformed-`%`→404 (`safeDecode`). typecheck + 237 units green. Boot-verified the core Keto interactions live (namespace listing, group-collapse counts, delete-group-by-filter, single-member removal). The full-stack groups-CRUD Playwright E2E is §8's scope (line 123), as with the Users screen. Roles/permissions + global-menu wiring are the next §5 items.
- [x] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. → `src/admin-roles.ts`: a role is a Keto subject set `Role:<name>#members` (OPL: members are users or groups, resolved transitively — the source of truth the §4 login projects into the JWT). Same shape as the Groups screen, so the pure membership helpers are reused from `admin-groups.ts` (`parseSubject`, `isValidGroupName`, `memberView`, `groupsFromTuples`, and now-exported `pagedTuples`/`memberCandidates`/`safeDecode`). Routes (`handleAdminRoles`, dispatched by app.ts): `GET /admin/roles` (list — search/sort/paginate over one Keto scan), `GET|POST /admin/roles/new`+`/` (create = assign first member; rejects invalid/duplicate name), `GET /admin/roles/:name` (detail), `POST …/members` (assign a user/group) · `…/members/delete` (revoke) · `…/delete` (remove all member tuples). The one role-specific piece is **effective access**: `keto.expand(Role:<name>#members, {maxDepth:50})``expandToEffectiveUsers` flattens the tree to the distinct users who hold the role directly *or transitively via a group* (the coarse JWT projection stays direct-only per the README's one-read-per-login design; this view is where group→role inheritance is surfaced). Writes go **only to Keto**; Kratos is read only to label members. Gated admin-only (anon→/login, non-admin→403) + CSRF-guarded, like Users/Groups. Added a "Roles" entry (`i-shield`) to the shared `admin-nav.ts`; new `.plain-list` CSS rule. Tests-first: `admin-roles.test.ts` (builders + expand-flatten matrix) + `app.test.ts` HTTP integration (gate/list/create/dup-reject/assign user&group/effective-access-via-expand/revoke/delete + CSRF + malformed-name→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its expand-depth nit (explicit `maxDepth`). 237→243 units + typecheck green. **Live boot-verify caught a real bug the tests missed:** Keto v26.2.0's expand nests the subject under `tuple` (`{type:"leaf",tuple:{subject_id}}`), not at the node top-level as the §4 `ExpandTree` type had guessed — fixed the type + walker + the (wrongly-shaped) fixtures, then re-verified live that a user reachable only through a group surfaces in effective access; torn down. Global-menu wiring is the next §5 item.
- [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles``roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security).
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §5 admin accretion. The §5 code was authored dense, so the wins are targeted: tightened the three near-identical module-header blocks (`admin-users`/`admin-groups`/`admin-roles`) — dropped per-file restatement the README/code already carry (subject-form detail → "see parseSubject", "no user/group store" → covered by README "stateless", the verbatim "it gates… CSRF-guards… maps each action to a RouteResult" boilerplate → "gated admin-only, CSRF-guarded"). README **Layout**: compressed the `views/` run-on (long admin/ + per-body-partial enumeration → grouped) and fixed an accuracy gap — it now lists the §5 delete-confirm view. Left intact: the EJS view config-doc headers (the only schema for untyped locals), the security-rationale comments, and the legitimate §9 forward-ref in `admin-roles.ts` (the deferred last-effective-admin check). Docs/comments-only (per AGENTS.md, no stability-reviewer needed); typecheck + 244 units green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §5 admin tests. The genuine §5-era duplication was all in `app.test.ts`: the three admin-screen HTTP tests (Users/Groups/Roles) each repeated an identical ~13-line harness preamble (createApp + listen + url + CSRF token + admin cookie + get/post), an identical 5-line gate block, and a stateful in-memory `KetoClient` defined 3× (the trivial `stubKeto` + two byte-identical inline fakes). Unified into shared helpers — `adminHarness(t, opts)``{url, token, get, post}`, `assertAdminGate(url, get, path)`, and one `fakeKeto(tuples?, over?)` that subsumes `stubKeto` (the login tests now use `fakeKeto([], …)`) and both inline admin fakes (`fakeKeto(tuples)` / `fakeKeto(tuples, { expand })`); hoisted the shared `sameSet`/`matchesTuple` up next to it. The per-module unit files (admin-users/groups/roles + the focused units) already follow the deliberate matrix pattern and the §3/§4 "don't force-merge across distinct modules" rule, so the near-identical `build*ListModel` tests stay per-file (each guards its own function; the source-side list-model dedup is the deferred arch-M3 item, not the test side). 30 net lines, zero coverage lost; typecheck + 244 units green.
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
- [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest`**skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=<absolute self URL>`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items.
- [x] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. → `src/hydra-admin.ts` gains the consent half of the handshake (`getConsentRequest`/`acceptConsentRequest`/`rejectConsentRequest` + `ConsentRequest`/`AcceptConsent`/`ConsentSession` types; the login/consent URL builder folded into one `reqUrl(kind,…)` + a shared `put()`). `src/oauth-consent.ts` (pure, sibling of `oauth-login.ts`): `resolveConsentChallenge`**skip** (Hydra already consented / a skip-consent client) or **first-party** (the client's Hydra `metadata.first_party === true`) ⇒ auto-accept, else return a `view` to show the themed consent screen; `acceptConsent` (re-reads the challenge so scopes/audience are **never** client-supplied) + `rejectConsent` (access_denied). The grant carries an OIDC `session.id_token` with `email`/`name` projected from the Kratos identity (`whoami` traits; absent ⇒ omitted). Wired in `app.ts` at `GET|POST /oauth2/consent` (gated on `hydra`+`kratos`): GET shows/auto-accepts (sets the page CSRF cookie when fresh), POST is **CSRF-guarded** (same signed double-submit as `/logout`) and dispatches `decision=allow`→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a genuine outage (5xx) → 500 (mirrors `/oauth2/login`). `views/oauth-consent.ejs` + `partials/consent-body.ejs` reuse the auth-card: the consent screen lists the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: `hydra-admin.test.ts` (consent request contracts), `oauth-consent.test.ts` (skip/first-party/third-party/audience/id_token/accept-refetch/reject matrix), `app.test.ts` HTTP integration (auto-accept / screen render+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended the full-stack E2E `e2e/oauth-login.spec.ts` to drive the **whole** authorization-code flow against real Hydra — login accept → follow the login_verifier through Hydra → web's consent screen (third-party client `e2e-login`, scopes listed) → Allow → consent_verifier → the client callback with a real `code` (per-host cookie jars; Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green; stack torn down. OAuth2 client registration is the next §6 item.
- [x] OAuth2 client registration (admin UI or CLI). → Built-in **OAuth2 clients** admin screen (`src/admin-clients.ts`, `/admin/clients`) — the §6 client side of Hydra (apps that log in *through* us). `src/hydra-admin.ts` gains the client half of the admin API: `createClient`/`listClients`/`getClient`/`deleteClient` over `/admin/clients` (+ a `nextPageToken` Link parser, mirrors kratos-admin) and the registration fields on `OAuth2Client`. The screen mirrors the §5 Users/Roles pattern — pure builders (`toClientView`, `clientPayload`, `validateClientInput`, `parseRedirectUris`, `buildClients{List,Form,Detail}Model`) + `handleAdminClients` (the imperative shell app.ts dispatches `/admin/clients*` to). Routes: `GET /admin/clients` (list — search/paginate over one Hydra page), `GET|POST /admin/clients/new`+`/` (register), `GET /admin/clients/:id` (read-only detail), `GET|POST …/:id/delete` (confirm + delete). Register builds a standard authorization-code client (+ refresh_token), confidential (`client_secret_basic`) or public (PKCE, `none`), with an optional first-party (auto-consent) flag; **Hydra returns the `client_secret` once**, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) — never re-shown (`getClient` carries no secret; detail asserts it). Writes go **only to Hydra**; gated admin-only (anon→/login, non-admin→403) + every mutation CSRF-guarded, like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx → 500 (mirrors `oauth-login.ts`); `:id` via `safeDecode` (malformed→404). Wired into the shared `adminSection` (Users·Groups·Roles·**OAuth2 clients**, `i-globe`) so it shows for admins, invisible otherwise. New views (`admin/clients`,`client-form`,`client-detail` + `partials/client-{form,detail}-body`) reuse the shell/filter-bar/data-table/field blocks; one `.detail-list` CSS rule. Tests-first: `hydra-admin.test.ts` (client CRUD contracts incl. Link pagination/404→null/204), `admin-clients.test.ts` (builder/validation/payload matrix), `app.test.ts` HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-`%`→404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead `URL.protocol` check in `validateClientInput`). Boot-verified the client CRUD live against real Hydra v26.2.0 (create→201 w/ one-time secret → list finds it → get → delete → get null); torn down. typecheck + 274 units green. Review/comment/test-cleanup are the next §6 items.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §6 Hydra OAuth2 surfaces). Architecture: **no Critical**; Product: **no Critical** this checkpoint. **Fixed now (tests-first):** (1) HIGH (arch) — `/oauth2/logout` was published to Hydra (`hydra.yml` `urls.logout`) and asserted in `hydra.test.ts`, but had **no handler** (a dead/published contract — Hydra's RP-initiated logout would 404). Added `hydra-admin.acceptLogoutRequest` (PUT logout/accept, folded into the shared `reqUrl(kind…)`) + a `GET /oauth2/logout` branch: accept the `logout_challenge` → 303 to Hydra's post-logout redirect; missing challenge → 400; a stale/consumed 4xx → recoverable 400, a 5xx outage → 500 (byte-identical degrade to the login/consent siblings). GET-accept is safe — the challenge is Hydra-minted + single-use; the first-party `POST /logout` still owns ending the Kratos session + our JWT cookie. (2) HIGH (arch) — added `oauth2` to `RESERVED_PLUGIN_IDS` (a `plugins/oauth2/` folder would silently shadow the provider routes — the one route surface the §4 reserved-id fix didn't cover; discovery now refuses it loud). (3) Product **Blocker** — the consent screen never told the user *whose* account they were authorizing (informed-consent gap on shared devices). It now renders "Signed in as `<email>`" (`ConsentView.account` from `whoami`) + a "Not you? Sign out" form (CSRF-guarded by the same signed double-submit). (4) MEDIUM (arch) — consent `accept()` now projects id_token claims **only when** the live Kratos session subject `===` the challenge subject Hydra bound at login (never leak a mismatched session's email/name into the issued token; guards the auto-accept path too). (5) Product nits — register-form confidential-vs-public guidance ("Browser/mobile apps can't keep a secret — choose Public…") and a client-detail "to change a client, delete and re-register — the secret is shown only once" note (covers the no-edit friction + lost-secret-on-reload). Stability-reviewer run as a local PR: **APPROVE, no Critical/High**; addressed its actionable follow-ups (README §6 now documents the logout handler + the consent identity line; a comment notes the GET-accept is Hydra-validated). Extended `e2e/oauth-login.spec.ts` to assert the consent screen names the signed-in account; **boot-verified the full OAuth2 login+consent flow live against real Hydra v26.2.0** (E2E green) then torn down. typecheck + 279 units green. **Deferred (reviewer-scoped, not the §6 checkpoint):** the host **internal route-table** (fold the admin/oauth if-ladder into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` from it — arch M1, the long-deferred §2/§5 item) → **§9**: H1/H2 are now point-fixed, so M1 is reduced to a pure dedup/structural improvement (Medium) best done as a focused standalone change to the central dispatcher, not bundled into a review-fix; the **RP-initiated-logout browser/live E2E** (needs token-exchange + `id_token_hint` + `post_logout_redirect_uris`) → **§8** (owns full E2E — the handler is unit+HTTP covered and reuses the `complete()` path already live-verified by the login/consent accepts); the redirect-URI **scheme allowlist** + the `safeUrl()` href helper (arch L1) → **§7** (first untrusted-URL flow); full **client edit** (registration-only by design — the detail page now says delete+re-register), the blank **empty-list** state (known §5 deferral), and success-flash after writes → §8/polish; unconditional `refresh_token` + raw custom-scope labels (product 🟢) → future.
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §6 Hydra/OAuth2 accretion. The §6 modules (`hydra-admin`/`oauth-login`/`oauth-consent`/`admin-clients`) were authored dense, so the wins are in `app.ts`, where the three sibling OAuth2 handlers had drifted toward repeating the same prose: trimmed the `/oauth2/login` block header (dropped "Challenge looked up over Hydra's admin API" — derivable from `resolveLoginChallenge` — and condensed the provider-role sentence to "Provider-only.", matching the consent/logout blocks); collapsed the `/oauth2/consent` degrade comment to the same one-liner the `/oauth2/logout` sibling already uses, so the canonical 4xx→400 / 5xx→500 explanation lives once (the `/oauth2/login` block). Left intact: the logout block's GET-accept safety rationale (reviewer-requested, todo line 108), the EJS view config-doc headers (the only schema for untyped locals), and the §6 README **OAuth2 provider (Hydra)** + admin-clients sections (authored concise in §6, todo line 108). The README **Status** note / `_(planned)_` markers / Layout refresh stays §9's (line 133). typecheck + 279 units green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §6 Hydra/OAuth2 accretion (the per-module matrices in `hydra-admin`/`oauth-login`/`admin-clients` are one-contract-per-test — no fat). Removed the genuine §6 overlaps: (1) the stale-4xx→400 / outage-5xx→500 degrade was **triplicated** across the `app.test.ts` `/oauth2/login`, `/consent`, `/logout` tests with near-identical app-spin-up boilerplate — production aims for "byte-identical" degrade across the three, so it's now one parametrized test (`OAuth2 challenge endpoints degrade identically`) iterating the three endpoints × {410→400, 503→500}, which both removes ~27 lines and makes the shared contract explicit/enforced; the three endpoint tests keep their happy-path + missing-challenge→400. (2) `oauth-consent.test.ts`: merged the two consent-screen view tests (account named when signed in / omitted when not — same `view` surface, one variable) and the two `acceptConsent` grant tests (scope re-read + id_token on subject-match / omitted on mismatch — same method's grant body). Pure test refactor, no production code touched; every assertion preserved. 279 → 278 units; typecheck + tests green.
## 7. Example plugin (reference)
- [x] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. → `plugins/scheduling/` is the worked example the docs already reference (so contract + reference agree). `shifts.ts` = an injectable-`fetch` upstream REST client (`createUpstream`, stand-in for the customer's backend — the plugin is stateless) + thin handler **factories** bound to it: `listShifts` fetches `/shifts`, filters by `?q`, renders the data-table (upstream down ⇒ a recoverable error page, never a host 500); `newShiftForm` renders the form; `createShift` reads its own body, **CSRF-guards via `ctx.verifyCsrf`** (403 on a bad token), validates, forwards the create upstream, then POST-redirect-GET (a 4xx upstream ⇒ a recoverable 502 form keeping the input). `plugin.ts` = the manifest: `apiVersion` literal, namespaced `scheduling:read`/`scheduling:write` perms, **permission-gated nav** ("Shifts" gated on `read` so the whole "Scheduling" header vanishes for non-holders), routes gated `read`/`write`. Views (`shifts.ejs`, `shift-new.ejs` + the plugin's **own** `partials/shift-form.ejs`) compose the core building blocks (shell/nav-tree/filter-bar/data-table/field/alert via `include()`) around the **native app shell**. **New host capability so a plugin page is native + secure** (`src/chrome.ts` `buildPluginChrome`): `ctx.chrome` = brand/global-nav/user/theme/csrf the view hands to `partials/shell` — the global menu (a Dashboard link + every discovered plugin's nav fragment + the gated admin section), composed + role-filtered + current-marked by request path; `ctx.verifyCsrf(submitted)` = the host's bound double-submit verifier (plugin never sees the secret). Both added to `RequestContext` (defaulted in `buildContext`, anonymous chrome / fail-closed verify), built per plugin route in `app.ts` (CSRF cookie set when fresh so forms carry a token). The dashboard now merges plugin nav fragments too (reachable from `/`; gated ⇒ invisible to anonymous, so the visual E2E is byte-identical). Out of the box: bootstrap now grants the demo admin `scheduling:read`/`scheduling:write` (generalized `seedAdmin` to a roles list, env `ADMIN_ROLES`); the dev compose runs a tiny stdlib mock upstream (`examples/shifts-upstream/`, `SCHEDULING_UPSTREAM`) so `docker compose up` shows it working. Tooling: `plugins/` added to tsconfig + the `npm test` glob (so plugin authors' tests run via `docker compose run web npm test`). Tests-first: `plugins/scheduling/shifts.test.ts` (client w/ mock fetch · validation · list/create handlers incl. CSRF-403, validation-400, PRG, upstream-502 · form model), `src/chrome.test.ts` (brand/nav/role-filter/current/branding), `app.test.ts` (a plugin view renders the chrome + the CSRF round-trip over HTTP), `dashboard.test.ts` (plugin-fragment merge, gated), `bootstrap.test.ts` (multi-role grant). README **Building a plugin** + Layout and `docs/plugin-contract.md` (the `ctx.chrome`/`ctx.verifyCsrf` additions, the upstream pattern, the dev/test pointer) updated. typecheck + **296 units** green; the Ory-free **visual E2E** (real built image) confirms the plugin is discovered at boot, the routes/nav are permission-gated (anonymous → 403, hidden from the dashboard), and the dashboard still renders identically; live full-stack boot-verified — the stack comes up with the plugin + mock upstream, the upstream serves the seeded shifts and is reachable from `web`, and bootstrap grants the admin `admin`/`scheduling:read`/`scheduling:write` in real Keto (all `allowed:true`); torn down. The authenticated browser happy-path (login → rendered list) is deferred to §8's full E2E (line 114 verifies the contract end-to-end) — it needs the cross-host Playwright login infra, not curl. `apiVersion` stays `1.0.0` (the contract is still being assembled in §7, so chrome/verifyCsrf are part of the initial surface — no minor bump, no warn noise).
- [x] Verify the full plugin contract end-to-end against the README. → Cross-checked every contract claim in `README.md` (**Building a plugin**, **Where plugins live**, **Layout**) + `docs/plugin-contract.md` + `plugins/scheduling/README.md` against the implementation (`plugin.ts`/`discovery.ts`/`router.ts`/`view-resolver.ts`/`chrome.ts`/`context.ts`/`guards.ts`/`app.ts` `sendResult`) and the shipped reference plugin. **Contract holds end-to-end**: derived id+mount (no `id`/`basePath` in the manifest), `apiVersion` literal + `checkApiVersion` table, permission-gated nav (the "Scheduling" header vanishes without `scheduling:read`) + gated routes, every `RouteResult` shape incl. `view`+`status` and `redirect`-PRG, `ctx.chrome` → native app shell, `ctx.verifyCsrf` double-submit (403 on bad token), `can()`/`GuardError(403)`, the view-resolver rendering `plugins/<id>/views/*` while `include()`-ing **both** core partials (shell/filter-bar/data-table/field/alert/nav-tree — all present in `views/partials/`) and the plugin's own `partials/shift-form`, per-plugin static, upstream-fetch statelessness; all three reference-plugin icons (`i-cal`/`i-plus`/`i-user`) are registered in `src/icons.ts`. **One material doc drift found + fixed:** `docs/plugin-contract.md`'s reserved-id list was stale — it named 8 ids but `RESERVED_PLUGIN_IDS` has 10 (the §5 `admin` + §6 `oauth2` mounts were missing), so an author would wrongly think `plugins/admin/` or `plugins/oauth2/` was allowed when discovery refuses them (test-covered in `discovery.test.ts`). Updated the doc to list all 10 accurately (docs-only, no behaviour change). typecheck + 296 units green.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §7 reference-plugin surfaces). Architecture: **no Critical**; Product: **no Critical** this checkpoint. **Fixed now (tests-first):** (1) HIGH (arch + product Blocker) — the contract claimed declaring `permissions` "seeds Keto" but nothing wired it, and the host hardcoded the plugin-specific `scheduling:read`/`write` into `ADMIN_ROLES` (`compose.yml` + `bootstrap.ts`) — core knowing about a plugin. Made the claim true *and* removed the coupling: new pure `seedRoles(adminRolesEnv, declaredTokens)` (union+dedup, trims both sides); `bootstrap.main()` now `discoverPlugins()` → seeds the demo admin `admin`(/`ADMIN_ROLES`) every discovered plugin's declared tokens; `compose.yml` `ADMIN_ROLES` default dropped to just `admin` (host names no plugin). Clean-clone behaviour is unchanged (admin still ends up with the scheduling tokens, now derived via discovery, not hardcoded — `readRoles` enumerates the Role namespace so a granted token reaches the JWT). Docs corrected: a route/nav `permission` **is a coarse role** granted as a Keto `Role:<token>#members` tuple (vs the separate fine-grained `Resource` tier). (2) HIGH (arch) — no delineated plugin SDK: the reference plugin imported six deep `src/*` internals, none of them the documented-stable surface. Added `src/plugin-api.ts` — the one barrel a plugin imports (`definePlugin` + manifest/handler/ctx types + guards + body/CSRF/list-query helpers); repointed `plugins/scheduling/{plugin,shifts}.ts` to it, so the contract boundary now lives in code (the host can refactor internals freely behind it). (3) MEDIUM (arch L1 + product) — per-plugin CSS was documented (`/public/<id>/`) but unusable (the shell `<head>` had no slot) and the reference shipped no `public/`. Added an optional `styles` slot to `shell.ejs` (extra stylesheet hrefs, default `[]`, backward-compatible) + `plugins/scheduling/public/scheduling.css` linked from both reference views (`.scheduling-page` scope) — closes the only unexercised contract feature + the anatomy-diagram drift. (4) MEDIUM (arch M4) — the reference demonstrated no hook; added `hooks.onBoot` validating `SCHEDULING_UPSTREAM` fail-loud at boot (`assertHttpUrl` — unparseable or non-http(s) aborts the boot, a typo no longer degrades every request), closing the hooks-coverage gap. (5) MEDIUM (arch M3) — `ctx.chrome` was built twice on a matched plugin request when any onRequest hook exists; now built at most once per request (memoized lazy `chrome()`), hot path still chrome-free. (6) HIGH/MEDIUM (product) — doc honesty: fixed the false `visual.spec.ts` comment ("covered by the full-stack suites" — it isn't), softened the contract's "every plugin ships *with* a Playwright test" to match reality (the reference's authed list/form happy-path is the §8 item), and added an **Upstream contract** block (routes/JSON shape/status-code expectations) to `plugins/scheduling/README.md`. Stability-reviewer run as a local PR: **APPROVE, no Critical/High**; addressed both Low nits (`seedRoles` now trims/dedups declared tokens too; added an `onBoot`-binding seam test on the real manifest). typecheck + **301 units** green (296 → 301). **Deferred (reviewer-scoped, not the §7 checkpoint):** the host **internal route-table** (fold the admin/oauth if-ladder into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` from it — arch M1) → **§9** (now the top structural item; standalone change, not a review-fix); the `safeUrl()` href helper + a reference row that renders an untrusted URL through it (arch L2 / product 🟢) → **§9** (first untrusted-URL flow / redirect-allowlist work); the data-table **empty state** + **success-flash** after writes (product 🟡, recurring §5/§6 deferral, now visible in the exemplar) → **§8/polish**; compile-time `apiVersion`-literal enforcement (arch M2) → accept prose-only; rename route/nav `permission``requireRole` to match the mechanism (arch H1.3) → a future minor `apiVersion` bump.
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §7 accretion (reference plugin + the §7-review host additions). The §7 modules were authored dense — the reference plugin (`plugin.ts`/`shifts.ts`/views/`examples/shifts-upstream`) is a deliberate teaching artifact, and the host bits (`context.ts`/`plugin-api.ts`/`bootstrap.ts`/`app.ts` §7 region) were written concise — so the wins are targeted: (1) tightened `chrome.ts`'s module header (7→5 lines) — dropped the parenthetical input-list (visible in `ChromeOptions`) and the nav-composition restatement (already carried by the `nav` field comment + `markCurrent`'s own comment). (2) Fixed a stale forward-ref in `docs/plugin-contract.md`: the `safeUrl()` helper note said "will land … §5/§7", but §7 deferred it to §9 (line 115) — now "planned for §9, with the redirect-URI allowlist work". Left intact: the reference plugin's instructive comments (it's the copy-target), the EJS view config-doc headers (the only schema for untyped locals), and the contract doc + plugin README (authored/refined concise in the §7 verify + review items). README **Building a plugin**/Layout/bootstrap prose was already tight (§7) — untouched; the README **Status**/`_(planned)_`/Layout refresh stays §9 (line 133). Docs/comments-only (per AGENTS.md, no stability reviewer); typecheck + 301 units green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §7 test accretion (`shifts.test.ts`, `chrome.test.ts`, `plugin-api.test.ts`, `shell.test.ts`, + the §7 additions to `app`/`dashboard`/`bootstrap`). The §7 tests were written tests-first across four small commits, so unlike §6 (a triplicated degrade matrix) there was no boilerplate accretion — the per-module matrices are one-contract-per-test and the reference plugin's `shifts.test.ts` deliberately tests its pure builders in isolation (the dev/test pattern the contract preaches), so those stay. **One genuine "combine in a good way":** `dashboard.test.ts` had two sibling tests — "wires in the permission-gated Admin section" (§5) and "merges discovered plugin nav fragments, permission-filtered" (§7) — that assert the *same* contract (`buildDashboardModel` role-filters a gated nav source via `composeNav`) on two sources. Merged into one "dashboard role-filters the gated Admin section and plugin fragments, each independently", which also **strengthens** coverage: it now asserts cross-gating (an `admin` doesn't see the plugin section, a `scheduling:read` holder doesn't see Admin) that neither original checked — 3 model builds vs 4, all prior assertions preserved. Left separate (distinct code paths/levels, not fat): the chrome-unit vs dashboard-unit vs app-HTTP plugin-nav tests (three different functions — `buildPluginChrome`, `buildDashboardModel`, the rendered shell — each independently merges fragments), and the two `app.test.ts` plugin integration tests (RouteResult shapes/static/405 vs chrome+CSRF round-trip — different surfaces, own fixtures). Pure test refactor, no production code touched (per the §6 test-cleanup precedent, no stability reviewer). 301 → 300 units; typecheck + tests green.
## 8. Testing & CI
- [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent).
- [x] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. → New browser-UI suite `e2e/full-flow.spec.ts` (`compose.e2e-full.yml`) — the real Playwright UI the earlier full-stack suites deferred here ("browser-UI login is owned by §8"). The themed login form posts straight to Kratos' action and cookies are host-scoped, so a tiny **stdlib reverse proxy** (`e2e/proxy.mjs`) fronts web + Kratos on **one origin** (the browser's only host), exactly like a prod reverse proxy; `ory/kratos/e2e-proxy.yml` points Kratos' base_url + every self-service URL at it, and Kratos runs `--dev` so cookies aren't marked Secure over http (Kratos marks them Secure for a non-loopback host like the gateway). Coverage (6 tests, all green): **password login** (themed form → Kratos → `/auth/complete` → dashboard); **mocked SSO** (a stdlib **mock OIDC provider** `e2e/mock-oidc.mjs` — RS256-signed id_token, nonce-bound single-use codes — wired via `SELFSERVICE_METHODS_OIDC_*` env + the committed claims jsonnet; click the provider button → auto-approve → identity created → signed in); **menu filtering by role** (the admin sees the gated Admin section + the plugin nav; anon/SSO-user don't); **users/groups/roles CRUD** (create → list → delete a user via the confirm step; create a group + role, each with a first member since a Keto set needs ≥1); the **permission-gated plugin page** (`/scheduling/shifts` renders the mock upstream's shifts in the native shell); **logout** (sign-out ends the session → back to /login, admin nav gone). **Found + fixed a real bug the E2E surfaced:** the SSO submit button sits in the same `<form>` as the `required` email/password fields, so clicking it tripped HTML5 validation ("Please fill out this field") and never submitted — added `formnovalidate` to the SSO buttons (`views/partials/auth-card.ejs`), tests-first (`auth-card.test.ts` + `app.test.ts`); password login still validates (separate button). Stability-reviewer run as a local PR: **APPROVE, no Critical/High** — every dev/insecure knob (`--dev`, `SECURE_COOKIES=false`, the OIDC provider + mock + proxy) is confined to the e2e overlay, base/prod compose unaffected; addressed its top follow-up (documented the file's parallel-safety invariant). typecheck + **305 units** green; full-flow **6/6 green on a clean stack**, then `down -v` torn down. README E2E section + suite count updated.
- [x] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. → Delivered by the §8 full-flow harness (`compose.e2e-full.yml`): one `run` brings up the whole stack (Postgres + Kratos + Keto + the one-command **bootstrap** + web + the same-origin gateway + the reference plugin's mock upstream + the mock OIDC provider), the bootstrap **seeds the demo admin identity in Kratos + its Keto roles** (`admin` + the plugin's declared `scheduling:read`/`write` tokens), the browser suite runs against that seeded identity, and `docker compose … down -v` **tears everything down** (run live, 6/6 green, torn down). The §4 auth-refresh + §6 oauth-login suites use the same full-stack-up / seed / tear-down pattern; this completes it for the browser-UI flows.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §8 Testing & CI surfaces). **Fixed now (tests-first where code):** (1) **CRITICAL (arch)** — §8 is "Testing & **CI**" but the CI automation was missing: the gate was five hand-run commands with manual teardown. Added **`scripts/ci.sh`** — the whole gate in one reproducible command: a Playwright pin-lockstep check (M4) → typecheck → units (with a count-floor guard, L1) → the four E2E suites, each on its **own named fresh stack** with a guaranteed `down -v` and a non-zero exit on first failure (documented in README). **The gate immediately earned its keep:** it surfaced a latent bug — the auth-refresh suite's `web` inherited the §6 `web→hydra` dep, but the e2e overlays don't run Hydra with `--dev`, so Hydra refused its http issuer and never went healthy → the suite couldn't boot. Fixed by dropping Hydra from the auth suite's `web` deps (`!override`, mirroring the full suite — it never needed Hydra). (2) **Product 🔴 (doc correctness)** — the README **Status** note still claimed auth + Hydra handlers were "the roadmap"/unbuilt (materially false after §4/§6/§8); rewrote it to reflect built reality and dropped the now-false `_(planned)_` markers from the **Auth** + **MVP** headings + fixed their anchor links. (3) **Product 🟡** — the recovery flow existed but nothing linked to it from `/login` (an end-user lockout gap): added a login-only **"Forgot password?"** link (`flow-view.ts` `recoverHref``flow-body.ejs`, `.auth-aside` CSS), tests-first (flow-view unit + a full-flow E2E assertion). (4) **Product 🟡 (recurring §5/§6/§7)** — empty list tables rendered blank: added an empty-state row to `data-table.ejs` (overridable `emptyText`, colspan over all columns, only when the table has columns) + `.table-empty` CSS — fixes all five list surfaces at once; tests-first (data-table unit). (5) **M3 (docs)** — README Layout `e2e/` line + `e2e/package.json` description updated for the §8 full-flow suite + harness files. Stability-reviewer run as a local PR: **APPROVE (with nits), no Critical/High**; addressed both robustness nits (per-suite compose **project names** so a flaky teardown can't cross-contaminate; `|| true` on the pin/count greps so `set -e`+pipefail doesn't abort before the diagnostic) — and caught a bug they introduced (a `.` in the derived project name is invalid → sanitized with `tr`). **Corrected a reviewer error:** arch claimed the bootstrap service uses `restart: unless-stopped` (infinite crash-loop); it actually uses `restart: "on-failure:5"` (bounded retries for transient Ory-not-ready blips) — defensible, left as-is. typecheck + **306 units** green; **`scripts/ci.sh` green end-to-end** (visual 9 · auth 1 · oauth 2 · full 6, every stack torn down). **Deferred (reviewer-scoped):** the host **internal route-table** (fold the admin/oauth if-ladder in `app.ts` into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` — arch H1, widened by §6/§7) → **§9** (top structural item, raised urgency); **visual-parity (computed-style) checks for the admin + consent screens** (visual.spec only covers the dashboard — arch M2) → §9/polish; a **JWT key-rotation E2E** (L2) → ships with the §9 rotation-runbook item; `shifts.test.ts` deep `src/*` imports → the plugin-api barrel (L3) → the §8 test-cleanup item below; success-flash after writes (product 🟢) → deferred (stateless, known); a **CI-service workflow** (Actions YAML calling `scripts/ci.sh`) → needs the operator's CI platform + Docker-capable runners (the portable gate script is in place). **Then re-ran both reviewers to convergence (5 rounds, until both returned zero new actionable findings).** Rounds 14 fixed, tests-first: bounded every outbound Ory `fetch` with a timeout (`src/fetch-timeout.ts` + `ORY_TIMEOUT_SEC`, default 5; also the http JWKS fetch) so a hung Ory can't park a handler; **anonymous on a gated plugin route now 303→`/login`** (was a dead-end 403; signed-in-without-role still 403); an already-signed-in user is sent home from `/login`/`/registration`; the **`onRequest` short-circuit now sets the fresh CSRF cookie**; `admin-users` malformed `:id` → 404 (was 500); **`parseJwks` validates key shape** (fails loud at load); **removed the dead `COOKIE_SECRET`** (loaded + enforced + documented but never read); documented `HYDRA_ADMIN_URL`; the admin **recovery** UI now shows the **code** (links to the public `/recovery`) instead of the browser-unreachable admin-API link; reference-plugin breadcrumb-label + pagination/datetime notes; corrected the contract doc to not over-promise a post-login "retry". **Declined:** unconditional base-ctx chrome (would build the menu per request, regressing the lazy hot path). **Deferred → §9:** `return_to`-preservation so a deep-link login lands on the requested page (non-trivial: must route through `/auth/complete` to mint the JWT without colliding with the OAuth2-challenge `return_to`). Stability-reviewer on the cumulative diff: **APPROVE, no Critical/High** (addressed its Low nits). typecheck + **310 units** + the full `scripts/ci.sh` gate green throughout.
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §8 accretion (`scripts/ci.sh`, the browser-E2E harness — `e2e/proxy.mjs`/`mock-oidc.mjs`/`full-flow.spec.ts` + the e2e compose/yml overlays — `src/fetch-timeout.ts`, and the §8-review comment additions across `app.ts`/`config.ts`/`jwks.ts`/`admin-users.ts`/`flow-view.ts`/`data-table.ejs`). All were authored dense — each comment carries a real non-obvious "why" (the gateway one-origin rationale, the `|| true`/project-name notes in ci.sh, the CSRF-cookie-on-short-circuit and already-signed-in caveats), so the wins are targeted (per the §6/§7 precedent): (1) fixed a **stale forward-ref**`config/menu.ts`'s `logo` field said "(rendered in the shell — next §2 item)", but §2 wired branding into the shell long ago; now "(rendered in the sidebar brand)", matching its sibling field comments. (2) trimmed a **locally-duplicated restatement**`full-flow.spec.ts`'s header re-listed its coverage (password/SSO login, menu-by-role, CRUD, plugin, logout) verbatim above the `test()` titles that already enumerate it *and* the README "Full browser flow" section; collapsed to "Coverage is the test titles below, plus the standalone SSO test", keeping the non-derivable framing (why the gateway, fresh-stack, the parallel-safety invariant). README §8 additions (the "full gate" section, the Full-browser-flow suite block, `ORY_TIMEOUT_SEC`) were written concise in §8 — untouched; the README **Status**/`_(planned)_`/Layout refresh stays §9 (line 133). Docs/comments-only (per AGENTS.md, no stability reviewer); typecheck + 310 units green.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §8 test accretion (`fetch-timeout.test.ts`, `e2e/full-flow.spec.ts`, + the §8-review additions across `app`/`config`/`jwks`/`flow-view`/`data-table`/`admin-users`). Most §8 changes were already woven into existing tests as assertions (the `recoverHref` link into flow-view, `parseJwks` key-shape into the loadJwks test, `ORY_TIMEOUT_SEC` as a sibling numeric-knob in config, empty-state as a focused data-table feature) — no fat. **Two genuine cleanups:** (1) the deferred L3 (line 123) — `plugins/scheduling/shifts.test.ts` imported four deep `src/*` internals (`chrome`/`context`/`guards`/`plugin`), none of them the documented-stable surface; repointed all four to the `src/plugin-api.ts` barrel (the one contract boundary, which re-exports them), so the test now models the dev/test story the contract preaches — exactly like `shifts.ts` itself does (no coverage change). (2) `app.test.ts` had two adjacent tests for the *same* surface (the themed-auth GET dispatch): "themed flow init" (anonymous → flow init / CSRF relay / stale restart) and "an already-signed-in user is sent home" — the latter literally re-asserted the former's anonymous-init (line 448 ≡ 424). Merged into one "themed auth GET: anonymous inits a flow …; a signed-in user is sent home, except /settings", dropping the duplicate; all assertions preserved. Left separate (distinct surfaces/stacks, not fat): the four E2E suites (visual computed-styles / auth-refresh JWT re-mint / oauth-login consent / full-flow browser-UI — each its own compose stack) and `app.test.ts`'s one-per-surface integration tests. Pure test refactor, no production code (per the §6/§7 precedent, no stability reviewer). 310 → 309 units; typecheck + tests green.
## 9. Production, security, ops
- [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green.
- [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — the open gap was **response security headers**, now closed. New pure `src/security-headers.ts` (`securityHeaders({secure})`): a strict CSP for the zero-JS core — `default-src 'self'`, `script-src 'self'` with **no** `'unsafe-inline'` (an injected `<script>` can't run; core ships none, a plugin may still serve its own `/public/<id>/*.js`), `style-src` adds `'unsafe-inline'` for the partials' inline `style=`, `img-src 'self' data:`, `frame-ancestors 'none'`, `object-src 'none'`; **`form-action` deliberately omitted** (the themed login POSTs to Kratos' often-cross-origin action URL) — plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, and HSTS only when `secureCookies` (https; ignored on dev http). Wired in `app.ts`: precomputed once at boot, `res.setHeader`'d at the very top of the handler before any branch, so **every** response (page/json/redirect/static/error/plugin) inherits them via `writeHead`'s merge; a plugin overrides per-route via `RouteResult.headers`. Verified no view/CSS loads cross-origin (no `<script>` anywhere, no external fonts/CDNs), so `default-src 'self'` breaks nothing. Tests-first: `security-headers.test.ts` (strict defaults, `script-src` has no `'unsafe-inline'`, HSTS-only-on-secure) + an `app.test.ts` integration (the page **and** a static asset both carry the headers; HSTS toggles with `SECURE_COOKIES`). Stability-reviewer on the diff: **APPROVE, no Critical/High** (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green.
- [x] Optional revocation denylist for instant role/session revoke. → Closes the documented ~10m role/session lag for security-critical revoke, **off by default** (`REVOCATION_DENYLIST`, zero hot-path cost + zero behaviour change when off). New pure `src/denylist.ts` (`createDenylist({ttlSec})`): an in-memory, auto-evicting `Map<sub, revokedAt>``revoke(sub)` records now, `isRevoked(sub, iat)` rejects a subject's tokens minted **at/before** the revoke (`iat <= revokedAt`; missing `iat` fails closed), so a *fresh* re-login (iat after the revoke) passes while a downgrade lands immediately. Entries self-evict after `REVOCATION_TTL_SEC` (default 900 ≥ the 10m tokenizer TTL + skew), so it stays a bounded cache like JWKS — **no database, Keto stays off the hot path**. Wired: `jwt-middleware.ts` takes the denylist in `VerifyOptions` and throws `TokenError(expired)` on a revoked sub, so `resolveSession` routes it through the existing §4 re-mint (live session → fresh post-revoke JWT with current Keto roles; dead/deactivated → cleared cookie). `app.ts` merges it into `authOptions` (the same `resolveSession` hot-path call) and hands a bound `revoke` to the Users + Roles admin deps; `admin-users.ts` revokes on **deactivate/delete**, `admin-roles.ts` revokes a direct `user:` member on **assign/unassign** (a `group:`/whole-role change is transitive → left to lag, documented). `server.ts` builds it only when the toggle is on. Tests-first: `denylist.test.ts` (iat semantics, cutoff-advance, TTL eviction), `jwt-middleware.test.ts` (revoked→expired→re-mint, fresh passes), `config.test.ts` (toggle + posint TTL), `app.test.ts` (hot-path reject + fresh-login pass; admin deactivate/role-assign/unassign record the revoke). Stability-reviewer on the diff: **APPROVE, no Critical/High/Medium** (addressed its one Low: a comment noting whole-role delete lags like a group change). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page; the operator toggle + handler paths are exercised directly). README (Auth trade-off + a new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green.
- [x] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly. → Structured, OTLP-native logging on **`@larvit/log`** (2.3.0, pinned; itself zero-dependency — the one new runtime dep, justified by this item). New pure `src/logger.ts`: `createLogger({format,level,otlpEndpoint,otlpProtocol,stdout,stderr})` → one app `Log` tagged `service.name=plainpages` (the OTLP resource attr Loki/Tempo group by); `requestLogger(appLog,{requestId,traceparent})` **clones** it per request (own root trace — *not* nested under one app-lifetime span — inheriting level/format/streams/OTLP) into a "request" span, **adopting** an inbound W3C `traceparent` so a request continues an upstream proxy's distributed trace (malformed/duplicate ⇒ fresh trace; verified `clone` honours a passed `traceparent` while dropping the parent's, unlike `parentLog`). Wired: `app.ts` builds the per-request log at the top of the handler and on `res` **"close"** (fires on both completion *and* abort/truncation, unlike "finish", so aborted/static-stream-error requests are still logged) emits one access line (`method`/`path` — query dropped, may carry tokens — `status`/`ms`/`requestId`, guarded by try/catch) then `end()`s to flush the span (fire-and-forget `.catch`, so a flaky collector never crashes a served request); the catch-all 500 + the Ory-unreachable re-mint now log via `reqLog.error`/`warn`; `static.ts`'s mid-stream error takes an injected `onError` (default console.error for standalone use). `server.ts` builds the app logger from config, logs discovery/listen/shutdown, and `end()`-flushes on SIGTERM/SIGINT (re-entry-guarded). `bootstrap.ts` events go structured; the human first-run banner stays a raw console.log (UX, not a log event). Config (environment-agnostic, fail-loud): `LOG_LEVEL` (info), `LOG_FORMAT` (text; prod compose → json), `OTLP_ENDPOINT` (unset ⇒ console-only; set ⇒ export logs + spans to an OTel Collector → Loki/Tempo), `OTLP_PROTOCOL` (http/json|http/protobuf). compose: base sets `LOG_FORMAT=json` (prod pipelines), dev override flips it to `text`. Tests-first: `logger.test.ts` (service.name/severity-routing/level-gate/format, level-none silent, OTLP-only-when-endpoint, a stubbed-global-fetch proof it POSTs `/v1/logs`, requestLogger context-merge / own-root-trace / traceparent-continue / malformed-ignored), `config.test.ts` (the 4 toggles + enum/URL validation), `app.test.ts` (a live request emits the JSON access line), `compose.test.ts` (prod json / dev text). Per the §9 security-headers/denylist precedent: unit + app-HTTP integration, **no new browser E2E** (no new user-facing page) — and live-boot-verified (dev text+colour, prod json, access lines for page/static/404, graceful-shutdown line). Stability-reviewer on the diff: **APPROVE, no Critical/High** — addressed both yellow nits (access line guarded + switched "finish"→"close" so aborted requests log; shutdown re-entry guard) and the green ones (README collector-outage stderr note, double-`end()` guard). README (config table, new **Observability** section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + **326 units** green (317 → 326). **Follow-up (route all fetch through the logger · ENV service name · leveled logging throughout):** an `AsyncLocalStorage<Log>` makes the per-request logger ambient (`runWithLog`/`currentLog`), so **every outbound `fetch`** traces with no signature churn — `tracedFetch` (a `typeof fetch`) routes through the active request log (client span + propagated W3C `traceparent`) and `server.ts` wires it under the Ory timeout into **all** Kratos/Keto/Hydra + JWKS calls; off the request path it's a plain fetch. `RequestContext` gained **`ctx.log`** (the request logger; additive/contract-stable) so a handler/plugin logs in-trace and `ctx.log.fetch(url)` traces its upstream calls — the reference plugin's `createUpstream` defaults to `tracedFetch`, and `plugin-api.ts` exports `tracedFetch` + the `Log` class. `SERVICE_NAME` (config + `createLogger({serviceName})`) makes the OTLP `service.name` implementer-brandable. Leveled logging across the app: who-did-what **audit** `info` lines on every admin write (user/group/role/client create·delete·assign, with `actor`/`target`/no secrets), `info` on login (session mint) + logout, `warn` on missing-role 403 + CSRF rejections + Ory-unreachable, `debug` on a JWKS kid-miss reload. app.ts's handler body was extracted to `handleRequest` and run inside `runWithLog`; `end()` is coordinated to fire exactly once after **both** the handler unwinds **and** the response closes, so a client abort mid-handler can't end the log out from under a still-running `ctx.log`/`tracedFetch` (regression-tested). Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log surface). Stability-reviewer on the diff: **APPROVE, no Critical/High** (one yellow — the abort-race `end()` — fixed as above; green nits addressed: traced-fetch comment, app-logger backstop on a handler escape; confirmed the Ory timeout still honoured through `log.fetch` and no secret reaches a log line). `docs/plugin-contract.md` (`ctx.log`/`ctx.log.fetch`/`tracedFetch`), README (config + Observability tracing/serviceName, plugin note, Layout) updated. typecheck + **333 units** + the full `scripts/ci.sh` E2E gate green (326 → 333).
- [x] JWT signing-key rotation runbook. → Expanded the README **JWT signing key & rotation** section from a 3-line note into an operational runbook, and closed the tooling gap that made the documented steps unrunnable: the old "prepend a key / drop it later" required hand-editing a JSON file holding a private signing key. Tests-first: new pure `rotateJwks(current, {prune})` in `gen-jwks.ts``--prepend` puts a fresh ES256 key first (Kratos signs with `keys[0]`, the old keys still verify in-flight JWTs) and keeps the rest in order; `--prune` keeps only the newest (drop superseded post-TTL). CLI reads the existing set from a path arg and writes the new set to stdout (header documents the temp-file redirect so the shell's `>` can't truncate the input). `gen-jwks.test.ts` covers prepend (length+1, fresh kid first, old set preserved) + prune (→ 1 key). Runbook documents: the two-sided install (Kratos signer env/mount + web `JWKS_URL`, `file://` hot-reloads / `base64://` immutable), why it's zero-downtime (sign-with-first + verify-by-`kid`), the **scheduled** path (prepend → `restart kratos` → verify new kid → wait ~12 min = 10m TTL + skew → prune; rollback before prune) and the **emergency** path (replace with a single key → every leaked-key token fails signature → forced re-login; the §9 denylist is moot since the signature is already invalid). Verified the CLI live against the committed dev JWKS (bare→1 key, `--prepend`→2 with the old kid second, `--prune`→1). Docs/CLI-only behaviour, covered by units (per the §9 precedent, no new browser E2E). README Status + Layout updated. typecheck + **335 units** green (333 → 335).
- [x] Refresh README `Layout` + drop `_(planned)_` markers as pieces land. → The `_(planned)_` markers were already dropped as each piece landed (swept the whole README — none remain, and the **Status** paragraph already reflects the built state). Refreshed the `Layout` block, which had drifted: added the three source modules it was missing — `fetch-timeout.ts` (`withTimeout()`, the Ory outbound-call deadline wrapper, §8), `guards.ts` (`requireSession()/can()/check()`, in-handler authz + `GuardError`, §4), `hooks.ts` (`runBoot/Request/ResponseHooks()`, plugin lifecycle, §2) — plus the `scripts/ci.sh` CI gate (§8). Cross-checked mechanically: every non-test `src/*.ts` and every top-level dir (bar `node_modules`) now has a line; `public/`/`plugins/`/`examples/` descriptions still match their contents. Docs-only.
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both (systems-architect + product-owner) on the whole project: **no Critical/High** — both report a converged, fit-for-purpose scaffold. **Fixed now (tests-first), the in-scope §9 customer-facing/security items:** (1) **`return_to` deep-link preservation** (product 🟡, explicitly deferred *to* §9) — a gated request hit while signed out (the plugin-route gate in `app.ts`, `requireSession`, `requireAdmin`) now bounces to `/login?return_to=<host-relative path>` via a new `loginRedirect(ctx)` (GET/HEAD only, skips `/`). `/login` bakes it into the Kratos flow: a **host-relative** target is wrapped through `<origin>/auth/complete?return_to=<path>` so the session JWT is minted *before* the user lands there, while an **absolute** target (the §6 OAuth2 login challenge) passes to Kratos unchanged (Kratos allow-lists it). `/auth/complete` redirects to the requested page; the OAuth2 path is untouched. (2) **`safeUrl()` + `localPath()`** — the doc-promised §9 URL-safety helpers, new pure `src/safe-url.ts`: `safeUrl` sanitises an untrusted href/src to relative-or-`http(s)` (else `"#"`), exported to plugins via `plugin-api.ts` and documented in the contract with a usage snippet (closing the "planned for §9" vapor pointer); `localPath` is the host-relative redirect-allowlist guard backing `return_to` (rejects `//evil.com`/`https:`/control chars, double-checked at both `/login` and `/auth/complete` so a crafted `?return_to=` can't open-redirect). (3) **Honest 503 on Ory-unreachable sign-in** (product 🟡) — the auth-flow block renders a new `views/503.ejs` ("sign-in temporarily unavailable") on a Kratos 5xx/network throw instead of the misattributed catch-all 500; an expired-flow 4xx still restarts, other 4xx still rethrow. Tests-first throughout: `safe-url.test.ts` (scheme/obfuscation + host-relative matrix); `guards`/`admin-nav`/`app` assertions updated to the new `return_to` contract + the `/login` wrap + the 503 (incl. a network-throw case) + `/auth/complete` honour/reject; `plugin-api` export guard; a full-flow **E2E** (deep link → login → lands on the page) and the visual-suite gated-bounce assertion. Stability-reviewer on the diff: **APPROVE** (no Critical/High) — open-redirect double-layered + the OAuth2 path verified intact; addressed its one Medium (scoped the 503 catch to genuine connectivity errors — the success render moved out of the `try` so a template bug hits the 500 with a stack, not a misleading 503) and its NBSP-strip-set note (documenting test). README (Production return_to/503 note, Layout) + `docs/plugin-contract.md` (return_to framing, `safeUrl` usage) updated. typecheck + **339 units** + the full `scripts/ci.sh` gate green (visual 9 · auth 1 · oauth 2 · full 7). **Deferred, with justification:** the **`app.ts` route-table** refactor (arch Medium — fold the admin/oauth if-ladder into a `{method,prefix,handler}` table, fixing the built-in-route 405/`allowedMethods` blindness + the dep-absent-404 honesty) → the architect explicitly recommends a **standalone change** (not bundled into review-fixes) and it's a **§10 prerequisite**; the **mock dashboard + dead `#` affordances** (arch/product) → **§10 line 139** (gate `/` + make it plugin-replaceable); **public-page blessing/docs** (product 🟢) → **§10 line 140**; success-flash after writes (stateless, known) and plugin-upstream-config visibility (no change recommended) → left as-is.
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
## 10. User added stuff
- [ ] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great.
- [ ] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available.