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.
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
### 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`.
|
||||
|
||||
Reference in New Issue
Block a user