Tighten §3 comments (todo §3); drop stale 'next §3 item' forward-refs, condense compose/Ory/bootstrap headers

This commit is contained in:
2026-06-17 17:00:47 +02:00
parent e83cf4da88
commit 360449e76b
7 changed files with 35 additions and 47 deletions

View File

@@ -10,9 +10,8 @@ services:
environment: environment:
CACHE_TEMPLATES: "true" CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
# Wait for the identity/permission services the app talks to (config.ts: kratos + keto) # Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap
# and for the one-shot bootstrap to seed the admin + JWKS. Hydra is post-MVP (§6) and # (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it.
# absent from config.ts, so web doesn't gate on it.
depends_on: depends_on:
bootstrap: bootstrap:
condition: service_completed_successfully condition: service_completed_successfully
@@ -20,8 +19,8 @@ services:
condition: service_healthy condition: service_healthy
keto: keto:
condition: service_healthy condition: service_healthy
# Read the session-JWT verify key from the same tokenizer JWKS Kratos signs with # §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# (config.ts JWKS_URL default; §4 verifier). Read-only — bootstrap is the only writer. # Read-only — bootstrap is the only writer.
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped restart: unless-stopped
@@ -45,8 +44,8 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# Ory Kratos — identity & self-service auth. Config + identity schema in ory/kratos/. # Ory Kratos — identity & self-service auth. Config + schema in ory/kratos/; DSN is
# DSN is the per-service `kratos` DB (init.sql); supply POSTGRES_* via env in prod. # its own `kratos` DB (init.sql), POSTGRES_* via env in prod.
kratos-migrate: kratos-migrate:
image: oryd/kratos:v26.2.0 image: oryd/kratos:v26.2.0
depends_on: depends_on:
@@ -76,8 +75,8 @@ services:
retries: 20 retries: 20
restart: unless-stopped restart: unless-stopped
# Ory Keto — authorization (ReBAC). Permission model in ory/keto/namespaces.keto.ts (OPL). # Ory Keto — authorization (ReBAC); OPL model in ory/keto/namespaces.keto.ts. DSN is
# DSN is the per-service `keto` DB (init.sql). The web app calls its read/write APIs (config.ts). # its own `keto` DB (init.sql). web calls its read/write APIs (config.ts).
keto-migrate: keto-migrate:
image: oryd/keto:v26.2.0 image: oryd/keto:v26.2.0
depends_on: depends_on:
@@ -107,11 +106,9 @@ services:
retries: 20 retries: 20
restart: unless-stopped restart: unless-stopped
# One-command bootstrap (§3, the MVP bar): a one-shot that seeds first-boot state, then # One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
# exits — generate the JWKS if absent, create the demo admin (admin@plainpages.local / # cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted
# admin) in Kratos, grant it the `admin` role in Keto. Idempotent, so it re-runs cleanly. # read-write (the only writer) so the absent-JWKS safety net can land the key.
# Runs once kratos+keto are healthy; web waits for it to complete. Tokenizer dir is
# mounted read-write (the only writer) so the absent-JWKS safety net can land the key.
bootstrap: bootstrap:
build: . build: .
depends_on: depends_on:
@@ -129,15 +126,13 @@ services:
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/bootstrap.ts command: node src/bootstrap.ts
# Bounded retry: the seed is idempotent (409-create + idempotent PUT), so transient Ory # Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent
# blips recover — but a permanent error must give up, not loop forever and hang `web` # error must give up, not loop forever and hang `web` (gates on completion).
# (which gates on service_completed_successfully).
restart: "on-failure:5" restart: "on-failure:5"
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is the per-service `hydra` DB (init.sql). Issuer + login/consent/logout run at # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml, handlers are §6.
# our app routes (ory/hydra/hydra.yml); the handlers that drive them are §6. Dev # Dev permits the http issuer via --dev (compose.override.yml); prod sets an https
# permits the http issuer via --dev (compose.override.yml); prod supplies an https
# issuer via env (URLS_SELF_ISSUER). # issuer via env (URLS_SELF_ISSUER).
hydra-migrate: hydra-migrate:
image: oryd/hydra:v26.2.0 image: oryd/hydra:v26.2.0

View File

@@ -1,8 +1,8 @@
# Ory Kratos — identity & self-service auth. Identity schema (email, name) + # Ory Kratos — identity & self-service auth. Identity schema (email, name) +
# password login; recovery & verification run on email codes. Every self-service # password login; recovery & verification run on email codes. Every self-service
# flow returns the browser to our own themed routes (§4 renders the fields). DSN + # flow returns to our own themed routes (§4 renders the fields). DSN + prod
# prod courier/secrets come from the env. The session→JWT tokenizer is wired below; # courier/secrets come from the env. Session→JWT tokenizer wired below (signing
# its JWKS signing key is generated/mounted by the next §3 item. # key in tokenizer/jwks.json).
serve: serve:
public: public:
base_url: http://127.0.0.1:4433/ base_url: http://127.0.0.1:4433/
@@ -20,11 +20,9 @@ selfservice:
enabled: true enabled: true
code: # email one-time code — powers recovery + verification (not login) code: # email one-time code — powers recovery + verification (not login)
enabled: true enabled: true
# Social sign-in (Google, Microsoft, or SAML via an OIDC bridge like Ory Polis — # Social sign-in, OFF by default → clean clone is password-only. Activate via env only
# OSS Kratos has no native SAML). OFF by default → a clean clone is password-only. # (no code; the whole-array form is the only env-settable one Kratos offers); §4 derives
# Activate WITHOUT code changes by supplying env (the whole-array form is the only # the buttons from this list. SAML isn't in OSS Kratos — bridge it as OIDC (README).
# env-settable one Kratos offers); providers reference the committed claims mapper,
# and §4 derives the buttons from this list:
# SELFSERVICE_METHODS_OIDC_ENABLED=true # SELFSERVICE_METHODS_OIDC_ENABLED=true
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google", # SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google",
# "client_id":"…","client_secret":"…","scope":["openid","email","profile"], # "client_id":"…","client_secret":"…","scope":["openid","email","profile"],
@@ -90,7 +88,7 @@ session:
# Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived, # Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the # locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the # committed Jsonnet mapper (sub = identity id, email from traits, roles from the
# metadata_admin projection); signed with the JWKS the next §3 item generates/mounts. # metadata_admin projection); signed with tokenizer/jwks.json.
whoami: whoami:
tokenizer: tokenizer:
templates: templates:

View File

@@ -1,11 +1,9 @@
// One-command bootstrap (todo §3, the MVP bar). Runs as the one-shot `bootstrap` compose // One-command bootstrap (todo §3, the MVP bar). One-shot compose service: runs after
// service after kratos+keto are healthy; `web` waits for it to finish. Idempotent — safe // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// to re-run on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin identity (admin@plainpages.local / admin) in Kratos; // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it the `admin` role in Keto so menu/permission checks resolve out of the box. // 3. grant it the `admin` role in Keto so menu/permission checks resolve out of the box.
// On finish it prints a first-run banner (login URL + creds + change-before-prod warning). // Then prints a first-run banner; fails loud on any unexpected upstream error.
// Fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";

View File

@@ -68,10 +68,9 @@ export function loadConfig(env: Env = process.env): Config {
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false), cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure), cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure),
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure), csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure),
// The session JWT is signed by the Kratos tokenizer key (kratos.yml jwks_url); the §4 // §4 verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url).
// verifier reads that same key. Kratos does not republish it over HTTP, so default to a // Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS
// file:// of the tokenizer JWKS mounted into the web container (compose.yml) — not a // mounted into web (compose.yml). Prod overrides with a real key (README: rotation).
// well-known endpoint. Prod overrides with a real key (README: JWT signing key & rotation).
jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"), jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"),
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),

View File

@@ -1,12 +1,10 @@
import { generateKeyPairSync, randomUUID } from "node:crypto"; import { generateKeyPairSync, randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
// ES256 signing JWKS for the Kratos session tokenizer (§3). Ory recommends ES* over the // ES256 signing JWKS for the Kratos session tokenizer (§3) Ory-recommended and the
// symmetric HS family; ES256 is also our verifier's preferred alg (src/jwt.ts). Kratos // verifier's preferred alg (src/jwt.ts). Rotation runbook: README, JWT signing key.
// signs with the FIRST key in the set and the app verifies by `kid` (§4) — so rotation is // (Re)generate the committed dev key (prod supplies its own):
// prepend a fresh key, keep the old one ~one TTL (10m) for in-flight tokens, then drop it. // docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
// (Re)generate the committed dev key (prod supplies its own — see README):
// docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
export interface SigningJwk { export interface SigningJwk {
kid: string; kid: string;

View File

@@ -70,7 +70,7 @@ test("session settings: branded cookie, bounded lifespan, sliding refresh", () =
test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => { test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => {
// whoami(tokenize_as: plainpages) → a locally-verifiable JWT, so the hot path never // whoami(tokenize_as: plainpages) → a locally-verifiable JWT, so the hot path never
// calls Ory (§4). The JWKS signer is generated/mounted by the next §3 item. // calls Ory (§4). Signed with the committed tokenizer/jwks.json (gen-jwks.ts).
assert.match(kratosYml, /tokenizer:\s*\n\s*templates:\s*\n\s*plainpages:/, "plainpages template defined"); assert.match(kratosYml, /tokenizer:\s*\n\s*templates:\s*\n\s*plainpages:/, "plainpages template defined");
assert.match(kratosYml, /ttl:\s*10m/, "~10m TTL — re-minted on refresh"); assert.match(kratosYml, /ttl:\s*10m/, "~10m TTL — re-minted on refresh");
assert.match(kratosYml, /subject_source:\s*id/, "sub = the Kratos identity id"); assert.match(kratosYml, /subject_source:\s*id/, "sub = the Kratos identity id");

View File

@@ -71,7 +71,7 @@ everything via Docker.
- [x] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. → `firstRunBanner()` in `src/bootstrap.ts` (pure, testable) renders a boxed banner — login URL · seeded email/password · "⚠ change before production" — that `main()` prints after seeding. Login URL from `APP_URL` (compose default `http://localhost:3000`, overridable per deployment); creds reuse the seeded `ADMIN_EMAIL`/`ADMIN_PASSWORD`. Tests-first (`bootstrap.test.ts`: asserts URL + creds + warning present); README **Development** notes the banner. Live-verified: rebuilt bootstrap prints the banner after the admin line; typecheck + 152 units green; stack torn down. - [x] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. → `firstRunBanner()` in `src/bootstrap.ts` (pure, testable) renders a boxed banner — login URL · seeded email/password · "⚠ change before production" — that `main()` prints after seeding. Login URL from `APP_URL` (compose default `http://localhost:3000`, overridable per deployment); creds reuse the seeded `ADMIN_EMAIL`/`ADMIN_PASSWORD`. Tests-first (`bootstrap.test.ts`: asserts URL + creds + warning present); README **Development** notes the banner. Live-verified: rebuilt bootstrap prints the banner after the admin line; typecheck + 152 units green; stack torn down.
- [x] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone. → New README **What you must supply (the only manual prep)** subsection (under Configuration) consolidates the previously-scattered facts into one authoritative list: a clean clone needs nothing; exactly two production-only things can't be auto-generated — (1) production secrets (`COOKIE_SECRET`/`CSRF_SECRET` + the JWT signing key, with `REQUIRE_SECURE_SECRETS=true` refusing throwaways) and (2) optional SSO provider creds (no creds ⇒ no button). States everything else (Ory migrations, dev signing key, demo admin + Keto roles, OPL model) is generated/seeded on first boot. Cross-links the existing SSO + JWT-rotation subsections (no duplication) and adds a pointer from **Production / deployment**. All four anchors verified; docs-only — typecheck + 152 units green. - [x] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone. → New README **What you must supply (the only manual prep)** subsection (under Configuration) consolidates the previously-scattered facts into one authoritative list: a clean clone needs nothing; exactly two production-only things can't be auto-generated — (1) production secrets (`COOKIE_SECRET`/`CSRF_SECRET` + the JWT signing key, with `REQUIRE_SECURE_SECRETS=true` refusing throwaways) and (2) optional SSO provider creds (no creds ⇒ no button). States everything else (Ory migrations, dev signing key, demo admin + Keto roles, OPL model) is generated/seeded on first boot. Cross-links the existing SSO + JWT-rotation subsections (no duplication) and adds a pointer from **Production / deployment**. All four anchors verified; docs-only — typecheck + 152 units green.
- [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §3 Ory stack). Verdict: architecture sound + disciplined, no Critical; both independently flagged the *same* top issue. **Fixed now:** (1) HIGH (both agents) — `JWKS_URL` default was `http://kratos:4433/.well-known/jwks.json`, but Kratos does **not** republish the session-tokenizer key there (no OIDC discovery on Kratos — that's Hydra), so the §4 verifier would have fetched the wrong/empty set and *no one* could be authorized. Repointed the default to `file:///etc/config/kratos/tokenizer/jwks.json` — the exact key Kratos signs with (`kratos.yml` `jwks_url`) — and mounted that tokenizer dir **read-only into `web`** (`compose.yml`) so the verifier resolves the live key in dev *and* prod (same file bootstrap regenerates). `config.test.ts` now locks the default to the tokenizer file + asserts the committed key is a real ES256 JWKS carrying a `kid` (the regression the old `/jwks/` match missed). (2) MEDIUM (stability) — `bootstrap` had uncapped `restart: on-failure`; a *permanent* seed error would loop forever and silently hang `web` (gates on `service_completed_successfully`). Capped to `on-failure:5` (seed is idempotent — 409-create + idempotent PUT — so transient Ory blips still recover, permanent ones give up loud). (3) §3's new `web` `depends_on` made the documented `docker compose run --rm web …` typecheck/test/gen-jwks commands drag up the whole Ory stack — added `--no-deps` (README + AGENTS.md). **Deferred (reviewer-scoped, not §3):** extract `buildShellContext` out of `dashboard.ts` + route built-in screens through `matchRoute`/`isAuthorized` → §5 (forcing function arrives with the 2nd/3rd screen); seed the demo admin's `metadata_admin.roles` projection so first login is non-empty → §4 (the login-completion projection owns it); enforce Ory `*.yml` prod secrets + self-service return-URLs via env → §9 (ops). typecheck + 153 units green; both compose files validated. - [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §3 Ory stack). Verdict: architecture sound + disciplined, no Critical; both independently flagged the *same* top issue. **Fixed now:** (1) HIGH (both agents) — `JWKS_URL` default was `http://kratos:4433/.well-known/jwks.json`, but Kratos does **not** republish the session-tokenizer key there (no OIDC discovery on Kratos — that's Hydra), so the §4 verifier would have fetched the wrong/empty set and *no one* could be authorized. Repointed the default to `file:///etc/config/kratos/tokenizer/jwks.json` — the exact key Kratos signs with (`kratos.yml` `jwks_url`) — and mounted that tokenizer dir **read-only into `web`** (`compose.yml`) so the verifier resolves the live key in dev *and* prod (same file bootstrap regenerates). `config.test.ts` now locks the default to the tokenizer file + asserts the committed key is a real ES256 JWKS carrying a `kid` (the regression the old `/jwks/` match missed). (2) MEDIUM (stability) — `bootstrap` had uncapped `restart: on-failure`; a *permanent* seed error would loop forever and silently hang `web` (gates on `service_completed_successfully`). Capped to `on-failure:5` (seed is idempotent — 409-create + idempotent PUT — so transient Ory blips still recover, permanent ones give up loud). (3) §3's new `web` `depends_on` made the documented `docker compose run --rm web …` typecheck/test/gen-jwks commands drag up the whole Ory stack — added `--no-deps` (README + AGENTS.md). **Deferred (reviewer-scoped, not §3):** extract `buildShellContext` out of `dashboard.ts` + route built-in screens through `matchRoute`/`isAuthorized` → §5 (forcing function arrives with the 2nd/3rd screen); seed the demo admin's `metadata_admin.roles` projection so first login is non-empty → §4 (the login-completion projection owns it); enforce Ory `*.yml` prod secrets + self-service return-URLs via env → §9 (ops). typecheck + 153 units green; both compose files validated.
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §3 Ory accretion. Killed the now-stale "the next §3 item generates/mounts" forward-refs (the JWKS shipped) in `kratos.yml` (×2) + `kratos.test.ts`. Tightened the verbose service/header blocks in `compose.yml` (web depends_on/JWKS-mount, the three Ory headers, the bootstrap block) and the `bootstrap.ts`/`gen-jwks.ts` module headers — dropping prose the README/`src/bootstrap.ts` already carry, keeping the security/stability rationale (read-only mount, bounded retry). Trimmed `config.ts`'s JWKS comment and the `kratos.yml` SSO block (kept the concrete env example), and aligned the `gen-jwks.ts` command with the README's `--no-deps`. Net 12 lines; typecheck + 153 units green. The §3 README sections (Development / What you must supply / SSO / JWT rotation) were already authored concise in §3 (todo lines 7072) and left intact.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
## 4. Auth — identity, session JWT, guards ## 4. Auth — identity, session JWT, guards