diff --git a/AGENTS.md b/AGENTS.md index 9cc8668..9313984 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ commands and layout. core code. See `README.md` for the architecture. 3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. +4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is + no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config + toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"), + read once in `src/config.ts`. Compose files set the toggles per deployment. ## Docker only — no host tooling diff --git a/README.md b/README.md index 5e4a327..3894e21 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ folders**. The only screens it ships itself are the ones for running the system: **users, groups, and permissions**. Everything else is a plugin. Priorities (unchanged from day one): **simplicity, few dependencies, strict -TypeScript, no build step, Docker-only.** Heavy lifting that *isn't* simple to do +TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — +every behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well — identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar services rather than reinvented. @@ -113,18 +114,24 @@ 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; every value defaults to the -dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied and -must differ from their dev throwaways — everything else still defaults. +URL, an out-of-range `PORT`, a non-boolean toggle, or a missing/throwaway enforced secret +fails loud before the server starts. A clean clone needs **none** of these; every value +defaults to the dev stack. + +The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used to flip +on "production" is now its own explicit toggle, so a deployment turns on exactly what it +wants. `compose.yml` (base) sets the hardened toggles; `compose.override.yml` (dev, +auto-merged by `docker compose up`) turns them back off for live editing. | Var | Default | Notes | | --- | --- | --- | | `PORT` | `3000` | web listen port | +| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) | +| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, the two secrets must be supplied and differ from the dev throwaways | | `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** | +| `COOKIE_SECRET` / `CSRF_SECRET` | dev throwaways | enforced by `REQUIRE_SECURE_SECRETS` | ## Type check & tests diff --git a/compose.override.yml b/compose.override.yml index 8bc9788..5234774 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -3,8 +3,10 @@ services: web: command: node --watch src/server.ts + # Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed. environment: - NODE_ENV: development + CACHE_TEMPLATES: "false" + REQUIRE_SECURE_SECRETS: "false" volumes: - .:/app - /app/node_modules diff --git a/compose.yml b/compose.yml index 04d415a..012a87c 100644 --- a/compose.yml +++ b/compose.yml @@ -5,6 +5,9 @@ services: build: . ports: - "3000:3000" + # Explicit behaviour toggles (the app is environment-agnostic — see AGENTS.md). + # Supply COOKIE_SECRET / CSRF_SECRET via env; REQUIRE_SECURE_SECRETS refuses dev throwaways. environment: - NODE_ENV: production + CACHE_TEMPLATES: "true" + REQUIRE_SECURE_SECRETS: "true" restart: unless-stopped diff --git a/src/app.ts b/src/app.ts index 7adbbae..55f004d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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"); diff --git a/src/config.test.ts b/src/config.test.ts index 9b4c726..0e785d4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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 }); diff --git a/src/config.ts b/src/config.ts index 0e533fa..0034b1d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; -// 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"), diff --git a/src/server.ts b/src/server.ts index 0d89288..74f1870 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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. diff --git a/todo.md b/todo.md index 9507891..857997c 100644 --- a/todo.md +++ b/todo.md @@ -22,7 +22,7 @@ everything via Docker. - [x] 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. → Merged related cases across jwt/cookie/app/context/config tests (59 → 42), every assertion preserved; typecheck + tests green. ### 0.1 Extra input from human -- [ ] Remove all usage of NODE_ENV - add a new core principle to the project that the app should at all times be unaware of what environment it is running in. Configuration should be explicit, like "disable email" or "cache templates". +- [x] Remove all usage of NODE_ENV - add a new core principle to the project that the app should at all times be unaware of what environment it is running in. Configuration should be explicit, like "disable email" or "cache templates". → Dropped NODE_ENV everywhere; added **environment-agnostic** principle (AGENTS.md §4 + README). Behaviour is now explicit toggles: `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS` (parsed/validated in `config.ts`, wired via `server.ts`); compose files set them per deployment. `app.ts` no longer reads `process.env`. ## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data) - [ ] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`.