Make bootstrap read its settings through config.ts and call Kratos and Keto through their clients #234
@@ -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
|
||||||
|
|||||||
+29
-19
@@ -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"],
|
||||||
});
|
});
|
||||||
@@ -119,19 +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}` });
|
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
const clients = (fetchImpl: typeof fetch) => ({
|
||||||
const fetchImpl = (async () => json(500, { error: "boom" })) as typeof fetch;
|
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
|
||||||
await assert.rejects(
|
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
|
||||||
seedAdmin({
|
});
|
||||||
email: "admin@plainpages.local",
|
|
||||||
fetchImpl,
|
test("seedAdmin fails loud on a Kratos error other than 409, without looking the admin up", async () => {
|
||||||
ketoWriteUrl: "http://keto:4467",
|
const urls: string[] = [];
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
const fetchImpl = (async (url) => {
|
||||||
password: "admin",
|
urls.push(String(url));
|
||||||
permissions: ["admin"],
|
return json(500, { error: "boom" });
|
||||||
}),
|
}) as typeof fetch;
|
||||||
/Kratos/,
|
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", () => {
|
test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => {
|
||||||
|
|||||||
+41
-63
@@ -1,20 +1,20 @@
|
|||||||
// 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. It starts once
|
||||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
// Kratos and Keto are healthy, `web` waits for it, and it repeats safely on every `docker compose up`.
|
||||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
// Steps, in the order `main` runs them: write the JWKS signing key if absent, create each storage
|
||||||
// 2. create a database and login role for each discovered plugin that declares storage;
|
// plugin's database and role, then seed the demo admin in Kratos and grant it in Keto every
|
||||||
// 3. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
// discovered plugin's declared permissions (plus ADMIN_PERMISSIONS). Any unexpected upstream error
|
||||||
// 4. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
|
// fails the run loudly.
|
||||||
// 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 { existsSync, writeFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
|
import { type BootstrapConfig, 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 +71,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 +84,29 @@ 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.
|
|
||||||
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: granting "${permission}" in Keto: ${(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 ----------------------------------------------------------------
|
||||||
@@ -146,21 +129,19 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
|
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 config = loadBootstrapConfig(env); // first, so a bad setting fails before any step has run
|
||||||
const log = createLogger({
|
// Structured like the web app so prod logs stay uniform.
|
||||||
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
|
const log = createLogger({ format: config.logFormat, ...(config.serviceName ? { serviceName: config.serviceName } : {}) });
|
||||||
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
|
// runWithLog makes `log` ambient so the Kratos/Keto clients' tracedFetch traces the seed calls.
|
||||||
});
|
|
||||||
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
|
|
||||||
await runWithLog(log, async () => {
|
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
|
// 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.
|
// 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_ADMIN_URL"];
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
delete process.env["PLUGIN_DB_SECRET"];
|
||||||
const plugins = await discoverPlugins();
|
const plugins = await discoverPlugins();
|
||||||
await provisionPluginStorage(env, plugins, log);
|
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
|
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
|
||||||
}
|
}
|
||||||
@@ -213,25 +194,22 @@ function safeHostPort(url: string): string | null {
|
|||||||
|
|
||||||
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
// 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.
|
// 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<void> {
|
async function seedAdminAndPermissions(config: BootstrapConfig, plugins: Plugin[], log: Log): Promise<void> {
|
||||||
const declared = declaredPermissions(plugins).map((decl) => decl.name);
|
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) {
|
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 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();
|
||||||
|
|||||||
+34
-1
@@ -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,39 @@ 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",
|
||||||
|
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");
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+38
-3
@@ -158,6 +158,43 @@ 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;
|
||||||
|
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, 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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 +218,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"),
|
||||||
|
|||||||
@@ -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,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`
|
||||||
|
|||||||
Reference in New Issue
Block a user