/** * The door: what every request passes before a route sees it, and before * a byte of its body is read. Three checks, in order: * * 1. **Rationing.** Writes are rationed per person (per address, for a * stranger), and a stranger's calls to the API are rationed too. * 2. **Same origin.** A write that a browser says came from another site * is refused: that is another page making this person's browser act. * 3. **The contract.** A request under a resource's `api` is that * resource's verb (see docs/CONTRACT.md in the forge: `GET api` lists, * `POST api` creates, `GET|PATCH|DELETE api/` read, update, * delete). If the table lets this person through that door for no row * at all, they are refused here — 401 for a stranger, 403 for anyone * else. The action asks again, about the real row; the door only turns * away who could never pass. `/openapi.json` is shut to strangers when * the table opens no door to them. * * Nothing here compares roles by hand: the answer is `decide`'s, from the * same table the actions read. Refused writes go into the audit trail. */ import type { Database } from "bun:sqlite"; import { record } from "./audit.ts"; import { decide, VERBS, type Contract, type Verb } from "./can.ts"; import { actorOf, contract } from "./contract.ts"; import { publicOrigin } from "./env.ts"; import { json, text } from "./http.ts"; import type { Who } from "./who.ts"; // A browser on another site can make a person's browser POST here; it cannot // make that request look same-origin. Browsers say where a request came from // (Sec-Fetch-Site, or Origin); a request that names another site is refused. // A script sends neither and is judged by its identity instead. export const sameOrigin = (request: Request): boolean => { const site = request.headers.get("sec-fetch-site"); if (site !== null) return site === "same-origin" || site === "none"; const origin = request.headers.get("origin"); return origin === null || origin === publicOrigin; }; // A bucket of `burst` tokens that refills at `perSecond`. In memory, so a // restart forgets — which is the right size for one process on one machine. export interface Rate { readonly burst: number; readonly perSecond: number; } export const WRITES: Rate = { burst: 20, perSecond: 2 }; /** A stranger's API calls, reads too. Behind the forge every stranger shares one address, so this is all of them together. */ export const STRANGERS: Rate = { burst: 60, perSecond: 10 }; const buckets = new Map(); export function allow( key: string, rate: Rate = WRITES, now = Date.now(), ): boolean { const bucket = buckets.get(key) ?? { tokens: rate.burst, at: now }; bucket.tokens = Math.min( rate.burst, bucket.tokens + ((now - bucket.at) / 1000) * rate.perSecond, ); bucket.at = now; buckets.set(key, bucket); if (buckets.size > 10_000) for (const [k, b] of buckets) if (now - b.at > 60_000) buckets.delete(k); if (bucket.tokens < 1) return false; bucket.tokens -= 1; return true; } const ROW_VERB: Readonly> = { GET: "read", PATCH: "update", DELETE: "delete", }; const SET_VERB: Readonly> = { GET: "list", POST: "create", }; /** The resource and verb a request is, by the contract's `api` paths; undefined for any other path. */ export function doorOf( method: string, pathname: string, table: Contract = contract, ): { resource: string; verb: Verb } | undefined { for (const [resource, { api }] of Object.entries(table.resources)) { if (api === undefined) continue; if (pathname === api) { const verb = SET_VERB[method]; return verb === undefined ? undefined : { resource, verb }; } const rest = pathname.startsWith(`${api}/`) ? pathname.slice(api.length + 1) : undefined; if (rest !== undefined && rest !== "" && !rest.includes("/")) { const verb = ROW_VERB[method]; return verb === undefined ? undefined : { resource, verb }; } } return undefined; } /** * Could `who` pass this door for any row at all? The door asks about a row * that names them in every column a role is held by — the best row they * could hope for — so it refuses only what no row would open. */ export function mayKnock( who: Who, resource: string, verb: Verb, table: Contract = contract, ): boolean { const actor = actorOf(who); const columns = Object.values(table.roles).flatMap((role) => role.held.by === "row" ? [role.held.column] : [], ); const best = verb === "list" || verb === "create" || actor.id === null ? undefined : Object.fromEntries(columns.map((column) => [column, actor.id])); return decide(table, resource, verb, actor, best) !== "no"; } /** Does the table open any door with an `api` to a stranger? Then the API's description is theirs to read. */ export const openToStrangers = (table: Contract = contract): boolean => Object.entries(table.resources).some( ([resource, { api }]) => api !== undefined && VERBS.some((verb) => mayKnock({ role: "anon" }, resource, verb, table)), ); const describedToStrangers = openToStrangers(); /** * The door. `undefined` lets the request through to its route; a response * is the refusal. `from` is the caller's address, for rationing a stranger. */ export function door( db: Database, request: Request, url: URL, who: Who, from: string, ): Response | undefined { const method = request.method === "HEAD" ? "GET" : request.method; const writing = method !== "GET"; const stranger = who.role === "anon"; const api = url.pathname.startsWith("/api/") || url.pathname === "/mcp" || url.pathname === "/openapi.json"; const key = stranger ? `ip:${from}` : `user:${who.id}`; if (writing && !allow(key)) return text("too many requests", 429); if (stranger && api && !allow(`api:${from}`, STRANGERS)) return text("too many requests", 429); const found = doorOf(method, url.pathname); if (writing && !sameOrigin(request)) { record(db, { who, resource: found?.resource ?? url.pathname, verb: found?.verb ?? method, outcome: "cross_site", }); return text("forbidden", 403); } const shut = found === undefined ? url.pathname === "/openapi.json" && stranger && !describedToStrangers : !mayKnock(who, found.resource, found.verb); if (!shut) return undefined; const outcome = stranger ? "signin_required" : "forbidden"; if (writing && found !== undefined) record(db, { who, resource: found.resource, verb: found.verb, target: url.pathname === contract.resources[found.resource]?.api ? undefined : url.pathname.split("/").at(-1), outcome, }); return stranger ? json({ error: outcome, message: "Sign in first." }, 401) : json({ error: outcome, message: "You may not." }, 403); }