Files
plainpages/src/bootstrap.ts
lilleman bea9a71d6f §9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333).
2026-06-20 15:46:48 +02:00

168 lines
7.6 KiB
TypeScript

// One-command bootstrap (todo §3, 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. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
// every discovered plugin's declared permission tokens, 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.
import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "./discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "./logger.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
export function identityPayload(email: string, password: string) {
return {
credentials: { password: { config: { password } } }, // cleartext; Kratos hashes it
schema_id: "default",
traits: { email, name: { first: "Admin", last: "User" } },
};
}
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
export function roleTuple(identityId: string, role: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
}
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
}
// --- JWKS safety net -----------------------------------------------------------------
export interface JwksFsHooks {
exists?: (path: string) => boolean;
generate?: () => JwkSet;
write?: (path: string, content: string) => void;
}
// Generate the signing key only when the file is missing; returns whether it wrote one.
export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean {
const exists = hooks.exists ?? existsSync;
if (exists(path)) return false;
const generate = hooks.generate ?? generateJwks;
const write = hooks.write ?? ((p, c) => writeFileSync(p, c));
write(path, `${JSON.stringify(generate(), null, 2)}\n`);
return true;
}
// --- Admin seeding -------------------------------------------------------------------
export interface SeedOptions {
email: string;
fetchImpl?: typeof fetch;
ketoWriteUrl: string;
kratosAdminUrl: string;
password: string;
roles: string[];
}
export interface SeedResult {
created: boolean;
id: string;
roles: string[];
}
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
const http = opts.fetchImpl ?? fetch;
// 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 role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const role of opts.roles) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(roleTuple(id, role)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
}
return { created, id, roles: opts.roles };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
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;
}
// --- 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() prints it verbatim.
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) ----------------------------------------
async function main() {
const env = process.env;
// Structured like the web app (§9) 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.
await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.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 }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();