Add RequestContext primitive (todo §0); harden static serving (HEAD, control-char, stream-error logging)

This commit is contained in:
2026-06-14 19:33:17 +02:00
parent b4c149db27
commit c544387d3a
12 changed files with 158 additions and 34 deletions

View File

@@ -26,7 +26,7 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
docker compose up # dev server, live reload
docker compose run --rm web npm run typecheck # strict type check
docker compose run --rm web npm test # tests
docker compose -f docker-compose.yml up --build -d # production
docker compose -f compose.yml up --build -d # production
```
## Rules

View File

@@ -52,14 +52,13 @@ shipping a client-side runtime, not using the platform.
## The MVP — "clone, one command, hack on a plugin" _(planned)_
The bar for a first usable release: **clone the repo, run one command, and you have
a working register/login and can start building your own plugin** — no manual key
generation, no hand-edited Ory config, no separate database setup. One command
brings up the whole stack (web + Ory + Postgres), generates signing keys and seeds
an admin on first boot, and drops you at a login screen. From there you copy the
example plugin folder and you're writing your own page. That moment — clone → one
command → login → your plugin renders — *is* the MVP. SSO and the OAuth2-provider
role (Hydra) come after; they aren't required to start.
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.
## Architecture
@@ -338,6 +337,8 @@ src/server.ts Entry point — starts the HTTP server (reads PORT, default
src/app.ts Request routing + EJS rendering
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/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/context.ts RequestContext handed to handlers + buildContext()
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
views/ Core EJS templates (index, 404, partials/)
public/ Static assets under /public/ (css/, favicon, robots.txt)

View File

@@ -1,5 +1,5 @@
# Base / production config. Run alone with: docker compose -f docker-compose.yml up
# Plain `docker compose up` also merges docker-compose.override.yml for development.
# Base / production config. Run alone with: docker compose -f compose.yml up
# Plain `docker compose up` also merges compose.override.yml for development.
services:
web:
build: .

View File

@@ -32,8 +32,26 @@ test("returns 404 for unknown routes", async () => {
assert.equal(res.status, 404);
});
test("resolveStaticPath blocks traversal, allows nested files", () => {
test("blocks encoded path traversal out of /public/ with 403", async () => {
const res = await fetch(base + "/public/..%2f..%2fapp.ts");
assert.equal(res.status, 403);
});
test("rejects a control char (NUL) in a static path with 403", async () => {
const res = await fetch(base + "/public/%00");
assert.equal(res.status, 403);
});
test("HEAD on a static file sends headers but no body", async () => {
const res = await fetch(base + "/public/css/style.css", { method: "HEAD" });
assert.equal(res.status, 200);
assert.ok(Number(res.headers.get("content-length")) > 0);
assert.equal((await res.text()).length, 0);
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
assert.equal(resolveStaticPath("/srv/public", "css/style.css"), "/srv/public/css/style.css");
});

View File

@@ -28,7 +28,7 @@ export function createApp(options: AppOptions = {}): Server {
const { pathname } = new URL(req.url ?? "/", "http://localhost");
if (pathname.startsWith("/public/")) {
await serveStatic(publicDir, pathname.slice("/public/".length), res);
await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD");
return;
}

51
src/context.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type User } from "./context.ts";
// A req/res pair without a live server — enough to build and inspect a context.
function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } {
const req = new IncomingMessage(new Socket());
if (url !== undefined) req.url = url;
req.method = "GET";
return { req, res: new ServerResponse(req) };
}
test("buildContext parses the URL and defaults to an anonymous user", () => {
const { req, res } = reqRes("/users?q=ann");
const ctx = buildContext(req, res);
assert.equal(ctx.req, req);
assert.equal(ctx.res, res);
assert.equal(ctx.url.pathname, "/users");
assert.equal(ctx.user, null);
assert.deepEqual(ctx.roles, []);
assert.deepEqual(ctx.params, {});
});
test("buildContext exposes query as the URL's search params", () => {
const { req, res } = reqRes("/users?q=ann&page=2");
const ctx = buildContext(req, res);
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2");
});
test("buildContext threads path params supplied by the router", () => {
const { req, res } = reqRes("/users/42");
const ctx = buildContext(req, res, { params: { id: "42" } });
assert.equal(ctx.params.id, "42");
});
test("buildContext threads the user and derives roles from it", () => {
const { req, res } = reqRes("/");
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
const ctx = buildContext(req, res, { user });
assert.equal(ctx.user, user);
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
});
test("buildContext defaults a missing request URL to /", () => {
const { req, res } = reqRes();
assert.equal(buildContext(req, res).url.pathname, "/");
});

47
src/context.ts Normal file
View File

@@ -0,0 +1,47 @@
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 authenticated user, projected from the verified session JWT claims (§4):
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User {
email: string;
id: string;
roles: string[];
}
export interface RequestContext {
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
url: URL;
user: User | null;
}
export interface BuildContextOptions {
params?: Record<string, string>;
user?: User | null;
}
export function buildContext(
req: IncomingMessage,
res: ServerResponse,
options: BuildContextOptions = {},
): RequestContext {
const url = new URL(req.url ?? "/", "http://localhost");
const user = options.user ?? null;
return {
params: options.params ?? {},
query: url.searchParams,
req,
res,
roles: user?.roles ?? [],
url,
user,
};
}

View File

@@ -1,10 +1,7 @@
// Cookie helpers — parse the request `Cookie` header and build `Set-Cookie`
// response headers with secure-by-default attributes. Stdlib only (no `cookie` dep).
// §4 auth uses these to store/clear the session JWT cookie and the CSRF token.
//
// Values round-trip via percent-encoding: `serializeCookie` encodes, `parseCookies`
// decodes. JWTs survive unescaped (their `-_.` base64url chars are URI-unreserved),
// so the header stays human-readable.
// 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.
export interface CookieOptions {
domain?: string;

View File

@@ -71,6 +71,11 @@ test("rejects when the JWK pins a different alg", () => {
assert.throws(() => verifyJws(token, { ...rsaJwk, alg: "RS512" }), /alg mismatch/);
});
test("rejects a symmetric JWK (kty:oct) for an asymmetric alg — second defense after the allowlist", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
assert.throws(() => verifyJws(token, { k: b64url("secret"), kty: "oct" }), /invalid JWK/);
});
test("rejects a token without three segments", () => {
assert.throws(() => verifyJws("only.two", rsaJwk), /expected 3 segments/);
});

View File

@@ -1,15 +1,12 @@
import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
// JWT signature verification with the Node standard library — no `jose`/JWT package.
// Decision (todo §0): `node:crypto` imports a JWK directly (`createPublicKey({format:"jwk"})`)
// and verifies the RS*/ES* signatures the Kratos session tokenizer produces — everything
// we need. A dependency would add supply-chain surface for capability we already have; see
// AGENTS.md (few dependencies, prefer stdlib).
// 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).
//
// Scope is signature verification only. The §4 auth layer builds the rest on top of this:
// claim checks (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and — at its
// network boundary — guarding `token` is a string and bounding its length before calling in.
// 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.
// Widen support by extending this map. Security invariant: never add an `HS*` (symmetric)

View File

@@ -22,8 +22,11 @@ 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 (path traversal).
// 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.
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
if (/[\x00-\x1f]/.test(requestedPath)) return null;
const filePath = join(dir, requestedPath);
const rel = relative(dir, filePath);
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
@@ -33,7 +36,7 @@ function plain(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
}
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse): Promise<void> {
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false): Promise<void> {
let decoded: string;
try {
decoded = decodeURIComponent(requestedPath);
@@ -48,10 +51,14 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
const info = await stat(filePath);
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 —
// destroy the response to signal a truncated body instead of leaving the socket open.
// log it and destroy the response to signal a truncated body, not a hung socket.
createReadStream(filePath)
.on("error", () => res.destroy())
.on("error", (err) => {
console.error(err);
res.destroy();
})
.pipe(res);
} catch {
plain(res, 404, "Not Found");

View File

@@ -14,12 +14,12 @@ everything via Docker.
## 0. Housekeeping / primitives
- [x] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding. → `node:crypto` (no new dep); `src/jwt.ts` verifies JWS signatures.
- [x] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite). → `src/cookie.ts` (`parseCookies`/`serializeCookie`); stdlib-only, injection/pollution-safe.
- [ ] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`.
- [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`.
- [ ] Error templates: add 403 + 500 (404 exists).
- [ ] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports.
## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data)
- [ ] Move `styles.css` + `auth.css` into `public/css/`; reconcile with 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.
- [ ] App-shell partial (sidebar + topbar + content slot).
- [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`.
@@ -33,6 +33,7 @@ everything via Docker.
- [ ] Helper `paginate(total, page, pageSize)` → page model.
- [ ] Unit tests for all helpers (first).
- [ ] Replace placeholder `index` with the app-shell dashboard.
- [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project.
## 2. Plugin host
- [ ] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime.