pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
| 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 type { Who } from "./who.ts"; |
| 14 | |
| 15 | export interface Note { |
| 16 | readonly id: number; |
| 17 | readonly body: string; |
| 18 | readonly author_id: string; |
| 19 | readonly author_name: string; |
| 20 | readonly created_at: string; |
| 21 | readonly updated_at: string; |
| 22 | } |
| 23 | |
| 24 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| 25 | export type Result<T> = |
| 26 | | { ok: true; value: T } |
| 27 | | { ok: false; error: Failure; message: string }; |
| 28 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 29 | const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 30 | ok: false, |
| 31 | error, |
| 32 | message, |
| 33 | }); |
| 34 | |
| 35 | export const BODY_MAX = 500; |
| 36 | |
| 37 | /** The refusal for a "no": who is not signed in is asked to; who is, is told. */ |
| 38 | const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| 39 | who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); |
| 40 | |
| 41 | /** |
| 42 | * A change, written into the audit trail whichever way it went: the row it |
| 43 | * made or touched, or the one it was refused. Every action that changes |
| 44 | * something returns through here, so the trail holds at every door. |
| 45 | */ |
| 46 | const audited = <T extends { readonly id: number }>( |
| 47 | db: Database, |
| 48 | who: Who, |
| 49 | resource: string, |
| 50 | verb: "create" | "update" | "delete", |
| 51 | target: number | undefined, |
| 52 | result: Result<T>, |
| 53 | ): Result<T> => { |
| 54 | record(db, { |
| 55 | who, |
| 56 | resource, |
| 57 | verb, |
| 58 | target: result.ok ? result.value.id : target, |
| 59 | outcome: result.ok ? "ok" : result.error, |
| 60 | }); |
| 61 | return result; |
| 62 | }; |
| 63 | |
| 64 | export function listNotes( |
| 65 | db: Database, |
| 66 | who: Who, |
| 67 | input: { limit?: number } = {}, |
| 68 | ): Result<Note[]> { |
| 69 | const limit = Math.min( |
| 70 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 71 | 500, |
| 72 | ); |
| 73 | // "yes" is every note. When the table gives `list` to a role a row holds |
| 74 | // (`author`), the answer names the columns instead, and the list is the |
| 75 | // notes that name whoever asks. The names are the contract's, checked when |
| 76 | // it was read; the value is bound. |
| 77 | const may = permit(who, "note", "list"); |
| 78 | if (may === "no") |
| 79 | return refuse(who, "Sign in to see notes.", "You may not see these notes."); |
| 80 | if (may === "yes") |
| 81 | return ok( |
| 82 | db |
| 83 | .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1") |
| 84 | .all(limit), |
| 85 | ); |
| 86 | const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); |
| 87 | return ok( |
| 88 | db |
| 89 | .query< |
| 90 | Note, |
| 91 | [number, string] |
| 92 | >(`SELECT * FROM note WHERE ${mine} ORDER BY id DESC LIMIT ?1`) |
| 93 | .all(limit, who.role === "anon" ? "" : who.id), |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | export const addNote = ( |
| 98 | db: Database, |
| 99 | who: Who, |
| 100 | input: { body: string }, |
| 101 | ): Result<Note> => |
| 102 | audited(db, who, "note", "create", undefined, add(db, who, input)); |
| 103 | |
| 104 | function add(db: Database, who: Who, input: { body: string }): Result<Note> { |
| 105 | // The table decides; the type only learns it here: whoever may create is |
| 106 | // signed in, and carries a name to write beside the note. |
| 107 | if (who.role === "anon" || permit(who, "note", "create") !== "yes") |
| 108 | return refuse(who, "Sign in to add a note.", "You may not add a note."); |
| 109 | const body = input.body.trim(); |
| 110 | if (body.length === 0 || body.length > BODY_MAX) |
| 111 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); |
| 112 | const note = db |
| 113 | .query< |
| 114 | Note, |
| 115 | [string, string, string] |
| 116 | >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *") |
| 117 | .get(body, who.id, who.name)!; |
| 118 | emit("note.added", note); |
| 119 | return ok(note); |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * One note, for `who` to do `verb` to. The door is asked before the row is |
| 124 | * looked for, so whoever may not is told that and never learns whether the |
| 125 | * row exists; whoever may, and asked for nothing, hears `not_found`. |
| 126 | */ |
| 127 | function noteFor( |
| 128 | db: Database, |
| 129 | who: Who, |
| 130 | verb: "read" | "update" | "delete", |
| 131 | id: number, |
| 132 | said: { signIn: string; no: string }, |
| 133 | ): Result<Note> { |
| 134 | const note = Number.isInteger(id) |
| 135 | ? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id) |
| 136 | : null; |
| 137 | // With no row to show, the door is asked about a row of their own: whoever |
| 138 | // could not pass even then is refused, and learns nothing about what |
| 139 | // exists; whoever could is told there is no such note. |
| 140 | const row: Readonly<Record<string, unknown>> = |
| 141 | note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note }; |
| 142 | if (permit(who, "note", verb, row) !== "yes") |
| 143 | return refuse(who, said.signIn, said.no); |
| 144 | if (note !== null) return ok(note); |
| 145 | if (!Number.isInteger(id)) |
| 146 | return fail("invalid", "A note id is a whole number."); |
| 147 | return fail("not_found", "No such note."); |
| 148 | } |
| 149 | |
| 150 | export function getNote( |
| 151 | db: Database, |
| 152 | who: Who, |
| 153 | input: { id: number }, |
| 154 | ): Result<Note> { |
| 155 | return noteFor(db, who, "read", input.id, { |
| 156 | signIn: "Sign in to read this note.", |
| 157 | no: "You may not read this note.", |
| 158 | }); |
| 159 | } |
| 160 | |
| 161 | export const updateNote = ( |
| 162 | db: Database, |
| 163 | who: Who, |
| 164 | input: { id: number; body: string }, |
| 165 | ): Result<Note> => |
| 166 | audited(db, who, "note", "update", input.id, update(db, who, input)); |
| 167 | |
| 168 | function update( |
| 169 | db: Database, |
| 170 | who: Who, |
| 171 | input: { id: number; body: string }, |
| 172 | ): Result<Note> { |
| 173 | const found = noteFor(db, who, "update", input.id, { |
| 174 | signIn: "Sign in to edit a note.", |
| 175 | no: "Only the author or an admin can edit a note.", |
| 176 | }); |
| 177 | if (!found.ok) return found; |
| 178 | const body = input.body.trim(); |
| 179 | if (body.length === 0 || body.length > BODY_MAX) |
| 180 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); |
| 181 | const note = db |
| 182 | .query< |
| 183 | Note, |
| 184 | [string, number] |
| 185 | >("UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *") |
| 186 | .get(body, found.value.id)!; |
| 187 | emit("note.changed", note); |
| 188 | return ok(note); |
| 189 | } |
| 190 | |
| 191 | export const deleteNote = ( |
| 192 | db: Database, |
| 193 | who: Who, |
| 194 | input: { id: number }, |
| 195 | ): Result<{ id: number }> => |
| 196 | audited(db, who, "note", "delete", input.id, remove(db, who, input)); |
| 197 | |
| 198 | function remove( |
| 199 | db: Database, |
| 200 | who: Who, |
| 201 | input: { id: number }, |
| 202 | ): Result<{ id: number }> { |
| 203 | const found = noteFor(db, who, "delete", input.id, { |
| 204 | signIn: "Sign in to remove a note.", |
| 205 | no: "Only the author or an admin can remove a note.", |
| 206 | }); |
| 207 | if (!found.ok) return found; |
| 208 | db.query("DELETE FROM note WHERE id = ?1").run(found.value.id); |
| 209 | emit("note.removed", { id: found.value.id }); |
| 210 | return ok({ id: found.value.id }); |
| 211 | } |
| 212 | |
| 213 | /** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */ |
| 214 | export const canDelete = (who: Who, note: Note): boolean => |
| 215 | permit(who, "note", "delete", { ...note }) === "yes"; |
| 216 | |
| 217 | /** |
| 218 | * The audit trail, newest first, for whoever the table lets read it (an |
| 219 | * admin, as the scaffold has it). `before` pages back by id. Audit rows name |
| 220 | * no author, so a cell that gave `list` to a row's role would find nothing |
| 221 | * of its own: anything but "yes" is a refusal here. |
| 222 | */ |
| 223 | export function listAudit( |
| 224 | db: Database, |
| 225 | who: Who, |
| 226 | input: { limit?: number; before?: number } = {}, |
| 227 | ): Result<AuditRow[]> { |
| 228 | if (permit(who, "audit", "list") !== "yes") |
| 229 | return refuse( |
| 230 | who, |
| 231 | "Sign in to read the audit trail.", |
| 232 | "Only an admin can read the audit trail.", |
| 233 | ); |
| 234 | const limit = Math.min( |
| 235 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 236 | 500, |
| 237 | ); |
| 238 | const before = Number.isInteger(input.before) |
| 239 | ? input.before! |
| 240 | : Number.MAX_SAFE_INTEGER; |
| 241 | return ok( |
| 242 | db |
| 243 | .query< |
| 244 | AuditRow, |
| 245 | [number, number] |
| 246 | >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") |
| 247 | .all(before, limit), |
| 248 | ); |
| 249 | } |
| 250 | |
| 251 | export function getAudit( |
| 252 | db: Database, |
| 253 | who: Who, |
| 254 | input: { id: number }, |
| 255 | ): Result<AuditRow> { |
| 256 | const row = Number.isInteger(input.id) |
| 257 | ? db |
| 258 | .query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1") |
| 259 | .get(input.id) |
| 260 | : null; |
| 261 | if (permit(who, "audit", "read", { ...row }) !== "yes") |
| 262 | return refuse( |
| 263 | who, |
| 264 | "Sign in to read the audit trail.", |
| 265 | "Only an admin can read the audit trail.", |
| 266 | ); |
| 267 | if (row !== null) return ok(row); |
| 268 | if (!Number.isInteger(input.id)) |
| 269 | return fail("invalid", "An audit id is a whole number."); |
| 270 | return fail("not_found", "No such audit row."); |
| 271 | } |