diff --git a/AGENTS.md b/AGENTS.md index 727c554..9cc8668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index fcf2cbb..caa1c30 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/compose.yml b/compose.yml index 576abb9..04d415a 100644 --- a/compose.yml +++ b/compose.yml @@ -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: . diff --git a/src/app.test.ts b/src/app.test.ts index c644da2..143c21d 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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"); }); diff --git a/src/app.ts b/src/app.ts index b9ca625..b8db1e3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; } diff --git a/src/context.test.ts b/src/context.test.ts new file mode 100644 index 0000000..405a71b --- /dev/null +++ b/src/context.test.ts @@ -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, "/"); +}); diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..9343f1e --- /dev/null +++ b/src/context.ts @@ -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; // 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; + 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, + }; +} diff --git a/src/cookie.ts b/src/cookie.ts index 2eb955f..e1331a8 100644 --- a/src/cookie.ts +++ b/src/cookie.ts @@ -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; diff --git a/src/jwt.test.ts b/src/jwt.test.ts index 85f2d9e..3a56cb3 100644 --- a/src/jwt.test.ts +++ b/src/jwt.test.ts @@ -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/); }); diff --git a/src/jwt.ts b/src/jwt.ts index 73aac44..33d61fc 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -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) diff --git a/src/static.ts b/src/static.ts index 1281220..d215c14 100644 --- a/src/static.ts +++ b/src/static.ts @@ -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 { +export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false): Promise { 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"); diff --git a/todo.md b/todo.md index 4a7f7ac..093c108 100644 --- a/todo.md +++ b/todo.md @@ -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.