From 784610eac8d2f963ee42fcc027bc60efedd8db2b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 9 Nov 2026 01:01:44 +0100 Subject: [PATCH 1/4] Make bootstrap read its settings through config.ts and call Kratos and Keto through their clients --- docs/reading-cost.md | 17 +++++++ src/auth/bootstrap.test.ts | 17 ++++--- src/auth/bootstrap.ts | 91 ++++++++++++++++--------------------- src/config.test.ts | 18 +++++++- src/config.ts | 32 +++++++++++-- src/function-limits.test.ts | 1 - todo.md | 12 ----- 7 files changed, 109 insertions(+), 79 deletions(-) diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 78bf6e4..3126d50 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -547,3 +547,20 @@ Read most (opens and searches): - `compose.yml` 2 - `examples/plugins/auth-admin/admin-groups.ts` 2 - `examples/plugins/auth-admin/views/partials/group-detail-body.ejs` 2 + +## Reading cost, chunk d95a4d7286d2, finished 2026-11-08T23:59:30Z + +82203 new input tokens before the first commit. + +Read most (opens and searches): + +- `src/http/app.ts` 8 +- `src/http/app-plugins.test.ts` 4 +- `todo.md` 4 +- `src/http/app.test.ts` 3 +- `src/function-limits.test.ts` 2 +- `src/test-tooling/app-harness.ts` 2 +- `docs/decisions.md` 1 +- `src/auth/routes.ts` 1 +- `src/http/builtin-route.test.ts` 1 +- `src/http/builtin-route.ts` 1 diff --git a/src/auth/bootstrap.test.ts b/src/auth/bootstrap.test.ts index f57655b..b95412e 100644 --- a/src/auth/bootstrap.test.ts +++ b/src/auth/bootstrap.test.ts @@ -7,6 +7,8 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts"; import { createLogger } from "../logger.ts"; +import { createKetoClient } from "./keto-client.ts"; +import { createKratosAdmin } from "./kratos-admin.ts"; import type { Plugin } from "../plugin-host/plugin.ts"; import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts"; @@ -75,9 +77,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio const result = await seedAdmin({ email: "admin@plainpages.local", - fetchImpl, - ketoWriteUrl: "http://keto:4467", - kratosAdminUrl: "http://kratos:4434", + keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), + kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), password: "admin", permissions: ["admin", "scheduling:read"], }); @@ -108,9 +109,8 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants const result = await seedAdmin({ email: "admin@plainpages.local", - fetchImpl, - ketoWriteUrl: "http://keto:4467", - kratosAdminUrl: "http://kratos:4434", + keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), + kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), password: "admin", permissions: ["admin"], }); @@ -124,9 +124,8 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => { await assert.rejects( seedAdmin({ email: "admin@plainpages.local", - fetchImpl, - ketoWriteUrl: "http://keto:4467", - kratosAdminUrl: "http://kratos:4434", + keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), + kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), password: "admin", permissions: ["admin"], }), diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index b9cca48..d81e302 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -1,20 +1,23 @@ -// One-command bootstrap (the MVP bar). One-shot compose service: runs after -// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: -// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); -// 2. create a database and login role for each discovered plugin that declares storage; -// 3. seed a demo admin (admin@plainpages.local / admin) in Kratos; -// 4. grant it its permissions in Keto so menu/permission checks resolve out of the box — every -// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in -// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic). -// Then prints a first-run banner; fails loud on any unexpected upstream error. +// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack; `web` waits for +// it. It runs once Kratos and Keto are healthy, repeats safely on every `docker compose up`, and +// fails loud on any unexpected upstream error. In order: +// 1. JWKS: write the session-token signing key if the file is missing. +// 2. Plugin storage: create a database and login role for each plugin that declares storage. +// 3. Admin: create the demo admin in Kratos, or find it again. +// 4. Permissions: grant that admin in Keto every discovered plugin's declared permission names plus +// ADMIN_PERMISSIONS, so a dropped-in plugin works with no host config edit. +// It ends by printing the first-run banner. Settings come from `loadBootstrapConfig`. import { existsSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; +import { loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts"; import { provisionStorage } from "../plugin-host/storage-provisioning.ts"; import { storagePluginIds } from "../plugin-host/storage.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts"; +import { createKetoClient, type KetoClient } from "./keto-client.ts"; +import { createKratosAdmin, type KratosAdmin } from "./kratos-admin.ts"; +import { KratosError } from "./kratos-public.ts"; import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts"; type Env = Record; @@ -71,9 +74,8 @@ export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean { export interface SeedOptions { email: string; - fetchImpl?: typeof fetch; - ketoWriteUrl: string; - kratosAdminUrl: string; + keto: KetoClient; + kratos: KratosAdmin; password: string; permissions: string[]; } @@ -85,45 +87,30 @@ export interface SeedResult { } export async function seedAdmin(opts: SeedOptions): Promise { - const http = opts.fetchImpl ?? fetch; + const { created, id } = await createOrFindAdmin(opts.kratos, opts.email, opts.password); - // Create the identity. A 409 means it already exists (a re-run) — look up its id. - const res = await http(`${opts.kratosAdminUrl}/admin/identities`, { - body: JSON.stringify(identityPayload(opts.email, opts.password)), - headers: { "content-type": "application/json" }, - method: "POST", - }); - let created: boolean; - let id: string; - if (res.status === 201) { - id = ((await res.json()) as { id: string }).id; - created = true; - } else if (res.status === 409) { - id = await findIdentityId(http, opts.kratosAdminUrl, opts.email); - created = false; - } else { - throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`); - } - - // Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple. + // PUT is idempotent — re-running just re-asserts the tuple. for (const permission of opts.permissions) { - const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, { - body: JSON.stringify(permissionTuple(id, permission)), - headers: { "content-type": "application/json" }, - method: "PUT", - }); - if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`); + try { + await opts.keto.writeTuple(permissionTuple(id, permission)); + } catch (err) { + throw new Error(`bootstrap: Keto grant permission "${permission}" failed: ${(err as Error).message}`, { cause: err }); + } } return { created, id, permissions: opts.permissions }; } -async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise { - const res = await http(`${adminUrl}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`); - if (!res.ok) throw new Error(`bootstrap: Kratos lookup failed (${res.status}): ${await res.text()}`); - const found = ((await res.json()) as { id: string }[])[0]; - if (!found?.id) throw new Error(`bootstrap: ${email} reported as existing but not found`); - return found.id; +// A Kratos 409 means the admin exists (a re-run), so look its id up by login identifier. +async function createOrFindAdmin(kratos: KratosAdmin, email: string, password: string): Promise<{ created: boolean; id: string }> { + try { + return { created: true, id: (await kratos.createIdentity(identityPayload(email, password))).id }; + } catch (err) { + if (!(err instanceof KratosError) || err.status !== 409) throw new Error(`bootstrap: ${(err as Error).message}`, { cause: err }); + } + const found = (await kratos.listIdentities({ credentialsIdentifier: email })).identities[0]; + if (!found) throw new Error(`bootstrap: ${email} reported as existing but not found`); + return { created: false, id: found.id }; } // --- First-run banner ---------------------------------------------------------------- @@ -219,19 +206,17 @@ async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): P if (ignored.length > 0) { log.warn("ignoring ADMIN_PERMISSIONS entries that are not :", { ignored: ignored.join(", ") }); } - const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; - const password = env["ADMIN_PASSWORD"] ?? "admin"; + const config = loadBootstrapConfig(env); const result = await seedAdmin({ - email, - fetchImpl: tracedFetch, - ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467", - kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434", - password, + email: config.adminEmail, + keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }), + kratos: createKratosAdmin({ baseUrl: config.kratosAdminUrl, fetchImpl: tracedFetch }), + password: config.adminPassword, permissions, }); log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); // The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw. - console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password })); + console.log(firstRunBanner({ appUrl: config.appUrl, email: config.adminEmail, password: config.adminPassword })); } if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); diff --git a/src/config.test.ts b/src/config.test.ts index e217319..74169cd 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts"; +import { loadBootstrapConfig, loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts"; // Explicit secure-secret enforcement (no environment sniffing): secrets are the only // thing a hardened deploy must supply. @@ -63,6 +63,22 @@ test("the per-role connection ceiling defaults to 10 and rejects nonsense", () = assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/); }); +test("the one-shot bootstrap config has dev defaults and demands no web secret, even when secure secrets are required", () => { + const c = loadBootstrapConfig({ REQUIRE_SECURE_SECRETS: "true" }); + assert.deepEqual(c, { + adminEmail: "admin@plainpages.local", + adminPassword: "admin", + appUrl: "http://localhost:3000", + ketoReadUrl: "http://keto:4466", + ketoWriteUrl: "http://keto:4467", + kratosAdminUrl: "http://kratos:4434", + }); + assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local"); // compose passes an unset variable as "" + const set = loadBootstrapConfig({ ADMIN_EMAIL: "a@b.c", APP_URL: "https://x.test", KRATOS_ADMIN_URL: "http://k:1" }); + assert.deepEqual([set.adminEmail, set.appUrl, set.kratosAdminUrl], ["a@b.c", "https://x.test", "http://k:1"]); + assert.throws(() => loadBootstrapConfig({ KETO_WRITE_URL: "not a url" }), /KETO_WRITE_URL/); +}); + test("loads dev defaults when the environment is empty", () => { const c = loadConfig({}); assert.equal(c.port, 3000); diff --git a/src/config.ts b/src/config.ts index b9baa2d..b82cb85 100644 --- a/src/config.ts +++ b/src/config.ts @@ -158,6 +158,34 @@ function readTrustedProxies(env: Env): string[] { return entries; } +function readKratosKetoUrls(env: Env) { + return { + ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), + ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), + kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"), + }; +} + +export interface BootstrapConfig { + adminEmail: string; + adminPassword: string; + appUrl: string; // only printed in the first-run banner; unlike Config.appUrl, it has a default + ketoReadUrl: string; + ketoWriteUrl: string; + kratosAdminUrl: string; +} + +// What the one-shot bootstrap service reads. Not `loadConfig`: that demands web's own secrets, which +// compose withholds from bootstrap while REQUIRE_SECURE_SECRETS is on there. +export function loadBootstrapConfig(env: Env): BootstrapConfig { + return { + adminEmail: env["ADMIN_EMAIL"] || "admin@plainpages.local", + adminPassword: env["ADMIN_PASSWORD"] || "admin", + appUrl: readOptionalUrl(env, "APP_URL") ?? "http://localhost:3000", + ...readKratosKetoUrls(env), + }; +} + export function loadConfig(env: Env = process.env): Config { const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false); return { @@ -181,9 +209,7 @@ export function loadConfig(env: Env = process.env): Config { // exp/nbf leeway (s) for Kratos↔web clock drift; the auth E2E sets 0 to time tokens out fast. jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60), jwtIssuer: readOptional(env, "JWT_ISSUER"), - ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), - ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), - kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"), + ...readKratosKetoUrls(env), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), // Console-only unless OTLP_ENDPOINT names a collector. logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), diff --git a/src/function-limits.test.ts b/src/function-limits.test.ts index 87287be..990d400 100644 --- a/src/function-limits.test.ts +++ b/src/function-limits.test.ts @@ -19,7 +19,6 @@ const grandfathered: Record = { "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, "src/auth/kratos-public.ts createKratosPublic": { lines: 60 }, "src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 }, - "src/config.ts loadConfig": { lines: 52 }, "release-tooling/dockerhub-overview.ts main": { lines: 57 }, "examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 }, }; diff --git a/todo.md b/todo.md index 2422d6a..aa2cbfe 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 | | 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 | | 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 | | 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | @@ -28,17 +27,6 @@ ## Details -### 66. Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules. - -`seedAdminAndPermissions` (~L221-233) reads `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `KETO_WRITE_URL`, -`KRATOS_ADMIN_URL` and `APP_URL` from the environment with its own defaults, which repeat -`src/config.ts` (~L170-186) beside its "loaded once" promise. `seedAdmin` (~L86) and -`findIdentityId` call Kratos and Keto with raw `fetch` where `createKratosAdmin` (`createIdentity`, -`listIdentities` with `credentialsIdentifier`) and `createKetoClient` (`writeTuple`) exist. Check -that `loadConfig` doesn't demand settings the one-shot service lacks; else reuse its readers. The -seed stays idempotent (a Kratos 409 still means an existing admin). Make the `bootstrap.ts` header -easier to read, a place the seats found hard. - ### 70. Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`. - `pagedTuples` (`examples/plugins/auth-admin/admin-groups.ts` ~L246) and `heldPermissions` -- 2.52.0 From ebb9757e80a60e1e4e41431c0317078ea2f86855 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 9 Nov 2026 01:06:43 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=204=20=F0=9F=9F=A1=20(0=20misread);=20read=20ever?= =?UTF-8?q?y=20bootstrap=20setting=20once=20at=20start,=20test=20the=20409?= =?UTF-8?q?,=20Keto-grant=20and=20password=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/bootstrap.test.ts | 35 ++++++++++++++++++++++------------ src/auth/bootstrap.ts | 39 ++++++++++++++++---------------------- src/config.test.ts | 23 +++++++++++++++++++--- src/config.ts | 13 +++++++++++-- 4 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/auth/bootstrap.test.ts b/src/auth/bootstrap.test.ts index b95412e..e7b6c3f 100644 --- a/src/auth/bootstrap.test.ts +++ b/src/auth/bootstrap.test.ts @@ -119,18 +119,29 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` }); }); -test("seedAdmin fails loud on an unexpected Kratos error", async () => { - const fetchImpl = (async () => json(500, { error: "boom" })) as typeof fetch; - await assert.rejects( - seedAdmin({ - email: "admin@plainpages.local", - keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), - kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), - password: "admin", - permissions: ["admin"], - }), - /Kratos/, - ); +const clients = (fetchImpl: typeof fetch) => ({ + keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), + kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), +}); + +test("seedAdmin fails loud on a Kratos error other than 409, without looking the admin up", async () => { + const urls: string[] = []; + const fetchImpl = (async (url) => { + urls.push(String(url)); + return json(500, { error: "boom" }); + }) as typeof fetch; + await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["admin"], ...clients(fetchImpl) }), /bootstrap: Kratos.*500/); + assert.equal(urls.length, 1); +}); + +test("seedAdmin fails loud when a 409 admin cannot be found", async () => { + const fetchImpl = (async (_url, init) => (init?.method === "POST" ? json(409, {}) : json(200, []))) as typeof fetch; + await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: [], ...clients(fetchImpl) }), /reported as existing but not found/); +}); + +test("seedAdmin fails loud when Keto refuses a grant, naming the permission", async () => { + const fetchImpl = (async (url) => (String(url).endsWith("/admin/identities") ? json(201, { id: "u1" }) : json(500, {}))) as typeof fetch; + await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["users:read"], ...clients(fetchImpl) }), /granting "users:read" in Keto/); }); test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => { diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index d81e302..7fd9c2f 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -1,15 +1,12 @@ -// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack; `web` waits for -// it. It runs once Kratos and Keto are healthy, repeats safely on every `docker compose up`, and -// fails loud on any unexpected upstream error. In order: -// 1. JWKS: write the session-token signing key if the file is missing. -// 2. Plugin storage: create a database and login role for each plugin that declares storage. -// 3. Admin: create the demo admin in Kratos, or find it again. -// 4. Permissions: grant that admin in Keto every discovered plugin's declared permission names plus -// ADMIN_PERMISSIONS, so a dropped-in plugin works with no host config edit. -// It ends by printing the first-run banner. Settings come from `loadBootstrapConfig`. +// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack. It starts once +// Kratos and Keto are healthy, `web` waits for it, and it repeats safely on every `docker compose up`. +// Steps, in the order `main` runs them: write the JWKS signing key if absent, create each storage +// plugin's database and role, then seed the demo admin in Kratos and grant it in Keto every +// discovered plugin's declared permissions (plus ADMIN_PERMISSIONS). Any unexpected upstream error +// fails the run loudly. import { existsSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; +import { type BootstrapConfig, loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts"; import { provisionStorage } from "../plugin-host/storage-provisioning.ts"; @@ -89,12 +86,11 @@ export interface SeedResult { export async function seedAdmin(opts: SeedOptions): Promise { const { created, id } = await createOrFindAdmin(opts.kratos, opts.email, opts.password); - // PUT is idempotent — re-running just re-asserts the tuple. for (const permission of opts.permissions) { try { await opts.keto.writeTuple(permissionTuple(id, permission)); } catch (err) { - throw new Error(`bootstrap: Keto grant permission "${permission}" failed: ${(err as Error).message}`, { cause: err }); + throw new Error(`bootstrap: granting "${permission}" in Keto: ${(err as Error).message}`, { cause: err }); } } @@ -133,21 +129,19 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password: async function main() { const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery - // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME. - const log = createLogger({ - format: env["LOG_FORMAT"] === "json" ? "json" : "text", - ...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}), - }); - // runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls. + const config = loadBootstrapConfig(env); // first, so a bad setting fails before any step has run + // Structured like the web app so prod logs stay uniform. + const log = createLogger({ format: config.logFormat, ...(config.serviceName ? { serviceName: config.serviceName } : {}) }); + // runWithLog makes `log` ambient so the Kratos/Keto clients' tracedFetch traces the seed calls. await runWithLog(log, async () => { - if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); + if (ensureJwks(config.jwksFile)) log.info("generated a JWKS signing key"); // Discovery imports every plugin module — and its dependencies — into *this* process, which holds // the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret. delete process.env["PLUGIN_DB_ADMIN_URL"]; delete process.env["PLUGIN_DB_SECRET"]; const plugins = await discoverPlugins(); await provisionPluginStorage(env, plugins, log); - await seedAdminAndPermissions(env, plugins, log); + await seedAdminAndPermissions(config, plugins, log); }); await log.end(); // flush any pending OTLP spans/logs before the one-shot exits } @@ -200,13 +194,12 @@ function safeHostPort(url: string): string | null { // Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the // shipped example — and any dropped-in plugin — works for the demo admin without a host edit. -async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise { +async function seedAdminAndPermissions(config: BootstrapConfig, plugins: Plugin[], log: Log): Promise { const declared = declaredPermissions(plugins).map((decl) => decl.name); - const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared); + const { ignored, permissions } = seedPermissions(config.adminPermissions, declared); if (ignored.length > 0) { log.warn("ignoring ADMIN_PERMISSIONS entries that are not :", { ignored: ignored.join(", ") }); } - const config = loadBootstrapConfig(env); const result = await seedAdmin({ email: config.adminEmail, keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }), diff --git a/src/config.test.ts b/src/config.test.ts index 74169cd..0b63b5a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -68,14 +68,31 @@ test("the one-shot bootstrap config has dev defaults and demands no web secret, assert.deepEqual(c, { adminEmail: "admin@plainpages.local", adminPassword: "admin", + adminPermissions: undefined, appUrl: "http://localhost:3000", ketoReadUrl: "http://keto:4466", ketoWriteUrl: "http://keto:4467", kratosAdminUrl: "http://kratos:4434", + jwksFile: "/etc/config/kratos/tokenizer/jwks.json", + logFormat: "text", + serviceName: undefined, }); - assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local"); // compose passes an unset variable as "" - const set = loadBootstrapConfig({ ADMIN_EMAIL: "a@b.c", APP_URL: "https://x.test", KRATOS_ADMIN_URL: "http://k:1" }); - assert.deepEqual([set.adminEmail, set.appUrl, set.kratosAdminUrl], ["a@b.c", "https://x.test", "http://k:1"]); + assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local"); + const set = loadBootstrapConfig({ + ADMIN_EMAIL: "a@b.c", + ADMIN_PASSWORD: "s3cret", + ADMIN_PERMISSIONS: "users:read", + APP_URL: "https://x.test", + JWKS_FILE: "/k.json", + KRATOS_ADMIN_URL: "http://k:1", + LOG_FORMAT: "json", + SERVICE_NAME: "boot", + }); + assert.deepEqual( + [set.adminEmail, set.adminPassword, set.adminPermissions, set.appUrl, set.jwksFile, set.kratosAdminUrl, set.logFormat, set.serviceName], + ["a@b.c", "s3cret", "users:read", "https://x.test", "/k.json", "http://k:1", "json", "boot"], + ); + assert.throws(() => loadBootstrapConfig({ LOG_FORMAT: "JSON" }), /LOG_FORMAT/); assert.throws(() => loadBootstrapConfig({ KETO_WRITE_URL: "not a url" }), /KETO_WRITE_URL/); }); diff --git a/src/config.ts b/src/config.ts index b82cb85..f171e77 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,19 +169,28 @@ function readKratosKetoUrls(env: Env) { export interface BootstrapConfig { adminEmail: string; adminPassword: string; + adminPermissions: string | undefined; // comma-separated, validated where it is used appUrl: string; // only printed in the first-run banner; unlike Config.appUrl, it has a default ketoReadUrl: string; ketoWriteUrl: string; kratosAdminUrl: string; + jwksFile: string; + logFormat: Config["logFormat"]; + serviceName: string | undefined; } -// What the one-shot bootstrap service reads. Not `loadConfig`: that demands web's own secrets, which -// compose withholds from bootstrap while REQUIRE_SECURE_SECRETS is on there. +// What the one-shot bootstrap service reads, apart from the plugin-storage variables, which +// `resolvePluginDb*` and `PLUGIN_DB_*` cover. Not `loadConfig`: that demands web's own secrets, which +// compose does not give the bootstrap service while it sets REQUIRE_SECURE_SECRETS there. export function loadBootstrapConfig(env: Env): BootstrapConfig { return { adminEmail: env["ADMIN_EMAIL"] || "admin@plainpages.local", adminPassword: env["ADMIN_PASSWORD"] || "admin", + adminPermissions: readOptional(env, "ADMIN_PERMISSIONS"), appUrl: readOptionalUrl(env, "APP_URL") ?? "http://localhost:3000", + jwksFile: env["JWKS_FILE"] || "/etc/config/kratos/tokenizer/jwks.json", + logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), + serviceName: readOptional(env, "SERVICE_NAME"), ...readKratosKetoUrls(env), }; } -- 2.52.0 From 14be7edcbf47687d0b13e0f925861cdb573d4142 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 9 Nov 2026 01:06:43 +0100 Subject: [PATCH 3/4] =?UTF-8?q?Answer=20prose=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=206=20=F0=9F=9F=A1=20(0=20misread);=20comments=20?= =?UTF-8?q?fixed=20in=20the=20stability=20round's=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From a4740c87ae7ee1a6a80d1d738b168dcdfc63a4f6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 9 Nov 2026 01:06:47 +0100 Subject: [PATCH 4/4] =?UTF-8?q?Answer=20security=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20no=20findin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0