/** * The door turns away, before a byte of the body is read, exactly who the * table says could never pass — and nobody it would let through. */ import { afterAll, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readContract } from "./can.ts"; import { openDb } from "./db.ts"; import { allow, door, doorOf, mayKnock, openToStrangers, STRANGERS, } from "./security.ts"; import { serve } from "./server.ts"; import { sign } from "./who.ts"; const KEY = "a-key-only-the-forge-holds"; const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-door-")), identityKey: KEY, }); const base = `http://${server.hostname}:${server.port}`; afterAll(() => server.stop(true)); const as = (id: string, role: "user" | "admin"): Record => { const identity = `${id}|${id}|${role}|${Date.now() + 60_000}`; return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), }; }; test("a path is a resource's door by the contract's api, and nothing else is", () => { expect(doorOf("GET", "/api/notes")).toEqual({ resource: "note", verb: "list", }); expect(doorOf("POST", "/api/notes")).toEqual({ resource: "note", verb: "create", }); expect(doorOf("GET", "/api/notes/7")).toEqual({ resource: "note", verb: "read", }); expect(doorOf("PATCH", "/api/notes/7")).toEqual({ resource: "note", verb: "update", }); expect(doorOf("DELETE", "/api/notes/7")).toEqual({ resource: "note", verb: "delete", }); expect(doorOf("GET", "/api/audit")).toEqual({ resource: "audit", verb: "list", }); for (const [method, path] of [ ["PUT", "/api/notes"], ["GET", "/api/notes/7/x"], ["GET", "/api/notesy"], ["GET", "/api/notes/"], ["POST", "/notes"], ] as const) expect(doorOf(method, path)).toBeUndefined(); }); test("the door refuses only what no row would open", () => { const anon = { role: "anon" } as const; const alice = { role: "user", id: "u1", name: "alice" } as const; const root = { role: "admin", id: "u0", name: "root" } as const; // A vault is its author's: a stranger has no door to any of it. expect(mayKnock(anon, "note", "list")).toBe(false); expect(mayKnock(anon, "note", "read")).toBe(false); expect(mayKnock(anon, "note", "create")).toBe(false); expect(mayKnock(anon, "note", "delete")).toBe(false); // alice may delete a note of her own, so the door lets her ask; the action decides which. expect(mayKnock(alice, "note", "delete")).toBe(true); expect(mayKnock(alice, "audit", "list")).toBe(false); expect(mayKnock(root, "audit", "list")).toBe(true); expect(mayKnock(root, "audit", "create")).toBe(false); }); test("a stranger is refused /api before the body is read, when the contract closes it", async () => { expect((await fetch(`${base}/api/audit`)).status).toBe(401); expect((await fetch(`${base}/api/audit/1`)).status).toBe(401); expect( (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status, ).toBe(403); // A stranger reads no note, and — the table opening them no door — not the API's description either. expect((await fetch(`${base}/api/notes`)).status).toBe(401); expect((await fetch(`${base}/api/notes/0`)).status).toBe(401); expect((await fetch(`${base}/openapi.json`)).status).toBe(401); // What the table opens to a person stays open to them. const alice = as("u1", "user"); expect((await fetch(`${base}/api/notes`, { headers: alice })).status).toBe(200); expect((await fetch(`${base}/api/notes/0`, { headers: alice })).status).toBe(404); expect((await fetch(`${base}/openapi.json`, { headers: alice })).status).toBe(200); // A body past the cap: read, it would be 413. The door answers first. // (Last, because the refusal leaves the body unread on its connection.) const huge = "x".repeat(2 * 1024 * 1024); const signedIn = await fetch(`${base}/api/notes`, { method: "POST", headers: { "content-type": "application/json", ...alice }, body: huge, }); expect(signedIn.status).toBe(413); const refused = await fetch(`${base}/api/notes`, { method: "POST", headers: { "content-type": "application/json" }, body: huge, }); expect(refused.status).toBe(401); expect(await refused.json()).toEqual({ error: "signin_required", message: "Sign in first.", }); }); test("the API's description is shut to strangers when the table opens them no door", () => { const closed = readContract( JSON.stringify({ roles: { anon: { above: [], held: { by: "everyone" } }, user: { above: ["anon"], held: { by: "identity", role: "user" } }, }, resources: { note: { api: "/api/notes", can: { create: "user", read: "user", update: "user", delete: "user", list: "user", }, }, }, }), ); if (!closed.ok) throw new Error(closed.problems.join("; ")); expect(openToStrangers(closed.contract)).toBe(false); expect(openToStrangers()).toBe(false); }); test("strangers are rationed at the API, reads too", () => { const db = openDb(mkdtempSync(join(tmpdir(), "app-door-rate-"))); const knock = () => door( db, new Request("http://app/mcp"), new URL("http://app/mcp"), { role: "anon" }, "203.0.113.7", ); const answers = Array.from({ length: STRANGERS.burst + 5 }, knock); expect(answers.slice(0, STRANGERS.burst).every((a) => a === undefined)).toBe( true, ); expect(answers.at(-1)?.status).toBe(429); // The page itself is not the API: a stranger reading it is never rationed here. expect( door( db, new Request("http://app/"), new URL("http://app/"), { role: "anon" }, "203.0.113.7", ), ).toBeUndefined(); // A bucket refills. const t = Date.now(); for (let i = 0; i < 25; i++) allow("k", { burst: 20, perSecond: 2 }, t); expect(allow("k", { burst: 20, perSecond: 2 }, t)).toBe(false); expect(allow("k", { burst: 20, perSecond: 2 }, t + 1000)).toBe(true); });