Bootstrap: print first-run login banner (URL + seeded creds + change-before-prod warning)

This commit is contained in:
2026-06-17 16:22:48 +02:00
parent a6900217cb
commit 4d65665063
5 changed files with 35 additions and 5 deletions

View File

@@ -119,7 +119,8 @@ change. A one-shot `bootstrap` service then seeds first-boot state with **zero m
prep** — it generates the JWT signing key if absent, creates a demo admin prep** — it generates the JWT signing key if absent, creates a demo admin
(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role in Keto (`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role in Keto
so permission checks resolve out of the box; it is idempotent, so every `up` re-runs it so permission checks resolve out of the box; it is idempotent, so every `up` re-runs it
safely. **Change the demo admin before production.** The web app waits for Kratos + Keto safely. It finishes by printing a banner with the login URL and seeded credentials.
**Change the demo admin before production.** The web app waits for Kratos + Keto
to be healthy *and* the bootstrap to finish before starting (each Ory service has a to be healthy *and* the bootstrap to finish before starting (each Ory service has a
readiness healthcheck). Dev publishes the host-facing Ory ports — readiness healthcheck). Dev publishes the host-facing Ory ports —
Kratos public `4433` (the browser POSTs self-service flows there) and Hydra public Kratos public `4433` (the browser POSTs self-service flows there) and Hydra public

View File

@@ -118,6 +118,7 @@ services:
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434

View File

@@ -5,7 +5,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, identityPayload, roleTuple, seedAdmin } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin } from "./bootstrap.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -98,6 +98,14 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
); );
}); });
test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => {
const banner = firstRunBanner({ appUrl: "http://localhost:3000", email: "admin@plainpages.local", password: "admin" });
assert.match(banner, /http:\/\/localhost:3000/);
assert.match(banner, /admin@plainpages\.local/);
assert.match(banner, /admin/); // the password
assert.match(banner, /before production/i);
});
test("ensureJwks generates a key only when the file is absent", () => { test("ensureJwks generates a key only when the file is absent", () => {
const writes: { content: string; path: string }[] = []; const writes: { content: string; path: string }[] = [];
const write = (path: string, content: string) => writes.push({ content, path }); const write = (path: string, content: string) => writes.push({ content, path });

View File

@@ -4,6 +4,7 @@
// 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 identity (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).
// 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";
@@ -100,6 +101,22 @@ async function findIdentityId(http: typeof fetch, adminUrl: string, email: strin
return found.id; return found.id;
} }
// --- First-run banner ----------------------------------------------------------------
// Loud, scannable block in the compose logs: where to log in + the seeded demo creds +
// the "change before production" warning. Pure so it's testable; main() console.logs it.
export function firstRunBanner(opts: { appUrl: string; email: string; password: string }): string {
const rule = "─".repeat(58);
return [
`${rule}`,
`│ Plainpages is ready — log in at ${opts.appUrl}`,
`│ email: ${opts.email}`,
`│ password: ${opts.password}`,
`│ ⚠ Demo admin credentials — change them before production.`,
`${rule}`,
].join("\n");
}
// --- CLI (the bootstrap container entrypoint) ---------------------------------------- // --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() { async function main() {
@@ -107,14 +124,17 @@ async function main() {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
const role = env["ADMIN_ROLE"] ?? "admin"; const role = env["ADMIN_ROLE"] ?? "admin";
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({
email: env["ADMIN_EMAIL"] ?? "admin@plainpages.local", email,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467", ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434", kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password: env["ADMIN_PASSWORD"] ?? "admin", password,
role, role,
}); });
console.log(`bootstrap: admin ${result.created ? "created" : "already present"} (${result.id}); role "${role}" granted`); console.log(`bootstrap: admin ${result.created ? "created" : "already present"} (${result.id}); role "${role}" granted`);
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();

View File

@@ -68,7 +68,7 @@ everything via Docker.
- [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. - [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.
- [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. - [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.
- [x] **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. → `src/bootstrap.ts` + a one-shot `bootstrap` compose service: runs after kratos+keto are healthy (web gates on its `service_completed_successfully`), idempotent so every `up` re-runs cleanly. (1) `ensureJwks` generates the ES256 signing key (reuses `gen-jwks.ts`) only when the committed dev key is absent — tokenizer dir mounted rw so it can land. (2) `seedAdmin` creates `admin@plainpages.local`/`admin` via the Kratos admin API (a re-run's 409 → look up + reuse the id). (3) grants `Role:admin#members@user:<id>` via the Keto write API (PUT, idempotent) — the source of truth the §4 login flow projects into the JWT. Migrations + default Ory configs already auto-run/committed (§3); OPL/namespaces load from `keto.yml` (§3). The password policy is bypassed by the admin API, so `admin`/`admin` is accepted. Tests-first: `bootstrap.test.ts` (payload builders, seed idempotency via mock fetch, generate-if-absent) + `compose.test.ts` (service wiring). Boot-verified the whole chain on the live stack: `docker compose up --wait` seeds with zero prep, Keto `check``allowed:true`, login with `admin@plainpages.local`/`admin` issues a session + tokenizes a JWT; re-run → "already present"; moving the committed key → "generated a JWKS signing key". JWT `roles` stays `[]` until §4 wires the Keto→`metadata_admin` projection. typecheck + 151 units green. The first-run banner (login URL + creds) and the prod-secret/SSO exception docs are the next §3 items. - [x] **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. → `src/bootstrap.ts` + a one-shot `bootstrap` compose service: runs after kratos+keto are healthy (web gates on its `service_completed_successfully`), idempotent so every `up` re-runs cleanly. (1) `ensureJwks` generates the ES256 signing key (reuses `gen-jwks.ts`) only when the committed dev key is absent — tokenizer dir mounted rw so it can land. (2) `seedAdmin` creates `admin@plainpages.local`/`admin` via the Kratos admin API (a re-run's 409 → look up + reuse the id). (3) grants `Role:admin#members@user:<id>` via the Keto write API (PUT, idempotent) — the source of truth the §4 login flow projects into the JWT. Migrations + default Ory configs already auto-run/committed (§3); OPL/namespaces load from `keto.yml` (§3). The password policy is bypassed by the admin API, so `admin`/`admin` is accepted. Tests-first: `bootstrap.test.ts` (payload builders, seed idempotency via mock fetch, generate-if-absent) + `compose.test.ts` (service wiring). Boot-verified the whole chain on the live stack: `docker compose up --wait` seeds with zero prep, Keto `check``allowed:true`, login with `admin@plainpages.local`/`admin` issues a session + tokenizes a JWT; re-run → "already present"; moving the committed key → "generated a JWKS signing key". JWT `roles` stays `[]` until §4 wires the Keto→`metadata_admin` projection. typecheck + 151 units green. The first-run banner (login URL + creds) and the prod-secret/SSO exception docs are the next §3 items.
- [ ] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. - [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.
- [ ] 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. - [ ] 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.
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
- [ ] 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. - [ ] 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.