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

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

Keyboard shortcuts