§9 prod compose secrets (todo §9); the base compose.yml was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + bootstrap, no source mount) but set REQUIRE_SECURE_SECRETS=true without ever passing CSRF_SECRET into web, so docker compose -f compose.yml up couldn't boot. Wired CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret} — env-supplied with the throwaway as the only fallback; config.ts's existing REQUIRE_SECURE_SECRETS logic rejects that throwaway so a forgotten prod secret fails loud (verified prod-unset→reject, prod-set→real, dev→throwaway+toggle-off→boots). Used :- not :? because compose interpolates the base per-file before merging the dev override (confirmed empirically), so :? would also break the zero-config dev up. Tests-first: compose.test.ts guards secret-via-env + no-source-mount + prod/dev toggle split + postgres-creds-via-env. README prod section corrected (dropped the stale planned note). typecheck + 310 units green.

This commit is contained in:
2026-06-20 01:05:15 +02:00
parent 56047815a0
commit b3b51db52b
4 changed files with 23 additions and 3 deletions

View File

@@ -568,7 +568,11 @@ request, because the session lives in Kratos and the data lives upstream.
docker compose -f compose.yml up --build -d # base config only, no source mount
```
_(Production compose grows to include the Ory services and Postgres — planned.)_
`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.
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

View File

@@ -6,9 +6,11 @@ services:
ports:
- "3000:3000"
# Explicit behaviour toggles (the app is environment-agnostic — see AGENTS.md).
# Supply CSRF_SECRET via env; REQUIRE_SECURE_SECRETS refuses the dev throwaway.
# Supply CSRF_SECRET via env; the dev-throwaway fallback boots a clean clone but
# REQUIRE_SECURE_SECRETS refuses it in prod (config.ts), so a forgotten secret fails loud.
environment:
CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/

View File

@@ -57,6 +57,20 @@ test("prod base publishes no internal Ory ports; dev exposes the host-facing one
assert.match(override, /"4444:4444"/, "dev publishes hydra public");
});
test("prod base supplies the app secret via env and mounts no source; dev override flips it", () => {
// §9 prod compose: CSRF_SECRET comes from the environment (dev-throwaway fallback that
// REQUIRE_SECURE_SECRETS rejects in prod — see config.ts); the base never bind-mounts the
// source tree (runs the built image), while the dev override does for live editing.
assert.match(webBlock, /CSRF_SECRET:\s*\$\{CSRF_SECRET\b/, "base wires CSRF_SECRET from env");
assert.doesNotMatch(webBlock, /-\s+\.:\/app\b/, "base mounts no source tree");
assert.match(override, /-\s+\.:\/app\b/, "dev override bind-mounts the source");
// Secret/cookie hardening: enforced in prod, off in dev so the throwaway + http cookies pass.
assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets");
assert.match(override, /REQUIRE_SECURE_SECRETS:\s*"false"/, "dev allows the throwaway");
// Postgres credentials are env-supplied (dev default), never a baked-in literal.
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
});
test("a one-shot bootstrap seeds the stack before web starts", () => {
// §3 MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.

View File

@@ -125,7 +125,7 @@ everything via Docker.
- [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 §8 test accretion (`fetch-timeout.test.ts`, `e2e/full-flow.spec.ts`, + the §8-review additions across `app`/`config`/`jwks`/`flow-view`/`data-table`/`admin-users`). Most §8 changes were already woven into existing tests as assertions (the `recoverHref` link into flow-view, `parseJwks` key-shape into the loadJwks test, `ORY_TIMEOUT_SEC` as a sibling numeric-knob in config, empty-state as a focused data-table feature) — no fat. **Two genuine cleanups:** (1) the deferred L3 (line 123) — `plugins/scheduling/shifts.test.ts` imported four deep `src/*` internals (`chrome`/`context`/`guards`/`plugin`), none of them the documented-stable surface; repointed all four to the `src/plugin-api.ts` barrel (the one contract boundary, which re-exports them), so the test now models the dev/test story the contract preaches — exactly like `shifts.ts` itself does (no coverage change). (2) `app.test.ts` had two adjacent tests for the *same* surface (the themed-auth GET dispatch): "themed flow init" (anonymous → flow init / CSRF relay / stale restart) and "an already-signed-in user is sent home" — the latter literally re-asserted the former's anonymous-init (line 448 ≡ 424). Merged into one "themed auth GET: anonymous inits a flow …; a signed-in user is sent home, except /settings", dropping the duplicate; all assertions preserved. Left separate (distinct surfaces/stacks, not fat): the four E2E suites (visual computed-styles / auth-refresh JWT re-mint / oauth-login consent / full-flow browser-UI — each its own compose stack) and `app.test.ts`'s one-per-surface integration tests. Pure test refactor, no production code (per the §6/§7 precedent, no stability reviewer). 310 → 309 units; typecheck + tests green.
## 9. Production, security, ops
- [ ] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount.
- [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green.
- [ ] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance.
- [ ] Optional revocation denylist for instant role/session revoke.
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly.