177 lines
8.5 KiB
TypeScript
177 lines
8.5 KiB
TypeScript
// 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. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
|
// 3. 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.
|
|
import { existsSync, writeFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
|
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.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-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
|
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
|
|
export function permissionTuple(userId: string, permission: string) {
|
|
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
|
}
|
|
|
|
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
|
|
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
|
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
|
|
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
|
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
|
// the plugin that gates on it — a host-invented default would gate nothing.
|
|
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
|
// same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
|
|
// tuple that gates nothing. A declared name has already passed that check at discovery.
|
|
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): string[] {
|
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
|
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
|
const bad = configured.filter((name) => !isValidPermissionName(name));
|
|
if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
|
|
return [...new Set([...configured, ...clean(declaredNames)])];
|
|
}
|
|
|
|
// --- 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;
|
|
permissions: string[];
|
|
}
|
|
|
|
export interface SeedResult {
|
|
created: boolean;
|
|
id: string;
|
|
permissions: 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 permission in Keto. 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()}`);
|
|
}
|
|
|
|
return { created, id, permissions: opts.permissions };
|
|
}
|
|
|
|
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 — sign 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 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 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.
|
|
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
|
|
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], 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,
|
|
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 }));
|
|
});
|
|
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|