Make bootstrap read its settings through config.ts and call Kratos and Keto through their clients
CI / full-gate (push) Successful in 3m21s

This commit is contained in:
2026-11-09 01:01:44 +01:00
parent df9a3f3665
commit 784610eac8
7 changed files with 109 additions and 79 deletions
+17
View File
@@ -547,3 +547,20 @@ Read most (opens and searches):
- `compose.yml` 2 - `compose.yml` 2
- `examples/plugins/auth-admin/admin-groups.ts` 2 - `examples/plugins/auth-admin/admin-groups.ts` 2
- `examples/plugins/auth-admin/views/partials/group-detail-body.ejs` 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
+8 -9
View File
@@ -7,6 +7,8 @@ import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.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 { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.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({ const result = await seedAdmin({
email: "admin@plainpages.local", email: "admin@plainpages.local",
fetchImpl, keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
ketoWriteUrl: "http://keto:4467", kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
permissions: ["admin", "scheduling:read"], 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({ const result = await seedAdmin({
email: "admin@plainpages.local", email: "admin@plainpages.local",
fetchImpl, keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
ketoWriteUrl: "http://keto:4467", kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
permissions: ["admin"], permissions: ["admin"],
}); });
@@ -124,9 +124,8 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
await assert.rejects( await assert.rejects(
seedAdmin({ seedAdmin({
email: "admin@plainpages.local", email: "admin@plainpages.local",
fetchImpl, keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
ketoWriteUrl: "http://keto:4467", kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
permissions: ["admin"], permissions: ["admin"],
}), }),
+38 -53
View File
@@ -1,20 +1,23 @@
// One-command bootstrap (the MVP bar). One-shot compose service: runs after // The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack; `web` waits for
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // it. It runs once Kratos and Keto are healthy, repeats safely on every `docker compose up`, and
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // fails loud on any unexpected upstream error. In order:
// 2. create a database and login role for each discovered plugin that declares storage; // 1. JWKS: write the session-token signing key if the file is missing.
// 3. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. Plugin storage: create a database and login role for each plugin that declares storage.
// 4. grant it its permissions in Keto so menu/permission checks resolve out of the box — every // 3. Admin: create the demo admin in Kratos, or find it again.
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in // 4. Permissions: grant that admin in Keto every discovered plugin's declared permission names plus
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic). // ADMIN_PERMISSIONS, so a dropped-in plugin works with no host config edit.
// Then prints a first-run banner; fails loud on any unexpected upstream error. // It ends by printing the first-run banner. Settings come from `loadBootstrapConfig`.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; 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 { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts"; import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts"; import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.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"; import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>; type Env = Record<string, string | undefined>;
@@ -71,9 +74,8 @@ export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean {
export interface SeedOptions { export interface SeedOptions {
email: string; email: string;
fetchImpl?: typeof fetch; keto: KetoClient;
ketoWriteUrl: string; kratos: KratosAdmin;
kratosAdminUrl: string;
password: string; password: string;
permissions: string[]; permissions: string[];
} }
@@ -85,45 +87,30 @@ export interface SeedResult {
} }
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> { export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
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. // PUT is idempotent — re-running just re-asserts the tuple.
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.
for (const permission of opts.permissions) { for (const permission of opts.permissions) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, { try {
body: JSON.stringify(permissionTuple(id, permission)), await opts.keto.writeTuple(permissionTuple(id, permission));
headers: { "content-type": "application/json" }, } catch (err) {
method: "PUT", throw new Error(`bootstrap: Keto grant permission "${permission}" failed: ${(err as Error).message}`, { cause: err });
}); }
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
} }
return { created, id, permissions: opts.permissions }; return { created, id, permissions: opts.permissions };
} }
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> { // A Kratos 409 means the admin exists (a re-run), so look its id up by login identifier.
const res = await http(`${adminUrl}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`); async function createOrFindAdmin(kratos: KratosAdmin, email: string, password: string): Promise<{ created: boolean; id: string }> {
if (!res.ok) throw new Error(`bootstrap: Kratos lookup failed (${res.status}): ${await res.text()}`); try {
const found = ((await res.json()) as { id: string }[])[0]; return { created: true, id: (await kratos.createIdentity(identityPayload(email, password))).id };
if (!found?.id) throw new Error(`bootstrap: ${email} reported as existing but not found`); } catch (err) {
return found.id; 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 ---------------------------------------------------------------- // --- First-run banner ----------------------------------------------------------------
@@ -219,19 +206,17 @@ async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): P
if (ignored.length > 0) { if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") }); log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
} }
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const config = loadBootstrapConfig(env);
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({
email, email: config.adminEmail,
fetchImpl: tracedFetch, keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }),
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467", kratos: createKratosAdmin({ baseUrl: config.kratosAdminUrl, fetchImpl: tracedFetch }),
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434", password: config.adminPassword,
password,
permissions, permissions,
}); });
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); 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. // 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(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+17 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; 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 // Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply. // 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/); 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", () => { test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({}); const c = loadConfig({});
assert.equal(c.port, 3000); assert.equal(c.port, 3000);
+29 -3
View File
@@ -158,6 +158,34 @@ function readTrustedProxies(env: Env): string[] {
return entries; 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 { export function loadConfig(env: Env = process.env): Config {
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false); const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return { 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. // 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), jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60),
jwtIssuer: readOptional(env, "JWT_ISSUER"), jwtIssuer: readOptional(env, "JWT_ISSUER"),
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ...readKratosKetoUrls(env),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// Console-only unless OTLP_ENDPOINT names a collector. // Console-only unless OTLP_ENDPOINT names a collector.
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
-1
View File
@@ -19,7 +19,6 @@ const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 }, "src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 }, "src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
"src/config.ts loadConfig": { lines: 52 },
"release-tooling/dockerhub-overview.ts main": { lines: 57 }, "release-tooling/dockerhub-overview.ts main": { lines: 57 },
"examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 }, "examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 },
}; };
-12
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | 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 | | 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 | | 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 | | 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 ## 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`. ### 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` - `pagedTuples` (`examples/plugins/auth-admin/admin-groups.ts` ~L246) and `heldPermissions`