Drop NODE_ENV for explicit config toggles (todo §0.1); app is environment-agnostic
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
19
README.md
19
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.
|
**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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
2
todo.md
2
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.
|
- [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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user