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

Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
271 lines · 8.4 KB · ~2.5k 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 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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts