// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced // secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a // working dev default, so `docker compose up` runs with zero config. // Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels). export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const; export type LogLevel = (typeof LOG_LEVELS)[number]; const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret"; // The one resolution both processes use — they must agree exactly, or web connects with a password // the role was never given. Compose passes an unset variable through as "", so empty means unset. // `enforce` says whether storage is actually in play: web once PLUGIN_DB_URL is configured, // bootstrap once a plugin declares storage. Enforced, the throwaway is refused — bootstrap is what // writes these passwords into Postgres, so it must refuse *before* creating a role with one. export function resolvePluginDbSecret(env: Env, enforce?: boolean): string { return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false)); } // Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place. export function resolvePluginDbConnectionLimit(env: Env): number { return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10); } // PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role. // Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and // leave a superuser password in the environment plugin code can read. function readCredentiallessUrl(env: Env, key: string): string | undefined { const value = readOptionalUrl(env, key); if (value === undefined) return undefined; const url = new URL(value); if (url.username || url.password) { throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`); } return value; } export interface Config { appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle) cacheTemplates: boolean; csrfSecret: string; hydraAdminUrl: string; jwksUrl: string; jwtAudience: string | undefined; jwtClockSkewSec: number; jwtIssuer: string | undefined; ketoReadUrl: string; ketoWriteUrl: string; kratosAdminUrl: string; kratosPublicUrl: string; logFormat: "json" | "text"; // console/OTLP entry format (json for structured prod logs) logLevel: LogLevel; // minimum severity emitted oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts) pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off port: number; requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret` revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew secureCookies: boolean; serviceName: string; // OTLP service.name — an implementer brands their own logs/traces } type Env = Record; // A secret: free to use a dev throwaway by default; when REQUIRE_SECURE_SECRETS is on it // must be supplied and must not be the throwaway (README: real secrets replace dev ones). function readSecret(env: Env, key: string, devDefault: string, requireSecure: boolean): string { const value = env[key]; if (!requireSecure) return value || devDefault; if (!value) throw new Error(`config: ${key} must be set when REQUIRE_SECURE_SECRETS=true`); if (value === devDefault) throw new Error(`config: ${key} must not be the dev throwaway when REQUIRE_SECURE_SECRETS=true`); return value; } // An explicit boolean toggle: only "true"/"false"; a typo fails at boot, never silently. function readBool(env: Env, key: string, devDefault: boolean): boolean { const value = env[key]; if (value === undefined) return devDefault; if (value === "true") return true; if (value === "false") return false; throw new Error(`config: ${key} must be "true" or "false", got "${value}"`); } // An optional pinned value: present only when set non-empty. Unset ⇒ the matching claim // check is skipped (clean clone — the dev tokenizer sets no iss/aud; verifier). function readOptional(env: Env, key: string): string | undefined { return env[key] || undefined; } // One of a fixed set; a typo fails at boot rather than degrading silently at runtime. function readEnum(env: Env, key: string, allowed: readonly T[], devDefault: T): T { const value = env[key]; if (value === undefined) return devDefault; if ((allowed as readonly string[]).includes(value)) return value as T; throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`); } // An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot. function readOptionalUrl(env: Env, key: string): string | undefined { const value = env[key] || undefined; if (value === undefined) return undefined; try { new URL(value); } catch { throw new Error(`config: ${key} is not a valid URL: ${value}`); } return value; } // An absolute URL: defaults to the Ory service; validated so a typo fails at boot. function readUrl(env: Env, key: string, devDefault: string): string { const value = env[key] ?? devDefault; try { new URL(value); } catch { throw new Error(`config: ${key} is not a valid URL: ${value}`); } return value; } function readPort(env: Env): number { const raw = env["PORT"]; if (raw === undefined) return 3000; const port = Number(raw); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`config: PORT must be an integer 1–65535, got "${raw}"`); } return port; } // A non-negative integer count of seconds, with a default. Used for the JWT exp/nbf leeway. function readNonNegInt(env: Env, key: string, devDefault: number): number { const raw = env[key]; if (raw === undefined) return devDefault; const n = Number(raw); if (!Number.isInteger(n) || n < 0) throw new Error(`config: ${key} must be a non-negative integer, got "${raw}"`); return n; } function readPosInt(env: Env, key: string, devDefault: number): number { const raw = env[key]; if (raw === undefined) return devDefault; const n = Number(raw); if (!Number.isInteger(n) || n < 1) throw new Error(`config: ${key} must be a positive integer, got "${raw}"`); return n; } export function loadConfig(env: Env = process.env): Config { const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false); return { // The canonical public URL — the single source for "where this deployment lives". When set, the // canonical-host redirect (app.ts) sends a visitor who reached the app on any other host // (localhost vs 127.0.0.1, a secondary domain) here, so the browser, the themed forms, and the // cross-origin Kratos POST all share ONE cookie host. Explicit toggle (no magic default): unset ⇒ // no redirect (a prod operator can't accidentally bounce real users to a forgotten default). The // dev stack sets it to localhost (compose.override.yml); Kratos' browser URLs derive from it too. appUrl: readOptionalUrl(env, "APP_URL"), cacheTemplates: readBool(env, "CACHE_TEMPLATES", false), csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure), // Hydra admin API — the OAuth2 login/consent challenge handshake; not on the first-party path. hydraAdminUrl: readUrl(env, "HYDRA_ADMIN_URL", "http://hydra:4445"), // verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url). // Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS // mounted into web (compose.yml). Prod overrides with a real key (README: rotation). jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"), // Optional, off by default: pin the session-JWT issuer/audience for a hardened deploy. jwtAudience: readOptional(env, "JWT_AUDIENCE"), // 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"), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), // observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an // OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo). logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), // Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no // credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it // out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot, // which is also why the secret is only enforced once a URL is configured. pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])), pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"), port: readPort(env), requireSecureSecrets: requireSecure, // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // tokenizer TTL + skew, so it outlasts any pre-revoke token). revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false), revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900), // Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it. secureCookies: readBool(env, "SECURE_COOKIES", false), serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default }; }