From 0bc7998cfec1487a1760a5e6ddc4d47ca43d7dfc Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 14 Jun 2026 19:46:26 +0200 Subject: [PATCH] =?UTF-8?q?Add=20env/config=20loader=20(todo=20=C2=A70);?= =?UTF-8?q?=20validate=20at=20boot,=20wire=20port=20into=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++++++++++++ src/config.test.ts | 52 ++++++++++++++++++++++++++++++++++++ src/config.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 3 ++- todo.md | 2 +- 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts diff --git a/README.md b/README.md index e361fea..b37e831 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,22 @@ docker compose up # http://localhost:3000, live reload via `node --wa restarts the server on change. _(The Ory + Postgres services join this compose file as they land — planned.)_ +## Configuration + +Read from the environment once at boot (`src/config.ts`) and validated there — a bad +URL, an out-of-range `PORT`, or a missing/throwaway production secret fails loud before +the server starts. A clean clone needs **none** of these set; every value defaults to +the dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied +and may not stay at their dev throwaways — everything else still defaults. + +| Var | Default | Notes | +| --- | --- | --- | +| `PORT` | `3000` | web listen port | +| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | +| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write | +| `JWKS_URL` | Kratos tokenizer JWKS | verifies the session JWT (§4) | +| `COOKIE_SECRET` / `CSRF_SECRET` | dev throwaways | **required in production** | + ## Type check & tests ```bash @@ -339,6 +355,7 @@ src/static.ts Static file serving with path-traversal protection src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4 src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/context.ts RequestContext handed to handlers + buildContext() +src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) views/ Core EJS templates (index, 403/404/500, partials/) public/ Static assets under /public/ (css/, favicon, robots.txt) diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..477ecb5 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,52 @@ +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" }; + +test("loads dev defaults when the environment is empty", () => { + const c = loadConfig({}); + assert.equal(c.port, 3000); + assert.equal(c.kratosPublicUrl, "http://kratos:4433"); + assert.equal(c.kratosAdminUrl, "http://kratos:4434"); + assert.equal(c.ketoReadUrl, "http://keto:4466"); + assert.equal(c.ketoWriteUrl, "http://keto:4467"); + assert.match(c.jwksUrl, /jwks/); + assert.match(c.cookieSecret, /dev-insecure/); + assert.match(c.csrfSecret, /dev-insecure/); +}); + +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); + assert.equal(c.kratosPublicUrl, "https://id.example.com"); + assert.equal(c.cookieSecret, "x"); +}); + +test("rejects an invalid PORT", () => { + for (const PORT of ["0", "70000", "abc", "3000.5"]) assert.throws(() => loadConfig({ PORT }), /PORT/); +}); + +test("rejects a malformed Ory URL", () => { + assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/); +}); + +test("production requires every secret to be set", () => { + assert.throws(() => loadConfig({ NODE_ENV: "production" }), /COOKIE_SECRET/); + assert.throws(() => loadConfig({ COOKIE_SECRET: "real", NODE_ENV: "production" }), /CSRF_SECRET/); +}); + +test("production rejects a dev throwaway secret", () => { + assert.throws( + () => loadConfig({ COOKIE_SECRET: "dev-insecure-cookie-secret", CSRF_SECRET: "real", NODE_ENV: "production" }), + /COOKIE_SECRET/, + ); +}); + +test("production succeeds with real secrets and still defaults the Ory URLs", () => { + const c = loadConfig(prodEnv); + 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 +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3b45eb4 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,66 @@ +// Config loaded once from the environment at boot (todo §0): Ory endpoints, the +// cookie/CSRF secrets, the JWKS location, and the listen port. Fail-loud — a missing +// production secret, a bad URL, or an out-of-range port throws here, before the server +// starts, never at request time. +// +// Clean-clone philosophy (README): every value has a working dev default so `docker +// compose up` runs with zero config; in production only the secrets must be supplied +// (the dev throwaways are refused), everything else still defaults to the Ory services. + +export interface Config { + cookieSecret: string; + csrfSecret: string; + jwksUrl: string; + ketoReadUrl: string; + ketoWriteUrl: string; + kratosAdminUrl: string; + kratosPublicUrl: string; + port: number; +} + +type Env = Record; + +// 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 { + 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`); + 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; +} + +export function loadConfig(env: Env = process.env): Config { + const production = env["NODE_ENV"] === "production"; + return { + cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", production), + csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", production), + 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"), + kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"), + kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), + port: readPort(env), + }; +} diff --git a/src/server.ts b/src/server.ts index 7d08a08..0104ddc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import { createApp } from "./app.ts"; +import { loadConfig } from "./config.ts"; -const port = Number(process.env["PORT"] ?? 3000); +const { port } = loadConfig(); // validates the env (incl. prod secrets) — fails loud at boot createApp().listen(port, () => { console.log(`Listening on http://localhost:${port}`); diff --git a/todo.md b/todo.md index 6b6d3e1..f2e8e13 100644 --- a/todo.md +++ b/todo.md @@ -16,7 +16,7 @@ everything via Docker. - [x] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite). → `src/cookie.ts` (`parseCookies`/`serializeCookie`); stdlib-only, injection/pollution-safe. - [x] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`. → `src/context.ts` (`RequestContext` + `buildContext`); `roles` mirror `user.roles`, the §2 router/§4 JWT middleware supply `params`/`user`. - [x] Error templates: add 403 + 500 (404 exists). → `views/403.ejs` + `views/500.ejs`; 500 wired into `app.ts` error handler (HTML, plain-text fallback). -- [ ] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. +- [x] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. → `src/config.ts` (`loadConfig`); validated at boot, dev defaults for clean-clone, prod requires real secrets; wired into `server.ts`. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.