Add env/config loader (todo §0); validate at boot, wire port into server
This commit is contained in:
17
README.md
17
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
|
restarts the server on change. _(The Ory + Postgres services join this compose
|
||||||
file as they land — planned.)_
|
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
|
## Type check & tests
|
||||||
|
|
||||||
```bash
|
```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/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/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
||||||
src/context.ts RequestContext handed to handlers + buildContext()
|
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)
|
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
|
||||||
views/ Core EJS templates (index, 403/404/500, partials/)
|
views/ Core EJS templates (index, 403/404/500, partials/)
|
||||||
public/ Static assets under /public/ (css/, favicon, robots.txt)
|
public/ Static assets under /public/ (css/, favicon, robots.txt)
|
||||||
|
|||||||
52
src/config.test.ts
Normal file
52
src/config.test.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
66
src/config.ts
Normal file
66
src/config.ts
Normal file
@@ -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<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 {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from "./app.ts";
|
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, () => {
|
createApp().listen(port, () => {
|
||||||
console.log(`Listening on http://localhost:${port}`);
|
console.log(`Listening on http://localhost:${port}`);
|
||||||
|
|||||||
2
todo.md
2
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] 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] 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).
|
- [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.
|
- [ ] 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 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.
|
- [ ] 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user