/** * Everything the app can do, as plain functions: `(db, who, input)` in, * data or a named failure out. The page, the JSON API, the MCP server and * the terminal are doors onto these and decide nothing themselves — so a * rule written here holds at every door, and a new door is only a new * way to call. */ import type { Database } from "bun:sqlite"; import { record, type AuditRow } from "./audit.ts"; import { permit } from "./contract.ts"; import { emit } from "./live.ts"; import type { Who } from "./who.ts"; export interface Note { readonly id: number; readonly body: string; readonly author_id: string; readonly author_name: string; readonly created_at: string; readonly updated_at: string; } export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; export type Result = | { ok: true; value: T } | { ok: false; error: Failure; message: string }; const ok = (value: T): Result => ({ ok: true, value }); const fail = (error: Failure, message: string): Result => ({ ok: false, error, message, }); export const BODY_MAX = 500; /** The refusal for a "no": who is not signed in is asked to; who is, is told. */ const refuse = (who: Who, signIn: string, no: string): Result => who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); /** * A change, written into the audit trail whichever way it went: the row it * made or touched, or the one it was refused. Every action that changes * something returns through here, so the trail holds at every door. */ const audited = ( db: Database, who: Who, resource: string, verb: "create" | "update" | "delete", target: number | undefined, result: Result, ): Result => { record(db, { who, resource, verb, target: result.ok ? result.value.id : target, outcome: result.ok ? "ok" : result.error, }); return result; }; export function listNotes( db: Database, who: Who, input: { limit?: number } = {}, ): Result { const limit = Math.min( Math.max(Math.trunc(input.limit ?? 100) || 100, 1), 500, ); // "yes" is every note. When the table gives `list` to a role a row holds // (`author`), the answer names the columns instead, and the list is the // notes that name whoever asks. The names are the contract's, checked when // it was read; the value is bound. const may = permit(who, "note", "list"); if (may === "no") return refuse(who, "Sign in to see notes.", "You may not see these notes."); if (may === "yes") return ok( db .query("SELECT * FROM note ORDER BY id DESC LIMIT ?1") .all(limit), ); const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); return ok( db .query< Note, [number, string] >(`SELECT * FROM note WHERE ${mine} ORDER BY id DESC LIMIT ?1`) .all(limit, who.role === "anon" ? "" : who.id), ); } export const addNote = ( db: Database, who: Who, input: { body: string }, ): Result => audited(db, who, "note", "create", undefined, add(db, who, input)); function add(db: Database, who: Who, input: { body: string }): Result { // The table decides; the type only learns it here: whoever may create is // signed in, and carries a name to write beside the note. if (who.role === "anon" || permit(who, "note", "create") !== "yes") return refuse(who, "Sign in to add a note.", "You may not add a note."); const body = input.body.trim(); if (body.length === 0 || body.length > BODY_MAX) return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); const note = db .query< Note, [string, string, string] >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *") .get(body, who.id, who.name)!; emit("note.added", note); return ok(note); } /** * One note, for `who` to do `verb` to. The door is asked before the row is * looked for, so whoever may not is told that and never learns whether the * row exists; whoever may, and asked for nothing, hears `not_found`. */ function noteFor( db: Database, who: Who, verb: "read" | "update" | "delete", id: number, said: { signIn: string; no: string }, ): Result { const note = Number.isInteger(id) ? db.query("SELECT * FROM note WHERE id = ?1").get(id) : null; // With no row to show, the door is asked about a row of their own: whoever // could not pass even then is refused, and learns nothing about what // exists; whoever could is told there is no such note. const row: Readonly> = note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note }; if (permit(who, "note", verb, row) !== "yes") return refuse(who, said.signIn, said.no); if (note !== null) return ok(note); if (!Number.isInteger(id)) return fail("invalid", "A note id is a whole number."); return fail("not_found", "No such note."); } export function getNote( db: Database, who: Who, input: { id: number }, ): Result { return noteFor(db, who, "read", input.id, { signIn: "Sign in to read this note.", no: "You may not read this note.", }); } export const updateNote = ( db: Database, who: Who, input: { id: number; body: string }, ): Result => audited(db, who, "note", "update", input.id, update(db, who, input)); function update( db: Database, who: Who, input: { id: number; body: string }, ): Result { const found = noteFor(db, who, "update", input.id, { signIn: "Sign in to edit a note.", no: "Only the author or an admin can edit a note.", }); if (!found.ok) return found; const body = input.body.trim(); if (body.length === 0 || body.length > BODY_MAX) return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); const note = db .query< Note, [string, number] >("UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *") .get(body, found.value.id)!; emit("note.changed", note); return ok(note); } export const deleteNote = ( db: Database, who: Who, input: { id: number }, ): Result<{ id: number }> => audited(db, who, "note", "delete", input.id, remove(db, who, input)); function remove( db: Database, who: Who, input: { id: number }, ): Result<{ id: number }> { const found = noteFor(db, who, "delete", input.id, { signIn: "Sign in to remove a note.", no: "Only the author or an admin can remove a note.", }); if (!found.ok) return found; db.query("DELETE FROM note WHERE id = ?1").run(found.value.id); emit("note.removed", { id: found.value.id }); return ok({ id: found.value.id }); } /** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */ export const canDelete = (who: Who, note: Note): boolean => permit(who, "note", "delete", { ...note }) === "yes"; /** * The audit trail, newest first, for whoever the table lets read it (an * admin, as the scaffold has it). `before` pages back by id. Audit rows name * no author, so a cell that gave `list` to a row's role would find nothing * of its own: anything but "yes" is a refusal here. */ export function listAudit( db: Database, who: Who, input: { limit?: number; before?: number } = {}, ): Result { if (permit(who, "audit", "list") !== "yes") return refuse( who, "Sign in to read the audit trail.", "Only an admin can read the audit trail.", ); const limit = Math.min( Math.max(Math.trunc(input.limit ?? 100) || 100, 1), 500, ); const before = Number.isInteger(input.before) ? input.before! : Number.MAX_SAFE_INTEGER; return ok( db .query< AuditRow, [number, number] >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") .all(before, limit), ); } export function getAudit( db: Database, who: Who, input: { id: number }, ): Result { const row = Number.isInteger(input.id) ? db .query("SELECT * FROM audit WHERE id = ?1") .get(input.id) : null; if (permit(who, "audit", "read", { ...row }) !== "yes") return refuse( who, "Sign in to read the audit trail.", "Only an admin can read the audit trail.", ); if (row !== null) return ok(row); if (!Number.isInteger(input.id)) return fail("invalid", "An audit id is a whole number."); return fail("not_found", "No such audit row."); }