diff --git a/README.md b/README.md index 980e3ce..8c7b16a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.e2e.yml b/compose.e2e.yml index d86f971..f3c8be3 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -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" diff --git a/compose.override.yml b/compose.override.yml index 6d45f52..61e8a7c 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -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: diff --git a/compose.yml b/compose.yml index 16a1a04..f1abf6e 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/src/compose.test.ts b/src/compose.test.ts new file mode 100644 index 0000000..d1685f0 --- /dev/null +++ b/src/compose.test.ts @@ -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"); +}); diff --git a/todo.md b/todo.md index cf39ce9..4b7b5d7 100644 --- a/todo.md +++ b/todo.md @@ -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:[]}` + exp−iat = 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.