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

Files 1060c95de59213030a45f268d791921201715cde
706 lines · 20.5 KB · ~6.0k tok Raw History
1
/**
2
 * Every path the app answers, in one table. A route reads its request,
3
 * calls an action, and shapes the answer for its door — HTML for a
4
 * browser, JSON for a script — and that is all a route does.
5
 *
6
 * A route that is part of the API says so beside itself (`api`): one
7
 * sentence, and the shape of what it takes and gives, as JSON Schema.
8
 * `bun run contract:emit` writes those into `contract/openapi.json`, the
9
 * tests fail when the committed file and this table disagree, and
10
 * `GET /openapi.json` serves it. The shapes describe; the checks that hold
11
 * are still the actions' own.
12
 */
13
14
import type { Database } from "bun:sqlite";
15
import { existsSync, readFileSync } from "node:fs";
16
import {
17
  addNote,
18
  BODY_MAX,
19
  deleteNote,
20
  getAudit,
21
  getNote,
22
  listAudit,
23
  listNotes,
24
  openOrCreate,
25
  searchNotes,
26
  TITLE_MAX,
27
  today,
28
  updateNote,
29
  WELCOME,
30
  type Change,
31
  type Failure,
32
  type Note,
33
  type Result,
34
} from "./actions.ts";
35
import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
36
import {
37
  fields,
38
  go,
39
  html,
40
  json,
41
  NOTE_BYTES,
42
  readForm,
43
  readJson,
44
  readNotice,
45
  SECURITY,
46
  staticFile,
47
  text,
48
} from "./http.ts";
49
import { events } from "./live.ts";
50
import { rpc } from "./mcp.ts";
51
import {
52
  emptyPage,
53
  graphPage,
54
  landing,
55
  missingPage,
56
  notePage,
57
  searchPage,
58
} from "./page.ts";
59
import { tokensCss } from "./tokens.ts";
60
import { search } from "./vault.ts";
61
import type { Who } from "./who.ts";
62
63
export interface Context {
64
  readonly request: Request;
65
  readonly url: URL;
66
  readonly db: Database;
67
  readonly who: Who;
68
  readonly params: Record<string, string>;
69
}
70
71
/** A JSON Schema, as plain data. */
72
export type Schema = Readonly<Record<string, unknown>>;
73
74
/** What an API route is, for the description: never consulted to decide anything. */
75
export interface Api {
76
  readonly summary: string;
77
  /** The body it reads. */
78
  readonly body?: Schema;
79
  /** What it answers when it succeeds, and with which status (200). */
80
  readonly answer: Schema;
81
  readonly status?: number;
82
  /** The query parameters it reads. */
83
  readonly query?: Readonly<Record<string, Schema>>;
84
}
85
86
export interface Route {
87
  readonly method: "GET" | "POST" | "PATCH" | "DELETE";
88
  readonly path: string;
89
  readonly handle: (ctx: Context) => Response | Promise<Response>;
90
  readonly api?: Api;
91
}
92
93
// The shapes the API speaks in, named once.
94
export const NOTE: Schema = {
95
  type: "object",
96
  properties: {
97
    id: { type: "integer" },
98
    title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
99
    body: { type: "string", maxLength: BODY_MAX },
100
    pinned: { enum: [0, 1] },
101
    author_id: { type: "string" },
102
    author_name: { type: "string" },
103
    created_at: { type: "string", format: "date-time" },
104
    updated_at: { type: "string", format: "date-time" },
105
  },
106
  required: [
107
    "id",
108
    "title",
109
    "body",
110
    "pinned",
111
    "author_id",
112
    "author_name",
113
    "created_at",
114
    "updated_at",
115
  ],
116
};
117
const NOTE_NEW: Schema = {
118
  type: "object",
119
  properties: {
120
    title: {
121
      type: "string",
122
      maxLength: TITLE_MAX,
123
      description: "Unique in your vault; “Untitled” when left out.",
124
    },
125
    body: { type: "string", maxLength: BODY_MAX, description: "Markdown." },
126
  },
127
};
128
const NOTE_CHANGE: Schema = {
129
  type: "object",
130
  properties: {
131
    title: {
132
      type: "string",
133
      minLength: 1,
134
      maxLength: TITLE_MAX,
135
      description: "A rename carries every [[link]] to the note with it.",
136
    },
137
    body: { type: "string", maxLength: BODY_MAX },
138
    pinned: { type: "boolean" },
139
  },
140
};
141
export const AUDIT_ROW: Schema = {
142
  type: "object",
143
  properties: {
144
    id: { type: "integer" },
145
    at: { type: "string", format: "date-time" },
146
    who_id: { type: ["string", "null"] },
147
    who_name: { type: ["string", "null"] },
148
    role: { enum: ["anon", "user", "admin"] },
149
    resource: { type: "string" },
150
    verb: { type: "string" },
151
    target: { type: ["string", "null"] },
152
    allowed: { enum: [0, 1] },
153
    outcome: { type: "string" },
154
  },
155
  required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"],
156
};
157
export const ERROR: Schema = {
158
  type: "object",
159
  properties: { error: { type: "string" }, message: { type: "string" } },
160
  required: ["error"],
161
};
162
const LIMIT: Schema = {
163
  type: "integer",
164
  minimum: 1,
165
  maximum: 500,
166
  default: 100,
167
};
168
const list = (items: Schema): Schema => ({ type: "array", items });
169
170
const STATUS: Record<Failure, number> = {
171
  signin_required: 401,
172
  forbidden: 403,
173
  not_found: 404,
174
  invalid: 422,
175
};
176
177
/** A result as JSON: the value, or `{ error, message }` with the matching status. */
178
const answer = <T>(result: Result<T>, status = 200): Response =>
179
  result.ok
180
    ? json(result.value, status)
181
    : json(
182
        { error: result.error, message: result.message },
183
        STATUS[result.error],
184
      );
185
186
/** A result for a form: on to where it leads, or back with what went wrong. */
187
const done = <T>(result: Result<T>, to: (value: T) => string, back = "/"): Response =>
188
  result.ok ? go(to(result.value)) : go(back, result.message);
189
190
const toNote = (n: { readonly id: number }): string => `/n/${n.id}`;
191
192
/** The fields of a JSON body that change a note, and only those. */
193
const change = (id: number, body: Record<string, unknown>): Change => ({
194
  id,
195
  ...(typeof body.title === "string" ? { title: body.title } : {}),
196
  ...(typeof body.body === "string" ? { body: body.body } : {}),
197
  ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}),
198
});
199
200
/** The vault around a page: who is signed in, and every note of theirs. */
201
function vaultOf(c: Context):
202
  | { who: Who & { role: "user" | "admin" }; vault: Note[]; notice?: string; clear: Record<string, string> }
203
  | Response {
204
  const { notice, clear } = readNotice(c.request);
205
  if (c.who.role === "anon") return html(landing(notice), 200, clear);
206
  const vault = listNotes(c.db, c.who);
207
  return {
208
    who: c.who,
209
    vault: vault.ok ? vault.value : [],
210
    ...(notice === undefined ? {} : { notice }),
211
    clear,
212
  };
213
}
214
215
/** A whole number from the query, or undefined. */
216
const whole = (value: string | null): number | undefined =>
217
  value !== null && /^\d+$/.test(value) ? Number(value) : undefined;
218
219
// The API's description, read from git once: what the tests hold equal to this table.
220
const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
221
const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
222
223
export const routes: readonly Route[] = [
224
  // The pages. Signed out, the way in; signed in, the vault. Forms post and
225
  // land where they lead, so every page works without JavaScript.
226
  {
227
    method: "GET",
228
    path: "/",
229
    handle: (c) => {
230
      const v = vaultOf(c);
231
      if (v instanceof Response) return v;
232
      const first = v.vault.reduce<Note | undefined>(
233
        (latest, n) => (latest === undefined || n.updated_at > latest.updated_at ? n : latest),
234
        undefined,
235
      );
236
      // The vault opens on the note you were last writing.
237
      if (first !== undefined && v.notice === undefined) return go(toNote(first));
238
      if (first !== undefined) return html(notePage({ ...v, active: first }), 200, v.clear);
239
      return html(emptyPage(v), 200, v.clear);
240
    },
241
  },
242
  {
243
    method: "GET",
244
    path: "/n/:id",
245
    handle: (c) => {
246
      const v = vaultOf(c);
247
      if (v instanceof Response) return v;
248
      const note = getNote(c.db, c.who, { id: Number(c.params.id) });
249
      if (!note.ok) return go("/", note.message);
250
      return html(notePage({ ...v, active: note.value }), 200, v.clear);
251
    },
252
  },
253
  {
254
    method: "GET",
255
    path: "/n/:id/raw",
256
    handle: (c) => {
257
      const note = getNote(c.db, c.who, { id: Number(c.params.id) });
258
      if (!note.ok) return answer(note);
259
      return new Response(note.value.body, {
260
        headers: {
261
          "content-type": "text/markdown; charset=utf-8",
262
          "content-disposition": `attachment; filename="${note.value.title.replace(/[^\w .-]+/g, "_")}.md"; filename*=UTF-8''${encodeURIComponent(`${note.value.title}.md`)}`,
263
          ...SECURITY,
264
        },
265
      });
266
    },
267
  },
268
  {
269
    method: "POST",
270
    path: "/n/:id",
271
    handle: async (c) => {
272
      const form = await readForm(c.request, NOTE_BYTES);
273
      if (form === undefined) return text("too large", 413);
274
      const id = Number(c.params.id);
275
      return done(
276
        updateNote(c.db, c.who, {
277
          id,
278
          title: String(form.get("title") ?? ""),
279
          body: String(form.get("body") ?? ""),
280
        }),
281
        toNote,
282
        toNote({ id }),
283
      );
284
    },
285
  },
286
  {
287
    method: "POST",
288
    path: "/n/:id/pin",
289
    handle: async (c) => {
290
      const form = await readForm(c.request);
291
      if (form === undefined) return text("too large", 413);
292
      const id = Number(c.params.id);
293
      return done(
294
        updateNote(c.db, c.who, { id, pinned: form.get("pinned") === "1" }),
295
        toNote,
296
        toNote({ id }),
297
      );
298
    },
299
  },
300
  {
301
    method: "POST",
302
    path: "/n/:id/delete",
303
    handle: (c) =>
304
      done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => "/"),
305
  },
306
  {
307
    method: "POST",
308
    path: "/new",
309
    handle: async (c) => {
310
      const form = await readForm(c.request);
311
      if (form === undefined) return text("too large", 413);
312
      const title = String(form.get("title") ?? "");
313
      return done(addNote(c.db, c.who, title === "" ? {} : { title }), toNote);
314
    },
315
  },
316
  {
317
    method: "POST",
318
    path: "/daily",
319
    handle: async (c) => {
320
      const form = await readForm(c.request);
321
      if (form === undefined) return text("too large", 413);
322
      const tz = Number(form.get("tz") ?? 0);
323
      const day = today(Number.isFinite(tz) && Math.abs(tz) <= 840 ? tz : 0);
324
      return done(openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n` }), toNote);
325
    },
326
  },
327
  {
328
    method: "POST",
329
    path: "/welcome",
330
    handle: (c) =>
331
      done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote),
332
  },
333
  {
334
    method: "GET",
335
    path: "/open",
336
    handle: (c) => {
337
      const v = vaultOf(c);
338
      if (v instanceof Response) return v;
339
      const title = (c.url.searchParams.get("title") ?? "").trim();
340
      const found = v.vault.find((n) => n.title.toLowerCase() === title.toLowerCase());
341
      if (found !== undefined) return go(toNote(found));
342
      if (title === "") return go("/");
343
      return html(missingPage({ ...v, title }), 200, v.clear);
344
    },
345
  },
346
  {
347
    method: "POST",
348
    path: "/open",
349
    handle: async (c) => {
350
      const form = await readForm(c.request);
351
      if (form === undefined) return text("too large", 413);
352
      return done(openOrCreate(c.db, c.who, { title: String(form.get("title") ?? "") }), toNote);
353
    },
354
  },
355
  {
356
    method: "GET",
357
    path: "/search",
358
    handle: (c) => {
359
      const v = vaultOf(c);
360
      if (v instanceof Response) return v;
361
      const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim();
362
      if (query === "") return go("/");
363
      return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear);
364
    },
365
  },
366
  {
367
    method: "GET",
368
    path: "/graph",
369
    handle: (c) => {
370
      const v = vaultOf(c);
371
      if (v instanceof Response) return v;
372
      return html(graphPage(v), 200, v.clear);
373
    },
374
  },
375
  {
376
    method: "GET",
377
    path: "/export",
378
    handle: (c) => {
379
      const notes = listNotes(c.db, c.who);
380
      if (!notes.ok) return answer(notes);
381
      const day = new Date().toISOString().slice(0, 10);
382
      return new Response(
383
        JSON.stringify(
384
          {
385
            exported_at: new Date().toISOString(),
386
            notes: notes.value.map(({ title, body, pinned, created_at, updated_at }) => ({
387
              title,
388
              body,
389
              pinned: pinned === 1,
390
              created_at,
391
              updated_at,
392
            })),
393
          },
394
          null,
395
          2,
396
        ),
397
        {
398
          headers: {
399
            "content-type": "application/json; charset=utf-8",
400
            "content-disposition": `attachment; filename="notes-${day}.json"`,
401
            "cache-control": "no-store",
402
            ...SECURITY,
403
          },
404
        },
405
      );
406
    },
407
  },
408
409
  // The JSON API: the same actions, the same identity, plain status codes.
410
  {
411
    method: "GET",
412
    path: "/api/notes",
413
    handle: (c) => {
414
      const q = c.url.searchParams.get("q");
415
      const limit = whole(c.url.searchParams.get("limit"));
416
      if (q === null || q.trim() === "")
417
        return answer(listNotes(c.db, c.who, limit === undefined ? {} : { limit }));
418
      // A search answers the same notes, best match first.
419
      const hits = searchNotes(c.db, c.who, { q, ...(limit === undefined ? {} : { limit }) });
420
      if (!hits.ok) return answer(hits);
421
      const notes = hits.value
422
        .map((h) => getNote(c.db, c.who, { id: h.id }))
423
        .flatMap((r) => (r.ok ? [r.value] : []));
424
      return json(notes);
425
    },
426
    api: {
427
      summary:
428
        "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.",
429
      query: {
430
        limit: { type: "integer", minimum: 1, maximum: 5000 },
431
        q: { type: "string", maxLength: 500 },
432
      },
433
      answer: list(NOTE),
434
    },
435
  },
436
  {
437
    method: "POST",
438
    path: "/api/notes",
439
    handle: async (c) => {
440
      const body = await readJson(c.request, NOTE_BYTES);
441
      if (body === undefined) return json({ error: "too_large" }, 413);
442
      const f = fields(body);
443
      return answer(
444
        addNote(c.db, c.who, {
445
          ...(typeof f.title === "string" ? { title: f.title } : {}),
446
          ...(typeof f.body === "string" ? { body: f.body } : {}),
447
        }),
448
        201,
449
      );
450
    },
451
    api: {
452
      summary: "Add a note to your vault.",
453
      body: NOTE_NEW,
454
      answer: NOTE,
455
      status: 201,
456
    },
457
  },
458
  {
459
    method: "GET",
460
    path: "/api/notes/:id",
461
    handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })),
462
    api: { summary: "One of your notes.", answer: NOTE },
463
  },
464
  {
465
    method: "PATCH",
466
    path: "/api/notes/:id",
467
    handle: async (c) => {
468
      const body = await readJson(c.request, NOTE_BYTES);
469
      if (body === undefined) return json({ error: "too_large" }, 413);
470
      return answer(updateNote(c.db, c.who, change(Number(c.params.id), fields(body))));
471
    },
472
    api: {
473
      summary:
474
        "Change a note's title, words or pin; a rename rewrites the links to it. `relinked` says how many notes that touched.",
475
      body: NOTE_CHANGE,
476
      answer: {
477
        allOf: [
478
          NOTE,
479
          {
480
            type: "object",
481
            properties: { relinked: { type: "integer" } },
482
            required: ["relinked"],
483
          },
484
        ],
485
      },
486
    },
487
  },
488
  {
489
    method: "DELETE",
490
    path: "/api/notes/:id",
491
    handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
492
    api: {
493
      summary: "Remove a note.",
494
      answer: {
495
        type: "object",
496
        properties: { id: { type: "integer" } },
497
        required: ["id"],
498
      },
499
    },
500
  },
501
502
  // Who tried to change what: the audit trail, for whoever the table lets read it.
503
  {
504
    method: "GET",
505
    path: "/api/audit",
506
    handle: (c) =>
507
      answer(
508
        listAudit(c.db, c.who, {
509
          limit: whole(c.url.searchParams.get("limit")) ?? 100,
510
          ...(whole(c.url.searchParams.get("before")) === undefined
511
            ? {}
512
            : { before: whole(c.url.searchParams.get("before"))! }),
513
        }),
514
      ),
515
    api: {
516
      summary: "The audit trail, newest first; `before` pages back by id.",
517
      query: { limit: LIMIT, before: { type: "integer" } },
518
      answer: list(AUDIT_ROW),
519
    },
520
  },
521
  {
522
    method: "GET",
523
    path: "/api/audit/:id",
524
    handle: (c) => answer(getAudit(c.db, c.who, { id: Number(c.params.id) })),
525
    api: { summary: "One row of the audit trail.", answer: AUDIT_ROW },
526
  },
527
528
  // Live events, and the MCP server (the tools are the actions too).
529
  { method: "GET", path: "/events", handle: (c) => events(c.request, c.who) },
530
  {
531
    method: "POST",
532
    path: "/mcp",
533
    handle: async (c) => {
534
      const message = await readJson(c.request);
535
      if (message === undefined) return json({ error: "too_large" }, 413);
536
      if (message === null)
537
        return json({
538
          jsonrpc: "2.0",
539
          id: null,
540
          error: { code: -32700, message: "parse error" },
541
        });
542
      const { status, body } = rpc(c.db, c.who, message);
543
      return body === undefined
544
        ? new Response(null, { status })
545
        : json(body, status);
546
    },
547
    api: {
548
      summary:
549
        "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.",
550
      body: {
551
        type: "object",
552
        properties: {
553
          jsonrpc: { const: "2.0" },
554
          id: {},
555
          method: { type: "string" },
556
          params: { type: "object" },
557
        },
558
        required: ["jsonrpc", "method"],
559
      },
560
      answer: { type: "object" },
561
    },
562
  },
563
  {
564
    method: "GET",
565
    path: "/mcp",
566
    handle: () => text("this endpoint speaks JSON-RPC over POST", 405),
567
  },
568
569
  // Up or not, for the forge and for anyone watching: a separate path from
570
  // the page, so a health check never renders it.
571
  {
572
    method: "GET",
573
    path: "/healthz",
574
    handle: (c) => {
575
      let ok = true;
576
      try {
577
        c.db.query("SELECT 1").get();
578
      } catch {
579
        ok = false;
580
      }
581
      const uptime = Math.floor((Date.now() - startedAt) / 1000);
582
      return new Response(
583
        JSON.stringify({ ok, version, commit, env, uptime }),
584
        {
585
          status: ok ? 200 : 503,
586
          headers: {
587
            "content-type": "application/json; charset=utf-8",
588
            "cache-control": "no-store",
589
            ...SECURITY,
590
          },
591
        },
592
      );
593
    },
594
    api: {
595
      summary:
596
        "Whether the app is up: its version, commit, environment and seconds since it started.",
597
      answer: {
598
        type: "object",
599
        properties: {
600
          ok: { type: "boolean" },
601
          version: { type: "string" },
602
          commit: { type: "string" },
603
          env: { type: "string" },
604
          uptime: { type: "integer" },
605
        },
606
        required: ["ok", "version", "uptime"],
607
      },
608
    },
609
  },
610
  {
611
    method: "GET",
612
    path: "/openapi.json",
613
    handle: () =>
614
      openapi === undefined
615
        ? text("not found", 404)
616
        : new Response(openapi, {
617
            headers: {
618
              "content-type": "application/json; charset=utf-8",
619
              "cache-control": "no-cache",
620
              ...SECURITY,
621
            },
622
          }),
623
  },
624
625
  // The look: design/tokens.json, compiled at boot, cached for a year when linked by content.
626
  {
627
    method: "GET",
628
    path: "/tokens.css",
629
    handle: (c) =>
630
      new Response(tokensCss, {
631
        headers: {
632
          "content-type": "text/css; charset=utf-8",
633
          "cache-control": c.url.searchParams.has("v")
634
            ? "public, max-age=31536000, immutable"
635
            : "no-cache",
636
          ...SECURITY,
637
        },
638
      }),
639
  },
640
641
  // What the web expects of a site. Crawlers read the page, not the API;
642
  // a preview is a draft and is read by nobody. Where to report a
643
  // vulnerability is the forge's to say: it runs this app.
644
  {
645
    method: "GET",
646
    path: "/robots.txt",
647
    handle: () =>
648
      text(
649
        env === "production"
650
          ? "User-agent: *\nDisallow: /api/\nDisallow: /mcp\nDisallow: /events\nDisallow: /__forge/\n"
651
          : "User-agent: *\nDisallow: /\n",
652
      ),
653
  },
654
  {
655
    method: "GET",
656
    path: "/.well-known/security.txt",
657
    handle: () =>
658
      forgeUrl === undefined
659
        ? text("not found", 404)
660
        : new Response(null, {
661
            status: 302,
662
            headers: {
663
              location: new URL(
664
                "/.well-known/security.txt",
665
                forgeUrl,
666
              ).toString(),
667
              ...SECURITY,
668
            },
669
          }),
670
  },
671
672
  // Files under ./static, cached for a year when linked by content (`asset`).
673
  {
674
    method: "GET",
675
    path: "/static/:file",
676
    handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")),
677
  },
678
];
679
680
/** The route for a method and path, with what its `:names` matched. */
681
export function match(
682
  method: string,
683
  pathname: string,
684
): { route: Route; params: Record<string, string> } | undefined {
685
  const parts = pathname.split("/");
686
  for (const route of routes) {
687
    if (route.method !== method) continue;
688
    const pattern = route.path.split("/");
689
    if (pattern.length !== parts.length) continue;
690
    const params: Record<string, string> = {};
691
    const hit = pattern.every((p, i) => {
692
      if (p.startsWith(":")) {
693
        // A malformed escape is no match, never a thrown error.
694
        try {
695
          params[p.slice(1)] = decodeURIComponent(parts[i] ?? "");
696
          return true;
697
        } catch {
698
          return false;
699
        }
700
      }
701
      return p === parts[i];
702
    });
703
    if (hit) return { route, params };
704
  }
705
  return undefined;
706
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts