Drop NODE_ENV for explicit config toggles (todo §0.1); app is environment-agnostic

This commit is contained in:
2026-06-15 10:53:33 +02:00
parent 2d43430405
commit a070362649
9 changed files with 79 additions and 37 deletions

View File

@@ -16,6 +16,10 @@ commands and layout.
core code. See `README.md` for the architecture. core code. See `README.md` for the architecture.
3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, 3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. `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 ## Docker only — no host tooling

View File

@@ -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. **users, groups, and permissions**. Everything else is a plugin.
Priorities (unchanged from day one): **simplicity, few dependencies, strict 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** well — identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory**
sidecar services rather than reinvented. sidecar services rather than reinvented.
@@ -113,18 +114,24 @@ file as they land — planned.)_
## Configuration ## Configuration
Read from the environment once at boot (`src/config.ts`) and validated there — a bad 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 URL, an out-of-range `PORT`, a non-boolean toggle, or a missing/throwaway enforced secret
the server starts. A clean clone needs **none** of these; every value defaults to the fails loud before the server starts. A clean clone needs **none** of these; every value
dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied and defaults to the dev stack.
must differ from their dev throwaways — everything else still defaults.
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 | | Var | Default | Notes |
| --- | --- | --- | | --- | --- | --- |
| `PORT` | `3000` | web listen port | | `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) | | `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 | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
| `JWKS_URL` | Kratos tokenizer JWKS | verifies the session JWT (§4) | | `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 ## Type check & tests

View File

@@ -3,8 +3,10 @@
services: services:
web: web:
command: node --watch src/server.ts command: node --watch src/server.ts
# Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed.
environment: environment:
NODE_ENV: development CACHE_TEMPLATES: "false"
REQUIRE_SECURE_SECRETS: "false"
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules

View File

@@ -5,6 +5,9 @@ services:
build: . build: .
ports: ports:
- "3000:3000" - "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: environment:
NODE_ENV: production CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "true"
restart: unless-stopped restart: unless-stopped

View File

@@ -8,14 +8,15 @@ import { serveStatic } from "./static.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
export interface AppOptions { 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; cache?: boolean;
publicDir?: string; publicDir?: string;
viewsDir?: string; viewsDir?: string;
} }
export function createApp(options: AppOptions = {}): Server { 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 publicDir = options.publicDir ?? join(rootDir, "public");
const viewsDir = options.viewsDir ?? join(rootDir, "views"); const viewsDir = options.viewsDir ?? join(rootDir, "views");

View File

@@ -2,12 +2,18 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
// Minimal valid production env: the secrets are the only thing prod must supply. // Explicit secure-secret enforcement (no environment sniffing): secrets are the only
const prodEnv = { COOKIE_SECRET: "real-cookie-secret", CSRF_SECRET: "real-csrf-secret", NODE_ENV: "production" }; // 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", () => { 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);
assert.equal(c.cacheTemplates, false);
assert.equal(c.kratosPublicUrl, "http://kratos:4433"); assert.equal(c.kratosPublicUrl, "http://kratos:4433");
assert.equal(c.kratosAdminUrl, "http://kratos:4434"); assert.equal(c.kratosAdminUrl, "http://kratos:4434");
assert.equal(c.ketoReadUrl, "http://keto:4466"); 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/); 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", () => { test("reads overrides from the environment", () => {
const c = loadConfig({ COOKIE_SECRET: "x", KRATOS_PUBLIC_URL: "https://id.example.com", PORT: "8080" }); const c = loadConfig({ COOKIE_SECRET: "x", KRATOS_PUBLIC_URL: "https://id.example.com", PORT: "8080" });
assert.equal(c.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/); assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/);
}); });
test("production rejects a missing or dev-throwaway secret", () => { test("REQUIRE_SECURE_SECRETS rejects a missing or dev-throwaway secret", () => {
assert.throws(() => loadConfig({ NODE_ENV: "production" }), /COOKIE_SECRET/); assert.throws(() => loadConfig({ REQUIRE_SECURE_SECRETS: "true" }), /COOKIE_SECRET/);
assert.throws(() => loadConfig({ COOKIE_SECRET: "real", NODE_ENV: "production" }), /CSRF_SECRET/); assert.throws(() => loadConfig({ COOKIE_SECRET: "real", REQUIRE_SECURE_SECRETS: "true" }), /CSRF_SECRET/);
assert.throws( 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/, /COOKIE_SECRET/,
); );
}); });
test("production succeeds with real secrets and still defaults the Ory URLs", () => { test("REQUIRE_SECURE_SECRETS succeeds with real secrets and still defaults the Ory URLs", () => {
const c = loadConfig(prodEnv); const c = loadConfig(secureEnv);
assert.equal(c.cookieSecret, "real-cookie-secret"); assert.equal(c.cookieSecret, "real-cookie-secret");
assert.equal(c.csrfSecret, "real-csrf-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
}); });

View File

@@ -1,12 +1,15 @@
// Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF // 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 // secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
// an out-of-range port throws here at boot, never at request time. // 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` // Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
// runs with zero config; in production the secrets must be supplied (dev throwaways // behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
// refused), everything else still defaults to the Ory services. // `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 { export interface Config {
cacheTemplates: boolean;
cookieSecret: string; cookieSecret: string;
csrfSecret: string; csrfSecret: string;
jwksUrl: string; jwksUrl: string;
@@ -19,16 +22,25 @@ export interface Config {
type Env = Record<string, string | undefined>; type Env = Record<string, string | undefined>;
// A secret: free to use a dev throwaway locally; in production it must be supplied and // A secret: free to use a dev throwaway by default; when REQUIRE_SECURE_SECRETS is on it
// must not be the throwaway (README: real secrets replace the dev ones). // must be supplied and must not be the throwaway (README: real secrets replace dev ones).
function readSecret(env: Env, key: string, devDefault: string, production: boolean): string { function readSecret(env: Env, key: string, devDefault: string, requireSecure: boolean): string {
const value = env[key]; const value = env[key];
if (!production) return value || devDefault; if (!requireSecure) return value || devDefault;
if (!value) throw new Error(`config: ${key} must be set in production`); 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 in production`); if (value === devDefault) throw new Error(`config: ${key} must not be the dev throwaway when REQUIRE_SECURE_SECRETS=true`);
return value; 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. // An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
function readUrl(env: Env, key: string, devDefault: string): string { function readUrl(env: Env, key: string, devDefault: string): string {
const value = env[key] ?? devDefault; const value = env[key] ?? devDefault;
@@ -51,10 +63,11 @@ function readPort(env: Env): number {
} }
export function loadConfig(env: Env = process.env): Config { export function loadConfig(env: Env = process.env): Config {
const production = env["NODE_ENV"] === "production"; const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return { return {
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", production), cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", production), 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"), jwksUrl: readUrl(env, "JWKS_URL", "http://kratos:4433/.well-known/jwks.json"),
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),

View File

@@ -1,10 +1,10 @@
import { createApp } from "./app.ts"; import { createApp } from "./app.ts";
import { loadConfig } from "./config.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, () => { const server = createApp({ cache: config.cacheTemplates }).listen(config.port, () => {
console.log(`Listening on http://localhost:${port}`); console.log(`Listening on http://localhost:${config.port}`);
}); });
// Drain in-flight requests on container stop instead of cutting them mid-response. // Drain in-flight requests on container stop instead of cutting them mid-response.

View File

@@ -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. - [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 ### 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) ## 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`. - [ ] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`.