Tighten code comments + README (todo §0): denser, drop redundant prose; no behavior change

This commit is contained in:
2026-06-15 10:30:06 +02:00
parent 17f4411518
commit 1fb6f23805
9 changed files with 102 additions and 116 deletions

View File

@@ -1,11 +1,9 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
# Pinned to an exact, human-readable version (node / alpine).
FROM node:24.16.0-alpine3.24 FROM node:24.16.0-alpine3.24
WORKDIR /app WORKDIR /app
# Reproducible install from the committed lockfile. Dev deps (typescript, types) # Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
# are kept so `npm run typecheck` / `npm test` work in the same image.
COPY package.json package-lock.json .npmrc ./ COPY package.json package-lock.json .npmrc ./
RUN npm ci RUN npm ci

122
README.md
View File

@@ -23,42 +23,39 @@ or migrate.
## Who this is for ## Who this is for
**Experienced developers building back-office, admin, and dashboard products** **Experienced developers building back-office, admin, and dashboard products** for
for their own use or for a client. You know your way around HTTP, Docker, and an their own use or for a client. You know HTTP, Docker, and identity providers, and
identity provider, and you'd rather assemble pages from solid building blocks than you'd rather assemble pages from building blocks than fight a framework or hand-roll
fight a framework or hand-roll auth for the tenth time. Plainpages hands you the auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz,
boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out menu, design system, plugin host) and stays out of your domain logic. It's not a
of the way of your domain logic. It does not try to be a no-code tool or hide its no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see
moving parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions-planned)) [Auth](#auth-sessions--permissions-planned)) reads as obvious rather than a surprise,
reads as an obvious consequence rather than a surprise, you're the audience. you're the audience.
## Project goals ## Project goals
Beyond the priorities above, Plainpages deliberately targets **low-end systems, odd Plainpages deliberately targets **low-end systems, odd hardware, and low-bandwidth
hardware, and low-bandwidth environments** — a tablet on a factory floor, an old environments** — a tablet on a factory floor, an old thin client at a reception desk,
thin client at a reception desk, a remote site on a flaky link. That's *why* the a remote site on a flaky link. That's *why* the baseline is boring, standards-compliant
baseline is standards-compliant, boring **HTML + CSS** with zero JavaScript: it **HTML + CSS** with zero JavaScript: it loads fast, degrades gracefully, and works on
loads fast, degrades gracefully, and works on whatever browser the site already whatever browser is already there. Where a modern **CSS** feature removes the need for
has. Where a modern **CSS** feature removes the need to ship JavaScript (theme JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is
switching, popovers, disclosure), we'll happily use it — the trade we avoid is
shipping a client-side runtime, not using the platform. shipping a client-side runtime, not using the platform.
> **Status.** This README describes the target architecture (the project's scope). > **Status.** This README describes the target architecture. What exists today is the
> What exists in the repo today is the **scaffold** — a Node 24 + EJS HTTP server > **scaffold** — a Node 24 + EJS HTTP server with static serving — plus the **design
> with static serving — plus the **design foundation** in `html-css-foundation/` > foundation** in `html-css-foundation/` (a complete zero-JS app shell + auth screens).
> (a complete zero-JS app shell + auth screens). The plugin host and Ory > The plugin host and Ory integration (Kratos/Keto/Hydra + their Postgres) are the
> integration (Kratos/Keto/Hydra + their Postgres) are the roadmap below, not yet > roadmap below. Sections marked _(planned)_ are not built yet.
> implemented. Sections marked _(planned)_ are not built yet.
## The MVP — "clone, one command, hack on a plugin" _(planned)_ ## The MVP — "clone, one command, hack on a plugin" _(planned)_
The bar for a first usable release: **clone, run one command, get a working The bar for a first usable release: **clone, run one command, get a working
register/login, and start building your own plugin** — no manual key generation, no register/login, and start building your own plugin** — no manual key generation, no
hand-edited Ory config, no separate database. That one command brings up the whole hand-edited Ory config, no separate database. That command brings up the whole stack
stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops
and drops you at a login screen; from there you copy the example plugin folder and you at a login screen; from there you copy the example plugin folder and write your own
write your own page. SSO and the OAuth2-provider role (Hydra) come after — not page. SSO and the OAuth2-provider role (Hydra) come after — not required to start.
required to start.
## Architecture ## Architecture
@@ -117,9 +114,9 @@ file as they land — planned.)_
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`, 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 server starts. A clean clone needs **none** of these; every value defaults to the
the dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied and
and may not stay at their dev throwaways — everything else still defaults. must differ from their dev throwaways — everything else still defaults.
| Var | Default | Notes | | Var | Default | Notes |
| --- | --- | --- | | --- | --- | --- |
@@ -219,13 +216,12 @@ the work is extracting it into reusable EJS partials + TS helpers:
## Interactivity: zero-JS spine, opt-in enhancement ## Interactivity: zero-JS spine, opt-in enhancement
The core and all building blocks **work with zero JavaScript** — menus, theme The core and all building blocks **work with zero JavaScript** — menus, theme
switching, and filtering are pure CSS + GET forms (server-side). This is the robust switching, and filtering are pure CSS + GET forms. On the [low-end, low-bandwidth
default for back-office and industrial use, and on the [low-end, low-bandwidth targets](#project-goals) we care about this is usually *faster*: a round-trip returning
targets](#project-goals) we care about it's usually *faster*: a full round-trip a small, pre-rendered HTML page beats a client-side runtime that must boot, fetch JSON,
that returns a small, already-rendered HTML page beats a client-side runtime that and re-render before anything shows. List state (`?q=…&status=…&sort=…&page=…`) lives
must boot, fetch JSON, and re-render before the user sees anything. List state **in the URL**, so a view is bookmarkable, shareable, and reproducible — the URL is the
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable, only state the UI keeps.
shareable, and reproducible — the URL is the only state the UI keeps.
Plugins that genuinely need it — live dashboards, bulk actions, client-side Plugins that genuinely need it — live dashboards, bulk actions, client-side
validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla
@@ -239,15 +235,14 @@ fine-grained, must-be-fresh check.
### Login → session JWT (the Kratos session tokenizer) ### Login → session JWT (the Kratos session tokenizer)
The themed sign-in / register / reset / SSO screens drive Kratos self-service The themed sign-in / register / reset / SSO screens drive Kratos self-service flows.
flows. **SSO is entirely optional and self-configuring:** each provider's button **SSO is optional and self-configuring:** each provider's button renders only when its
renders only when its credentials are present, and if no provider is configured the credentials are present, and the whole SSO section disappears when none are configured
SSO section disappears altogether — leaving plain password login. A developer never leaving plain password login. A developer never has to touch SSO to get started. On
has to touch SSO to get started. On success, instead of keeping the opaque Kratos success, rather than keeping the opaque Kratos cookie and calling `whoami` on every
cookie and calling `whoami` on every request, the app **exchanges the session for a request, the app **exchanges the session for a signed JWT once** via the Kratos
signed JWT once** **session tokenizer** (`whoami` with a `tokenize_as` template) and stores it as the
via the Kratos **session tokenizer**`whoami` with a `tokenize_as` template — and session cookie.
stores that JWT as the session cookie.
``` ```
── AT LOGIN / REFRESH (the only time Ory is on the path) ────────── ── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
@@ -266,12 +261,12 @@ stores that JWT as the session cookie.
**Keto is the single source of truth for roles.** Coarse roles are Keto relations **Keto is the single source of truth for roles.** Coarse roles are Keto relations
(e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto. (e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto.
But the tokenizer's claims mapper can only read the **identity**, not call Keto — so But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at
at login the app reads the user's roles from Keto and refreshes a **derived login the app reads the roles from Keto and refreshes a **derived projection**: a
projection** — a read-only copy of those roles written onto the identity's read-only copy written onto the identity's `metadata_admin` for the tokenizer to see,
`metadata_admin` so the tokenizer can see them — which the tokenizer template then which the template maps into the JWT `roles` claim. That projection is a per-login
maps into the JWT `roles` claim. That projection is a per-login cache, authoritative cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on
nowhere; nothing edits it by hand, and a stale one self-heals on the next login. the next login.
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
is cached, so even signature verification hits the network only on key rotation. The is cached, so even signature verification hits the network only on key rotation. The
@@ -280,20 +275,19 @@ moment authz is recomputed from Keto.
#### Two trade-offs — both deliberate #### Two trade-offs — both deliberate
This design buys an I/O-free hot path that scales to **tens of thousands of This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
concurrent users** on modest hardware. In return: users** on modest hardware. In return:
- **Role changes lag by up to one TTL (~10m).** Because gating reads the JWT, not - **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a
Keto, a granted or revoked role only takes effect when the token is next minted granted or revoked role only takes effect when the token is next minted (re-login or
(re-login or TTL refresh). For an admin tool this is intentional: the alternative TTL refresh). For an admin tool this is intentional the alternative is a Keto call
is a Keto call on every request, which we explicitly traded away. If a deployment per request, which we traded away. For instant revoke, the optional revocation
needs instant revoke, the optional revocation denylist (roadmap) closes the gap denylist (roadmap) closes the gap for security-critical cases without putting Keto
for the security-critical cases without putting Keto back on the hot path. back on the hot path.
- **Ory is on the critical path for sign-in.** If Kratos is down, no one can log - **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if
in; if it stays down past the TTL, existing sessions can't refresh and the UI it stays down past the TTL, existing sessions can't refresh and the UI goes dark.
goes dark. This is the direct consequence of being stateless and delegating That's the direct consequence of being stateless and delegating identity — no local
identity — there is no local fallback, by design. Run Ory with the same fallback, by design. Run Ory with the availability you'd give any auth provider.
availability you'd give any auth provider.
### Three tiers of "may I?" ### Three tiers of "may I?"

View File

@@ -8,8 +8,7 @@ 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 (compile once vs. re-read+recompile per request). // Cache compiled templates: on in production, off in dev so edits show live.
// Defaults to on in production, off in dev so source edits show up live.
cache?: boolean; cache?: boolean;
publicDir?: string; publicDir?: string;
viewsDir?: string; viewsDir?: string;
@@ -35,8 +34,8 @@ export function createApp(options: AppOptions = {}): Server {
return; return;
} }
// The single request shape handlers receive (§2/§4 router passes it on); routing // The request shape handlers receive (§2/§4 router passes it on); routing
// reads its parsed URL instead of building a throwaway one. // reuses its parsed URL instead of building a throwaway.
const { pathname } = buildContext(req, res).url; const { pathname } = buildContext(req, res).url;
if (pathname.startsWith("/public/")) { if (pathname.startsWith("/public/")) {
@@ -54,8 +53,8 @@ export function createApp(options: AppOptions = {}): Server {
console.error(err); console.error(err);
if (res.headersSent) return void res.end(); // a partial body is already on the wire if (res.headersSent) return void res.end(); // a partial body is already on the wire
try { try {
// Render first: if the error page itself fails, headers stay unsent and we // Render before writing: if the 500 page itself throws, headers stay unsent
// fall back to plain text below rather than emit a half-written response. // and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await render("500", { title: "Server error" })); sendHtml(res, 500, await render("500", { title: "Server error" }));
} catch (renderErr) { } catch (renderErr) {
console.error(renderErr); console.error(renderErr);

View File

@@ -1,11 +1,10 @@
// Config loaded once from the environment at boot (todo §0): Ory endpoints, the // Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF
// cookie/CSRF secrets, the JWKS location, and the listen port. Fail-loud — a missing // secrets, JWKS location, listen port. Fail-loud — a missing prod secret, a bad URL, or
// production secret, a bad URL, or an out-of-range port throws here, before the server // an out-of-range port throws here at boot, never at request time.
// starts, never at request time.
// //
// Clean-clone philosophy (README): every value has a working dev default so `docker // Clean-clone (README): every value has a working dev default, so `docker compose up`
// compose up` runs with zero config; in production only the secrets must be supplied // runs with zero config; in production the secrets must be supplied (dev throwaways
// (the dev throwaways are refused), everything else still defaults to the Ory services. // refused), everything else still defaults to the Ory services.
export interface Config { export interface Config {
cookieSecret: string; cookieSecret: string;

View File

@@ -1,11 +1,10 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
// The request context threaded to every route handler (plugin + built-in). Built // The request context threaded to every route handler (plugin + built-in), built once
// once per request by `buildContext`: the router supplies matched path `params`, // per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
// the §4 JWT middleware supplies the `user` (null/[] until then). Handlers read the // middleware supplies `user` (null until then). The host's single handler argument.
// request and write the response through it — the host's single handler argument.
// The authenticated user, projected from the verified session JWT claims (§4): // The authenticated user, projected from verified session JWT claims (§4):
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token. // `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User { export interface User {
email: string; email: string;

View File

@@ -1,7 +1,7 @@
// Cookie helpers — parse the request `Cookie` header, build secure-by-default // Cookie helpers — parse the request `Cookie` header, build secure-by-default
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session // `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session
// JWT + CSRF token with these. Values round-trip via percent-encoding (serialize // JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
// encodes, parse decodes); JWT `-_.` chars are URI-unreserved, so JWTs stay readable. // URI-unreserved, so JWTs stay readable.
export interface CookieOptions { export interface CookieOptions {
domain?: string; domain?: string;
@@ -22,7 +22,7 @@ const minExpires = Date.UTC(1601, 0, 1);
const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999); const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
function decode(value: string): string { function decode(value: string): string {
if (!value.includes("%")) return value; // optimization only: an unencoded value has no escapes to decode if (!value.includes("%")) return value; // fast path: nothing to decode
try { try {
return decodeURIComponent(value); return decodeURIComponent(value);
} catch { } catch {
@@ -30,10 +30,9 @@ function decode(value: string): string {
} }
} }
// Parse a request `Cookie` header into a name→value map. First occurrence of a // Parse a `Cookie` header into a name→value map. First occurrence of a name wins.
// name wins (a later duplicate can't shadow it). The result is a null-prototype // Null-prototype result, so a `__proto__`/`constructor` key can't pollute. Header
// object, so an attacker-supplied `__proto__`/`constructor` key can't pollute. // length is bounded upstream by Node's `maxHeaderSize` (~16 KB).
// Input length is bounded upstream by Node's HTTP `maxHeaderSize` (~16 KB default).
export function parseCookies(header: string | undefined): Record<string, string> { export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = Object.create(null); const out: Record<string, string> = Object.create(null);
if (!header) return out; if (!header) return out;
@@ -50,10 +49,9 @@ export function parseCookies(header: string | undefined): Record<string, string>
return out; return out;
} }
// Validate a Domain/Path attribute: non-empty (an empty one emits a junk `Path=` // Validate a Domain/Path attribute: non-empty (fail loud on a misconfig) and free of
// browsers ignore — fail loud on a misconfig), and free of chars that could inject // chars that could inject extra attributes or split the header (CRLF). Cheap insurance
// extra attributes or split the response header (CRLF). These come from config, but // against Set-Cookie injection, even though these come from config.
// validating is cheap insurance against Set-Cookie injection.
function assertAttrSafe(label: string, value: string): void { function assertAttrSafe(label: string, value: string): void {
if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`); if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`);
} }

View File

@@ -2,16 +2,13 @@ import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto"; import type { JsonWebKey, KeyObject } from "node:crypto";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0): // JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0):
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures the // `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// Kratos tokenizer produces — all we need, no supply-chain surface (see AGENTS.md). // the Kratos tokenizer produces (see AGENTS.md). Signature only — §4 adds claim checks
// // (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
// Signature only. §4 builds the rest on top: claim checks (exp/iss/aud, clock skew),
// JWKS-by-`kid` fetch/cache/rotation, and bounding `token` type/length at the boundary.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER. // JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
// Widen support by extending this map. Security invariant: never add an `HS*` (symmetric) // Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
// entry — this map is the allowlist, and one would let an attacker-supplied HMAC key verify. // is the allowlist, and a symmetric entry lets an attacker-supplied HMAC key verify.
// `none` is absent for the same reason.
const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = { const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = {
ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" }, ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" },
RS256: { hash: "RSA-SHA256", keyType: "rsa" }, RS256: { hash: "RSA-SHA256", keyType: "rsa" },
@@ -29,9 +26,9 @@ export interface DecodedJws {
signature: Buffer; signature: Buffer;
} }
// Unpadded base64url alphabet — `Buffer.from(_, "base64url")` is lax (drops junk, tolerates // Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
// non-canonical padding), so reject non-canonical segments up front. §4 reads `kid` from the // bad padding), so reject non-canonical segments up front. §4 reads `kid` from the still-
// still-unverified header, so this stops laundered bytes reaching key selection. // unverified header, so this stops laundered bytes reaching key selection.
const base64url = /^[A-Za-z0-9_-]+$/; const base64url = /^[A-Za-z0-9_-]+$/;
function decodeSegment(segment: string): unknown { function decodeSegment(segment: string): unknown {
@@ -68,8 +65,8 @@ export function decodeJws(token: string): DecodedJws {
} }
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws. // Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
// Signature only — the caller validates claims. The returned header is post-verification, // Signature only — caller validates claims. Returned header is post-verification, so §4
// so §4 can trust its `alg`/`kid` when logging. // can trust its `alg`/`kid` when logging.
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws { export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
const decoded = decodeJws(token); const decoded = decodeJws(token);
const { header, signingInput, signature } = decoded; const { header, signingInput, signature } = decoded;

View File

@@ -22,9 +22,8 @@ export function contentTypeFor(filePath: string): string {
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"; return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
} }
// Resolves a request path inside `dir`, or null if it would escape (traversal) or // Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a
// carries a control char (NUL etc.) — rejecting those here makes the guard explicit // control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw.
// rather than relying on a downstream `stat` to throw.
export function resolveStaticPath(dir: string, requestedPath: string): string | null { export function resolveStaticPath(dir: string, requestedPath: string): string | null {
if (/[\x00-\x1f]/.test(requestedPath)) return null; if (/[\x00-\x1f]/.test(requestedPath)) return null;
const filePath = join(dir, requestedPath); const filePath = join(dir, requestedPath);
@@ -52,8 +51,8 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
if (!info.isFile()) return plain(res, 404, "Not Found"); if (!info.isFile()) return plain(res, 404, "Not Found");
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) }); res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
if (head) return void res.end(); // headers only — skip opening the file if (head) return void res.end(); // headers only — skip opening the file
// Headers are already sent, so a mid-stream read error can't become an HTTP error // Headers are already sent, so a mid-stream read error can't become an HTTP status
// log it and destroy the response to signal a truncated body, not a hung socket. // log and destroy the response to signal a truncated body, not a hung socket.
createReadStream(filePath) createReadStream(filePath)
.on("error", (err) => { .on("error", (err) => {
console.error(err); console.error(err);

View File

@@ -18,9 +18,12 @@ everything via Docker.
- [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).
- [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`. - [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`.
- [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4). - [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4).
- [ ] 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. - [x] 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. → Tightened comments across `src/*.ts`, Dockerfile, and trimmed verbose/duplicated prose in README; tests + typecheck green.
- [ ] 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.
### 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".
## 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`.
- [ ] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used. - [ ] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used.