From 1fb6f23805fda95f0419a20ce1c4c16a2e2eb358 Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 15 Jun 2026 10:30:06 +0200 Subject: [PATCH] =?UTF-8?q?Tighten=20code=20comments=20+=20README=20(todo?= =?UTF-8?q?=20=C2=A70):=20denser,=20drop=20redundant=20prose;=20no=20behav?= =?UTF-8?q?ior=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 +-- README.md | 122 +++++++++++++++++++++++-------------------------- src/app.ts | 11 ++--- src/config.ts | 13 +++--- src/context.ts | 9 ++-- src/cookie.ts | 20 ++++---- src/jwt.ts | 23 ++++------ src/static.ts | 9 ++-- todo.md | 5 +- 9 files changed, 102 insertions(+), 116 deletions(-) diff --git a/Dockerfile b/Dockerfile index 569ff00..babf871 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,9 @@ -# Node 24 runs TypeScript directly (type stripping) — no build step. -# Pinned to an exact, human-readable version (node / alpine). +# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. FROM node:24.16.0-alpine3.24 WORKDIR /app -# Reproducible install from the committed lockfile. Dev deps (typescript, types) -# are kept so `npm run typecheck` / `npm test` work in the same image. +# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image. COPY package.json package-lock.json .npmrc ./ RUN npm ci diff --git a/README.md b/README.md index 8b0ae93..5e4a327 100644 --- a/README.md +++ b/README.md @@ -23,42 +23,39 @@ or migrate. ## Who this is for -**Experienced developers building back-office, admin, and dashboard products** — -for their own use or for a client. You know your way around HTTP, Docker, and an -identity provider, and you'd rather assemble pages from solid building blocks than -fight a framework or hand-roll auth for the tenth time. Plainpages hands you the -boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out -of the way of your domain logic. It does not try to be a no-code tool or hide its -moving parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions-planned)) -reads as an obvious consequence rather than a surprise, you're the audience. +**Experienced developers building back-office, admin, and dashboard products** — for +their own use or for a client. You know HTTP, Docker, and identity providers, and +you'd rather assemble pages from building blocks than fight a framework or hand-roll +auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz, +menu, design system, plugin host) and stays out of your domain logic. It's not a +no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see +[Auth](#auth-sessions--permissions-planned)) reads as obvious rather than a surprise, +you're the audience. ## Project goals -Beyond the priorities above, Plainpages deliberately targets **low-end systems, odd -hardware, and low-bandwidth environments** — a tablet on a factory floor, an old -thin client at a reception desk, a remote site on a flaky link. That's *why* the -baseline is standards-compliant, boring **HTML + CSS** with zero JavaScript: it -loads fast, degrades gracefully, and works on whatever browser the site already -has. Where a modern **CSS** feature removes the need to ship JavaScript (theme -switching, popovers, disclosure), we'll happily use it — the trade we avoid is +Plainpages deliberately targets **low-end systems, odd hardware, and low-bandwidth +environments** — a tablet on a factory floor, an old thin client at a reception desk, +a remote site on a flaky link. That's *why* the baseline is boring, standards-compliant +**HTML + CSS** with zero JavaScript: it loads fast, degrades gracefully, and works on +whatever browser is already there. Where a modern **CSS** feature removes the need for +JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. -> **Status.** This README describes the target architecture (the project's scope). -> What exists in the repo today is the **scaffold** — a Node 24 + EJS HTTP server -> with static serving — plus the **design foundation** in `html-css-foundation/` -> (a complete zero-JS app shell + auth screens). The plugin host and Ory -> integration (Kratos/Keto/Hydra + their Postgres) are the roadmap below, not yet -> implemented. Sections marked _(planned)_ are not built yet. +> **Status.** This README describes the target architecture. What exists today is the +> **scaffold** — a Node 24 + EJS HTTP server with static serving — plus the **design +> foundation** in `html-css-foundation/` (a complete zero-JS app shell + auth screens). +> The plugin host and Ory integration (Kratos/Keto/Hydra + their Postgres) are the +> roadmap below. Sections marked _(planned)_ are not built yet. ## The MVP — "clone, one command, hack on a plugin" _(planned)_ 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 -hand-edited Ory config, no separate database. That one command brings up the whole -stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, -and drops you at a login screen; from there you copy the example plugin folder and -write your own page. SSO and the OAuth2-provider role (Hydra) come after — not -required to start. +hand-edited Ory config, no separate database. That command brings up the whole stack +(web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops +you at a login screen; from there you copy the example plugin folder and write your own +page. SSO and the OAuth2-provider role (Hydra) come after — not required to start. ## 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 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. +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. | 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 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 -default for back-office and industrial use, and on the [low-end, low-bandwidth -targets](#project-goals) we care about it's usually *faster*: a full round-trip -that returns a small, already-rendered HTML page beats a client-side runtime that -must boot, fetch JSON, and re-render before the user sees anything. List state -(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable, -shareable, and reproducible — the URL is the only state the UI keeps. +switching, and filtering are pure CSS + GET forms. On the [low-end, low-bandwidth +targets](#project-goals) we care about this is usually *faster*: a round-trip returning +a small, pre-rendered HTML page beats a client-side runtime that must boot, fetch JSON, +and re-render before anything shows. List state (`?q=…&status=…&sort=…&page=…`) lives +**in the URL**, so a view is bookmarkable, shareable, and reproducible — the URL is the +only state the UI keeps. Plugins that genuinely need it — live dashboards, bulk actions, client-side 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) -The themed sign-in / register / reset / SSO screens drive Kratos self-service -flows. **SSO is entirely optional and self-configuring:** each provider's button -renders only when its credentials are present, and if no provider is configured the -SSO section disappears altogether — leaving plain password login. A developer never -has to touch SSO to get started. On success, instead of keeping the opaque Kratos -cookie and calling `whoami` on every request, the app **exchanges the session for a -signed JWT once** -via the Kratos **session tokenizer** — `whoami` with a `tokenize_as` template — and -stores that JWT as the session cookie. +The themed sign-in / register / reset / SSO screens drive Kratos self-service flows. +**SSO is optional and self-configuring:** each provider's button renders only when its +credentials are present, and the whole SSO section disappears when none are configured — +leaving plain password login. A developer never has to touch SSO to get started. On +success, rather than keeping the opaque Kratos cookie and calling `whoami` on every +request, the app **exchanges the session for a signed JWT once** via the Kratos +**session tokenizer** (`whoami` with a `tokenize_as` template) and stores it as the +session cookie. ``` ── 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 (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 -at login the app reads the user's roles from Keto and refreshes a **derived -projection** — a read-only copy of those roles written onto the identity's -`metadata_admin` so the tokenizer can see them — which the tokenizer template then -maps into the JWT `roles` claim. That projection is a per-login cache, authoritative -nowhere; nothing edits it by hand, and a stale one self-heals on the next login. +But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at +login the app reads the roles from Keto and refreshes a **derived projection**: a +read-only copy written onto the identity's `metadata_admin` for the tokenizer to see, +which the template maps into the JWT `roles` claim. That projection is a per-login +cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on +the next login. 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 @@ -280,20 +275,19 @@ moment authz is recomputed from Keto. #### Two trade-offs — both deliberate -This design buys an I/O-free hot path that scales to **tens of thousands of -concurrent users** on modest hardware. In return: +This design buys an I/O-free hot path that scales to **tens of thousands of concurrent +users** on modest hardware. In return: -- **Role changes lag by up to one TTL (~10m).** Because gating reads the JWT, not - Keto, a granted or revoked role only takes effect when the token is next minted - (re-login or TTL refresh). For an admin tool this is intentional: the alternative - is a Keto call on every request, which we explicitly traded away. If a deployment - needs instant revoke, the optional revocation denylist (roadmap) closes the gap - for the security-critical cases without putting Keto back on the hot path. -- **Ory is on the critical path for sign-in.** If Kratos is down, no one can log - in; if it stays down past the TTL, existing sessions can't refresh and the UI - goes dark. This is the direct consequence of being stateless and delegating - identity — there is no local fallback, by design. Run Ory with the same - availability you'd give any auth provider. +- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a + granted or revoked role only takes effect when the token is next minted (re-login or + TTL refresh). For an admin tool this is intentional — the alternative is a Keto call + per request, which we traded away. For instant revoke, the optional revocation + denylist (roadmap) closes the gap for security-critical cases without putting Keto + back on the hot path. +- **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if + it stays down past the TTL, existing sessions can't refresh and the UI goes dark. + That's the direct consequence of being stateless and delegating identity — no local + fallback, by design. Run Ory with the availability you'd give any auth provider. ### Three tiers of "may I?" diff --git a/src/app.ts b/src/app.ts index f055c72..7adbbae 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,8 +8,7 @@ import { serveStatic } from "./static.ts"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); export interface AppOptions { - // Cache compiled templates (compile once vs. re-read+recompile per request). - // Defaults to on in production, off in dev so source edits show up live. + // Cache compiled templates: on in production, off in dev so edits show live. cache?: boolean; publicDir?: string; viewsDir?: string; @@ -35,8 +34,8 @@ export function createApp(options: AppOptions = {}): Server { return; } - // The single request shape handlers receive (§2/§4 router passes it on); routing - // reads its parsed URL instead of building a throwaway one. + // The request shape handlers receive (§2/§4 router passes it on); routing + // reuses its parsed URL instead of building a throwaway. const { pathname } = buildContext(req, res).url; if (pathname.startsWith("/public/")) { @@ -54,8 +53,8 @@ export function createApp(options: AppOptions = {}): Server { console.error(err); if (res.headersSent) return void res.end(); // a partial body is already on the wire try { - // Render first: if the error page itself fails, headers stay unsent and we - // fall back to plain text below rather than emit a half-written response. + // Render before writing: if the 500 page itself throws, headers stay unsent + // and we fall back to plain text below instead of a half-written response. sendHtml(res, 500, await render("500", { title: "Server error" })); } catch (renderErr) { console.error(renderErr); diff --git a/src/config.ts b/src/config.ts index 3b45eb4..0e533fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,10 @@ -// 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. +// 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. // -// 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. +// 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. export interface Config { cookieSecret: string; diff --git a/src/context.ts b/src/context.ts index 9343f1e..5facbdf 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,11 +1,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -// The request context threaded to every route handler (plugin + built-in). Built -// once per request by `buildContext`: the router supplies matched path `params`, -// the §4 JWT middleware supplies the `user` (null/[] until then). Handlers read the -// request and write the response through it — the host's single handler argument. +// The request context threaded to every route handler (plugin + built-in), built once +// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT +// middleware supplies `user` (null until then). 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. export interface User { email: string; diff --git a/src/cookie.ts b/src/cookie.ts index e1331a8..2e25923 100644 --- a/src/cookie.ts +++ b/src/cookie.ts @@ -1,7 +1,7 @@ // Cookie helpers — parse the request `Cookie` header, build secure-by-default // `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 -// encodes, parse decodes); JWT `-_.` chars are URI-unreserved, so JWTs stay readable. +// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are +// URI-unreserved, so JWTs stay readable. export interface CookieOptions { domain?: string; @@ -22,7 +22,7 @@ const minExpires = Date.UTC(1601, 0, 1); const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999); 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 { return decodeURIComponent(value); } catch { @@ -30,10 +30,9 @@ function decode(value: string): string { } } -// Parse a request `Cookie` header into a name→value map. First occurrence of a -// name wins (a later duplicate can't shadow it). The result is a null-prototype -// object, so an attacker-supplied `__proto__`/`constructor` key can't pollute. -// Input length is bounded upstream by Node's HTTP `maxHeaderSize` (~16 KB default). +// Parse a `Cookie` header into a name→value map. First occurrence of a name wins. +// Null-prototype result, so a `__proto__`/`constructor` key can't pollute. Header +// length is bounded upstream by Node's `maxHeaderSize` (~16 KB). export function parseCookies(header: string | undefined): Record { const out: Record = Object.create(null); if (!header) return out; @@ -50,10 +49,9 @@ export function parseCookies(header: string | undefined): Record return out; } -// Validate a Domain/Path attribute: non-empty (an empty one emits a junk `Path=` -// browsers ignore — fail loud on a misconfig), and free of chars that could inject -// extra attributes or split the response header (CRLF). These come from config, but -// validating is cheap insurance against Set-Cookie injection. +// Validate a Domain/Path attribute: non-empty (fail loud on a misconfig) and free of +// chars that could inject extra attributes or split the header (CRLF). Cheap insurance +// against Set-Cookie injection, even though these come from config. function assertAttrSafe(label: string, value: string): void { if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`); } diff --git a/src/jwt.ts b/src/jwt.ts index 33d61fc..94e6f89 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -2,16 +2,13 @@ import { createPublicKey, verify } from "node:crypto"; import type { JsonWebKey, KeyObject } from "node:crypto"; // 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 -// Kratos tokenizer produces — all we need, no supply-chain surface (see AGENTS.md). -// -// 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. +// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures +// 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. // 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) -// entry — this map is the allowlist, and one would let an attacker-supplied HMAC key verify. -// `none` is absent for the same reason. +// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map +// is the allowlist, and a symmetric entry lets an attacker-supplied HMAC key verify. const algParams: Record = { ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" }, RS256: { hash: "RSA-SHA256", keyType: "rsa" }, @@ -29,9 +26,9 @@ export interface DecodedJws { signature: Buffer; } -// 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 -// still-unverified header, so this stops laundered bytes reaching key selection. +// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates +// bad padding), so reject non-canonical segments up front. §4 reads `kid` from the still- +// unverified header, so this stops laundered bytes reaching key selection. const base64url = /^[A-Za-z0-9_-]+$/; 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. -// Signature only — the caller validates claims. The returned header is post-verification, -// so §4 can trust its `alg`/`kid` when logging. +// Signature only — caller validates claims. Returned header is post-verification, so §4 +// can trust its `alg`/`kid` when logging. export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws { const decoded = decodeJws(token); const { header, signingInput, signature } = decoded; diff --git a/src/static.ts b/src/static.ts index d215c14..c0fefc6 100644 --- a/src/static.ts +++ b/src/static.ts @@ -22,9 +22,8 @@ export function contentTypeFor(filePath: string): string { return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"; } -// Resolves a request path inside `dir`, or null if it would escape (traversal) or -// carries a control char (NUL etc.) — rejecting those here makes the guard explicit -// rather than relying on a downstream `stat` to throw. +// Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a +// control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw. export function resolveStaticPath(dir: string, requestedPath: string): string | null { if (/[\x00-\x1f]/.test(requestedPath)) return null; 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"); res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) }); 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 — - // log it and destroy the response to signal a truncated body, not a hung socket. + // Headers are already sent, so a mid-stream read error can't become an HTTP status — + // log and destroy the response to signal a truncated body, not a hung socket. createReadStream(filePath) .on("error", (err) => { console.error(err); diff --git a/todo.md b/todo.md index 510d74f..afbdc53 100644 --- a/todo.md +++ b/todo.md @@ -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] 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). -- [ ] 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. +### 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) - [ ] 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.