apps/glyph Public The code is public; the app answers only to 1 person who can change it and the crew.

Files cd3c7d74b323cac4ab0520540ce33bcf71a582db
263 lines · 8.8 KB · ~2.6k tok Raw History
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts