pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files e45e4989a79ef3f801c453528dd5b471f91bea4f
| 1 | /** |
| 2 | * Everything the app can do, as plain functions: `(db, who, input)` in, |
| 3 | * data or a named failure out. The page, the JSON API, the MCP server and |
| 4 | * the terminal are doors onto these and decide nothing themselves — so a |
| 5 | * rule written here holds at every door, and a new door is only a new |
| 6 | * way to call. |
| 7 | */ |
| 8 | |
| 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | import { permit } from "./contract.ts"; |
| 12 | import { emit } from "./live.ts"; |
| 13 | import { kick } from "./draw.ts"; |
| 14 | import { isKind, KINDS, PROMPT_MAX, type Kind } from "./glyph.ts"; |
| 15 | import type { Who } from "./who.ts"; |
| 16 | |
| 17 | export interface Drawing { |
| 18 | readonly id: number; |
| 19 | readonly prompt: string; |
| 20 | readonly kind: Kind; |
| 21 | readonly status: "drawing" | "done" | "failed"; |
| 22 | /** The SVGs, as a JSON array of strings: `svgsOf` reads it. */ |
| 23 | readonly svgs: string; |
| 24 | readonly error: string | null; |
| 25 | readonly author_id: string; |
| 26 | readonly author_name: string; |
| 27 | readonly created_at: string; |
| 28 | readonly updated_at: string; |
| 29 | } |
| 30 | |
| 31 | export type Failure = |
| 32 | | "signin_required" |
| 33 | | "forbidden" |
| 34 | | "not_found" |
| 35 | | "invalid" |
| 36 | | "busy"; |
| 37 | export type Result<T> = |
| 38 | | { ok: true; value: T } |
| 39 | | { ok: false; error: Failure; message: string }; |
| 40 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 41 | const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 42 | ok: false, |
| 43 | error, |
| 44 | message, |
| 45 | }); |
| 46 | |
| 47 | /** The refusal for a "no": who is not signed in is asked to; who is, is told. */ |
| 48 | const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| 49 | who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); |
| 50 | |
| 51 | /** |
| 52 | * A change, written into the audit trail whichever way it went: the row it |
| 53 | * made or touched, or the one it was refused. Every action that changes |
| 54 | * something returns through here, so the trail holds at every door. |
| 55 | */ |
| 56 | const audited = <T extends { readonly id: number }>( |
| 57 | db: Database, |
| 58 | who: Who, |
| 59 | resource: string, |
| 60 | verb: "create" | "update" | "delete", |
| 61 | target: number | undefined, |
| 62 | result: Result<T>, |
| 63 | ): Result<T> => { |
| 64 | record(db, { |
| 65 | who, |
| 66 | resource, |
| 67 | verb, |
| 68 | target: result.ok ? result.value.id : target, |
| 69 | outcome: result.ok ? "ok" : result.error, |
| 70 | }); |
| 71 | return result; |
| 72 | }; |
| 73 | |
| 74 | /** The SVGs a drawing holds. */ |
| 75 | export const svgsOf = (drawing: Drawing): string[] => { |
| 76 | try { |
| 77 | const parsed: unknown = JSON.parse(drawing.svgs); |
| 78 | return Array.isArray(parsed) ? parsed.filter((s) => typeof s === "string") : []; |
| 79 | } catch { |
| 80 | return []; |
| 81 | } |
| 82 | }; |
| 83 | |
| 84 | /** At most this many drawings are with Claude at once, across everyone. */ |
| 85 | export const AT_ONCE = 3; |
| 86 | |
| 87 | export function listDrawings( |
| 88 | db: Database, |
| 89 | who: Who, |
| 90 | input: { limit?: number } = {}, |
| 91 | ): Result<Drawing[]> { |
| 92 | const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 200); |
| 93 | // "yes" is every drawing (an admin). For anyone else the table answers with |
| 94 | // the columns that make a drawing theirs, and the list is theirs alone. |
| 95 | const may = permit(who, "drawing", "list"); |
| 96 | if (may === "no") |
| 97 | return refuse(who, "Sign in to see your drawings.", "You may not see these drawings."); |
| 98 | if (may === "yes") |
| 99 | return ok( |
| 100 | db |
| 101 | .query<Drawing, [number]>("SELECT * FROM drawing ORDER BY id DESC LIMIT ?1") |
| 102 | .all(limit), |
| 103 | ); |
| 104 | const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); |
| 105 | return ok( |
| 106 | db |
| 107 | .query<Drawing, [number, string]>( |
| 108 | `SELECT * FROM drawing WHERE ${mine} ORDER BY id DESC LIMIT ?1`, |
| 109 | ) |
| 110 | .all(limit, who.role === "anon" ? "" : who.id), |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Ask for a drawing. The row is made at once, `drawing`; Claude fills it in |
| 116 | * after the response (`src/draw.ts`) and the page hears `drawing.changed`. |
| 117 | */ |
| 118 | export const startDrawing = ( |
| 119 | db: Database, |
| 120 | who: Who, |
| 121 | input: { prompt: string; kind?: string }, |
| 122 | ): Result<Drawing> => |
| 123 | audited(db, who, "drawing", "create", undefined, start(db, who, input)); |
| 124 | |
| 125 | function start( |
| 126 | db: Database, |
| 127 | who: Who, |
| 128 | input: { prompt: string; kind?: string }, |
| 129 | ): Result<Drawing> { |
| 130 | if (who.role === "anon" || permit(who, "drawing", "create") !== "yes") |
| 131 | return refuse(who, "Sign in to draw.", "You may not draw here."); |
| 132 | const prompt = input.prompt.trim().replace(/\s+/g, " "); |
| 133 | if (prompt.length === 0 || prompt.length > PROMPT_MAX) |
| 134 | return fail("invalid", `Describe it in 1 to ${PROMPT_MAX} characters.`); |
| 135 | const kind = input.kind === undefined || input.kind === "" ? "mark" : input.kind; |
| 136 | if (!isKind(kind)) |
| 137 | return fail("invalid", `A kind is one of: ${Object.keys(KINDS).join(", ")}.`); |
| 138 | const busy = db |
| 139 | .query<{ mine: number; everyone: number }, [string]>( |
| 140 | "SELECT count(*) FILTER (WHERE author_id = ?1) AS mine, count(*) AS everyone FROM drawing WHERE status = 'drawing'", |
| 141 | ) |
| 142 | .get(who.id)!; |
| 143 | if (busy.mine > 0) |
| 144 | return fail("busy", "Your last drawing is still on the board. One at a time."); |
| 145 | if (busy.everyone >= AT_ONCE) |
| 146 | return fail("busy", "Claude is drawing for others right now. Try again in a minute."); |
| 147 | const drawing = db |
| 148 | .query<Drawing, [string, string, string, string]>( |
| 149 | "INSERT INTO drawing (prompt, kind, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", |
| 150 | ) |
| 151 | .get(prompt, kind, who.id, who.name)!; |
| 152 | emit("drawing.added", drawing); |
| 153 | kick(db, drawing.id, prompt, kind); |
| 154 | return ok(drawing); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * One drawing, for `who` to do `verb` to. The door is asked before the row is |
| 159 | * looked for, so whoever may not is told that and never learns whether the |
| 160 | * row exists; whoever may, and asked for nothing, hears `not_found`. |
| 161 | */ |
| 162 | function drawingFor( |
| 163 | db: Database, |
| 164 | who: Who, |
| 165 | verb: "read" | "delete", |
| 166 | id: number, |
| 167 | said: { signIn: string; no: string }, |
| 168 | ): Result<Drawing> { |
| 169 | const drawing = Number.isInteger(id) |
| 170 | ? db.query<Drawing, [number]>("SELECT * FROM drawing WHERE id = ?1").get(id) |
| 171 | : null; |
| 172 | const row: Readonly<Record<string, unknown>> = |
| 173 | drawing === null ? { author_id: who.role === "anon" ? null : who.id } : { ...drawing }; |
| 174 | if (permit(who, "drawing", verb, row) !== "yes") return refuse(who, said.signIn, said.no); |
| 175 | if (drawing !== null) return ok(drawing); |
| 176 | if (!Number.isInteger(id)) return fail("invalid", "A drawing id is a whole number."); |
| 177 | return fail("not_found", "No such drawing."); |
| 178 | } |
| 179 | |
| 180 | export function getDrawing(db: Database, who: Who, input: { id: number }): Result<Drawing> { |
| 181 | return drawingFor(db, who, "read", input.id, { |
| 182 | signIn: "Sign in to see this drawing.", |
| 183 | no: "Only whoever asked for a drawing can see it.", |
| 184 | }); |
| 185 | } |
| 186 | |
| 187 | export const deleteDrawing = ( |
| 188 | db: Database, |
| 189 | who: Who, |
| 190 | input: { id: number }, |
| 191 | ): Result<{ id: number }> => |
| 192 | audited(db, who, "drawing", "delete", input.id, remove(db, who, input)); |
| 193 | |
| 194 | function remove(db: Database, who: Who, input: { id: number }): Result<{ id: number }> { |
| 195 | const found = drawingFor(db, who, "delete", input.id, { |
| 196 | signIn: "Sign in to remove a drawing.", |
| 197 | no: "Only whoever asked for a drawing, or an admin, can remove it.", |
| 198 | }); |
| 199 | if (!found.ok) return found; |
| 200 | db.query("DELETE FROM drawing WHERE id = ?1").run(found.value.id); |
| 201 | emit("drawing.removed", { id: found.value.id }); |
| 202 | return ok({ id: found.value.id }); |
| 203 | } |
| 204 | |
| 205 | /** Whether `who` may remove `drawing`: the table's answer, for the page and the terminal to draw from. */ |
| 206 | export const canDelete = (who: Who, drawing: Drawing): boolean => |
| 207 | permit(who, "drawing", "delete", { ...drawing }) === "yes"; |
| 208 | |
| 209 | /** |
| 210 | * The audit trail, newest first, for whoever the table lets read it (an |
| 211 | * admin, as the scaffold has it). `before` pages back by id. Audit rows name |
| 212 | * no author, so a cell that gave `list` to a row's role would find nothing |
| 213 | * of its own: anything but "yes" is a refusal here. |
| 214 | */ |
| 215 | export function listAudit( |
| 216 | db: Database, |
| 217 | who: Who, |
| 218 | input: { limit?: number; before?: number } = {}, |
| 219 | ): Result<AuditRow[]> { |
| 220 | if (permit(who, "audit", "list") !== "yes") |
| 221 | return refuse( |
| 222 | who, |
| 223 | "Sign in to read the audit trail.", |
| 224 | "Only an admin can read the audit trail.", |
| 225 | ); |
| 226 | const limit = Math.min( |
| 227 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 228 | 500, |
| 229 | ); |
| 230 | const before = Number.isInteger(input.before) |
| 231 | ? input.before! |
| 232 | : Number.MAX_SAFE_INTEGER; |
| 233 | return ok( |
| 234 | db |
| 235 | .query< |
| 236 | AuditRow, |
| 237 | [number, number] |
| 238 | >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") |
| 239 | .all(before, limit), |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | export function getAudit( |
| 244 | db: Database, |
| 245 | who: Who, |
| 246 | input: { id: number }, |
| 247 | ): Result<AuditRow> { |
| 248 | const row = Number.isInteger(input.id) |
| 249 | ? db |
| 250 | .query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1") |
| 251 | .get(input.id) |
| 252 | : null; |
| 253 | if (permit(who, "audit", "read", { ...row }) !== "yes") |
| 254 | return refuse( |
| 255 | who, |
| 256 | "Sign in to read the audit trail.", |
| 257 | "Only an admin can read the audit trail.", |
| 258 | ); |
| 259 | if (row !== null) return ok(row); |
| 260 | if (!Number.isInteger(input.id)) |
| 261 | return fail("invalid", "An audit id is a whole number."); |
| 262 | return fail("not_found", "No such audit row."); |
| 263 | } |