/** * 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, newSpacePage, notePage, searchPage, spacePage, } from "./page.ts"; import { createSpace, deleteSpace, getSpace, joinSpace, members, mySpaces, removeMember, setLink, spaceHref, spaceOf, type Space, } from "./spaces.ts"; import { syncSpace, type Forge } from "./sync.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; /** Where shared folders commit; absent, there are none to make. */ readonly forge?: Forge | undefined; } /** 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" }, path: { type: ["string", "null"], description: "A shared folder's note: its file in the repository. Null for a note of your own.", }, }, 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." }, space: { type: "integer", description: "A shared folder you are in, to add the note there instead of your vault." }, folder: { type: "string", description: "In a shared folder: the folder to put it in (`ideas/q4`)." }, }, }; 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" }, folder: { type: "string", description: "A shared note's folder: moves its file." }, base: { type: "string", format: "date-time", description: "The updated_at you edited from; when someone has saved since, new words are refused with 409.", }, }, }; 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, conflict: 409, }; /** 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 } : {}), ...(typeof body.folder === "string" ? { folder: body.folder } : {}), ...(typeof body.base === "string" ? { base: body.base } : {}), }); interface Around { who: Who & { role: "user" | "admin" }; vault: Note[]; notice?: string; clear: Record; space?: Space; spaces: Space[]; } /** * The vault around a page: who is signed in, every note of theirs — or of * the shared folder `space`, when the page is in one — and the folders * they are in, for the switcher. */ function vaultOf(c: Context, space?: number): Around | Response { const { notice, clear } = readNotice(c.request); if (c.who.role === "anon") return html(landing(notice), 200, clear); let found: Space | undefined; if (space !== undefined) { const got = getSpace(c.db, c.who, space); if (!got.ok) return go("/", got.message); found = got.value; } const vault = listNotes(c.db, c.who, space === undefined ? {} : { space }); return { who: c.who, vault: vault.ok ? vault.value : [], ...(notice === undefined ? {} : { notice }), clear, ...(found === undefined ? {} : { space: found }), spaces: mySpaces(c.db, c.who), }; } /** The shared folder a page's path names (`/s/:sid/…`); none for your own vault. */ const sidOf = (c: Context): number | undefined => c.params.sid === undefined ? undefined : Number(c.params.sid); /** A vault's own address: `""` for yours, `/s/` for a shared folder. */ const baseFor = (space: number | undefined): string => (space === undefined ? "" : spaceHref(space)); /** Where sign-in may send someone afterwards: back to the join link that brought them, or home. */ export const safeTo = (to: string | null | undefined): string => typeof to === "string" && /^\/join\/[\w-]{8,64}$/.test(to) ? to : "/"; /** * The pages every vault has — its home, search, graph, following a link, * a new note, today's note — once for your own (`""`) and once for a * shared folder (`/s/:sid`). The same handlers, a different vault. */ function vaultRoutes(prefix: string): Route[] { return [ { method: "GET", path: prefix || "/", handle: (c) => { const v = vaultOf(c, sidOf(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: "POST", path: `${prefix}/new`, handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const title = String(form.get("title") ?? ""); const folder = String(form.get("folder") ?? ""); const space = sidOf(c); return done( addNote(c.db, c.who, { ...(title === "" ? {} : { title }), ...(space === undefined ? {} : { space, folder }), }), toNote, baseFor(space) || "/", ); }, }, { method: "POST", path: `${prefix}/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); const space = sidOf(c); return done( openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n`, ...(space === undefined ? {} : { space }) }), toNote, baseFor(space) || "/", ); }, }, { method: "GET", path: `${prefix}/open`, handle: (c) => { const v = vaultOf(c, sidOf(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(baseFor(sidOf(c)) || "/"); return html(missingPage({ ...v, title }), 200, v.clear); }, }, { method: "POST", path: `${prefix}/open`, handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const space = sidOf(c); return done( openOrCreate(c.db, c.who, { title: String(form.get("title") ?? ""), ...(space === undefined ? {} : { space }) }), toNote, baseFor(space) || "/", ); }, }, { method: "GET", path: `${prefix}/search`, handle: (c) => { const v = vaultOf(c, sidOf(c)); if (v instanceof Response) return v; const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim(); if (query === "") return go(baseFor(sidOf(c)) || "/"); return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear); }, }, { method: "GET", path: `${prefix}/graph`, handle: (c) => { const v = vaultOf(c, sidOf(c)); if (v instanceof Response) return v; return html(graphPage(v), 200, v.clear); }, }, ]; } /** A shared folder's own page, for one of its members — or the way home. */ function spaceAround(c: Context): (Around & { space: Space }) | Response { const v = vaultOf(c, sidOf(c)); if (v instanceof Response) return v; if (v.space === undefined) return go("/"); return v as Around & { space: Space }; } /** 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. ...vaultRoutes(""), ...vaultRoutes("/s/:sid"), { method: "GET", path: "/n/:id", handle: (c) => { if (c.who.role === "anon") return vaultOf(c) as Response; const note = getNote(c.db, c.who, { id: Number(c.params.id) }); if (!note.ok) return go("/", note.message); // A shared note is shown in its folder. const v = vaultOf(c, spaceOf(note.value.author_id)); if (v instanceof Response) return v; 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/move", 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, folder: String(form.get("folder") ?? "") }), 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) => { const note = getNote(c.db, c.who, { id: Number(c.params.id) }); const back = note.ok ? baseFor(spaceOf(note.value.author_id)) || "/" : "/"; return done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => back); }, }, // Shared folders: connecting one, joining one, and running one. { method: "GET", path: "/spaces/new", handle: (c) => { const v = vaultOf(c); if (v instanceof Response) return v; if (c.who.role !== "admin") return go("/", "Only an app admin can connect a repository."); if (c.forge === undefined) return go("/", "Shared folders need NOTES_REPO_TOKEN set in the app's environment."); return html(newSpacePage(v), 200, v.clear); }, }, { method: "POST", path: "/spaces", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const input = { name: String(form.get("name") ?? ""), repo: String(form.get("repo") ?? ""), branch: String(form.get("branch") ?? ""), folder: String(form.get("folder") ?? ""), }; const again = (notice: string) => { const v = vaultOf(c); return v instanceof Response ? v : html(newSpacePage({ ...v, notice, form: input }), 422); }; if (c.forge === undefined) return again("Shared folders need NOTES_REPO_TOKEN set in the app's environment."); const made = createSpace(c.db, c.who, input); if (!made.ok) return made.error === "invalid" ? again(made.message) : go("/", made.message); // Read it now, so it opens with its notes — and a wrong name is said at once. try { const read = await syncSpace(c.db, c.forge, made.value.id); return go(spaceHref(made.value.id), `Connected — ${read.pulled} ${read.pulled === 1 ? "note" : "notes"} read from ${made.value.repo}.`); } catch (e) { deleteSpace(c.db, c.who, { id: made.value.id }); return again(`Couldn't read ${made.value.repo}: ${e instanceof Error ? e.message : String(e)}`); } }, }, { method: "GET", path: "/join/:code", handle: (c) => { const to = `/join/${c.params.code ?? ""}`; if (c.who.role === "anon") return html(landing("Sign in (or make an account) to join the shared folder.", safeTo(to)), 200); const joined = joinSpace(c.db, c.who, { code: c.params.code ?? "" }); if (!joined.ok) return go("/", joined.message); return go(spaceHref(joined.value.id), `You're in ${joined.value.name}.`); }, }, { method: "GET", path: "/s/:sid/settings", handle: (c) => { const v = spaceAround(c); if (v instanceof Response) return v; return html(spacePage({ ...v, members: members(c.db, v.space.id) }), 200, v.clear); }, }, { method: "POST", path: "/s/:sid/sync", handle: async (c) => { const v = spaceAround(c); if (v instanceof Response) return v; const back = `${spaceHref(v.space.id)}/settings`; if (c.forge === undefined) return go(back, "Shared folders need NOTES_REPO_TOKEN set in the app's environment."); try { const r = await syncSpace(c.db, c.forge, v.space.id); const said = [ r.pulled > 0 ? `${r.pulled} from the repository` : "", r.pushed > 0 ? `${r.pushed} ${r.pushed === 1 ? "file" : "files"} committed` : "", r.copies > 0 ? `${r.copies} kept as repo copies` : "", ].filter((x) => x !== ""); return go(back, said.length === 0 ? "Already in step." : `Synced: ${said.join(", ")}.`); } catch (e) { return go(back, `Sync failed: ${e instanceof Error ? e.message : String(e)}`); } }, }, { method: "POST", path: "/s/:sid/link", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const id = Number(c.params.sid); const on = form.get("on") === "1"; return done(setLink(c.db, c.who, { id, on }), () => `${spaceHref(id)}/settings`, `${spaceHref(id)}/settings`); }, }, { method: "POST", path: "/s/:sid/remove", handle: async (c) => { const form = await readForm(c.request); if (form === undefined) return text("too large", 413); const id = Number(c.params.sid); return done( removeMember(c.db, c.who, { id, member: String(form.get("member") ?? "") }), () => `${spaceHref(id)}/settings`, `${spaceHref(id)}/settings`, ); }, }, { method: "POST", path: "/s/:sid/leave", handle: (c) => { const id = Number(c.params.sid); if (c.who.role === "anon") return go("/"); return done(removeMember(c.db, c.who, { id, member: c.who.id }), () => "/", `${spaceHref(id)}/settings`); }, }, { method: "POST", path: "/s/:sid/delete", handle: (c) => { const id = Number(c.params.sid); return done(deleteSpace(c.db, c.who, { id }), () => "/", `${spaceHref(id)}/settings`); }, }, { method: "POST", path: "/welcome", handle: (c) => done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote), }, { 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 ?? "") !== "", to: safeTo(c.url.searchParams.get("to")) }), 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 to = safeTo(String(form.get("to") ?? "")); const again = (notice: string, status = 422) => html(authPage({ mode: "signup", notice, email, name, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "", to }), 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(to, 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, to: safeTo(c.url.searchParams.get("to")) }), 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 to = safeTo(String(form.get("to") ?? "")); const again = (notice: string, status: number) => html(authPage({ mode: "login", notice, email, needsCode: false, to }), 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(to, 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")); const space = whole(c.url.searchParams.get("space")); const scope = { ...(limit === undefined ? {} : { limit }), ...(space === undefined ? {} : { space }), }; if (q === null || q.trim() === "") return answer(listNotes(c.db, c.who, scope)); // A search answers the same notes, best match first. const hits = searchNotes(c.db, c.who, { q, ...scope }); 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. With `space`, a shared folder's instead.", query: { limit: { type: "integer", minimum: 1, maximum: 5000 }, space: { type: "integer" }, 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 } : {}), ...(typeof f.space === "number" ? { space: f.space } : {}), ...(typeof f.folder === "string" ? { folder: f.folder } : {}), }), 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; }