diff --git a/.dockerignore b/.dockerignore
index b8dec35..4d9cd33 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,6 +3,5 @@ node_modules
npm-debug.log
*.log
.DS_Store
-html-css-foundation
e2e/artifacts
diff --git a/AGENTS.md b/AGENTS.md
index 1184a78..a0e1cd7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,5 +73,5 @@ docker compose -f compose.yml up --build -d # production
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`).
- Run the stability reviewer agent after every implementation of something that can be like
- a PR. That includes an implementation from the todo file that is pushed directly to master.
+ a PR. That includes any change pushed directly to master.
Skip this if the changes are purely documentation and/or comments.
\ No newline at end of file
diff --git a/README.md b/README.md
index 9a611cb..fd85a09 100644
--- a/README.md
+++ b/README.md
@@ -46,16 +46,15 @@ makes **semantic, accessible markup** a priority: real landmarks, one `
` per
lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`)
only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)).
-> **Status.** Nearly all of the architecture this README describes is built today (see `todo.md`):
-> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, filters,
-> pagination, forms), the **plugin host** (discovery, router, per-plugin views + static, the
-> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
+> **Status.** The full architecture this README describes is built and exercised end-to-end by the
+> Playwright suites: the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data
+> table, filters, pagination, forms), the **plugin host** (discovery, router, per-plugin views + static,
+> the `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
> tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset /
-> SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent
-> / logout handlers** — all driven end-to-end by the Playwright suites, plus **production & ops
-> hardening** (the prod compose profile, response security headers, **structured logging + OTLP
-> observability**, the **[JWT key-rotation runbook](#jwt-signing-key--rotation)**). Remaining
-> polish is tracked in `todo.md` (§9–§10).
+> SSO, the session→JWT hot path, the users/groups/roles admin screens), **Hydra's login / consent /
+> logout handlers**, and **production & ops hardening** (the prod compose profile, response security
+> headers, **structured logging + OTLP observability**, the
+> **[JWT key-rotation runbook](#jwt-signing-key--rotation)**).
## The MVP — "clone, one command, hack on a plugin"
@@ -160,8 +159,8 @@ auto-merged by `docker compose up`) turns them back off for live editing.
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
-| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (§6 login/consent handshake) |
-| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT (§4) |
+| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
+| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
@@ -237,7 +236,7 @@ same way.
### JWT signing key & rotation
-The session tokenizer (§3) signs each session→JWT with an **ES256** key at
+The session tokenizer signs each session→JWT with an **ES256** key at
`ory/kratos/tokenizer/jwks.json`. The committed one is a **dev throwaway** (like the
cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in
production**. Mint a fresh key with the bundled generator:
@@ -256,7 +255,7 @@ docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/token
`file://` on the web side** so it picks up new keys without a restart.
**Why rotation is zero-downtime.** Kratos signs with the **first** key in the set and
-stamps its `kid` in each JWT header; web selects the verify key by that `kid` (§4). So a
+stamps its `kid` in each JWT header; web selects the verify key by that `kid`. So a
set can hold the new key *and* the old one at once — tokens minted before and after the
swap both verify.
@@ -302,9 +301,9 @@ docker compose restart kratos
```
Every existing JWT now fails signature verification → its bearer falls back to anonymous
-and must re-authenticate (the §4 re-mint only covers *expired* tokens, not bad signatures,
+and must re-authenticate (the re-mint only covers *expired* tokens, not bad signatures,
so a forged/leaked-key token can't be silently refreshed). The instant-revoke denylist
-(§9) is unnecessary here — the signature itself is already invalid.
+ is unnecessary here — the signature itself is already invalid.
## Type check & tests
@@ -321,10 +320,9 @@ otherwise drags up its `depends_on` services.
E2E runs in the official Playwright image (browsers preinstalled) against the live `web`
service — no Node/browsers on the host. There are four suites:
-**Visual + design system** (`visual.spec.ts`) — Ory-free (mock-data dashboard), so it stays fast.
-It screenshots the live pages **and** the `html-css-foundation` mockups, then asserts the live DOM
-computes the **same design-system styles** as the reference (so a styling regression fails the
-build, independent of the row data).
+**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots the live
+pages and asserts the rendered design system — the app shell, theme switch, mobile off-canvas layout,
+icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and plugin permission-gating.
```bash
docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e # run the suite
@@ -334,7 +332,7 @@ docker compose -f compose.yml -f compose.e2e.yml down -v # tear
**Auth — token timeout + refresh** (`auth-refresh.spec.ts`) — the full-stack counterpart: it
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to
8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin and proves
-the §4 "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the live Kratos
+the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the live Kratos
session (roles re-read from Keto), and once that session is revoked the stale cookie is **cleared**.
```bash
@@ -344,7 +342,7 @@ docker compose -f compose.yml -f compose.e2e-auth.yml down -v #
**OAuth2 login + consent** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the
real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the
-§6 handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and
+handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and
**accepts** the challenge once a Kratos session exists; `/oauth2/consent` then shows the consent
screen for the third-party client and **Allow** drives Hydra to issue the authorization code.
@@ -567,8 +565,8 @@ docs for the full template-type list and the data each template receives.
## Building blocks
Plainpages is a **component library, not a page generator** — you assemble pages from partials
-and helpers rather than declaring a schema and getting magic. The vocabulary is extracted from
-`html-css-foundation/` into reusable EJS partials + TS helpers, fully styled and zero-JS:
+and helpers rather than declaring a schema and getting magic. The vocabulary is a set of reusable
+EJS partials + TS helpers, fully styled and zero-JS:
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row
actions), pagination, form fields, badges, menus, auth cards.
@@ -808,66 +806,62 @@ retried per request, not queued), so run a local collector/agent you trust.
```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
-src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4)
+src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes)
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public// → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
-src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint (§4)
+src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
-src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
-src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
-src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
-src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6)
-src/fetch-timeout.ts withTimeout(): bound every outbound Ory call (§8) — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
-src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
-src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
-src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
-src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
-src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
-src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
-src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
-src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
-src/denylist.ts Optional instant-revoke denylist (§9): in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
-src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
-src/safe-url.ts safeUrl() (sanitise an untrusted href/src to relative-or-http(s), exposed to plugins) + localPath() (host-relative redirect-allowlist guard for return_to) (§9)
-src/logger.ts createLogger()/requestLogger() + the ambient request log (runWithLog/currentLog) and tracedFetch: structured logger (service.name) + per-request trace span on @larvit/log; every outbound fetch joins the trace; OTLP export when OTLP_ENDPOINT set (§9)
-src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
+src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
+src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
+src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
+src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
+src/fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
+src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
+src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
+src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
+src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
+src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
+src/bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
+src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies)
+src/csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
+src/denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
+src/security-headers.ts Response security headers set on every reply: strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
+src/safe-url.ts safeUrl() (sanitise an untrusted href/src to relative-or-http(s), exposed to plugins) + localPath() (host-relative redirect-allowlist guard for return_to)
+src/logger.ts createLogger()/requestLogger() + the ambient request log (runWithLog/currentLog) and tracedFetch: structured logger (service.name) + per-request trace span on @larvit/log; every outbound fetch joins the trace; OTLP export when OTLP_ENDPOINT set
+src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms)
src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
-src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler) (§10). Both render the one unified menu (ctx.chrome)
-src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
-src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
-src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
-src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
+src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
+src/admin-users.ts Built-in Users admin screen: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
+src/admin-groups.ts Built-in Groups admin screen: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
+src/admin-roles.ts Built-in Roles admin screen: list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
+src/admin-clients.ts Built-in OAuth2 clients admin screen: list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
-src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (§7, unified across all pages in §10) — exposed on ctx.chrome
+src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
-src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
-src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
-src/guards.ts requireSession()/can()/check(): in-handler authorization (§4) — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
-src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order (§2); no sandbox (a throwing hook fails loud), skipped when no plugin declares one
-src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2)
-src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
-views/ Core EJS templates, all in the one app shell (§10): home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite)
+src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
+src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
+src/guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
+src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
+src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials
+src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot
+views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
-plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy
+plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the reference plugin (list/form over an upstream + permission-gated nav) you copy
examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service)
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full|-devstack].yml run them
-html-css-foundation/ HTML design mockups — the source for the building-block
- partials; reference the stylesheets in public/css/.
-scripts/ci.sh The full CI gate (§8): typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash scripts/ci.sh`)
+scripts/ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash scripts/ci.sh`)
```
-Comments and docs cite roadmap phases as `§N` — the sections in `todo.md`.
-
## Extending the core
- **New page in a plugin:** add a route + handler to the plugin manifest and a
diff --git a/compose.e2e-auth.yml b/compose.e2e-auth.yml
index 2f792ea..79c86b6 100644
--- a/compose.e2e-auth.yml
+++ b/compose.e2e-auth.yml
@@ -1,4 +1,4 @@
-# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in", §4). The Ory-free
+# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in"). The Ory-free
# visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart:
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
diff --git a/compose.e2e-full.yml b/compose.e2e-full.yml
index 3d8770a..67edbb2 100644
--- a/compose.e2e-full.yml
+++ b/compose.e2e-full.yml
@@ -1,4 +1,4 @@
-# Full browser E2E (todo §8) — the real Playwright UI flow against the live stack: password +
+# Full browser E2E — the real Playwright UI flow against the live stack: password +
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
# same-origin gateway (proxy, e2e/proxy.mjs) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
diff --git a/compose.e2e-oauth.yml b/compose.e2e-oauth.yml
index 382d74e..89bdc27 100644
--- a/compose.e2e-oauth.yml
+++ b/compose.e2e-oauth.yml
@@ -1,4 +1,4 @@
-# Full-stack OAuth2 E2E — the §6 login-challenge handler. Another app logs in *through* us:
+# Full-stack OAuth2 E2E — the login-challenge handler. Another app logs in *through* us:
# Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
diff --git a/compose.e2e.yml b/compose.e2e.yml
index 1f676c0..d11f3fa 100644
--- a/compose.e2e.yml
+++ b/compose.e2e.yml
@@ -1,5 +1,5 @@
-# Playwright E2E. Brings up the app + a Playwright runner, screenshots the live pages and the
-# html-css-foundation mockups, and asserts the live DOM computes the same design styles.
+# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
+# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating) — Ory-free, so it's fast.
# docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e/) so spec edits are picked up.
@@ -33,10 +33,7 @@ services:
environment:
BASE_URL: http://web:3000
volumes:
- # The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves.
- - ./html-css-foundation:/repo/html-css-foundation:ro
- - ./public:/repo/public:ro
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated
- # dashboard (§10) renders; web verifies it with the same key (the file it mounts read-only).
+ # dashboard renders; web verifies it with the same key (the file it mounts read-only).
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
- ./e2e/artifacts:/e2e/artifacts
diff --git a/compose.yml b/compose.yml
index b43612b..75cbf35 100644
--- a/compose.yml
+++ b/compose.yml
@@ -19,7 +19,7 @@ services:
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
- # Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/
+ # Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
depends_on:
bootstrap:
@@ -30,7 +30,7 @@ services:
condition: service_healthy
hydra:
condition: service_healthy
- # §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
+ # verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer.
volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
@@ -117,7 +117,7 @@ services:
retries: 20
restart: unless-stopped
- # One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
+ # One-shot first-boot seed (the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
# cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted
# read-write (the only writer) so the absent-JWKS safety net can land the key.
bootstrap:
@@ -146,7 +146,7 @@ services:
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
- # login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev
+ # login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
hydra-migrate:
image: oryd/hydra:v26.2.0
diff --git a/config/menu.ts b/config/menu.ts
index 9f8d5e4..232c471 100644
--- a/config/menu.ts
+++ b/config/menu.ts
@@ -1,4 +1,4 @@
-// Central menu override + branding (todo §2). Brand the app and reorder/rename/group/hide nav
+// Central menu override + branding. Brand the app and reorder/rename/group/hide nav
// nodes (by their `id`) across all plugins — the override always wins, applied before the
// per-user permission filter. Every field is optional; delete one to fall back to the default.
// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md
index ba3ad67..ceb738f 100644
--- a/docs/plugin-contract.md
+++ b/docs/plugin-contract.md
@@ -13,7 +13,7 @@ manifest, a version mismatch, or a conflict stops startup with a clear message.
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
time, not in production.
-> **Status.** This is the contract the §2 host implements. The types and pure rules
+> **Status.** This is the contract the host implements. The types and pure rules
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
> permission gate, `RouteResult` → response), and the **per-plugin view resolver**
@@ -23,7 +23,7 @@ time, not in production.
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard)
-> and [public pages & menu items](#public-pages--menu-items) (§10), both documented below.
+> and [public pages & menu items](#public-pages--menu-items), both documented below.
## Anatomy of a plugin
@@ -226,7 +226,7 @@ request:
```ts
interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
- log: Log; // request-scoped logger, in this request's trace (§9)
+ log: Log; // request-scoped logger, in this request's trace
params: Record; // path params from the route match, e.g. /shifts/:id → { id }
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
@@ -257,8 +257,8 @@ login/registration/front pages), so the menu looks identical signed in or out
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
sidebar, single column); everything else still renders.
-**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log),
-§9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
+**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log))
+already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream.
The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log)
@@ -271,7 +271,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
-from the §4 JWT middleware and are `null`/`[]` until a session exists.
+from the JWT middleware and are `null`/`[]` until a session exists.
## Nav & permissions
@@ -307,7 +307,7 @@ Permission tokens are a **shared global namespace** — that's deliberate, so an
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
your tokens as `:` to avoid accidental clashes. Declaring them in `permissions` is
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
-bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so
+bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so
a dropped-in plugin works out of the box without editing host config.
## Contract versioning
@@ -394,14 +394,14 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/`
— either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external
folder (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For
- an isolated harness, the §2 host exposes plugin injection (`createApp({ plugins: [myPlugin] })`)
+ an isolated harness, the host exposes plugin injection (`createApp({ plugins: [myPlugin] })`)
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
of the stack.
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
- its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra).
+ its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
diff --git a/e2e/auth-refresh.spec.ts b/e2e/auth-refresh.spec.ts
index 66479a5..0044ed1 100644
--- a/e2e/auth-refresh.spec.ts
+++ b/e2e/auth-refresh.spec.ts
@@ -1,15 +1,15 @@
import { expect, test } from "@playwright/test";
-// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in", §4). Runs against the
+// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the
// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos
// and web sit on different hosts here; web's own server-side cookie relay is what we exercise.
-// The browser-UI login is owned by §8; this proves the timeout/refresh server behaviour end-to-end.
+// The browser-UI login is owned by the full-flow E2E; this proves the timeout/refresh server behaviour end-to-end.
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
-const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto
+const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
const ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
diff --git a/e2e/devstack-login.spec.ts b/e2e/devstack-login.spec.ts
index 79eb5cb..c949058 100644
--- a/e2e/devstack-login.spec.ts
+++ b/e2e/devstack-login.spec.ts
@@ -15,7 +15,7 @@ import { expect, test } from "@playwright/test";
// (compose.e2e-devstack.yml) against the plain `docker compose up` topology, so it sees
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
-const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
+const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin";
async function signIn(page: import("@playwright/test").Page): Promise {
diff --git a/e2e/full-flow.spec.ts b/e2e/full-flow.spec.ts
index b00d2a6..e7160f6 100644
--- a/e2e/full-flow.spec.ts
+++ b/e2e/full-flow.spec.ts
@@ -1,7 +1,7 @@
import { type Browser, type Page, expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
-// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin
+// Full browser E2E: the real Playwright UI against the live stack via the same-origin
// gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here.
// Coverage is the test titles below, plus the standalone SSO test.
//
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
-const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto
+const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
@@ -103,7 +103,7 @@ test.describe.serial("authenticated admin journey", () => {
});
});
-test("return_to: a deep link while logged out returns to that page after login (§9)", async ({ page }) => {
+test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL).
diff --git a/e2e/mock-oidc.mjs b/e2e/mock-oidc.mjs
index 9a8f32a..8fe76e2 100644
--- a/e2e/mock-oidc.mjs
+++ b/e2e/mock-oidc.mjs
@@ -1,4 +1,4 @@
-// Mock OIDC provider for the SSO browser E2E (todo §8) — a stand-in for Google/etc. so the test
+// Mock OIDC provider for the SSO browser E2E — a stand-in for Google/etc. so the test
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
diff --git a/e2e/oauth-login.spec.ts b/e2e/oauth-login.spec.ts
index 70b95ed..3c1973f 100644
--- a/e2e/oauth-login.spec.ts
+++ b/e2e/oauth-login.spec.ts
@@ -1,16 +1,16 @@
import { expect, test } from "@playwright/test";
-// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra
+// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
-// exercises web's server-side challenge handling. The browser-UI login is owned by §8.
+// exercises web's server-side challenge handling. The browser-UI login is owned by the full-flow E2E (full-flow.spec.ts).
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
-const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
+const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin";
function setCookieLine(res: Response, name: string): string | undefined {
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index 7ddec22..df10b4c 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -1,9 +1,8 @@
import { defineConfig, devices } from "@playwright/test";
-// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the
-// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel
-// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the
-// computed-style parity vs the reference design is stable.
+// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
+// compose.e2e.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
+// so the rendered design is stable across runs.
export default defineConfig({
testDir: ".",
outputDir: "artifacts/test-output",
diff --git a/e2e/proxy.mjs b/e2e/proxy.mjs
index 40fdbbb..0522435 100644
--- a/e2e/proxy.mjs
+++ b/e2e/proxy.mjs
@@ -1,4 +1,4 @@
-// Same-origin gateway for the browser E2E (todo §8). The themed login form posts straight to
+// Same-origin gateway for the browser E2E. The themed login form posts straight to
// Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real
// browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts
index b1b41a2..8e96931 100644
--- a/e2e/visual.spec.ts
+++ b/e2e/visual.spec.ts
@@ -3,10 +3,6 @@ import { readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
-// The mockups are bind-mounted at /repo (sibling to /repo/public so their ../public/css/ resolves).
-const MOCKUP = "file:///repo/html-css-foundation";
-const APP_SHELL = `${MOCKUP}/App%20Shell.html`;
-const AUTH = `${MOCKUP}/Auth.html`;
const SHOTS = "artifacts/screenshots";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS
@@ -15,7 +11,7 @@ const shot = (page: Page, name: string): Promise =>
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
-// gated dashboard (§10) renders for a "signed-in" user without standing up Ory — web verifies it
+// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
function devSession(roles: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
@@ -28,16 +24,16 @@ function devSession(roles: string[] = []): string {
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
-// The dashboard is gated (§10): a page navigation needs a session. Plant one per test — a plain
-// member (no roles) so the gated scheduling/admin nav stays filtered out, matching the mockup.
+// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
+// member (no roles) so the gated scheduling/admin nav stays filtered out.
test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
});
-test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
+test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible();
- // §10: the default /dashboard is the instructional starter, not a mock-data list.
+ // the default /dashboard is the instructional starter, not a mock-data list.
await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible();
await shot(page, "live-01-dashboard");
@@ -49,35 +45,6 @@ test("captures live pages + reference mockups for side-by-side review", async ({
await page.goto("/dashboard");
await shot(page, "live-04-mobile");
await page.setViewportSize({ width: 1280, height: 800 });
-
- await page.goto(APP_SHELL);
- await shot(page, "mockup-01-app-shell");
- await page.goto(AUTH);
- await shot(page, "mockup-02-auth");
-});
-
-// The live DOM reuses the foundation's classes, so the same styles.css must compute identically
-// on both — proof we render the intended graphics, independent of the (different) row data.
-const PROPS = ["backgroundColor", "borderRadius", "borderTopColor", "color", "fontSize", "fontWeight"] as const;
-const styleOf = (page: Page, selector: string): Promise> =>
- page.locator(selector).first().evaluate((el, props) => {
- const cs = getComputedStyle(el as Element);
- return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || (cs as unknown as Record)[p]]));
- }, PROPS as unknown as string[]);
-
-test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => {
- await page.goto("/dashboard");
- const ref = await context.newPage();
- await ref.goto(APP_SHELL);
-
- // Shell components appear on every page, incl. the instructional /dashboard. The data components
- // (.filters/.table/.pager) used to live on the mock dashboard (removed in §10); they're now covered
- // by the mockup screenshots + their unit tests, and rendered live with real data by the full-flow
- // E2E (the admin Users list) which the Ory-free visual suite can't stand up.
- for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch"]) { // .btn-primary = the dashboard's "Browse the example plugin" CTA
- expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector));
- }
- await ref.close();
});
test("every icon