Split dev/prod compose wiring (todo §3); Ory readiness healthchecks, web gated on kratos+keto, dev-only host ports, Ory-free E2E

This commit is contained in:
2026-06-17 16:06:05 +02:00
parent 93e62d8661
commit 4af090f803
6 changed files with 86 additions and 7 deletions

View File

@@ -113,11 +113,15 @@ That's it. Do not install or run Node/npm on the host — use the commands below
docker compose up # http://localhost:3000, live reload via `node --watch`
```
`docker compose up` merges `compose.override.yml`, which mounts the source and
restarts the server on change. _(The Ory + Postgres services join this compose
file as they land — planned.)_ 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).
`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. The web app waits for Kratos + Keto to be healthy 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).
## Configuration

View File

@@ -6,6 +6,9 @@
# Screenshots + HTML report land in ./e2e/artifacts/ (git-ignored).
services:
web:
# The dashboard renders mock data — no Ory needed. Drop the base file's kratos/keto
# dependency so the visual suite stays fast and doesn't boot Postgres + the Ory stack.
depends_on: !reset []
# Dev throwaways are fine for tests; cache templates for production-like rendering.
environment:
CACHE_TEMPLATES: "true"

View File

@@ -19,8 +19,15 @@ services:
- "8025:8025"
restart: unless-stopped
# Ory Kratos dev: expose the public API so the browser can POST self-service flows to
# flow.ui.action (kratos.yml base_url = 127.0.0.1:4433). Prod fronts Ory same-origin,
# so the base file publishes no Ory ports.
kratos:
ports:
- "4433:4433"
# Ory Hydra dev: --dev permits the http issuer/redirect URLs; expose the public port
# so OAuth2 flows reach the host. Prod (§3 dev/prod split) drops --dev for https.
# so OAuth2 flows reach the host. Prod (base file) drops --dev for an https issuer.
hydra:
command: serve all --dev -c /etc/config/hydra/hydra.yml
ports:

View File

@@ -10,6 +10,13 @@ services:
environment:
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "true"
# Wait for the identity/permission services the app talks to (config.ts: kratos + keto).
# Hydra is post-MVP (§6) and absent from config.ts, so web doesn't gate on it.
depends_on:
kratos:
condition: service_healthy
keto:
condition: service_healthy
restart: unless-stopped
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
@@ -55,6 +62,11 @@ services:
volumes:
- ./ory/kratos:/etc/config/kratos:ro
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
# Ory Keto — authorization (ReBAC). Permission model in ory/keto/namespaces.keto.ts (OPL).
@@ -81,6 +93,11 @@ services:
volumes:
- ./ory/keto:/etc/config/keto:ro
command: serve -c /etc/config/keto/keto.yml
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
@@ -110,6 +127,11 @@ services:
volumes:
- ./ory/hydra:/etc/config/hydra:ro
command: serve all -c /etc/config/hydra/hydra.yml
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4444/health/ready"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
volumes:

43
src/compose.test.ts Normal file
View File

@@ -0,0 +1,43 @@
// 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.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const compose = read("compose.yml");
const override = read("compose.override.yml");
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("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`),
`${svc} probes :${port}/health/ready`);
});
test("web waits for kratos and keto to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies");
for (const svc of ["kratos", "keto"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`);
});
test("prod base publishes no internal Ory ports; dev exposes the host-facing ones", () => {
for (const p of [4433, 4434, 4444, 4445, 4466, 4467])
assert.ok(!compose.includes(`${p}:${p}`), `base does not publish :${p}`);
// Browser completes Kratos flows at kratos public (kratos.yml base_url 127.0.0.1:4433)
// and OAuth2 at hydra public — both reachable on the host only in dev.
assert.match(override, /"4433:4433"/, "dev publishes kratos public");
assert.match(override, /"4444:4444"/, "dev publishes hydra public");
});
test("the visual E2E does not drag in the Ory stack", () => {
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
assert.match(e2e, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
});

View File

@@ -66,7 +66,7 @@ everything via Docker.
- [x] Generate + mount the JWT signing JWKS; document key rotation. → `src/gen-jwks.ts` (`generateJwks()` + CLI) mints an **ES256** EC P-256 signing key as a JWK Set — Ory's recommended alg and the verifier's preferred (`src/jwt.ts`). The committed `ory/kratos/tokenizer/jwks.json` is the **dev throwaway** (like the cookie/cipher secrets in `kratos.yml`), already mounted via `./ory/kratos:/etc/config/kratos:ro` at the `jwks_url` the tokenizer template points to — so a clean clone signs out of the box. Regenerate/rotate: `docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json` (also `npm run gen-jwks`). README documents prod override (mount a real key or `…_JWKS_URL=base64://…`) + zero-downtime rotation (Kratos signs with the first key, app verifies by `kid` (§4) → prepend new, keep old ~one 10m TTL, drop). Tests-first (`gen-jwks.test.ts`: generator shape + unique kid, committed key validity, **round-trip** — a JWS signed with a generated key verifies through `verifyJws`). Boot-verified the full chain end-to-end: live Kratos registered an identity (API flow), `whoami?tokenize_as=plainpages` returned a real JWT signed with our `kid`, `verifyJws` validated it against the committed public half, claims `{sub, email, roles:[]}` + expiat = 600s (10m); torn down. typecheck + 128 units green.
- [x] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). → `compose.yml` adds `keto`/`keto-migrate` pinned to `oryd/keto:v26.2.0` (Ory's unified versioning — same train as kratos; verified latest stable); `keto-migrate` runs `migrate up -y` against the per-service `keto` DB after postgres is healthy, `keto` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/keto/keto.yml` serves read on 4466 + write on 4467 (the ports `config.ts` already targets), DSN via env, loads the OPL from the mounted file. `ory/keto/namespaces.keto.ts` is the OPL model: `User` (subject = Kratos id), `Group`/`Role` as subject sets with `members` (the coarse roles read at login → JWT, README), and a fine-grained `Resource` with `permits` view/edit/delete over owner ⊇ editor ⊇ viewer (README's third "may I?" tier). OPL stays out of tsconfig `include` (Keto-dialect, like the jsonnets). README: Status note + Layout updated, the role tuple example fixed to `#members` to match the OPL. Tests-first (`keto.test.ts`: version pin + migrate-before-serve + DSN→keto DB + read/write ports + OPL namespaces/permits). Fixed a pre-existing kratos test that over-asserted *every* compose DSN was kratos's (now scoped to kratos DSNs). Boot-verified the whole model live: migrate exits 0, read API ready, then over the write/read APIs — `role:admin#members@user:alice` checks allowed; `Resource:doc1` owner→delete/view allowed, viewer→view allowed but delete denied, stranger denied; and a transitive `Group:eng members ⊆ Role:editor` resolved `user:erin`→editor; torn down. typecheck + 135 units green.
- [x] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. → `compose.yml` adds `hydra`/`hydra-migrate` pinned to `oryd/hydra:v26.2.0` (Ory's unified train — same version as kratos/keto; verified latest); `hydra-migrate` runs `migrate sql -e --yes` against the per-service `hydra` DB after postgres is healthy, `hydra` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/hydra/hydra.yml` serves public 4444 + admin 4445, `urls.self.issuer` = the public OAuth2 URL, and `urls.login`/`consent`/`logout` point at our app routes (`/oauth2/login`, `/oauth2/consent`, `/oauth2/logout`; §6 renders the handlers, namespaced under `/oauth2/` so they don't collide with Kratos's first-party `/login`). Dev throwaway `secrets.system` (prod overrides via env). Hydra refuses an http issuer in prod, so `compose.override.yml` adds `serve all --dev` + exposes `4444` for dev (the full dev/prod split + health checks is the next §3 item). Tests-first (`hydra.test.ts`: version pin + migrate-before-serve + DSN→hydra DB + public/admin ports + issuer/login/consent/logout URLs). Boot-verified end-to-end: migrate exits 0, public+admin `/health/ready` 200, OIDC discovery reports `issuer: http://127.0.0.1:4444/`, and a real authorization flow (created an OAuth2 client, hit `/oauth2/auth`) 302-redirected to `http://127.0.0.1:3000/oauth2/login?login_challenge=…` — our app; torn down. typecheck + 140 units green.
- [ ] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering.
- [x] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering.`compose.yml` (base/prod) adds busybox-`wget` `/health/ready` healthchecks to the long-running Ory services (kratos:4433, keto:4466, hydra:4444) and gates `web` on `kratos`+`keto` `service_healthy` (the services `config.ts` talks to — hydra is post-MVP §6, absent from config, so web doesn't gate on it; ordering is transitive through the migrate gates). Dev/prod split: prod publishes **no** internal Ory ports; `compose.override.yml` exposes only the host-facing ones the browser needs — kratos public 4433 (self-service flows POST to `flow.ui.action`, kratos.yml base_url) alongside the existing hydra 4444 + mailpit 8025. The visual E2E stays Ory-free via `depends_on: !reset []` on `web` in `compose.e2e.yml` (the dashboard is mock data — no Postgres/Ory boot). Tests-first (`compose.test.ts`: Ory healthchecks + web ordering + the port split + the e2e reset). Boot-verified the full dev stack with `--wait`: kratos/keto/hydra/postgres/mailpit all healthy, `web` started **only after** kratos+keto healthy, the host reaches kratos 4433 + hydra 4444 + web 3000 while keto 4466 is refused (internal-only); torn down. README **Development** refreshed (dropped the stale "Ory…planned" note). typecheck + 144 units green.
- [ ] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box.
- [ ] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning.
- [ ] 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.