§7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114).
2026-06-19 14:48:27 +02:00
§10 split landing into a public "/" + gated "/dashboard", both plugin-replaceable (todo §10 follow-up); per human feedback, "/" is now an ungated public landing (default views/home.ejs: brand + intro + prominent Log in / Create account links, or "go to dashboard" when signed in) and "/dashboard" is the gated post-login app home (anonymous → /login?return_to=/dashboard). Both are fully replaceable via two optional RouteHandlers on PluginManifest — home? (public /) and dashboard? (gated /dashboard) — rendered against the plugin's own views with the native shell via ctx.chrome (full route parity: HEAD, void-return, response hooks, fresh CSRF cookie; a home handler is public so ctx.user may be null). Single-slot + loud: findConflicts errors on >1 owner of either slot (new "home"/"dashboard" kinds), discovery rejects a non-function handler, and "dashboard" is reserved so a plugin folder can't shadow it ("/" can't be shadowed — route paths carry the /<id> prefix). Post-login + already-signed-in redirects and the global Dashboard/People nav hrefs moved to /dashboard. Tests-first (348 units): public-/ + gated-/dashboard + dual plugin-override in app.test; per-slot conflict in plugin.test; non-function/reserved/two-owners in discovery.test. Docs: plugin-contract "The landing pages" section + README. E2E: visual.spec plants a session for /dashboard design-system tests + a cookie-free public-landing test; full-flow repointed to /dashboard. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 348 units + visual(10) + full-flow(7) green.
2026-06-20 17:43:01 +02:00
§8 review checkpoint (todo §8); ran the architecture + product reviewers on the whole project and addressed findings. Critical (arch): "Testing & CI" shipped no CI automation — added scripts/ci.sh, the whole gate in one command (pin-lockstep check → typecheck → units (count guard) → the 4 E2E suites, each on its own named fresh stack with guaranteed down -v + non-zero exit on first failure). The gate immediately caught a latent bug: the auth-refresh suite booted Hydra (inherited §6 web→hydra dep) but the e2e overlays don't run Hydra with --dev, so it never went healthy — dropped Hydra from the auth suite's web deps (it never needed it). Product 🔴: the README Status note claimed auth/Hydra were unbuilt (false after §4/§6/§8) — corrected it + dropped the now-false _(planned)_ markers on the Auth/MVP sections. Product 🟡: added a login-only "Forgot password?" link (the recovery flow was unreachable from /login) and a data-table empty-state row (blank list tables, recurring deferral) — both tests-first. Docs: README Layout e2e line + e2e/package.json updated for the §8 suites. Stability-reviewer APPROVE-with-nits; addressed both (per-suite compose project names; grep || true) and fixed a project-name dot bug it introduced. Corrected a reviewer error (bootstrap uses restart on-failure:5, not unless-stopped). typecheck + 306 units green; scripts/ci.sh green end-to-end (visual 9 · auth 1 · oauth 2 · full 6), all stacks torn down. Deferred to §9: the app.ts internal route-table (raised urgency), visual-parity for admin/consent screens, a key-rotation E2E; L3 (plugin-api barrel in shifts.test) → the §8 test-cleanup item.
2026-06-19 20:08:48 +02:00
§9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, 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 ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). 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), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).
2026-06-20 02:11:10 +02:00
§9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, 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 ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). 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), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).
2026-06-20 02:11:10 +02:00
§9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, 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 ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). 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), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).
2026-06-20 02:11:10 +02:00
§9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, 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 ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). 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), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).
2026-06-20 02:11:10 +02:00
§9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, 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 ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). 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), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).
2026-06-20 02:11:10 +02:00
§10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green.
2026-06-20 18:12:46 +02:00
§10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green.
2026-06-20 18:12:46 +02:00
§7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114).
2026-06-19 14:48:27 +02:00

Plainpages

A self-hostable foundation for admin and operational web UIs — the kind of back-office you build for a webshop, a scheduling system for schools, a water treatment plant, or any tool where staff register, find, and work with data.

Plainpages gives you the parts that are the same every time — authentication, authorization, a config-driven menu, and a server-rendered, zero-JS design system — and lets you add everything domain-specific by dropping in plugin folders. The only screens it ships itself are the ones for running the system: users, groups, and permissions. Everything else is a plugin.

Priorities (unchanged from day one): simplicity, few dependencies, strict TypeScript, no build step, Docker-only, environment-agnostic (no NODE_ENV — every behaviour is an explicit config toggle). Heavy lifting that isn't simple to do well — identity, sessions, SSO, OAuth2, permission checks — is delegated to Ory sidecar services rather than reinvented.

"Simple" here is about the whole architecture staying simple — not just at the start, but after you've dropped in 240 plugins and run it hard in production. The shape doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the same I/O-free JWT check, and there's no app database to scale or migrate.

Who this is for

Experienced developers building back-office, admin, and dashboard products — for their own use or for a client. You know HTTP, Docker, and identity providers, and you'd rather assemble pages from building blocks than fight a framework or hand-roll auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out of your domain logic. It's not a no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see Auth) reads as obvious rather than a surprise, you're the audience.

Project goals

Plainpages deliberately targets low-end systems, odd hardware, and low-bandwidth environments — a tablet on a factory floor, an old thin client at a reception desk, a remote site on a flaky link. That's why the baseline is boring, standards-compliant HTML + CSS with zero JavaScript: it loads fast, degrades gracefully, and works on whatever browser is already there. Where a modern CSS feature removes the need for JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. That standards-first stance also makes semantic, accessible markup a priority: real landmarks, one <h1> per page, 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).

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 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). Remaining polish is tracked in todo.md (§9§10).

The MVP — "clone, one command, hack on a plugin"

The bar for a first usable release: clone, run one command, get a working register/login, and start building your own plugin — no manual key generation, no hand-edited Ory config, no separate database. That command brings up the whole stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops you at a public landing page with a one-click path to sign in (the gated dashboard lives at /dashboard); from there you copy the example plugin folder and write your own page. SSO and the OAuth2-provider role (Hydra) come after — not required to start.

Architecture

Plainpages runs as a small set of containers, orchestrated by Docker Compose:

Container Role
web The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny.
kratos Ory Kratos — identity: login, registration, password reset, SSO, sessions.
keto Ory Keto — permissions: the authorization decisions (can user X do Y on Z?).
hydra Ory Hydra — OAuth2/OIDC provider, so other apps can log in through plainpages.
postgres Ory's storage only (Kratos/Keto/Hydra). The web app never connects to it.

The web app is an Ory relying party: it never stores passwords. At login it turns the Kratos session into a short-lived, locally-validated JWT (the Kratos session tokenizer) carrying the user's coarse roles — so every later request gates the menu and pages by verifying the JWT in-process, with no per-request call to Ory. Keto answers the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 login & consent provider for other apps. It reaches the Ory services over their REST APIs using Node's built-in fetch — no SDK dependency. See Auth, sessions & permissions.

So the web app is stateless and its npm footprint stays tiny — a small, pinned set of runtime deps (today ejs for templating, lucide-static for icons, and @larvit/log — itself zero-dependency — for structured/OTLP logging), grown only with justification and never a framework. Auth, sessions, SSO, and OAuth2 add services, not npm packages; data lives upstream (see Stateless — no application database).

What's included vs. what you add

  • Included: sign-in / register / reset (themed, Kratos-backed), and the admin screens for users, groups, permissions (users via Kratos, the relationship graph via Keto).
  • You add: everything domain-specific, as plugins — a list page, a form, a scheduler, a register, a dashboard. Plugins get the same building blocks the built-in screens use.

Requirements

  • Docker
  • Docker Compose

That's it. Do not install or run Node/npm on the host — use the commands below.

Development

docker compose up            # http://localhost:3000, live reload via `node --watch`

docker compose up brings up the full stack — web + Postgres + Kratos/Keto/Hydra — merging compose.override.yml, which mounts the source and restarts the server on change. A one-shot bootstrap service then seeds first-boot state with zero manual prep — it generates the JWT signing key if absent, creates a demo admin (admin@plainpages.local / admin) in Kratos, and grants it the admin role plus every discovered plugin's declared permission tokens in Keto, so permission checks (and any dropped-in plugin) resolve out of the box; it is idempotent, so every up re-runs it safely. It finishes by printing a banner with the login URL and seeded credentials. Change the demo admin before production. The web app waits for Kratos + Keto to be healthy and the bootstrap to finish before starting (each Ory service has a readiness healthcheck). Dev publishes the host-facing Ory ports — Kratos public 4433 (the browser POSTs self-service flows there) and Hydra public 4444; prod (docker compose -f compose.yml up) keeps them internal. Kratos recovery/verification emails are caught by mailpit in dev — read the codes at http://localhost:8025. To work on your own plugin, see Where plugins live.

Configuration

Read from the environment once at boot (src/config.ts) and validated there — a bad URL, an out-of-range PORT, a non-boolean toggle, or a missing/throwaway enforced secret fails loud before the server starts. A clean clone needs none of these; every value defaults to the dev stack.

The app is environment-agnostic: there is no NODE_ENV. Behaviour that used to flip on "production" is now its own explicit toggle, so a deployment turns on exactly what it wants. compose.yml (base) sets the hardened toggles; compose.override.yml (dev, auto-merged by docker compose up) turns them back off for live editing.

Var Default Notes
PORT 3000 web listen port
CACHE_TEMPLATES false cache compiled EJS templates (true in prod)
SECURE_COOKIES false mark our session/CSRF cookies Secure (true in prod https; off in dev http)
REQUIRE_SECURE_SECRETS false when true, CSRF_SECRET must be supplied and differ from the dev throwaway
LOG_LEVEL info min severity logged: error/warn/info/verbose/debug/silly/none
LOG_FORMAT text log line format: text (human-readable, dev) or json (structured, prod)
SERVICE_NAME plainpages OTLP service.name on every log + span — brand it as your own deployment
OTLP_ENDPOINT unset OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only)
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)
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
REVOCATION_DENYLIST false when true, enable the optional instant role/session revoke denylist
REVOCATION_TTL_SEC 900 how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew
CSRF_SECRET dev throwaway signs our double-submit CSRF token; enforced by REQUIRE_SECURE_SECRETS

What you must supply (the only manual prep)

A clean clone needs none of the above — docker compose up brings up the whole stack with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see Development). Exactly two things can't be auto-generated, and both are production-only — neither blocks a clean clone:

  1. Production secrets — replace the committed dev throwaway CSRF_SECRET (env), plus the JWT signing key (mount a real jwks.json or set …_JWKS_URL — see JWT signing key & rotation). Set REQUIRE_SECURE_SECRETS=true and the app refuses to boot until CSRF_SECRET is supplied and differs from the throwaway.
  2. SSO provider client id/secretoptional; password login works without them. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see Social sign-in (SSO)).

Everything else is generated or seeded on first boot — Ory migrations, the dev signing key, the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to hand-configure.

Social sign-in (SSO)

Off by default — a clean clone is password-only. Kratos activates a provider purely from the environment (no code, no rebuild): set SELFSERVICE_METHODS_OIDC_ENABLED=true and SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS to a JSON array of providers (google, microsoft, …), each carrying its client_id/client_secret and referencing the committed claims mapper ory/kratos/oidc/claims.jsonnet. The themed sign-in/register pages derive one button per provider from the live flow's oidc nodes, so no creds ⇒ no provider ⇒ no button, and the whole SSO section disappears when none are configured — no code change to add or remove one. Open-source Kratos has no native SAML — front it with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider the same way.

JWT signing key & rotation

The session tokenizer (§3) 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:

docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json

Install in production. Two endpoints must read the same key material:

  • Kratos (signer) — mount the file over …/tokenizer/jwks.json, or set SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://<the JWKS JSON, base64>.
  • web (verifier)JWKS_URL (default file://…/tokenizer/jwks.json). A file:// set is re-read live (5-min TTL, plus an immediate reload on an unknown kid); a base64:// set is immutable and rotates only on a web redeploy. For rotation, use 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 set can hold the new key and the old one at once — tokens minted before and after the swap both verify.

Scheduled rotation

The token TTL is 10 min (kratos.ymlwhoami.tokenizer.…ttl); the wait window below is one TTL + clock skew, round up to ~12 min. Run from the repo root (paths are container-relative; with the dev bind-mount they edit the real file).

  1. Prepend a fresh key (new key first, old key kept) — write via a temp file so the shell's > can't truncate the input before it's read:
    docker compose run --rm -T --no-deps web sh -c \
      'node src/gen-jwks.ts --prepend ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \
      && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
    
  2. Restart Kratos so it signs with the new first key: docker compose restart kratos. (web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a brief Kratos blip only touches login/re-mint.)
  3. Verify new logins mint the new kid — decode the plainpages_session cookie's JWT header, or watch web's logs for a jwks reload on kid miss debug line as old clients present the new key.
  4. Wait ~12 min, then prune the superseded key:
    docker compose run --rm -T --no-deps web sh -c \
      'node src/gen-jwks.ts --prune ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \
      && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
    
    No Kratos restart needed — it already signs with that key; this only drops a now-unused verify key.

Rollback (before the prune): the old key is still in the set, so revert step 1's file and restart kratos — in-flight tokens never broke.

Emergency rotation (key compromise)

Skip the overlap — you want every token signed with the leaked key to die now. Replace the set with a single fresh key (no --prepend):

docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
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, 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.

Type check & tests

docker compose run --rm --no-deps web npm run typecheck   # strict tsc --noEmit
docker compose run --rm --no-deps web npm test            # node --test (units)

--no-deps keeps these off the Ory stack — units need no Postgres/Kratos/Keto, and web otherwise drags up its depends_on services.

End-to-end (Playwright)

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).

docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e.yml down -v                 # tear down after

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 session (roles re-read from Keto), and once that session is revoked the stale cookie is cleared.

docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e-auth.yml down -v                 # tear down after

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 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.

docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e-oauth.yml down -v                 # tear down after

Full browser flow (full-flow.spec.ts) — the real Playwright UI against the live stack: the themed password login and a mocked-SSO login (an in-network mock OIDC provider, e2e/mock-oidc.mjs), menu filtering by role, the users/groups/roles admin CRUD, a permission-gated plugin page, and logout. Because the themed form posts straight to Kratos and cookies are host-scoped, a tiny same-origin gateway (e2e/proxy.mjs) fronts web + Kratos on one host (ory/kratos/e2e-proxy.yml points Kratos at it) — exactly as a production reverse proxy would.

docker compose -f compose.yml -f compose.e2e-full.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e-full.yml down -v                 # tear down after

--build rebuilds the runner so spec edits are always picked up (the image bakes in e2e/).

Screenshots + an HTML report land in e2e/artifacts/ (git-ignored). Every user-facing flow is covered end-to-end; tests are independent and run fully in parallel for speed (AGENTS.md §6) — keep new tests side-effect-free so the suite stays fast.

The full gate (one command)

scripts/ci.sh is the whole gate in one reproducible command — typecheck → unit tests → each E2E suite against its own fresh stack, with a guaranteed down -v after each (even on failure) and a non-zero exit on the first failure. Run it locally before a release, or wire it into your CI service:

bash scripts/ci.sh

Each E2E suite owns a clean stack — never point two suites at one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the gate runs them serially, one stack up/down per suite.

Building a plugin

A plugin is a folder under plugins/. The host discovers it at boot — no registration step, no central wiring. The full, authoritative API surface — manifest shape, handler/RequestContext contract, versioning, conflict rules, hooks, and the dev/test story — is docs/plugin-contract.md (src/plugin.ts holds the types). A complete, runnable reference ships in plugins/scheduling/ — a public overview page, a permission-gated list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, and a mix of public + role-gated nav. Copy it and adapt. The sketch below is the shape.

There are two replaceable landing slots: / is a public front page (default: an intro with sign-in / register links) and /dashboard is the gated post-login app home (default: the People list). A plugin owns either by exporting a home (public /) or dashboard (gated /dashboard) handler — one owner each. See the contract's landing pages section.

plugins/scheduling/      # folder name = the plugin id; mounted at /scheduling
  plugin.ts              # default export: the typed manifest (see below)
  views/                 # EJS templates for this plugin's pages
    shifts.ejs
  public/                # CSS / assets, served under /public/scheduling/
    scheduling.css

The manifest is TypeScript — typed, commented, no separate schema to keep in sync. The id and mount path are derived from the folder name, not declared:

import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs)
import { listShifts, overview } from "./shifts.ts";

export default definePlugin({
  apiVersion: "1.0.0",      // semver of the host contract this was built against (a literal — see docs)

  // Nav fragment, composed into the global menu. Permission-gated: items the current user can't
  // access are hidden. `public: true` shows an item to everyone (signed in or not). Arbitrary
  // depth. `icon` is a Lucide icon by its sprite id (src/icons.ts).
  nav: [
    {
      label: "Scheduling", icon: "i-cal",
      children: [
        { label: "Overview", href: "/scheduling", public: true },             // shown to everyone
        { label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" },
      ],
    },
  ],

  // Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role
  // (a JWT-claim check) enforced before the handler runs; `public: true` makes a page reachable by
  // anyone (mutually exclusive with `permission`).
  routes: [
    { method: "GET", path: "/", public: true, handler: overview },
    { method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
  ],
});

The handler (listShifts) fetches its data from an upstream service and renders it — the plugin holds no state of its own (see below); the reference points SCHEDULING_UPSTREAM at its backend (the dev compose ships a tiny mock, examples/shifts-upstream/). A view result renders against the native app shell via ctx.chrome (branding, the global nav, the signed-in user), and a write form guards itself with ctx.verifyCsrf + the token in ctx.chrome.csrfToken. It logs through ctx.log and traces upstream calls with ctx.log.fetch (or tracedFetch), joining the request's trace (see Observability). Each plugin is self-contained (its own nav, routes, views, CSS), so installing one is "drop the folder, restart." An operator stays in control via a central override.

Where plugins live (and how to mount them)

The host scans /app/plugins/ inside the web container — so "installing a plugin" means getting its folder there. There are two ways, depending on where the plugin's source lives:

1. In your clone (the default dev loop). Create plugins/<id>/ in the working tree. docker compose up already bind-mounts the whole tree (compose.override.yml: .:/app), so the folder is live in the container — restart to pick it up. This is the "copy the example plugin and go" path.

2. A plugin kept in its own repo, or added to a prebuilt image. Bind-mount the plugin folder onto /app/plugins/<id> with a small compose override. Plugins are stateless, so mount it read-only:

# compose.plugins.yml — mount external plugin folders into the host
services:
  web:
    volumes:
      - ../scheduling-plugin:/app/plugins/scheduling:ro   # host path : /app/plugins/<id>
# Dev: list the files explicitly (a third file disables the implicit override merge)
docker compose -f compose.yml -f compose.override.yml -f compose.plugins.yml up
# Prod (image already built, no source mount):
docker compose -f compose.yml -f compose.plugins.yml up -d

A named volume or volume container works the same way (target /app/plugins/<id>), but a bind mount matches the edit-and-reload loop. For a baked production image, just keep the plugin in the build context and it's COPY'd in at build time — pinned and reproducible; mount a volume only to add plugins to an already-built image.

Discovery — scanning plugins/, importing each plugin.ts default export, and validating it (id, apiVersion, conflicts) — runs at boot (src/discovery.ts); a bad plugin stops startup with a precise message. The router (src/router.ts) then mounts each route at /<id>, resolves :name params, runs the permission gate, and turns the handler's RouteResult into the response; a view result renders plugins/<id>/views/<view>.ejs (src/view-resolver.ts), which may include() the core building-block partials. A plugin's public/ assets are served at /public/<id>/ (src/static.ts). The mount mechanics above are how the files get into the container either way.

The menu system

The menu is driven entirely by config and assembled from two sources:

  1. Plugin fragments — each plugin contributes its own nav (above).
  2. A central overrideconfig/menu.ts (loaded by src/menu-config.ts, validated at boot) — where the operator reorders, renames, groups, or hides items (by node id), and sets branding (app name, logo, default theme). The override always wins, applied before the per-user filter. A clean clone needs no config/menu.ts; defaults apply.

Every nav item may carry a permission; the rendered tree is filtered per user by reading the roles in the session JWT (no per-request authz call — see Auth, sessions & permissions), so the menu only ever shows what that person can reach. An item (or a whole page) may instead be marked public: true to show it to everyone, signed in or not — the blessed, explicit way to expose a public page and its menu entry (a no-permission item is already public; public just says so on purpose, and is mutually exclusive with permission). The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default.

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:

  • Partials: app shell, nav tree, filter bar, data table (sort / select / row actions), pagination, form fields, badges, menus, auth cards.
  • Helpers: composeNav (menu from config), parseListQuery (?q=…&status=…&sort=…&page=… → filter/sort/pagination), paginate (page math), and the auth guards a handler calls to authorize (src/guards.ts): requireSession (assert a session — a GuardError the host turns into a redirect to sign in), can(role) (a coarse JWT-claim check, zero I/O), check(relation, object) (the one live Keto call, for relationship rules).

Interactivity: zero-JS spine, opt-in enhancement

The core and all building blocks work with zero JavaScript — menus, theme switching, and filtering are pure CSS + GET forms. On the low-end, low-bandwidth targets we care about this is usually faster: a round-trip returning a small, pre-rendered HTML page beats a client-side runtime that must boot, fetch JSON, and re-render before anything shows. List state (?q=…&status=…&sort=…&page=…) lives in the URL, so a view is bookmarkable, shareable, and reproducible — the URL is the only state the UI keeps.

Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may opt into progressive enhancement (htmx, Alpine, or vanilla JS) on top of working server-rendered HTML. The baseline never depends on it.

Auth, sessions & permissions

Identity comes from Kratos; the hot path stays I/O-free by carrying coarse authorization in a locally-validated JWT, and Keto is reserved for the rare fine-grained, must-be-fresh check.

Login → session JWT (the Kratos session tokenizer)

The themed sign-in / register / reset / SSO screens drive Kratos self-service flows. SSO is optional and self-configuring: each provider's button renders only when its credentials are present, and the whole SSO section disappears when none are configured — leaving plain password login. A developer never has to touch SSO to get started. On success, rather than keeping the opaque Kratos cookie and calling whoami on every request, the app exchanges the session for a signed JWT once via the Kratos session tokenizer (whoami with a tokenize_as template) and stores it as the session cookie.

  ── AT LOGIN / REFRESH  (the only time Ory is on the path) ──────────
   Kratos verifies credentials
     └─► app reads the user's roles from Keto       (direct + transitive via groups)
     └─► app writes them as a derived projection on the identity (admin API)
     └─► whoami(tokenize_as: "plainpages")  ─►  signed JWT
           claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
     └─► stored as the session cookie

  ── EVERY REQUEST  (hot path — pure CPU, no I/O) ───────────────────
   Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
                                read claims.roles
                                filter menu · gate routes

Keto is the single source of truth for roles. Coarse roles are Keto relations (e.g. role:admin#members@user:alice); the admin screens write them only to Keto. But the tokenizer's claims mapper can read only the identity, not call Keto — so at login the app reads the roles from Keto and refreshes a derived projection: a read-only copy written onto the identity's metadata_public for the tokenizer to see, which the template maps into the JWT roles claim. (It must be metadata_public, not metadata_admin: the session Kratos hands the tokenizer carries only public metadata — and the user can already read these coarse roles in their own JWT, so nothing is leaked.) That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on the next login.

A role can be granted to a user directly or to a group the user belongs to; login resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT roles match what the admin Effective access view shows.

Cost: a handful of Keto reads + one identity refresh per login — never per request. JWKS is cached, so even signature verification hits the network only on key rotation. The app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one moment authz is recomputed from Keto.

Two trade-offs — both deliberate

This design buys an I/O-free hot path that scales to tens of thousands of concurrent users on modest hardware. In return:

  • Role changes lag by up to one TTL (~10m). Gating reads the JWT, not Keto, so a granted or revoked role only takes effect when the token is next minted (re-login or TTL refresh). For an admin tool this is intentional — the alternative is a Keto call per request, which we traded away. For instant revoke, turn on the optional revocation denylist — it closes the gap for security-critical cases without putting Keto back on the hot path.
  • Ory is on the critical path for sign-in. If Kratos is down no one can log in; if it stays down past the TTL, existing sessions can't refresh and the UI goes dark. That's the direct consequence of being stateless and delegating identity — no local fallback, by design. Run Ory with the availability you'd give any auth provider.

Instant revoke — the optional denylist

Off by default; turn it on with REVOCATION_DENYLIST=true (src/denylist.ts). For security-critical revoke (offboarding, a compromised account) the ~10m role/session lag above is too long. When enabled, an admin deactivating or deleting a user, or granting/revoking a role to a user, records that subject as revoked-now; the hot path then rejects every token for it minted before the revoke and forces a re-mint — which re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued after the revoke) passes, so a role downgrade lands immediately without locking the account.

It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside the stateless model. Entries self-evict after REVOCATION_TTL_SEC (default 900s ≥ the 10m token TTL

  • skew), by which point any pre-revoke token has expired anyway. The check is pure CPU — Keto stays off the hot path. Two deliberate bounds: it's instant on the single instance that handled the revoke (across replicas/restarts the guarantee falls back to the token TTL — back the denylist with a shared store for hard multi-instance instant-revoke), and a group membership change is transitive across many users, so it's left to lag — deactivate the user, or use a direct user-role change, for an instant effect.

Three tiers of "may I?"

  coarse  (menu / route / feature)        → JWT claim     · in-process, zero I/O
  fine + attribute (owner / tenant / …)   → upstream service that owns the row
  fine + relationship (shared / inherited)→ Keto, live check at the action
  • Coarse gates the menu and routes — read straight from the JWT.
  • Attribute-based row rules (ownership, tenant, status) live in the upstream service that holds the data: it's the source of truth and the check is free.
  • Relationship-based rules (sharing, delegation, inherited/transitive access, or authz that must mean the same thing across several services) go to Keto — that's what ReBAC is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already answer from its own data.

The built-in users / groups / permissions screens write authorization only to Keto — coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from Keto at login and projected through the tokenizer (above); nothing authors them anywhere else.

OAuth2 provider (Hydra)

Only relevant when other apps authenticate through plainpages. The app implements Hydra's login & consent steps — authenticating the user via their Kratos session — and Hydra issues the access / refresh / id tokens those apps use. Nothing in the menu or first-party pages needs Hydra.

The login challenge is wired (src/oauth-login.ts at /oauth2/login): Hydra hands the browser here, the app resolves it against the Kratos session and accepts (or bounces an unauthenticated user to the themed login, returning here once signed in). The consent challenge is wired too (src/oauth-consent.ts at /oauth2/consent): a first-party client (its Hydra metadata.first_party: true) — or one Hydra already skipped — is auto-granted the requested scopes; any other client gets a themed consent screen (naming the signed-in account, with a sign-out escape) whose CSRF-guarded Allow/Deny accepts or rejects. id_token claims (email, name) come from the Kratos identity. RP-initiated logout is wired too (/oauth2/logout): Hydra hands the browser here, the app accepts the logout_challenge and resumes to Hydra's post-logout redirect — the first-party POST /logout still owns ending the Kratos session + our JWT cookie.

Those clients are registered from the admin OAuth2 clients screen (/admin/clients, src/admin-clients.ts): register (Hydra shows the generated client_secret once, on the confirmation page — confidential clients), list, and delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.

Stateless — no application database

Plainpages and its plugins hold no state of their own. The only database in the stack is Postgres, and it belongs to Ory (Kratos/Keto/Hydra); the web app never connects to it.

A plugin gets its data by calling an upstream service from its route handler — a REST API, an ERP, a plant historian, the customer's own backend — and renders the response with the building blocks; writes are forwarded the same way. The partials only need rows to render and don't care where they came from.

This keeps web trivially scalable and crash-safe: any instance can serve any request, because the session lives in Kratos and the data lives upstream.

Production / deployment

docker compose -f compose.yml up --build -d   # base config only, no source mount

compose.yml is the full prod stack — web + Postgres + the three Ory services (Kratos/Keto/Hydra, with migrations + the one-shot bootstrap) — and mounts no source. Secrets come from the environment (CSRF_SECRET, POSTGRES_USER/POSTGRES_PASSWORD); the base already sets REQUIRE_SECURE_SECRETS=true, so a missing or dev-throwaway CSRF_SECRET fails the boot rather than running insecure.

Before going live, supply the production secrets and any SSO credentials — the only manual prep (What you must supply); the rest is auto-generated.

Every response carries security headers (src/security-headers.ts, set once per request): a strict Content-Security-Policy (the core is zero-JSscript-src 'self', no inline scripts, so an injected <script> can't run), X-Content-Type-Options: nosniff, X-Frame-Options: DENY + frame-ancestors 'none', Referrer-Policy, and — when SECURE_COOKIES=true (https) — HSTS. The CSP allows same-origin assets only, so a branding logo must live under /public/ (or be a data: URI); a plugin route can override any header per-response via RouteResult.headers (e.g. to ship its own JS).

A deep link reached while signed out — or after the ~10m session JWT lapses mid-task — bounces to the themed sign-in and, once authenticated, returns to the page that was requested (return_to, validated host-relative by localPath in src/safe-url.ts, so a crafted ?return_to= can't turn login completion into an open redirect). If Ory is unreachable on the sign-in path itself, the user gets an honest 503 ("sign-in is temporarily unavailable"), distinct from the catch-all 500.

The server drains in-flight requests on SIGTERM/SIGINT rather than cutting them mid-response, so container restarts are clean.

Observability

Logging is structured and OTLP-native, on @larvit/log (zero-dependency). One app logger tags every line with service.name (SERVICE_NAME, default plainpages — brand your own deployment); each request is cloned into a short-lived trace span, made ambient for the whole handler (an AsyncLocalStorage), so logs and traces correlate. Three explicit toggles (no NODE_ENV):

  • LOG_LEVEL (default info) — error · warn · info · verbose · debug · silly · none.
  • LOG_FORMATtext in dev (human-readable), json in prod (the base compose sets it) for a log pipeline.
  • SERVICE_NAME — the service.name on every log and span.

Every request emits one access line (method, path — the query is dropped, it can carry tokens — status, ms, requestId); login/logout, admin writes (who-did-what), and missing-role/CSRF rejections log at info/warn, and the catch-all 500 + the Ory-unreachable re-mint at error/warn. An inbound W3C traceparent is adopted, so a request continues a trace started by an upstream proxy/gateway.

Distributed tracing — every outbound call. Because the request logger is ambient, all outbound HTTP — the Kratos/Keto/Hydra clients and the JWKS fetch — runs through it (tracedFetch), so each becomes a client span under the request and carries the traceparent downstream (Ory continues the same trace). A plugin does the same: ctx.log is its request logger and ctx.log.fetch(url) (or defaulting an upstream client to the exported tracedFetch, as the reference plugin does) traces its upstream calls too. The result is one trace per request spanning web → Ory/upstream.

OTLP export (off by default). Point OTLP_ENDPOINT at an OpenTelemetry Collector's HTTP base URI (e.g. http://otel-collector:4318) and logs and spans also export there — feed Grafana Loki (logs) + Tempo (traces), or any OTLP backend. OTLP_PROTOCOL selects the wire format (http/json default, or http/protobuf for collectors that only accept protobuf). Export is fire-and-forget — it never blocks or fails a served request, and nothing exports when the endpoint is unset (zero cost). A collector outage is survivable but noisy: each request's failed export writes a line to stderr (it's retried per request, not queued), so run a local collector/agent you trust.

Layout

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/static.ts        Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → 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/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/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 built-in "/dashboard" People list view model (mock data, wires the §1 helpers); /dashboard is gated to a session, "/" is the public landing — both replaceable by a plugin `dashboard`/`home` handler (§10)
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/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 brand/global-nav/user/theme/csrf a plugin view renders the native shell from — exposed on ctx.chrome (§7)
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/<id>/views/<view>.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: home (public "/" landing), index (app-shell dashboard at /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, 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
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); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full].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`)

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 template in its views/.
  • Static asset: drop it in the plugin's public/; served at /public/<plugin>/<path>.
  • New dependency: docker compose run --rm web npm install <pkg> (updates package.json + package-lock.json), then docker compose build. Keep deps minimal — prefer the Node standard library, and prefer an Ory REST call over an SDK.

All versions are pinned to exact, human-readable semantic versions (no ranges, no digests): npm deps via .npmrc (save-exact=true) + the committed lockfile (npm ci), and container images by tag in the Dockerfile / compose files (e.g. node:24.16.0-alpine3.24, pinned Ory and Postgres tags).

Description
A pluggable web GUI for systems that handle data, lists, and forms.
Readme MIT 6.4 MiB
Languages
TypeScript 78.2%
EJS 8.6%
HTML 6.9%
CSS 5.1%
JavaScript 0.6%
Other 0.5%