From bb612baa2ce9dadf7c2fa21707dda9565719b8b0 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 23 Jun 2026 23:15:24 +0200 Subject: [PATCH] Reorganize README for two readers: first-run Quick start + nested ToC, overview-first ordering; document the convention in AGENTS.md --- AGENTS.md | 19 + README.md | 1296 ++++++++++++++++++++++++++++------------------------- 2 files changed, 703 insertions(+), 612 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0e1cd7..f5c5bc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,25 @@ docker compose run --rm --no-deps web npm test # tests docker compose -f compose.yml up --build -d # production ``` +## README structure (keep it this way) + +`README.md` serves two readers, in this order — preserve it when editing: + +1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets + the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes + before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the + example plugin as small as possible; deeper detail lives in its own section, linked. +2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then + sections ordered **most-used/overview first → least-used/in-depth last**: Overview → + Architecture → Building plugins → menu/blocks/interactivity → Configuration → Auth → + Email → Testing → Production → Observability → the JWT-rotation runbook → the + Project-layout file map → Extending. Niche ops runbooks and the file-map reference stay + near the end. + +When editing: put content in the section it belongs to (don't prepend rationale above Quick +start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in +one home, linking to it rather than restating (credentials, env vars, rotation steps). + ## Rules - Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable** diff --git a/README.md b/README.md index fd85a09..b17b876 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,144 @@ # 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. +A self-hostable **foundation for admin and operational web UIs** — the back-office you +build for a webshop, a school scheduling system, a water-treatment plant, or any tool +where staff register, find, and work with data. It ships 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**. -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: +## Quick start + +> **Requirements:** **Docker** and **Docker Compose** — and nothing else. Never run +> `node`/`npm`/`tsc` on the host; every command goes through Compose. + +**1. Clone and start the whole stack.** + +```bash +git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git +cd plainpages +docker compose up # http://localhost:3000, live-reloads on source changes +``` + +One command brings up `web` + Postgres + Ory (Kratos/Keto/Hydra), generates the JWT +signing key, seeds a demo admin, and prints a banner with the login URL — no key +generation, no hand-edited Ory config, no separate database. + +**2. Sign in.** Open and sign in as the seeded admin — +**`admin@plainpages.local` / `admin`** (change before production). `/` is the public +landing; the gated app home is `/dashboard`. + +**3. Add your first plugin.** The clone is bind-mounted into the container, so a new +folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: + +```ts +import { definePlugin } from "../../src/plugin-api.ts"; + +export default definePlugin({ + apiVersion: "1.0.0", + nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], + routes: [ + { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, + ], +}); +``` + +```bash +docker compose restart web +``` + +Visit — the page is mounted at `/hello` (the folder name +is the plugin id *and* the mount path) and "Hello" is in the menu. That's the whole loop: +**drop a folder in `plugins/`, restart, it's live.** + +From here, render real pages against the app shell and fetch upstream data — see +[Building plugins](#building-plugins) and the runnable reference in +[`plugins/scheduling/`](plugins/scheduling/). + +## Contents + +- [Overview](#overview) +- [Architecture](#architecture) + - [Stateless — no application database](#stateless--no-application-database) +- [Building plugins](#building-plugins) + - [the shape](#the-plugin-shape) + - [landing pages](#landing-pages) + - [where they live & mounting](#where-plugins-live-and-how-to-mount-them) +- [The menu system](#the-menu-system) +- [Building blocks](#building-blocks) +- [Interactivity: zero-JS spine](#interactivity-zero-js-spine) +- [Configuration](#configuration) + - [canonical host](#canonical-host-one-public-url) + - [what you must supply](#what-you-must-supply-the-only-manual-prep) + - [SSO](#social-sign-in-sso) +- [Auth, sessions & permissions](#auth-sessions--permissions) + - [login & the session JWT](#login-and-the-session-jwt) + - [instant revoke](#instant-revoke-the-optional-denylist) + - [three tiers](#three-tiers-of-may-i) + - [OAuth2 (Hydra)](#oauth2-provider-hydra) +- [Email](#email) +- [Testing](#testing) + - [end-to-end](#end-to-end-playwright) + - [the full gate](#the-full-gate-one-command) +- [Production & deployment](#production--deployment) +- [Observability](#observability) +- [JWT signing key & rotation](#jwt-signing-key--rotation) +- [Project layout](#project-layout) +- [Extending the core](#extending-the-core) + +## Overview + +Plainpages gives you the boring-but-hard parts of a back-office and stays out of your +domain logic. 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. +**Who it's 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. It's not a no-code tool and doesn't hide its moving +parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as +obvious rather than surprising, you're the audience. -"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 +**Included vs. what you add.** + +- **Included:** themed sign-in / register / reset (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 — built from the same building blocks the built-in + screens use. + +**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" 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 +**Low-end by design.** 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 `

` 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](AGENTS.md)). -**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](#auth-sessions--permissions)) 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 `

` 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](AGENTS.md)). - -> **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), **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" - -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. +> **Status.** The full architecture this README describes is built and exercised +> end-to-end by the Playwright suites (see [Testing](#testing)): 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), **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)). ## Architecture @@ -78,342 +152,56 @@ Plainpages runs as a small set of containers, orchestrated by Docker Compose: | `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](#auth-sessions--permissions). +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](#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](#stateless--no-application-database)). +In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser +POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml +up`) keeps them internal. -## What's included vs. what you add +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. -- **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. +### Stateless — no application database -## Requirements +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. -- Docker -- Docker Compose +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. -That's it. Do not install or run Node/npm on the host — use the commands below. +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. -## Development +## Building plugins -```bash -docker compose up # http://localhost:3000, live reload via `node --watch` -``` +A plugin is a folder under `plugins/`. The host discovers it at boot — no registration +step, no central wiring. The folder name is the plugin **id** *and* its **mount path** +(`plugins/scheduling/` → `/scheduling`); neither is declared in the manifest, so they +can't drift or be claimed twice. 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 (see [The menu system](#the-menu-system)). -`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](#where-plugins-live-and-how-to-mount-them). +The full, authoritative API surface — manifest shape, handler/`RequestContext` contract, +versioning, conflict rules, hooks, and the dev/test story — is +**[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin.ts` holds the types). A +complete, runnable reference ships in **[`plugins/scheduling/`](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. -## 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 | -| --- | --- | --- | -| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) | -| `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 (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 | -| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-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` | - -### Canonical host (one public URL) - -A site is often reachable at several URLs that resolve to the same place — `localhost` vs -`127.0.0.1`, an apex vs `www.`, an IP vs a domain. That matters here because **cookies are -host-scoped**: the themed login form POSTs to Kratos, and Kratos' CSRF cookie is set on the host the -browser is on. Reach the app on one host but let the form post from another and that cookie is lost — -Kratos rejects the flow and bounces to its error page. (The original symptom: open the banner's -`http://localhost:3000`, sign in, land on `http://127.0.0.1:3000/error` "Page not found".) - -`APP_URL` is the **single source of truth** for the public host. Set it and the web app **redirects -any off-host GET/HEAD visitor to it** (308, path + query preserved) *before* a flow starts, so the -browser, the themed forms, and the cross-origin Kratos POST all share one cookie host. Static assets -under `/public/` are served on any host (so health checks don't bounce). Everything else derives from -the same `APP_URL`: the first-run banner, and — via compose — Kratos' browser-facing URLs -(`compose.override.yml` maps `${APP_URL}` onto every `ui_url`, return URL, and `allowed_return_urls`). -Set `APP_URL` and the whole stack follows; there is no second place to edit. A genuine Kratos flow -error now renders a themed **`/error`** page (a path back to sign-in), not the catch-all 404. - -The redirect is an **explicit opt-in** (per the no-`NODE_ENV` rule): **unset ⇒ no redirect**, so a -deploy that forgets `APP_URL` never bounces real users to a stale default. The clean clone still works -with zero config because the bundled Kratos and the dev stack both default to `localhost` (the dev -override sets `APP_URL=http://localhost:3000`); browse `localhost:3000` and login just works, and -`127.0.0.1` is canonicalised onto it. - -> **Behind a reverse proxy:** the proxy must pass the public `Host` through (or rewrite Kratos' -> `base_url`/`ui_url`s to match what the browser sees). If it rewrites `Host` to an internal upstream -> name while `APP_URL` is the public domain, the canonical redirect will loop — preserve `Host`. -> -> **Dev caveat (custom host).** Only if you point `APP_URL` at a non-default host (e.g. a LAN IP to -> test from a tablet) must you also point the dev-published Kratos port at that host: set -> `KRATOS_PUBLIC_BROWSER_URL=http://:4433/` (it shares `APP_URL`'s host but keeps the Ory -> port, so it can't be `APP_URL` verbatim). In production Ory is fronted same-origin, so this doesn't arise. - -### 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](#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](#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/secret** — **optional**; password login works without them. - Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see - [Social sign-in (SSO)](#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 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: - -```bash -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://`. -- **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`. 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.yml` → `whoami.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: - ```bash - 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: - ```bash - 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`): - -```bash -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 re-mint only covers *expired* tokens, not bad signatures, -so a forged/leaked-key token can't be silently refreshed). The instant-revoke denylist - is unnecessary here — the signature itself is already invalid. - -## Type check & tests - -```bash -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, 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 -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 "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 -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 -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. - -```bash -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. - -```bash -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/`). - -**Dev-stack login regression** (`devstack-login.spec.ts`) — drives the *plain* `docker compose up` -topology (not the same-origin gateway above) with the runner on the **host network**, so the browser -sees `http://localhost:3000` (web) and `http://127.0.0.1:4433` (Kratos public) exactly as a host -browser does. It signs in the seeded admin from the URL the first-run banner advertises -(`http://localhost:3000`) **and** from the wrong host (`http://127.0.0.1:3000`), asserting both reach -the dashboard signed in — the latter via the [canonical-host redirect](#canonical-host-one-public-url). -It guards against the regression where the advertised login URL dumps the user on the `/error` "Page -not found" page; the proxied full-flow suite can't catch this (it fronts web + Kratos on one origin). -Part of `scripts/ci.sh` — it needs host networking and the host ports `3000`/`4433` free (Linux). - -```bash -docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml run --build --rm e2e # run it -docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml down -v # tear down -``` - -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](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 -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](docs/plugin-contract.md)** -(`src/plugin.ts` holds the types). A complete, runnable reference ships in -**[`plugins/scheduling/`](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](docs/plugin-contract.md#the-landing-pages-home--dashboard). +### The plugin shape ``` plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling @@ -424,8 +212,8 @@ plugins/scheduling/ # folder name = the plugin id; mounted at /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: +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: ```ts import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs) @@ -457,31 +245,40 @@ export default definePlugin({ }); ``` -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 +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`. The handler fetches its data from an upstream service +and renders it — the plugin holds no state of its own (see +[Stateless](#stateless--no-application-database)); 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](#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. +`examples/shifts-upstream/`). It logs through **`ctx.log`** and traces upstream calls with +**`ctx.log.fetch`** (or `tracedFetch`), joining the request's trace (see +[Observability](#observability)). The simplest possible handler skips views and returns +`{ html }` (the [Quick-start](#quick-start) plugin); most pages return a `view` so they get +the shell for free. + +### Landing pages + +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](docs/plugin-contract.md#the-landing-pages-home--dashboard). ### 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: +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//` in the working -tree. `docker compose up` already bind-mounts the whole tree (`compose.override.yml`: +**1. In your clone (the default dev loop).** Create `plugins//` 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. +[Quick-start](#quick-start) path. -**2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the -plugin folder onto `/app/plugins/` with a small compose override. Plugins are -stateless, so mount it read-only: +**2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin +folder onto `/app/plugins/` with a small compose override. Plugins are stateless, so +mount it read-only: ```yaml # compose.plugins.yml — mount external plugin folders into the host @@ -498,114 +295,198 @@ docker compose -f compose.yml -f compose.override.yml -f compose.plugins.yml up docker compose -f compose.yml -f compose.plugins.yml up -d ``` -A named volume or volume container works the same way (target `/app/plugins/`), -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. +A named volume or volume container works the same way (target `/app/plugins/`), 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 `/`, -> resolves `:name` params, runs the permission gate, and turns the handler's `RouteResult` into -> the response; a `view` result renders `plugins//views/.ejs` (`src/view-resolver.ts`), -> which may `include()` the core building-block partials. A plugin's `public/` assets are served -> at `/public//` (`src/static.ts`). The mount mechanics above are how the files get into the -> container either way. +> 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 `/`, resolves `:name` params, runs the permission gate, and turns the +> handler's `RouteResult` into the response; a `view` result renders +> `plugins//views/.ejs` (`src/view-resolver.ts`), which may `include()` the core +> building-block partials. A plugin's `public/` assets are served at `/public//` +> (`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 override** — `config/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. +2. **A central override** — `config/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](#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. +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](#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. -**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts` `buildPluginChrome`), -rendered by the same app shell on **every** page — the dashboard, the admin screens, plugin pages, -and the login / registration / recovery / front (`/`) pages. So it looks identical signed in or out; -it just shows fewer items to an anonymous visitor (only `public` ones, plus a Sign-in link), filtered -by the same per-user rule. The sidebar collapses to a burger on a narrow screen. A page that wants a -focused, chrome-free layout (e.g. a print view) opts out with the shell's `menu: false`. - -## Email - -The only emails are the **recovery** and **verification** codes from Kratos' self-service flows, and -**Kratos renders and sends them** (delegated, like the rest of identity — `web` never touches SMTP). -Dev catches them in **mailpit** (http://localhost:8025); prod points Kratos at a real server via -`COURIER_SMTP_CONNECTION_URI` (`courier.smtp` in `ory/kratos/kratos.yml`). - -**Customizing the email content** is a built-in Kratos feature — no code here. Set -`courier.template_override_path` to a mounted directory and drop Go templates in it, keyed by type: - -``` -/recovery_code/valid/email.subject.gotmpl -/recovery_code/valid/email.body.gotmpl (+ email.body.plaintext.gotmpl) -/verification_code/valid/email.subject.gotmpl -/verification_code/valid/email.body.gotmpl -``` - -The `ory/kratos/` tree is already mounted into the Kratos container, so an override dir there is the -simplest place. See Ory's [courier message templates](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates) -docs for the full template-type list and the data each template receives. +**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts` +`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the +admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages. +So it looks identical signed in or out; it just shows fewer items to an anonymous visitor +(only `public` ones, plus a Sign-in link), filtered by the same per-user rule. The sidebar +collapses to a burger on a narrow screen. A page that wants a focused, chrome-free layout +(e.g. a print view) opts out with the shell's `menu: false`. ## 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 a set of reusable -EJS partials + TS helpers, fully styled and zero-JS: +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 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. +- **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). + (`?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 +## Interactivity: zero-JS spine -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](#project-goals) 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. +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](#overview) +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. +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. + +## 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 | +| --- | --- | --- | +| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) | +| `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 (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 | +| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-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` | + +### Canonical host (one public URL) + +A site is often reachable at several URLs that resolve to the same place — `localhost` vs +`127.0.0.1`, an apex vs `www.`, an IP vs a domain. That matters here because **cookies are +host-scoped**: the themed login form POSTs to Kratos, and Kratos' CSRF cookie is set on the +host the browser is on. Reach the app on one host but let the form post from another and +that cookie is lost — Kratos rejects the flow and bounces to its error page. (The original +symptom: open the banner's `http://localhost:3000`, sign in, land on +`http://127.0.0.1:3000/error` "Page not found".) + +`APP_URL` is the **single source of truth** for the public host. Set it and the web app +**redirects any off-host GET/HEAD visitor to it** (308, path + query preserved) *before* a +flow starts, so the browser, the themed forms, and the cross-origin Kratos POST all share +one cookie host. Static assets under `/public/` are served on any host (so health checks +don't bounce). Everything else derives from the same `APP_URL`: the first-run banner, and — +via compose — Kratos' browser-facing URLs (`compose.override.yml` maps `${APP_URL}` onto +every `ui_url`, return URL, and `allowed_return_urls`). Set `APP_URL` and the whole stack +follows; there is no second place to edit. A genuine Kratos flow error now renders a themed +**`/error`** page (a path back to sign-in), not the catch-all 404. + +The redirect is an **explicit opt-in** (per the no-`NODE_ENV` rule): **unset ⇒ no +redirect**, so a deploy that forgets `APP_URL` never bounces real users to a stale default. +The clean clone still works with zero config because the bundled Kratos and the dev stack +both default to `localhost` (the dev override sets `APP_URL=http://localhost:3000`); browse +`localhost:3000` and login just works, and `127.0.0.1` is canonicalised onto it. + +> **Behind a reverse proxy:** the proxy must pass the public `Host` through (or rewrite +> Kratos' `base_url`/`ui_url`s to match what the browser sees). If it rewrites `Host` to an +> internal upstream name while `APP_URL` is the public domain, the canonical redirect will +> loop — preserve `Host`. +> +> **Dev caveat (custom host).** Only if you point `APP_URL` at a non-default host (e.g. a +> LAN IP to test from a tablet) must you also point the dev-published Kratos port at that +> host: set `KRATOS_PUBLIC_BROWSER_URL=http://:4433/` (it shares `APP_URL`'s host +> but keeps the Ory port, so it can't be `APP_URL` verbatim). In production Ory is fronted +> same-origin, so this doesn't arise. + +### 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 +[Quick start](#quick-start)). 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](#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/secret** — **optional**; password login works without them. + Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see + [Social sign-in (SSO)](#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. ## 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. +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) +### Login and the session JWT -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. +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) ────────── @@ -622,43 +503,43 @@ session cookie. 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. +**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. +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. +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 +- **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](#instant-revoke-the-optional-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. +- **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 +### 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 @@ -666,16 +547,17 @@ above is too long. When enabled, an admin **deactivating** or **deleting** a use **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. +*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. +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?" @@ -686,123 +568,315 @@ user-role change, for an instant effect. ``` - **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. +- **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. +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. +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 +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. +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. +`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 +## Email -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. +The only emails are the **recovery** and **verification** codes from Kratos' self-service +flows, and **Kratos renders and sends them** (delegated, like the rest of identity — `web` +never touches SMTP). Dev catches them in **mailpit** (); prod points +Kratos at a real server via `COURIER_SMTP_CONNECTION_URI` (`courier.smtp` in +`ory/kratos/kratos.yml`). -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. +**Customizing the email content** is a built-in Kratos feature — no code here. Set +`courier.template_override_path` to a mounted directory and drop Go templates in it, keyed by +type: -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. +``` +/recovery_code/valid/email.subject.gotmpl +/recovery_code/valid/email.body.gotmpl (+ email.body.plaintext.gotmpl) +/verification_code/valid/email.subject.gotmpl +/verification_code/valid/email.body.gotmpl +``` -## Production / deployment +The `ory/kratos/` tree is already mounted into the Kratos container, so an override dir there +is the simplest place. See Ory's +[courier message templates](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates) +docs for the full template-type list and the data each template receives. + +## Testing + +Type check and unit tests run off the Ory stack — units need no Postgres/Kratos/Keto, and +`--no-deps` keeps `web` from dragging up its `depends_on` services: + +```bash +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) +``` + +### 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 five suites: + +**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 +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 "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 +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 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. + +```bash +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. + +```bash +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/`). + +**Dev-stack login regression** (`devstack-login.spec.ts`) — drives the *plain* `docker +compose up` topology (not the same-origin gateway above) with the runner on the **host +network**, so the browser sees `http://localhost:3000` (web) and `http://127.0.0.1:4433` +(Kratos public) exactly as a host browser does. It signs in the seeded admin from the URL the +first-run banner advertises (`http://localhost:3000`) **and** from the wrong host +(`http://127.0.0.1:3000`), asserting both reach the dashboard signed in — the latter via the +[canonical-host redirect](#canonical-host-one-public-url). It guards against the regression +where the advertised login URL dumps the user on the `/error` "Page not found" page; the +proxied full-flow suite can't catch this (it fronts web + Kratos on one origin). Part of +`scripts/ci.sh` — it needs host networking and the host ports `3000`/`4433` free (Linux). + +```bash +docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml run --build --rm e2e # run it +docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml down -v # tear down +``` + +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](AGENTS.md)) — 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 +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. + +## Production & deployment ```bash 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. +(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](#what-you-must-supply-the-only-manual-prep)); the rest -is auto-generated. +manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)); 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-JS** — `script-src 'self'`, no inline scripts, so an injected `