Add RequestContext primitive (todo §0); harden static serving (HEAD, control-char, stream-error logging)
This commit is contained in:
@@ -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
|
||||
|
||||
17
README.md
17
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)
|
||||
|
||||
@@ -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: .
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
51
src/context.test.ts
Normal 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
47
src/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
13
src/jwt.ts
13
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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
5
todo.md
5
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.
|
||||
|
||||
Reference in New Issue
Block a user