Drop NODE_ENV for explicit config toggles (todo §0.1); app is environment-agnostic
This commit is contained in:
@@ -8,14 +8,15 @@ import { serveStatic } from "./static.ts";
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export interface AppOptions {
|
||||
// Cache compiled templates: on in production, off in dev so edits show live.
|
||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
publicDir?: string;
|
||||
viewsDir?: string;
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
const cache = options.cache ?? process.env["NODE_ENV"] === "production";
|
||||
const cache = options.cache ?? false;
|
||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { loadConfig } from "./config.ts";
|
||||
|
||||
// Minimal valid production env: the secrets are the only thing prod must supply.
|
||||
const prodEnv = { COOKIE_SECRET: "real-cookie-secret", CSRF_SECRET: "real-csrf-secret", NODE_ENV: "production" };
|
||||
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only
|
||||
// thing a hardened deploy must supply.
|
||||
const secureEnv = {
|
||||
COOKIE_SECRET: "real-cookie-secret",
|
||||
CSRF_SECRET: "real-csrf-secret",
|
||||
REQUIRE_SECURE_SECRETS: "true",
|
||||
};
|
||||
|
||||
test("loads dev defaults when the environment is empty", () => {
|
||||
const c = loadConfig({});
|
||||
assert.equal(c.port, 3000);
|
||||
assert.equal(c.cacheTemplates, false);
|
||||
assert.equal(c.kratosPublicUrl, "http://kratos:4433");
|
||||
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
|
||||
assert.equal(c.ketoReadUrl, "http://keto:4466");
|
||||
@@ -17,6 +23,12 @@ test("loads dev defaults when the environment is empty", () => {
|
||||
assert.match(c.csrfSecret, /dev-insecure/);
|
||||
});
|
||||
|
||||
test("parses explicit boolean toggles and rejects non-boolean values", () => {
|
||||
assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true);
|
||||
assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false);
|
||||
assert.throws(() => loadConfig({ CACHE_TEMPLATES: "yes" }), /CACHE_TEMPLATES/);
|
||||
});
|
||||
|
||||
test("reads overrides from the environment", () => {
|
||||
const c = loadConfig({ COOKIE_SECRET: "x", KRATOS_PUBLIC_URL: "https://id.example.com", PORT: "8080" });
|
||||
assert.equal(c.port, 8080);
|
||||
@@ -32,18 +44,18 @@ test("rejects a malformed Ory URL", () => {
|
||||
assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/);
|
||||
});
|
||||
|
||||
test("production rejects a missing or dev-throwaway secret", () => {
|
||||
assert.throws(() => loadConfig({ NODE_ENV: "production" }), /COOKIE_SECRET/);
|
||||
assert.throws(() => loadConfig({ COOKIE_SECRET: "real", NODE_ENV: "production" }), /CSRF_SECRET/);
|
||||
test("REQUIRE_SECURE_SECRETS rejects a missing or dev-throwaway secret", () => {
|
||||
assert.throws(() => loadConfig({ REQUIRE_SECURE_SECRETS: "true" }), /COOKIE_SECRET/);
|
||||
assert.throws(() => loadConfig({ COOKIE_SECRET: "real", REQUIRE_SECURE_SECRETS: "true" }), /CSRF_SECRET/);
|
||||
assert.throws(
|
||||
() => loadConfig({ COOKIE_SECRET: "dev-insecure-cookie-secret", CSRF_SECRET: "real", NODE_ENV: "production" }),
|
||||
() => loadConfig({ COOKIE_SECRET: "dev-insecure-cookie-secret", CSRF_SECRET: "real", REQUIRE_SECURE_SECRETS: "true" }),
|
||||
/COOKIE_SECRET/,
|
||||
);
|
||||
});
|
||||
|
||||
test("production succeeds with real secrets and still defaults the Ory URLs", () => {
|
||||
const c = loadConfig(prodEnv);
|
||||
test("REQUIRE_SECURE_SECRETS succeeds with real secrets and still defaults the Ory URLs", () => {
|
||||
const c = loadConfig(secureEnv);
|
||||
assert.equal(c.cookieSecret, "real-cookie-secret");
|
||||
assert.equal(c.csrfSecret, "real-csrf-secret");
|
||||
assert.equal(c.kratosPublicUrl, "http://kratos:4433"); // only secrets are required in prod; URLs still default
|
||||
assert.equal(c.kratosPublicUrl, "http://kratos:4433"); // only secrets are enforced; URLs still default
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF
|
||||
// secrets, JWKS location, listen port. Fail-loud — a missing prod secret, a bad URL, or
|
||||
// an out-of-range port throws here at boot, never at request time.
|
||||
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
|
||||
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
|
||||
// request time.
|
||||
//
|
||||
// Clean-clone (README): every value has a working dev default, so `docker compose up`
|
||||
// runs with zero config; in production the secrets must be supplied (dev throwaways
|
||||
// refused), everything else still defaults to the Ory services.
|
||||
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
|
||||
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
|
||||
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
|
||||
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
|
||||
|
||||
export interface Config {
|
||||
cacheTemplates: boolean;
|
||||
cookieSecret: string;
|
||||
csrfSecret: string;
|
||||
jwksUrl: string;
|
||||
@@ -19,16 +22,25 @@ export interface Config {
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
// A secret: free to use a dev throwaway locally; in production it must be supplied and
|
||||
// must not be the throwaway (README: real secrets replace the dev ones).
|
||||
function readSecret(env: Env, key: string, devDefault: string, production: boolean): string {
|
||||
// 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 (!production) return value || devDefault;
|
||||
if (!value) throw new Error(`config: ${key} must be set in production`);
|
||||
if (value === devDefault) throw new Error(`config: ${key} must not be the dev throwaway in production`);
|
||||
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 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;
|
||||
@@ -51,10 +63,11 @@ function readPort(env: Env): number {
|
||||
}
|
||||
|
||||
export function loadConfig(env: Env = process.env): Config {
|
||||
const production = env["NODE_ENV"] === "production";
|
||||
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
|
||||
return {
|
||||
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", production),
|
||||
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", production),
|
||||
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
|
||||
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure),
|
||||
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure),
|
||||
jwksUrl: readUrl(env, "JWKS_URL", "http://kratos:4433/.well-known/jwks.json"),
|
||||
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
|
||||
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createApp } from "./app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
|
||||
const { port } = loadConfig(); // validates the env (incl. prod secrets) — fails loud at boot
|
||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||
|
||||
const server = createApp().listen(port, () => {
|
||||
console.log(`Listening on http://localhost:${port}`);
|
||||
const server = createApp({ cache: config.cacheTemplates }).listen(config.port, () => {
|
||||
console.log(`Listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
// Drain in-flight requests on container stop instead of cutting them mid-response.
|
||||
|
||||
Reference in New Issue
Block a user