/** * 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 { kick } from "./draw.ts"; import { isKind, KINDS, PROMPT_MAX, type Kind } from "./glyph.ts"; import type { Who } from "./who.ts"; export interface Drawing { readonly id: number; readonly prompt: string; readonly kind: Kind; readonly status: "drawing" | "done" | "failed"; /** The SVGs, as a JSON array of strings: `svgsOf` reads it. */ readonly svgs: string; readonly error: string | null; 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" | "busy"; 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, }); /** 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; }; /** The SVGs a drawing holds. */ export const svgsOf = (drawing: Drawing): string[] => { try { const parsed: unknown = JSON.parse(drawing.svgs); return Array.isArray(parsed) ? parsed.filter((s) => typeof s === "string") : []; } catch { return []; } }; /** At most this many drawings are with Claude at once, across everyone. */ export const AT_ONCE = 3; export function listDrawings( db: Database, who: Who, input: { limit?: number } = {}, ): Result { const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 200); // "yes" is every drawing (an admin). For anyone else the table answers with // the columns that make a drawing theirs, and the list is theirs alone. const may = permit(who, "drawing", "list"); if (may === "no") return refuse(who, "Sign in to see your drawings.", "You may not see these drawings."); if (may === "yes") return ok( db .query("SELECT * FROM drawing ORDER BY id DESC LIMIT ?1") .all(limit), ); const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); return ok( db .query( `SELECT * FROM drawing WHERE ${mine} ORDER BY id DESC LIMIT ?1`, ) .all(limit, who.role === "anon" ? "" : who.id), ); } /** * Ask for a drawing. The row is made at once, `drawing`; Claude fills it in * after the response (`src/draw.ts`) and the page hears `drawing.changed`. */ export const startDrawing = ( db: Database, who: Who, input: { prompt: string; kind?: string }, ): Result => audited(db, who, "drawing", "create", undefined, start(db, who, input)); function start( db: Database, who: Who, input: { prompt: string; kind?: string }, ): Result { if (who.role === "anon" || permit(who, "drawing", "create") !== "yes") return refuse(who, "Sign in to draw.", "You may not draw here."); const prompt = input.prompt.trim().replace(/\s+/g, " "); if (prompt.length === 0 || prompt.length > PROMPT_MAX) return fail("invalid", `Describe it in 1 to ${PROMPT_MAX} characters.`); const kind = input.kind === undefined || input.kind === "" ? "mark" : input.kind; if (!isKind(kind)) return fail("invalid", `A kind is one of: ${Object.keys(KINDS).join(", ")}.`); const busy = db .query<{ mine: number; everyone: number }, [string]>( "SELECT count(*) FILTER (WHERE author_id = ?1) AS mine, count(*) AS everyone FROM drawing WHERE status = 'drawing'", ) .get(who.id)!; if (busy.mine > 0) return fail("busy", "Your last drawing is still on the board. One at a time."); if (busy.everyone >= AT_ONCE) return fail("busy", "Claude is drawing for others right now. Try again in a minute."); const drawing = db .query( "INSERT INTO drawing (prompt, kind, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", ) .get(prompt, kind, who.id, who.name)!; emit("drawing.added", { id: drawing.id, status: drawing.status }); kick(db, drawing.id, prompt, kind); return ok(drawing); } /** * One drawing, 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 drawingFor( db: Database, who: Who, verb: "read" | "delete", id: number, said: { signIn: string; no: string }, ): Result { const drawing = Number.isInteger(id) ? db.query("SELECT * FROM drawing WHERE id = ?1").get(id) : null; const row: Readonly> = drawing === null ? { author_id: who.role === "anon" ? null : who.id } : { ...drawing }; if (permit(who, "drawing", verb, row) !== "yes") return refuse(who, said.signIn, said.no); if (drawing !== null) return ok(drawing); if (!Number.isInteger(id)) return fail("invalid", "A drawing id is a whole number."); return fail("not_found", "No such drawing."); } export function getDrawing(db: Database, who: Who, input: { id: number }): Result { return drawingFor(db, who, "read", input.id, { signIn: "Sign in to see this drawing.", no: "Only whoever asked for a drawing can see it.", }); } export const deleteDrawing = ( db: Database, who: Who, input: { id: number }, ): Result<{ id: number }> => audited(db, who, "drawing", "delete", input.id, remove(db, who, input)); function remove(db: Database, who: Who, input: { id: number }): Result<{ id: number }> { const found = drawingFor(db, who, "delete", input.id, { signIn: "Sign in to remove a drawing.", no: "Only whoever asked for a drawing, or an admin, can remove it.", }); if (!found.ok) return found; db.query("DELETE FROM drawing WHERE id = ?1").run(found.value.id); emit("drawing.removed", { id: found.value.id }); return ok({ id: found.value.id }); } /** Whether `who` may remove `drawing`: the table's answer, for the page and the terminal to draw from. */ export const canDelete = (who: Who, drawing: Drawing): boolean => permit(who, "drawing", "delete", { ...drawing }) === "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."); }