diff --git a/src/compose.test.ts b/src/compose.test.ts index 1888f09..66327e8 100644 --- a/src/compose.test.ts +++ b/src/compose.test.ts @@ -1,8 +1,9 @@ -// Guards the dev/prod compose split + stack ordering (§3): long-running Ory services -// carry readiness healthchecks so `depends_on: service_healthy` works, the web app waits -// for the services it talks to (kratos + keto, per config.ts), prod publishes no internal -// Ory ports while dev exposes the ones a browser must reach, and the visual E2E stays -// Ory-free. Real boot is verified by running the stack; this catches edits. +// Guards the dev/prod compose split + stack ordering (§3): every image is pinned to an +// exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so +// `depends_on: service_healthy` works, the web app waits for the services it talks to +// (kratos + keto, per config.ts), prod publishes no internal Ory ports while dev exposes +// the ones a browser must reach, and the visual E2E stays Ory-free. Real boot is verified +// by running the stack; this catches edits. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -15,6 +16,24 @@ const e2e = read("compose.e2e.yml"); // compose.yml lists web first, postgres second — slice the web service block. const webBlock = compose.slice(compose.indexOf("\n web:"), compose.indexOf("\n postgres:")); +test("every image is pinned to an exact version, never a floating tag", () => { + // One scan over all compose files (postgres, the three Ory pairs, mailpit); web/e2e build. + const images = [...[compose, override, e2e].join("\n").matchAll(/image:\s*(\S+)/g)].map((m) => m[1]!); + assert.ok(images.length >= 8, "scans the pinned images"); + for (const img of images) { + assert.match(img.split(":").at(-1)!, /^v?\d+\.\d+/, `${img} pins a version-like tag`); + assert.doesNotMatch(img, /latest|edge|[\^~*]/, `${img} is exact, not floating`); + } +}); + +test("each Ory service and its migrate sidecar share one pinned version", () => { + for (const svc of ["hydra", "keto", "kratos"]) { + const tags = [...compose.matchAll(new RegExp(`image:\\s*oryd/${svc}:(\\S+)`, "g"))].map((m) => m[1]); + assert.equal(tags.length, 2, `${svc} + ${svc}-migrate both present`); + assert.equal(tags[0], tags[1], `${svc} server + migrate pinned to the same version`); + } +}); + test("long-running Ory services declare readiness healthchecks", () => { for (const [svc, port] of [["kratos", 4433], ["keto", 4466], ["hydra", 4444]] as const) assert.match(compose, new RegExp(`wget[^\\n]*:${port}/health/ready`), diff --git a/src/config.test.ts b/src/config.test.ts index 14dd098..d9d50cc 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,7 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { test } from "node:test"; -import { fileURLToPath } from "node:url"; import { loadConfig } from "./config.ts"; // Explicit secure-secret enforcement (no environment sniffing): secrets are the only @@ -27,17 +25,10 @@ test("loads dev defaults when the environment is empty", () => { test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => { // The session JWT is signed by the tokenizer key (kratos.yml jwks_url); Kratos does NOT // republish it at /.well-known/jwks.json, so the §4 verifier reads that same file://. + // gen-jwks.test.ts owns that the file is a valid ES256 signing key with a kid. const url = new URL(loadConfig({}).jwksUrl); assert.equal(url.protocol, "file:"); assert.match(url.pathname, /tokenizer\/jwks\.json$/); - - // And that file is a real ES256 signing JWKS carrying a kid (what the verifier resolves by). - const path = fileURLToPath(new URL("../ory/kratos/tokenizer/jwks.json", import.meta.url)); - const key = (JSON.parse(readFileSync(path, "utf8")) as { keys: { alg: string; kid: string; kty: string }[] }).keys[0]; - assert.ok(key, "tokenizer JWKS must have a key"); - assert.equal(key.alg, "ES256"); - assert.equal(key.kty, "EC"); - assert.ok(key.kid, "tokenizer JWKS key must carry a kid"); }); test("parses explicit boolean toggles and rejects non-boolean values", () => { diff --git a/src/hydra.test.ts b/src/hydra.test.ts index 7e3f4ce..061e8b1 100644 --- a/src/hydra.test.ts +++ b/src/hydra.test.ts @@ -1,8 +1,7 @@ -// Guards the Ory Hydra config (§3): image pinned to an exact version (AGENTS.md), -// migrations run before the server (hydra-migrate → hydra), the DSN targets the hydra -// database, the server listens on the public/admin ports, and the issuer + -// login/consent/logout URLs point at our app. Real boot is verified by running the -// stack; this catches edits. +// Guards the Ory Hydra config (§3): migrations run before the server (hydra-migrate → +// hydra), the DSN targets the hydra database, the server listens on the public/admin +// ports, and the issuer + login/consent/logout URLs point at our app. Version pinning is +// in compose.test.ts. Real boot is verified by running the stack; this catches edits. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -11,15 +10,6 @@ const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "u const compose = read("compose.yml"); const hydraYml = read("ory/hydra/hydra.yml"); -test("compose pins both hydra services to one exact version", () => { - const tags = [...compose.matchAll(/image:\s*oryd\/hydra:(\S+)/g)].map((m) => m[1]); - assert.equal(tags.length, 2, "hydra + hydra-migrate both present"); - assert.equal(tags[0], tags[1], "both pinned to the same version"); - const tag = tags[0]!; - assert.match(tag, /^v\d+\.\d+\.\d+$/, `${tag} is an exact vMAJOR.MINOR.PATCH`); - assert.doesNotMatch(tag, /latest|[\^~*]/, `${tag} is exact, not floating`); -}); - test("hydra migrations run once before the server starts", () => { assert.ok((compose.match(/migrate sql -e --yes/g) ?? []).length >= 2, "hydra-migrate runs SQL migrations (alongside kratos)"); diff --git a/src/keto.test.ts b/src/keto.test.ts index a93fbeb..1066d0f 100644 --- a/src/keto.test.ts +++ b/src/keto.test.ts @@ -1,8 +1,7 @@ -// Guards the Ory Keto config (§3): image pinned to an exact version (AGENTS.md), -// migrations run before the server (keto-migrate → keto), the DSN targets the keto -// database, read/write APIs serve on the ports config.ts points at, and the OPL -// declares the role/group/resource namespaces. Real boot is verified by running the -// stack; this catches edits. +// Guards the Ory Keto config (§3): migrations run before the server (keto-migrate → +// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts +// points at, and the OPL declares the role/group/resource namespaces. Version pinning is +// in compose.test.ts. Real boot is verified by running the stack; this catches edits. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -12,15 +11,6 @@ const compose = read("compose.yml"); const ketoYml = read("ory/keto/keto.yml"); const opl = read("ory/keto/namespaces.keto.ts"); -test("compose pins both keto services to one exact version", () => { - const tags = [...compose.matchAll(/image:\s*oryd\/keto:(\S+)/g)].map((m) => m[1]); - assert.equal(tags.length, 2, "keto + keto-migrate both present"); - assert.equal(tags[0], tags[1], "both pinned to the same version"); - const tag = tags[0]!; - assert.match(tag, /^v\d+\.\d+\.\d+$/, `${tag} is an exact vMAJOR.MINOR.PATCH`); - assert.doesNotMatch(tag, /latest|[\^~*]/, `${tag} is exact, not floating`); -}); - test("keto migrations run once before the server starts", () => { assert.match(compose, /migrate\s+up\s+-y/, "keto-migrate runs migrations"); assert.ok((compose.match(/condition:\s*service_completed_successfully/g) ?? []).length >= 2, diff --git a/src/kratos.test.ts b/src/kratos.test.ts index cb60f44..a95c05a 100644 --- a/src/kratos.test.ts +++ b/src/kratos.test.ts @@ -1,7 +1,7 @@ -// Guards the Ory Kratos config (§3): image pinned to an exact version (AGENTS.md), -// migrations run before the server (kratos-migrate → kratos), the DSN targets the -// kratos database, and the identity schema carries email (password identifier) + -// name traits. Real boot is verified by running the stack; this catches edits. +// Guards the Ory Kratos config (§3): migrations run before the server (kratos-migrate → +// kratos), the DSN targets the kratos database, and the identity schema carries email +// (password identifier) + name traits. Version pinning is in compose.test.ts. Real boot +// is verified by running the stack; this catches edits. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -11,15 +11,6 @@ const compose = read("compose.yml"); const kratosYml = read("ory/kratos/kratos.yml"); const schema = JSON.parse(read("ory/kratos/identity.schema.json")); -test("compose pins both kratos services to one exact version", () => { - const tags = [...compose.matchAll(/image:\s*oryd\/kratos:(\S+)/g)].map((m) => m[1]); - assert.equal(tags.length, 2, "kratos + kratos-migrate both present"); - assert.equal(tags[0], tags[1], "both pinned to the same version"); - const tag = tags[0]!; - assert.match(tag, /^v\d+\.\d+\.\d+$/, `${tag} is an exact vMAJOR.MINOR.PATCH`); - assert.doesNotMatch(tag, /latest|[\^~*]/, `${tag} is exact, not floating`); -}); - test("migrations run once before the server starts", () => { assert.match(compose, /migrate sql -e --yes/, "kratos-migrate runs SQL migrations"); assert.match(compose, /condition:\s*service_completed_successfully/, @@ -98,10 +89,3 @@ test("the committed OIDC claims mapper maps email + name", () => { assert.match(mapper, /given_name/, "given name → name.first"); assert.match(mapper, /family_name/, "family name → name.last"); }); - -test("compose pins the dev mail catcher to an exact version", () => { - const tag = read("compose.override.yml").match(/image:\s*axllent\/mailpit:(\S+)/)?.[1]; - assert.ok(tag, "compose.override.yml pins a mailpit image"); - assert.match(tag, /^v\d+\.\d+\.\d+$/, `${tag} is an exact version`); - assert.doesNotMatch(tag, /latest|edge|[\^~*]/, `${tag} is exact, not floating`); -}); diff --git a/src/postgres.test.ts b/src/postgres.test.ts index c9deffd..3db9e1d 100644 --- a/src/postgres.test.ts +++ b/src/postgres.test.ts @@ -1,6 +1,6 @@ -// Guards the Ory Postgres config (§3): image stays pinned to an exact version -// (AGENTS.md rule) and each Ory service keeps its own database. Real container -// behaviour is verified by booting postgres in CI/e2e; this catches edits. +// Guards the Ory Postgres config (§3): each Ory service keeps its own database (the +// image pin is covered by compose.test.ts's global scan). Real container behaviour is +// verified by booting postgres in CI/e2e; this catches edits. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -8,13 +8,6 @@ import { readFileSync } from "node:fs"; const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); const ORY_DATABASES = ["hydra", "keto", "kratos"]; // one DB per Ory service -test("compose pins the postgres image to an exact version", () => { - const tag = read("compose.yml").match(/image:\s*postgres:(\S+)/)?.[1]; - assert.ok(tag, "compose.yml pins a postgres image"); - assert.match(tag, /^\d+\.\d+/, `${tag} pins major.minor`); - assert.doesNotMatch(tag, /latest|[\^~*]/, `${tag} is exact, not floating`); -}); - test("init SQL gives each Ory service its own database", () => { const sql = read("ory/postgres/init/init.sql"); for (const db of ORY_DATABASES) { diff --git a/todo.md b/todo.md index cb74d81..3d5c2eb 100644 --- a/todo.md +++ b/todo.md @@ -72,7 +72,7 @@ everything via Docker. - [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] 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 70–72) 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. +- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §3 Ory-stack tests. The clear overlap: the "image pinned to an exact version" AGENTS.md check was re-implemented 5× (postgres/kratos/keto/hydra + mailpit). Unified into one `compose.test.ts` scan over all three compose files (strictly stronger — auto-covers any future image) + one test asserting each Ory service & its migrate sidecar share one version (subsumes the per-service "both present + same version" halves). Dropped the now-redundant pin tests from `postgres/kratos/keto/hydra.test.ts` (each keeps its config-semantics tests; comments point pinning at `compose.test.ts`). Also trimmed `config.test.ts`'s duplicate re-validation of the committed JWKS key — `gen-jwks.test.ts` already owns key validity (round-trips a signature); the config test keeps the default-path assertion. The migrate-before-server / DSN / port / URL tests stay per-service (distinct config, distinct files — merging would hurt the per-module structure). 153 → 150 tests, zero coverage lost; typecheck + tests green. ## 4. Auth — identity, session JWT, guards - [ ] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`.