benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 9965609c46dc660e7b5b52f91658bb8a67c37294
610 lines · 22.2 KB · ~6.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 {
14
  cleanFolder,
15
  cleanSegment,
16
  edited,
17
  fileOf,
18
  folderOf,
19
  getSpace,
20
  isMember,
21
  spaceOf,
22
  spaceVault,
23
  tellSpace,
24
  type Space,
25
} from "./spaces.ts";
26
import { relink, search } from "./vault.ts";
27
import type { Who } from "./who.ts";
28
29
export interface Note {
30
  readonly id: number;
31
  readonly title: string;
32
  readonly body: string;
33
  readonly pinned: number;
34
  readonly author_id: string;
35
  readonly author_name: string;
36
  readonly created_at: string;
37
  readonly updated_at: string;
38
  /** A shared note's file in its repository; null for a note of your own. */
39
  readonly path: string | null;
40
  /** Where the repository last had it, and the git blob it had (src/sync.ts). */
41
  readonly synced_path: string | null;
42
  readonly synced_oid: string | null;
43
}
44
45
export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid" | "conflict";
46
export type Result<T> =
47
  | { ok: true; value: T }
48
  | { ok: false; error: Failure; message: string };
49
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
50
const fail = <T>(error: Failure, message: string): Result<T> => ({
51
  ok: false,
52
  error,
53
  message,
54
});
55
56
export const TITLE_MAX = 200;
57
export const BODY_MAX = 200_000;
58
/** The vault, at most: a list asks for no more than this many. */
59
export const VAULT_MAX = 5000;
60
61
/** The refusal for a "no": who is not signed in is asked to; who is, is told. */
62
const refuse = <T>(who: Who, signIn: string, no: string): Result<T> =>
63
  who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no);
64
65
/**
66
 * A change, written into the audit trail whichever way it went: the row it
67
 * made or touched, or the one it was refused. Every action that changes
68
 * something returns through here, so the trail holds at every door.
69
 */
70
const audited = <T extends { readonly id: number }>(
71
  db: Database,
72
  who: Who,
73
  resource: string,
74
  verb: "create" | "update" | "delete",
75
  target: number | undefined,
76
  result: Result<T>,
77
): Result<T> => {
78
  record(db, {
79
    who,
80
    resource,
81
    verb,
82
    target: result.ok ? result.value.id : target,
83
    outcome: result.ok ? "ok" : result.error,
84
  });
85
  return result;
86
};
87
88
/** Whose vault a change belongs to: its live events reach only them — or, in a shared folder, its members. */
89
const tell = (db: Database, type: string, note: Note): void => {
90
  const data = { id: note.id, title: note.title, updated_at: note.updated_at };
91
  const space = spaceOf(note.author_id);
92
  if (space === undefined) emit(type, data, note.author_id);
93
  else tellSpace(db, space, type, data);
94
};
95
96
/**
97
 * The vault `who` means: their own, or — named by `space` — a shared
98
 * folder they are in. Every vault query takes the answer's `vault` where
99
 * it once took `who.id`, which is all a shared folder needs.
100
 */
101
function vaultFor(
102
  db: Database,
103
  who: Who,
104
  space: number | undefined,
105
): Result<{ vault: string; space?: Space }> {
106
  if (who.role === "anon") return fail("signin_required", "Sign in to open your notes.");
107
  if (space === undefined) return ok({ vault: who.id });
108
  const found = getSpace(db, who, space);
109
  if (!found.ok) return found;
110
  return ok({ vault: spaceVault(space), space: found.value });
111
}
112
113
/**
114
 * A title, as the vault keeps it: trimmed, one line, and free of the
115
 * characters a [[link]] uses to say something else (`[ ] | # ^`).
116
 */
117
export function cleanTitle(raw: string): Result<string> {
118
  const title = raw.replace(/\s+/g, " ").trim();
119
  if (title.length === 0 || title.length > TITLE_MAX)
120
    return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`);
121
  if (/[\[\]|#^\\]/.test(title))
122
    return fail("invalid", "A title cannot hold [ ] | # ^ or \\ — links use them.");
123
  return ok(title);
124
}
125
126
const checkBody = (body: string): Result<string> =>
127
  body.length > BODY_MAX
128
    ? fail("invalid", `A note is at most ${BODY_MAX.toLocaleString("en")} characters.`)
129
    : ok(body);
130
131
/**
132
 * The vault: the notes of whoever asks, pinned first, then the most
133
 * recently changed. A vault is its author's — the table gives `list` to the
134
 * row's author — so the list is always the asker's own notes, and nobody
135
 * else's, whatever role they hold.
136
 */
137
export function listNotes(
138
  db: Database,
139
  who: Who,
140
  input: { limit?: number; space?: number } = {},
141
): Result<Note[]> {
142
  const limit = Math.min(
143
    Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1),
144
    VAULT_MAX,
145
  );
146
  const may = permit(who, "note", "list");
147
  if (may === "no" || who.role === "anon")
148
    return refuse(who, "Sign in to open your notes.", "You may not see these notes.");
149
  const vault = vaultFor(db, who, input.space);
150
  if (!vault.ok) return vault;
151
  return ok(
152
    db
153
      .query<Note, [string, number]>(
154
        "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?2",
155
      )
156
      .all(vault.value.vault, limit),
157
  );
158
}
159
160
/** The next free "Untitled", "Untitled 2", … in a vault. */
161
function freeTitle(db: Database, authorId: string, base: string): string {
162
  const taken = (t: string) =>
163
    db
164
      .query<{ id: number }, [string, string]>(
165
        "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE",
166
      )
167
      .get(authorId, t) !== null;
168
  if (!taken(base)) return base;
169
  for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`;
170
}
171
172
const titleTaken = (db: Database, authorId: string, title: string, except = -1): boolean =>
173
  db
174
    .query<{ id: number }, [string, string, number]>(
175
      "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE AND id != ?3",
176
    )
177
    .get(authorId, title, except) !== null;
178
179
/** What a new note may say: in a shared folder, also which folder (or a title `folder/Title`). */
180
export interface NewNote {
181
  readonly title?: string;
182
  readonly body?: string;
183
  readonly space?: number;
184
  readonly folder?: string;
185
}
186
187
export const addNote = (db: Database, who: Who, input: NewNote): Result<Note> =>
188
  audited(db, who, "note", "create", undefined, add(db, who, input));
189
190
/** A shared note's title and folder: its title is a file name, and `a/b/Title` puts it in a folder. */
191
function placeOf(input: { title: string; folder?: string }): Result<{ title: string; folder: string }> {
192
  const cut = input.title.lastIndexOf("/");
193
  const title = cleanSegment(cut < 0 ? input.title : input.title.slice(cut + 1));
194
  if (!title.ok) return title;
195
  const folder = cleanFolder(
196
    [input.folder ?? "", cut < 0 ? "" : input.title.slice(0, cut)].filter((f) => f !== "").join("/"),
197
  );
198
  if (!folder.ok) return folder;
199
  return ok({ title: title.value, folder: folder.value });
200
}
201
202
function add(db: Database, who: Who, input: NewNote): Result<Note> {
203
  // The table decides; the type only learns it here: whoever may create is
204
  // signed in, and carries a name to write beside the note.
205
  if (who.role === "anon" || permit(who, "note", "create") !== "yes")
206
    return refuse(who, "Sign in to add a note.", "You may not add a note.");
207
  const where = vaultFor(db, who, input.space);
208
  if (!where.ok) return where;
209
  const { vault, space } = where.value;
210
  const body = checkBody(input.body ?? "");
211
  if (!body.ok) return body;
212
  const count = db
213
    .query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1")
214
    .get(vault)!.n;
215
  if (count >= VAULT_MAX)
216
    return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`);
217
  let title: string;
218
  let folder = "";
219
  if (input.title === undefined || input.title.trim() === "" || input.title.trim().endsWith("/")) {
220
    if (space !== undefined) {
221
      const at = cleanFolder(`${input.folder ?? ""}/${input.title ?? ""}`);
222
      if (!at.ok) return at;
223
      folder = at.value;
224
    }
225
    title = freeTitle(db, vault, "Untitled");
226
  } else if (space !== undefined) {
227
    const place = placeOf({ title: input.title, ...(input.folder === undefined ? {} : { folder: input.folder }) });
228
    if (!place.ok) return place;
229
    title = place.value.title;
230
    folder = place.value.folder;
231
  } else {
232
    const clean = cleanTitle(input.title);
233
    if (!clean.ok) return clean;
234
    title = clean.value;
235
  }
236
  if (titleTaken(db, vault, title))
237
    return fail("invalid", `A note called “${title}” already exists.`);
238
  const path = space === undefined ? null : fileOf(space, folder, title);
239
  if (path !== null && pathTaken(db, vault, path))
240
    return fail("invalid", `There is already a file at ${path}.`);
241
  const note = db
242
    .query<Note, [string, string, string, string, string | null]>(
243
      "INSERT INTO note (title, body, author_id, author_name, path) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *",
244
    )
245
    .get(title, body.value, vault, who.name, path)!;
246
  tell(db, "note.added", note);
247
  if (space !== undefined) edited(space.id);
248
  return ok(note);
249
}
250
251
const pathTaken = (db: Database, vault: string, path: string, except = -1): boolean =>
252
  db
253
    .query<{ id: number }, [string, string, number]>(
254
      "SELECT id FROM note WHERE author_id = ?1 AND path = ?2 COLLATE NOCASE AND id != ?3",
255
    )
256
    .get(vault, path, except) !== null;
257
258
/**
259
 * One note, for `who` to do `verb` to. The door is asked before the row is
260
 * looked for, so whoever may not is told that and never learns whether the
261
 * row exists; whoever may, and asked for nothing, hears `not_found`.
262
 */
263
function noteFor(
264
  db: Database,
265
  who: Who,
266
  verb: "read" | "update" | "delete",
267
  id: number,
268
  said: { signIn: string; no: string },
269
): Result<Note> {
270
  const note = Number.isInteger(id)
271
    ? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id)
272
    : null;
273
  // With no row to show, the door is asked about a row of their own: whoever
274
  // could not pass even then is refused, and learns nothing about what
275
  // exists; whoever could is told there is no such note.
276
  // A shared note is its folder's: whoever is in the folder is the row's
277
  // member, which is what the table gives a shared note's verbs to.
278
  const space = note === null ? undefined : spaceOf(note.author_id);
279
  const row: Readonly<Record<string, unknown>> =
280
    note === null
281
      ? { author_id: who.role === "anon" ? null : who.id }
282
      : {
283
          ...note,
284
          member_id:
285
            space !== undefined && who.role !== "anon" && isMember(db, space, who) ? who.id : null,
286
        };
287
  if (permit(who, "note", verb, row) !== "yes")
288
    return refuse(who, said.signIn, said.no);
289
  if (note !== null) return ok(note);
290
  if (!Number.isInteger(id))
291
    return fail("invalid", "A note id is a whole number.");
292
  return fail("not_found", "No such note.");
293
}
294
295
export function getNote(
296
  db: Database,
297
  who: Who,
298
  input: { id: number },
299
): Result<Note> {
300
  return noteFor(db, who, "read", input.id, {
301
    signIn: "Sign in to read this note.",
302
    no: "You may not read this note.",
303
  });
304
}
305
306
/** A note in the asker's vault (or a shared folder of theirs) by its title, any case. */
307
export function findNote(
308
  db: Database,
309
  who: Who,
310
  input: { title: string; space?: number },
311
): Result<Note> {
312
  const where = vaultFor(db, who, input.space);
313
  if (!where.ok) return where;
314
  // In a shared folder `ideas/Pricing` names the note Pricing.
315
  const raw = input.space === undefined ? input.title : input.title.slice(input.title.lastIndexOf("/") + 1);
316
  const title = raw.replace(/\s+/g, " ").trim();
317
  const note = db
318
    .query<Note, [string, string]>(
319
      "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE",
320
    )
321
    .get(where.value.vault, title);
322
  if (note === null) return fail("not_found", `No note called “${title}”.`);
323
  return getNote(db, who, { id: note.id });
324
}
325
326
/** The note with this title, made (empty, or from `body`) when there is none yet: following a link, opening today. */
327
export function openOrCreate(
328
  db: Database,
329
  who: Who,
330
  input: { title: string; body?: string; space?: number; folder?: string },
331
): Result<Note & { created: boolean }> {
332
  const found = findNote(db, who, input);
333
  if (found.ok) return ok({ ...found.value, created: false });
334
  if (found.error !== "not_found") return found;
335
  const made = addNote(db, who, { ...input, body: input.body ?? "" });
336
  return made.ok ? ok({ ...made.value, created: true }) : made;
337
}
338
339
export interface Change {
340
  readonly id: number;
341
  readonly title?: string;
342
  readonly body?: string;
343
  readonly pinned?: boolean;
344
  /** A shared note's folder, relative to its space's: a move. */
345
  readonly folder?: string;
346
  /**
347
   * The `updated_at` the change was written against. When the note has
348
   * moved on since — someone else saved it — new words or a new title are
349
   * refused as a `conflict` rather than written over theirs.
350
   */
351
  readonly base?: string;
352
}
353
354
export const updateNote = (
355
  db: Database,
356
  who: Who,
357
  input: Change,
358
): Result<Note & { relinked: number }> =>
359
  audited(db, who, "note", "update", input.id, update(db, who, input));
360
361
/**
362
 * Change a note's title, words or pin. A new title is carried into every
363
 * note of the vault that linked to the old one, in the same transaction,
364
 * so a rename never leaves a link pointing at nothing.
365
 */
366
function update(
367
  db: Database,
368
  who: Who,
369
  input: Change,
370
): Result<Note & { relinked: number }> {
371
  const found = noteFor(db, who, "update", input.id, {
372
    signIn: "Sign in to edit a note.",
373
    no: "Only the author can edit a note.",
374
  });
375
  if (!found.ok) return found;
376
  const before = found.value;
377
  const spaceId = spaceOf(before.author_id);
378
  const space = spaceId === undefined ? undefined : getSpace(db, who, spaceId);
379
  if (space !== undefined && !space.ok) return space;
380
  if (
381
    input.base !== undefined &&
382
    input.base !== before.updated_at &&
383
    ((input.body !== undefined && input.body !== before.body) ||
384
      (input.title !== undefined && input.title !== before.title))
385
  )
386
    return fail("conflict", `${before.author_name} saved this note while you were writing.`);
387
  let title = before.title;
388
  if (input.title !== undefined) {
389
    const clean = space === undefined ? cleanTitle(input.title) : cleanSegment(input.title);
390
    if (!clean.ok) return clean;
391
    if (titleTaken(db, before.author_id, clean.value, before.id))
392
      return fail("invalid", `A note called “${clean.value}” already exists.`);
393
    title = clean.value;
394
  }
395
  let body = before.body;
396
  if (input.body !== undefined) {
397
    const checked = checkBody(input.body);
398
    if (!checked.ok) return checked;
399
    body = checked.value;
400
  }
401
  const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0;
402
  // A shared note's file follows its title, and its folder when it moves.
403
  let path = before.path;
404
  if (space !== undefined && before.path !== null) {
405
    let folder = folderOf(space.value, before.path);
406
    if (input.folder !== undefined) {
407
      const clean = cleanFolder(input.folder);
408
      if (!clean.ok) return clean;
409
      folder = clean.value;
410
    }
411
    // A file whose name was never its title (two files of one name, read
412
    // from the repository) keeps its name until it is renamed or moved.
413
    path =
414
      title === before.title && input.folder === undefined ? before.path : fileOf(space.value, folder, title);
415
    if (path !== before.path && pathTaken(db, before.author_id, path, before.id))
416
      return fail("invalid", `There is already a file at ${path}.`);
417
  }
418
  if (title === before.title && body === before.body && pinned === before.pinned && path === before.path)
419
    return ok({ ...before, relinked: 0 });
420
  const renamed = title.toLowerCase() !== before.title.toLowerCase();
421
  const touched: Note[] = [];
422
  // In a shared folder the name beside a note is whoever wrote in it last.
423
  const by = space !== undefined && who.role !== "anon" ? who.name : before.author_name;
424
  const note = db.transaction(() => {
425
    const saved = db
426
      .query<Note, [string, string, number, number, number, string | null, string]>(
427
        // A pin is not an edit: only new words, a new title or a move move a note up by recency.
428
        "UPDATE note SET title = ?1, body = ?2, pinned = ?3, path = ?6, author_name = CASE WHEN ?5 THEN ?7 ELSE author_name END, updated_at = CASE WHEN ?5 THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ELSE updated_at END WHERE id = ?4 RETURNING *",
429
      )
430
      .get(
431
        title,
432
        body,
433
        pinned,
434
        before.id,
435
        title !== before.title || body !== before.body || path !== before.path ? 1 : 0,
436
        path,
437
        by,
438
      )!;
439
    if (renamed || title !== before.title)
440
      for (const other of db
441
        .query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2")
442
        .all(before.author_id, before.id)) {
443
        const next = relink(other.body, before.title, title);
444
        if (next === other.body) continue;
445
        touched.push(
446
          db
447
            .query<Note, [string, number]>(
448
              "UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *",
449
            )
450
            .get(next, other.id)!,
451
        );
452
      }
453
    return saved;
454
  })();
455
  tell(db, "note.changed", note);
456
  for (const other of touched) tell(db, "note.changed", other);
457
  if (spaceId !== undefined) edited(spaceId);
458
  return ok({ ...note, relinked: touched.length });
459
}
460
461
export const deleteNote = (
462
  db: Database,
463
  who: Who,
464
  input: { id: number },
465
): Result<{ id: number }> =>
466
  audited(db, who, "note", "delete", input.id, remove(db, who, input));
467
468
function remove(
469
  db: Database,
470
  who: Who,
471
  input: { id: number },
472
): Result<{ id: number }> {
473
  const found = noteFor(db, who, "delete", input.id, {
474
    signIn: "Sign in to remove a note.",
475
    no: "Only the author can remove a note.",
476
  });
477
  if (!found.ok) return found;
478
  const note = found.value;
479
  const space = spaceOf(note.author_id);
480
  db.transaction(() => {
481
    db.query("DELETE FROM note WHERE id = ?1").run(note.id);
482
    // The repository still has its file until the next commit takes it away.
483
    if (space !== undefined && note.synced_path !== null)
484
      db.query("INSERT OR IGNORE INTO space_gone (space_id, path) VALUES (?1, ?2)").run(space, note.synced_path);
485
  })();
486
  if (space === undefined) emit("note.removed", { id: note.id }, note.author_id);
487
  else {
488
    tellSpace(db, space, "note.removed", { id: note.id });
489
    edited(space);
490
  }
491
  return ok({ id: note.id });
492
}
493
494
/** Search the asker's vault (see `vault.search` for the language). */
495
export function searchNotes(
496
  db: Database,
497
  who: Who,
498
  input: { q: string; limit?: number; space?: number },
499
): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> {
500
  const vault = listNotes(db, who, input.space === undefined ? {} : { space: input.space });
501
  if (!vault.ok) return vault;
502
  const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500);
503
  return ok(
504
    search(vault.value, input.q.slice(0, 500))
505
      .slice(0, limit)
506
      .map(({ note, snippet }) => ({
507
        id: note.id,
508
        title: note.title,
509
        snippet,
510
        updated_at: note.updated_at,
511
      })),
512
  );
513
}
514
515
/** Today's daily note title, `YYYY-MM-DD`, in the given time zone offset (minutes, as `Date#getTimezoneOffset`). */
516
export const today = (offsetMinutes = 0, now = new Date()): string =>
517
  new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10);
518
519
/** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */
520
export const canDelete = (who: Who, note: Note): boolean =>
521
  permit(who, "note", "delete", { ...note }) === "yes";
522
523
/** A welcome note: what the vault can do, written in what it can do. */
524
export const WELCOME = `# Welcome to your vault
525
526
This is a note. Every note is **markdown**, saved as you type, and private to you.
527
528
## Linking thoughts
529
Write [[double brackets]] around a title to link notes — that one doesn't exist yet, so following it creates it. Links work both ways: open a note and its **backlinks** show everything pointing at it. Rename a note and every link to it follows.
530
531
Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]]
532
533
## Tags
534
Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it.
535
536
## The rest of markdown
537
- ==highlights==, ~~strikes~~, \`code\`, *emphasis*
538
- [x] tasks you've done
539
- [ ] tasks you haven't
540
541
> [!tip] Keys
542
> **⌘K** / **Ctrl K** — jump to any note, or create one
543
> **⌘E** / **Ctrl E** — switch between editing and reading
544
> **⌘⇧D** / **Ctrl ⇧ D** — today's daily note
545
> **⌘G** / **Ctrl G** — the graph
546
547
| Where | What |
548
| --- | --- |
549
| Left | your notes, pinned first, and your tags |
550
| Middle | the note |
551
| Right | outline, links and backlinks |
552
553
Open the **graph** to see how everything connects.
554
`;
555
556
/**
557
 * The audit trail, newest first, for whoever the table lets read it (an
558
 * admin, as the scaffold has it). `before` pages back by id. Audit rows name
559
 * no author, so a cell that gave `list` to a row's role would find nothing
560
 * of its own: anything but "yes" is a refusal here.
561
 */
562
export function listAudit(
563
  db: Database,
564
  who: Who,
565
  input: { limit?: number; before?: number } = {},
566
): Result<AuditRow[]> {
567
  if (permit(who, "audit", "list") !== "yes")
568
    return refuse(
569
      who,
570
      "Sign in to read the audit trail.",
571
      "Only an admin can read the audit trail.",
572
    );
573
  const limit = Math.min(
574
    Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
575
    500,
576
  );
577
  const before = Number.isInteger(input.before)
578
    ? input.before!
579
    : Number.MAX_SAFE_INTEGER;
580
  return ok(
581
    db
582
      .query<
583
        AuditRow,
584
        [number, number]
585
      >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2")
586
      .all(before, limit),
587
  );
588
}
589
590
export function getAudit(
591
  db: Database,
592
  who: Who,
593
  input: { id: number },
594
): Result<AuditRow> {
595
  const row = Number.isInteger(input.id)
596
    ? db
597
        .query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1")
598
        .get(input.id)
599
    : null;
600
  if (permit(who, "audit", "read", { ...row }) !== "yes")
601
    return refuse(
602
      who,
603
      "Sign in to read the audit trail.",
604
      "Only an admin can read the audit trail.",
605
    );
606
  if (row !== null) return ok(row);
607
  if (!Number.isInteger(input.id))
608
    return fail("invalid", "An audit id is a whole number.");
609
  return fail("not_found", "No such audit row.");
610
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts