/** * Every path the app answers, in one table. A route reads its request, * calls an action, and shapes the answer for its door — HTML for a * browser, JSON for a script — and that is all a route does. * * A route that is part of the API says so beside itself (`api`): one * sentence, and the shape of what it takes and gives, as JSON Schema. * `bun run contract:emit` writes those into `contract/openapi.json`, the * tests fail when the committed file and this table disagree, and * `GET /openapi.json` serves it. The shapes describe; the checks that hold * are still the actions' own. */ import type { Database } from "bun:sqlite"; import { existsSync, readFileSync } from "node:fs"; import { addNote, BODY_MAX, deleteNote, getAudit, getNote, listAudit, listNotes, openOrCreate, searchNotes, TITLE_MAX, today, updateNote, WELCOME, type Change, type Failure, type Note, type Result, } from "./actions.ts"; import { changePassword, deleteAccount, getAccount, logIn, logOut, SESSION_DAYS, signUp, } from "./accounts.ts"; import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; import { cookie, fields, go, html, json, NOTE_BYTES, readForm, readJson, readCookie, readNotice, SECURITY, staticFile, text, } from "./http.ts"; import { events } from "./live.ts"; import { rpc } from "./mcp.ts"; import { accountPage, authPage, emptyPage, graphPage, landing, missingPage, notePage, searchPage, } from "./page.ts"; import { tokensCss } from "./tokens.ts"; import { allow } from "./security.ts"; import { search } from "./vault.ts"; /** The app's own sign-in: a random token, HttpOnly, for SESSION_DAYS. */ export const SESSION_COOKIE = "sid"; const session = (token: string): string => cookie(SESSION_COOKIE, token, SESSION_DAYS * 86_400); const ended = (): string => cookie(SESSION_COOKIE, "", 0); // Guessing is rationed: per email, and for everyone together (behind the // forge every stranger arrives from one address). const LOGIN_PER_EMAIL = { burst: 8, perSecond: 1 / 60 }; const LOGIN_ALL = { burst: 60, perSecond: 1 }; const SIGNUP_ALL = { burst: 10, perSecond: 1 / 360 }; import type { Who } from "./who.ts"; export interface Context { readonly request: Request; readonly url: URL; readonly db: Database; readonly who: Who; readonly params: Record; } /** A JSON Schema, as plain data. */ export type Schema = Readonly>; /** What an API route is, for the description: never consulted to decide anything. */ export interface Api { readonly summary: string; /** The body it reads. */ readonly body?: Schema; /** What it answers when it succeeds, and with which status (200). */ readonly answer: Schema; readonly status?: number; /** The query parameters it reads. */ readonly query?: Readonly>; } export interface Route { readonly method: "GET" | "POST" | "PATCH" | "DELETE"; readonly path: string; readonly handle: (ctx: Context) => Response | Promise; readonly api?: Api; } // The shapes the API speaks in, named once. export const NOTE: Schema = { type: "object", properties: { id: { type: "integer" }, title: { type: "string", minLength: 1, maxLength: TITLE_MAX }, body: { type: "string", maxLength: BODY_MAX }, pinned: { enum: [0, 1] }, author_id: { type: "string" }, author_name: { type: "string" }, created_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" }, }, required: [ "id", "title", "body", "pinned", "author_id", "author_name", "created_at", "updated_at", ], }; const NOTE_NEW: Schema = { type: "object", properties: { title: { type: "string", maxLength: TITLE_MAX, description: "Unique in your vault; “Untitled” when left out.", }, body: { type: "string", maxLength: BODY_MAX, description: "Markdown." }, }, }; const NOTE_CHANGE: Schema = { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: TITLE_MAX, description: "A rename carries every [[link]] to the note with it.", }, body: { type: "string", maxLength: BODY_MAX }, pinned: { type: "boolean" }, }, }; export const AUDIT_ROW: Schema = { type: "object", properties: { id: { type: "integer" }, at: { type: "string", format: "date-time" }, who_id: { type: ["string", "null"] }, who_name: { type: ["string", "null"] }, role: { enum: ["anon", "user", "admin"] }, resource: { type: "string" }, verb: { type: "string" }, target: { type: ["string", "null"] }, allowed: { enum: [0, 1] }, outcome: { type: "string" }, }, required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"], }; export const ERROR: Schema = { type: "object", properties: { error: { type: "string" }, message: { type: "string" } }, required: ["error"], }; const LIMIT: Schema = { type: "integer", minimum: 1, maximum: 500, default: 100, }; const list = (items: Schema): Schema => ({ type: "array", items }); const STATUS: Record = { signin_required: 401, forbidden: 403, not_found: 404, invalid: 422, }; /** A result as JSON: the value, or `{ error, message }` with the matching status. */ const answer = (result: Result, status = 200): Response => result.ok ? json(result.value, status) : json( { error: result.error, message: result.message }, STATUS[result.error], ); /** A result for a form: on to where it leads, or back with what went wrong. */ const done = (result: Result, to: (value: T) => string, back = "/"): Response => result.ok ? go(to(result.value)) : go(back, result.message); const toNote = (n: { readonly id: number }): string => `/n/${n.id}`; /** The fields of a JSON body that change a note, and only those. */ const change = (id: number, body: Record): Change => ({ id, ...(typeof body.title === "string" ? { title: body.title } : {}), ...(typeof body.body === "string" ? { body: body.body } : {}), ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}), }); /** The vault around a page: who is signed in, and every note of theirs. */ function vaultOf(c: Context): | { who: Who & { role: "user" | "admin" }; vault: Note[]; notice?: string; clear: Record } | Response { const { notice, clear } = readNotice(c.request); if (c.who.role === "anon") return html(landing(notice), 200, clear); const vault = listNotes(c.db, c.who); return { who: c.who, vault: vault.ok ? vault.value : [], ...(notice === undefined ? {} : { notice }), clear, }; } /** A whole number from the query, or undefined. */ const whole = (value: string | null): number | undefined => value !== null && /^\d+$/.test(value) ? Number(value) : undefined; // The API's description, read from git once: what the tests hold equal to this table. const OPENAPI = new URL("../contract/openapi.json", import.meta.url); const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; export const routes: readonly Route[] = [ // The pages. Signed out, the way in; signed in, the vault. Forms post and // land where they lead, so every page works without JavaScript. { method: "GET", path: "/", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; const first = v.vault.reduce( (latest, n) => (latest === undefined || n.updated_at > latest.updated_at ? n : latest), undefined, ); // The vault opens on the note you were last writing. if (first !== undefined && v.notice === undefined) return go(toNote(first)); if (first !== undefined) return html(notePage({ ...v, active: first }), 200, v.clear); return html(emptyPage(v), 200, v.clear); }, }, { method: "GET", path: "/n/:id", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; const note = getNote(c.db, c.who, { id: Number(c.params.id) }); if (!note.ok) return go("/", note.message); return html(notePage({ ...v, active: note.value }), 200, v.clear); }, }, { method: "GET", path: "/n/:id/raw", handle: (c) => { const note = getNote(c.db, c.who, { id: Number(c.params.id) }); if (!note.ok) return answer(note); return new Response(note.value.body, { headers: { "content-type": "text/markdown; charset=utf-8", "content-disposition": `attachment; filename="${note.value.title.replace(/[^\w .-]+/g, "_")}.md"; filename*=UTF-8''${encodeURIComponent(`${note.value.title}.md`)}`, ...SECURITY, }, }); }, }, { method: "POST", path: "/n/:id", handle: async (c) => { const form = await readForm(c.request, NOTE_BYTES); if (form === undefined) return text("too large", 413); const id = Number(c.params.id); return done( updateNote(c.db, c.who, { id, title: String(form.get("title") ?? ""), body: String(form.get("body") ?? ""), }), toNote, toNote({ id }), ); }, }, { method: "POST", path: "/n/:id/pin", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const id = Number(c.params.id); return done( updateNote(c.db, c.who, { id, pinned: form.get("pinned") === "1" }), toNote, toNote({ id }), ); }, }, { method: "POST", path: "/n/:id/delete", handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => "/"), }, { method: "POST", path: "/new", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const title = String(form.get("title") ?? ""); return done(addNote(c.db, c.who, title === "" ? {} : { title }), toNote); }, }, { method: "POST", path: "/daily", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const tz = Number(form.get("tz") ?? 0); const day = today(Number.isFinite(tz) && Math.abs(tz) <= 840 ? tz : 0); return done(openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n` }), toNote); }, }, { method: "POST", path: "/welcome", handle: (c) => done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote), }, { method: "GET", path: "/open", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; const title = (c.url.searchParams.get("title") ?? "").trim(); const found = v.vault.find((n) => n.title.toLowerCase() === title.toLowerCase()); if (found !== undefined) return go(toNote(found)); if (title === "") return go("/"); return html(missingPage({ ...v, title }), 200, v.clear); }, }, { method: "POST", path: "/open", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); return done(openOrCreate(c.db, c.who, { title: String(form.get("title") ?? "") }), toNote); }, }, { method: "GET", path: "/search", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim(); if (query === "") return go("/"); return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear); }, }, { method: "GET", path: "/graph", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; return html(graphPage(v), 200, v.clear); }, }, { method: "GET", path: "/export", handle: (c) => { const notes = listNotes(c.db, c.who); if (!notes.ok) return answer(notes); const day = new Date().toISOString().slice(0, 10); return new Response( JSON.stringify( { exported_at: new Date().toISOString(), notes: notes.value.map(({ title, body, pinned, created_at, updated_at }) => ({ title, body, pinned: pinned === 1, created_at, updated_at, })), }, null, 2, ), { headers: { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="notes-${day}.json"`, "cache-control": "no-store", ...SECURITY, }, }, ); }, }, // The app's own accounts: sign up, log in, log out, and the account page. { method: "GET", path: "/signup", handle: (c) => { if (c.who.role !== "anon") return go("/"); const { notice, clear } = readNotice(c.request); return html(authPage({ mode: "signup", notice, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "" }), 200, clear); }, }, { method: "POST", path: "/signup", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const email = String(form.get("email") ?? ""); const name = String(form.get("name") ?? ""); const again = (notice: string, status = 422) => html(authPage({ mode: "signup", notice, email, name, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "" }), status); if (!allow("signup", SIGNUP_ALL)) return again("Lots of people are signing up right now — try again in a few minutes.", 429); const made = await signUp(c.db, { email, name, password: String(form.get("password") ?? ""), code: String(form.get("code") ?? ""), }); if (!made.ok) return again(made.message, made.error === "forbidden" ? 403 : 422); return go("/", undefined, [session(made.value.token)]); }, }, { method: "GET", path: "/login", handle: (c) => { if (c.who.role !== "anon") return go("/"); const { notice, clear } = readNotice(c.request); return html(authPage({ mode: "login", notice, needsCode: false }), 200, clear); }, }, { method: "POST", path: "/login", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const email = String(form.get("email") ?? ""); const again = (notice: string, status: number) => html(authPage({ mode: "login", notice, email, needsCode: false }), status); if (!allow(`login:${email.trim().toLowerCase()}`, LOGIN_PER_EMAIL) || !allow("login", LOGIN_ALL)) return again("Too many tries. Wait a minute and try again.", 429); const inside = await logIn(c.db, { email, password: String(form.get("password") ?? "") }); if (!inside.ok) return again(inside.message, 401); return go("/", undefined, [session(inside.value.token)]); }, }, { method: "POST", path: "/logout", handle: (c) => { logOut(c.db, readCookie(c.request, SESSION_COOKIE)); return go("/", "Logged out.", [ended()]); }, }, { method: "GET", path: "/account", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; const account = getAccount(c.db, c.who); if (account === undefined) return go("/", "Forge accounts are managed on the forge."); return html(accountPage({ ...v, account }), 200, v.clear); }, }, { method: "POST", path: "/account/password", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); if (!allow(`password:${c.who.role === "anon" ? "" : c.who.id}`, LOGIN_PER_EMAIL)) return go("/account", "Too many tries. Wait a minute and try again."); const changed = await changePassword(c.db, c.who, { current: String(form.get("current") ?? ""), next: String(form.get("next") ?? ""), }); if (!changed.ok) return go("/account", changed.message); return go("/account", "Password changed.", [session(changed.value.token)]); }, }, { method: "POST", path: "/account/delete", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); if (!allow(`password:${c.who.role === "anon" ? "" : c.who.id}`, LOGIN_PER_EMAIL)) return go("/account", "Too many tries. Wait a minute and try again."); const gone = await deleteAccount(c.db, c.who, { password: String(form.get("password") ?? "") }); if (!gone.ok) return go("/account", gone.message); return go("/", "Your account and notes are deleted.", [ended()]); }, }, // The JSON API: the same actions, the same identity, plain status codes. { method: "GET", path: "/api/notes", handle: (c) => { const q = c.url.searchParams.get("q"); const limit = whole(c.url.searchParams.get("limit")); if (q === null || q.trim() === "") return answer(listNotes(c.db, c.who, limit === undefined ? {} : { limit })); // A search answers the same notes, best match first. const hits = searchNotes(c.db, c.who, { q, ...(limit === undefined ? {} : { limit }) }); if (!hits.ok) return answer(hits); const notes = hits.value .map((h) => getNote(c.db, c.who, { id: h.id })) .flatMap((r) => (r.ok ? [r.value] : [])); return json(notes); }, api: { summary: "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.", query: { limit: { type: "integer", minimum: 1, maximum: 5000 }, q: { type: "string", maxLength: 500 }, }, answer: list(NOTE), }, }, { method: "POST", path: "/api/notes", handle: async (c) => { const body = await readJson(c.request, NOTE_BYTES); if (body === undefined) return json({ error: "too_large" }, 413); const f = fields(body); return answer( addNote(c.db, c.who, { ...(typeof f.title === "string" ? { title: f.title } : {}), ...(typeof f.body === "string" ? { body: f.body } : {}), }), 201, ); }, api: { summary: "Add a note to your vault.", body: NOTE_NEW, answer: NOTE, status: 201, }, }, { method: "GET", path: "/api/notes/:id", handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "One of your notes.", answer: NOTE }, }, { method: "PATCH", path: "/api/notes/:id", handle: async (c) => { const body = await readJson(c.request, NOTE_BYTES); if (body === undefined) return json({ error: "too_large" }, 413); return answer(updateNote(c.db, c.who, change(Number(c.params.id), fields(body)))); }, api: { summary: "Change a note's title, words or pin; a rename rewrites the links to it. `relinked` says how many notes that touched.", body: NOTE_CHANGE, answer: { allOf: [ NOTE, { type: "object", properties: { relinked: { type: "integer" } }, required: ["relinked"], }, ], }, }, }, { method: "DELETE", path: "/api/notes/:id", handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "Remove a note.", answer: { type: "object", properties: { id: { type: "integer" } }, required: ["id"], }, }, }, // Who tried to change what: the audit trail, for whoever the table lets read it. { method: "GET", path: "/api/audit", handle: (c) => answer( listAudit(c.db, c.who, { limit: whole(c.url.searchParams.get("limit")) ?? 100, ...(whole(c.url.searchParams.get("before")) === undefined ? {} : { before: whole(c.url.searchParams.get("before"))! }), }), ), api: { summary: "The audit trail, newest first; `before` pages back by id.", query: { limit: LIMIT, before: { type: "integer" } }, answer: list(AUDIT_ROW), }, }, { method: "GET", path: "/api/audit/:id", handle: (c) => answer(getAudit(c.db, c.who, { id: Number(c.params.id) })), api: { summary: "One row of the audit trail.", answer: AUDIT_ROW }, }, // Live events, and the MCP server (the tools are the actions too). { method: "GET", path: "/events", handle: (c) => events(c.request, c.who) }, { method: "POST", path: "/mcp", handle: async (c) => { const message = await readJson(c.request); if (message === undefined) return json({ error: "too_large" }, 413); if (message === null) return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" }, }); const { status, body } = rpc(c.db, c.who, message); return body === undefined ? new Response(null, { status }) : json(body, status); }, api: { summary: "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.", body: { type: "object", properties: { jsonrpc: { const: "2.0" }, id: {}, method: { type: "string" }, params: { type: "object" }, }, required: ["jsonrpc", "method"], }, answer: { type: "object" }, }, }, { method: "GET", path: "/mcp", handle: () => text("this endpoint speaks JSON-RPC over POST", 405), }, // Up or not, for the forge and for anyone watching: a separate path from // the page, so a health check never renders it. { method: "GET", path: "/healthz", handle: (c) => { let ok = true; try { c.db.query("SELECT 1").get(); } catch { ok = false; } const uptime = Math.floor((Date.now() - startedAt) / 1000); return new Response( JSON.stringify({ ok, version, commit, env, uptime }), { status: ok ? 200 : 503, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...SECURITY, }, }, ); }, api: { summary: "Whether the app is up: its version, commit, environment and seconds since it started.", answer: { type: "object", properties: { ok: { type: "boolean" }, version: { type: "string" }, commit: { type: "string" }, env: { type: "string" }, uptime: { type: "integer" }, }, required: ["ok", "version", "uptime"], }, }, }, { method: "GET", path: "/openapi.json", handle: () => openapi === undefined ? text("not found", 404) : new Response(openapi, { headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-cache", ...SECURITY, }, }), }, // The look: design/tokens.json, compiled at boot, cached for a year when linked by content. { method: "GET", path: "/tokens.css", handle: (c) => new Response(tokensCss, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": c.url.searchParams.has("v") ? "public, max-age=31536000, immutable" : "no-cache", ...SECURITY, }, }), }, // What the web expects of a site. Crawlers read the page, not the API; // a preview is a draft and is read by nobody. Where to report a // vulnerability is the forge's to say: it runs this app. { method: "GET", path: "/robots.txt", handle: () => text( env === "production" ? "User-agent: *\nDisallow: /api/\nDisallow: /mcp\nDisallow: /events\nDisallow: /__forge/\n" : "User-agent: *\nDisallow: /\n", ), }, { method: "GET", path: "/.well-known/security.txt", handle: () => forgeUrl === undefined ? text("not found", 404) : new Response(null, { status: 302, headers: { location: new URL( "/.well-known/security.txt", forgeUrl, ).toString(), ...SECURITY, }, }), }, // Files under ./static, cached for a year when linked by content (`asset`). { method: "GET", path: "/static/:file", handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")), }, ]; /** The route for a method and path, with what its `:names` matched. */ export function match( method: string, pathname: string, ): { route: Route; params: Record } | undefined { const parts = pathname.split("/"); for (const route of routes) { if (route.method !== method) continue; const pattern = route.path.split("/"); if (pattern.length !== parts.length) continue; const params: Record = {}; const hit = pattern.every((p, i) => { if (p.startsWith(":")) { // A malformed escape is no match, never a thrown error. try { params[p.slice(1)] = decodeURIComponent(parts[i] ?? ""); return true; } catch { return false; } } return p === parts[i]; }); if (hit) return { route, params }; } return undefined; }