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

Files 9965609c46dc660e7b5b52f91658bb8a67c37294
1,085 lines · 34.9 KB · ~10k 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
  newSpacePage,
70
  notePage,
71
  searchPage,
72
  spacePage,
73
} from "./page.ts";
74
import {
75
  createSpace,
76
  deleteSpace,
77
  getSpace,
78
  joinSpace,
79
  members,
80
  mySpaces,
81
  removeMember,
82
  setLink,
83
  spaceHref,
84
  spaceOf,
85
  type Space,
86
} from "./spaces.ts";
87
import { syncSpace, type Forge } from "./sync.ts";
88
import { tokensCss } from "./tokens.ts";
89
import { allow } from "./security.ts";
90
import { search } from "./vault.ts";
91
92
/** The app's own sign-in: a random token, HttpOnly, for SESSION_DAYS. */
93
export const SESSION_COOKIE = "sid";
94
const session = (token: string): string => cookie(SESSION_COOKIE, token, SESSION_DAYS * 86_400);
95
const ended = (): string => cookie(SESSION_COOKIE, "", 0);
96
// Guessing is rationed: per email, and for everyone together (behind the
97
// forge every stranger arrives from one address).
98
const LOGIN_PER_EMAIL = { burst: 8, perSecond: 1 / 60 };
99
const LOGIN_ALL = { burst: 60, perSecond: 1 };
100
const SIGNUP_ALL = { burst: 10, perSecond: 1 / 360 };
101
import type { Who } from "./who.ts";
102
103
export interface Context {
104
  readonly request: Request;
105
  readonly url: URL;
106
  readonly db: Database;
107
  readonly who: Who;
108
  readonly params: Record<string, string>;
109
  /** Where shared folders commit; absent, there are none to make. */
110
  readonly forge?: Forge | undefined;
111
}
112
113
/** A JSON Schema, as plain data. */
114
export type Schema = Readonly<Record<string, unknown>>;
115
116
/** What an API route is, for the description: never consulted to decide anything. */
117
export interface Api {
118
  readonly summary: string;
119
  /** The body it reads. */
120
  readonly body?: Schema;
121
  /** What it answers when it succeeds, and with which status (200). */
122
  readonly answer: Schema;
123
  readonly status?: number;
124
  /** The query parameters it reads. */
125
  readonly query?: Readonly<Record<string, Schema>>;
126
}
127
128
export interface Route {
129
  readonly method: "GET" | "POST" | "PATCH" | "DELETE";
130
  readonly path: string;
131
  readonly handle: (ctx: Context) => Response | Promise<Response>;
132
  readonly api?: Api;
133
}
134
135
// The shapes the API speaks in, named once.
136
export const NOTE: Schema = {
137
  type: "object",
138
  properties: {
139
    id: { type: "integer" },
140
    title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
141
    body: { type: "string", maxLength: BODY_MAX },
142
    pinned: { enum: [0, 1] },
143
    author_id: { type: "string" },
144
    author_name: { type: "string" },
145
    created_at: { type: "string", format: "date-time" },
146
    updated_at: { type: "string", format: "date-time" },
147
    path: {
148
      type: ["string", "null"],
149
      description: "A shared folder's note: its file in the repository. Null for a note of your own.",
150
    },
151
  },
152
  required: [
153
    "id",
154
    "title",
155
    "body",
156
    "pinned",
157
    "author_id",
158
    "author_name",
159
    "created_at",
160
    "updated_at",
161
  ],
162
};
163
const NOTE_NEW: Schema = {
164
  type: "object",
165
  properties: {
166
    title: {
167
      type: "string",
168
      maxLength: TITLE_MAX,
169
      description: "Unique in your vault; “Untitled” when left out.",
170
    },
171
    body: { type: "string", maxLength: BODY_MAX, description: "Markdown." },
172
    space: { type: "integer", description: "A shared folder you are in, to add the note there instead of your vault." },
173
    folder: { type: "string", description: "In a shared folder: the folder to put it in (`ideas/q4`)." },
174
  },
175
};
176
const NOTE_CHANGE: Schema = {
177
  type: "object",
178
  properties: {
179
    title: {
180
      type: "string",
181
      minLength: 1,
182
      maxLength: TITLE_MAX,
183
      description: "A rename carries every [[link]] to the note with it.",
184
    },
185
    body: { type: "string", maxLength: BODY_MAX },
186
    pinned: { type: "boolean" },
187
    folder: { type: "string", description: "A shared note's folder: moves its file." },
188
    base: {
189
      type: "string",
190
      format: "date-time",
191
      description: "The updated_at you edited from; when someone has saved since, new words are refused with 409.",
192
    },
193
  },
194
};
195
export const AUDIT_ROW: Schema = {
196
  type: "object",
197
  properties: {
198
    id: { type: "integer" },
199
    at: { type: "string", format: "date-time" },
200
    who_id: { type: ["string", "null"] },
201
    who_name: { type: ["string", "null"] },
202
    role: { enum: ["anon", "user", "admin"] },
203
    resource: { type: "string" },
204
    verb: { type: "string" },
205
    target: { type: ["string", "null"] },
206
    allowed: { enum: [0, 1] },
207
    outcome: { type: "string" },
208
  },
209
  required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"],
210
};
211
export const ERROR: Schema = {
212
  type: "object",
213
  properties: { error: { type: "string" }, message: { type: "string" } },
214
  required: ["error"],
215
};
216
const LIMIT: Schema = {
217
  type: "integer",
218
  minimum: 1,
219
  maximum: 500,
220
  default: 100,
221
};
222
const list = (items: Schema): Schema => ({ type: "array", items });
223
224
const STATUS: Record<Failure, number> = {
225
  signin_required: 401,
226
  forbidden: 403,
227
  not_found: 404,
228
  invalid: 422,
229
  conflict: 409,
230
};
231
232
/** A result as JSON: the value, or `{ error, message }` with the matching status. */
233
const answer = <T>(result: Result<T>, status = 200): Response =>
234
  result.ok
235
    ? json(result.value, status)
236
    : json(
237
        { error: result.error, message: result.message },
238
        STATUS[result.error],
239
      );
240
241
/** A result for a form: on to where it leads, or back with what went wrong. */
242
const done = <T>(result: Result<T>, to: (value: T) => string, back = "/"): Response =>
243
  result.ok ? go(to(result.value)) : go(back, result.message);
244
245
const toNote = (n: { readonly id: number }): string => `/n/${n.id}`;
246
247
/** The fields of a JSON body that change a note, and only those. */
248
const change = (id: number, body: Record<string, unknown>): Change => ({
249
  id,
250
  ...(typeof body.title === "string" ? { title: body.title } : {}),
251
  ...(typeof body.body === "string" ? { body: body.body } : {}),
252
  ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}),
253
  ...(typeof body.folder === "string" ? { folder: body.folder } : {}),
254
  ...(typeof body.base === "string" ? { base: body.base } : {}),
255
});
256
257
interface Around {
258
  who: Who & { role: "user" | "admin" };
259
  vault: Note[];
260
  notice?: string;
261
  clear: Record<string, string>;
262
  space?: Space;
263
  spaces: Space[];
264
}
265
266
/**
267
 * The vault around a page: who is signed in, every note of theirs — or of
268
 * the shared folder `space`, when the page is in one — and the folders
269
 * they are in, for the switcher.
270
 */
271
function vaultOf(c: Context, space?: number): Around | Response {
272
  const { notice, clear } = readNotice(c.request);
273
  if (c.who.role === "anon") return html(landing(notice), 200, clear);
274
  let found: Space | undefined;
275
  if (space !== undefined) {
276
    const got = getSpace(c.db, c.who, space);
277
    if (!got.ok) return go("/", got.message);
278
    found = got.value;
279
  }
280
  const vault = listNotes(c.db, c.who, space === undefined ? {} : { space });
281
  return {
282
    who: c.who,
283
    vault: vault.ok ? vault.value : [],
284
    ...(notice === undefined ? {} : { notice }),
285
    clear,
286
    ...(found === undefined ? {} : { space: found }),
287
    spaces: mySpaces(c.db, c.who),
288
  };
289
}
290
291
/** The shared folder a page's path names (`/s/:sid/…`); none for your own vault. */
292
const sidOf = (c: Context): number | undefined =>
293
  c.params.sid === undefined ? undefined : Number(c.params.sid);
294
/** A vault's own address: `""` for yours, `/s/<id>` for a shared folder. */
295
const baseFor = (space: number | undefined): string => (space === undefined ? "" : spaceHref(space));
296
/** Where sign-in may send someone afterwards: back to the join link that brought them, or home. */
297
export const safeTo = (to: string | null | undefined): string =>
298
  typeof to === "string" && /^\/join\/[\w-]{8,64}$/.test(to) ? to : "/";
299
300
/**
301
 * The pages every vault has — its home, search, graph, following a link,
302
 * a new note, today's note — once for your own (`""`) and once for a
303
 * shared folder (`/s/:sid`). The same handlers, a different vault.
304
 */
305
function vaultRoutes(prefix: string): Route[] {
306
  return [
307
    {
308
      method: "GET",
309
      path: prefix || "/",
310
      handle: (c) => {
311
        const v = vaultOf(c, sidOf(c));
312
        if (v instanceof Response) return v;
313
        const first = v.vault.reduce<Note | undefined>(
314
          (latest, n) => (latest === undefined || n.updated_at > latest.updated_at ? n : latest),
315
          undefined,
316
        );
317
        // The vault opens on the note you were last writing.
318
        if (first !== undefined && v.notice === undefined) return go(toNote(first));
319
        if (first !== undefined) return html(notePage({ ...v, active: first }), 200, v.clear);
320
        return html(emptyPage(v), 200, v.clear);
321
      },
322
    },
323
    {
324
      method: "POST",
325
      path: `${prefix}/new`,
326
      handle: async (c) => {
327
        const form = await readForm(c.request);
328
        if (form === undefined) return text("too large", 413);
329
        const title = String(form.get("title") ?? "");
330
        const folder = String(form.get("folder") ?? "");
331
        const space = sidOf(c);
332
        return done(
333
          addNote(c.db, c.who, {
334
            ...(title === "" ? {} : { title }),
335
            ...(space === undefined ? {} : { space, folder }),
336
          }),
337
          toNote,
338
          baseFor(space) || "/",
339
        );
340
      },
341
    },
342
    {
343
      method: "POST",
344
      path: `${prefix}/daily`,
345
      handle: async (c) => {
346
        const form = await readForm(c.request);
347
        if (form === undefined) return text("too large", 413);
348
        const tz = Number(form.get("tz") ?? 0);
349
        const day = today(Number.isFinite(tz) && Math.abs(tz) <= 840 ? tz : 0);
350
        const space = sidOf(c);
351
        return done(
352
          openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n`, ...(space === undefined ? {} : { space }) }),
353
          toNote,
354
          baseFor(space) || "/",
355
        );
356
      },
357
    },
358
    {
359
      method: "GET",
360
      path: `${prefix}/open`,
361
      handle: (c) => {
362
        const v = vaultOf(c, sidOf(c));
363
        if (v instanceof Response) return v;
364
        const title = (c.url.searchParams.get("title") ?? "").trim();
365
        const found = v.vault.find((n) => n.title.toLowerCase() === title.toLowerCase());
366
        if (found !== undefined) return go(toNote(found));
367
        if (title === "") return go(baseFor(sidOf(c)) || "/");
368
        return html(missingPage({ ...v, title }), 200, v.clear);
369
      },
370
    },
371
    {
372
      method: "POST",
373
      path: `${prefix}/open`,
374
      handle: async (c) => {
375
        const form = await readForm(c.request);
376
        if (form === undefined) return text("too large", 413);
377
        const space = sidOf(c);
378
        return done(
379
          openOrCreate(c.db, c.who, { title: String(form.get("title") ?? ""), ...(space === undefined ? {} : { space }) }),
380
          toNote,
381
          baseFor(space) || "/",
382
        );
383
      },
384
    },
385
    {
386
      method: "GET",
387
      path: `${prefix}/search`,
388
      handle: (c) => {
389
        const v = vaultOf(c, sidOf(c));
390
        if (v instanceof Response) return v;
391
        const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim();
392
        if (query === "") return go(baseFor(sidOf(c)) || "/");
393
        return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear);
394
      },
395
    },
396
    {
397
      method: "GET",
398
      path: `${prefix}/graph`,
399
      handle: (c) => {
400
        const v = vaultOf(c, sidOf(c));
401
        if (v instanceof Response) return v;
402
        return html(graphPage(v), 200, v.clear);
403
      },
404
    },
405
  ];
406
}
407
408
/** A shared folder's own page, for one of its members — or the way home. */
409
function spaceAround(c: Context): (Around & { space: Space }) | Response {
410
  const v = vaultOf(c, sidOf(c));
411
  if (v instanceof Response) return v;
412
  if (v.space === undefined) return go("/");
413
  return v as Around & { space: Space };
414
}
415
416
/** A whole number from the query, or undefined. */
417
const whole = (value: string | null): number | undefined =>
418
  value !== null && /^\d+$/.test(value) ? Number(value) : undefined;
419
420
// The API's description, read from git once: what the tests hold equal to this table.
421
const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
422
const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
423
424
export const routes: readonly Route[] = [
425
  // The pages. Signed out, the way in; signed in, the vault. Forms post and
426
  // land where they lead, so every page works without JavaScript.
427
  ...vaultRoutes(""),
428
  ...vaultRoutes("/s/:sid"),
429
  {
430
    method: "GET",
431
    path: "/n/:id",
432
    handle: (c) => {
433
      if (c.who.role === "anon") return vaultOf(c) as Response;
434
      const note = getNote(c.db, c.who, { id: Number(c.params.id) });
435
      if (!note.ok) return go("/", note.message);
436
      // A shared note is shown in its folder.
437
      const v = vaultOf(c, spaceOf(note.value.author_id));
438
      if (v instanceof Response) return v;
439
      return html(notePage({ ...v, active: note.value }), 200, v.clear);
440
    },
441
  },
442
  {
443
    method: "GET",
444
    path: "/n/:id/raw",
445
    handle: (c) => {
446
      const note = getNote(c.db, c.who, { id: Number(c.params.id) });
447
      if (!note.ok) return answer(note);
448
      return new Response(note.value.body, {
449
        headers: {
450
          "content-type": "text/markdown; charset=utf-8",
451
          "content-disposition": `attachment; filename="${note.value.title.replace(/[^\w .-]+/g, "_")}.md"; filename*=UTF-8''${encodeURIComponent(`${note.value.title}.md`)}`,
452
          ...SECURITY,
453
        },
454
      });
455
    },
456
  },
457
  {
458
    method: "POST",
459
    path: "/n/:id",
460
    handle: async (c) => {
461
      const form = await readForm(c.request, NOTE_BYTES);
462
      if (form === undefined) return text("too large", 413);
463
      const id = Number(c.params.id);
464
      return done(
465
        updateNote(c.db, c.who, {
466
          id,
467
          title: String(form.get("title") ?? ""),
468
          body: String(form.get("body") ?? ""),
469
        }),
470
        toNote,
471
        toNote({ id }),
472
      );
473
    },
474
  },
475
  {
476
    method: "POST",
477
    path: "/n/:id/move",
478
    handle: async (c) => {
479
      const form = await readForm(c.request);
480
      if (form === undefined) return text("too large", 413);
481
      const id = Number(c.params.id);
482
      return done(updateNote(c.db, c.who, { id, folder: String(form.get("folder") ?? "") }), toNote, toNote({ id }));
483
    },
484
  },
485
  {
486
    method: "POST",
487
    path: "/n/:id/pin",
488
    handle: async (c) => {
489
      const form = await readForm(c.request);
490
      if (form === undefined) return text("too large", 413);
491
      const id = Number(c.params.id);
492
      return done(
493
        updateNote(c.db, c.who, { id, pinned: form.get("pinned") === "1" }),
494
        toNote,
495
        toNote({ id }),
496
      );
497
    },
498
  },
499
  {
500
    method: "POST",
501
    path: "/n/:id/delete",
502
    handle: (c) => {
503
      const note = getNote(c.db, c.who, { id: Number(c.params.id) });
504
      const back = note.ok ? baseFor(spaceOf(note.value.author_id)) || "/" : "/";
505
      return done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => back);
506
    },
507
  },
508
509
  // Shared folders: connecting one, joining one, and running one.
510
  {
511
    method: "GET",
512
    path: "/spaces/new",
513
    handle: (c) => {
514
      const v = vaultOf(c);
515
      if (v instanceof Response) return v;
516
      if (c.who.role !== "admin") return go("/", "Only an app admin can connect a repository.");
517
      if (c.forge === undefined) return go("/", "Shared folders need NOTES_REPO_TOKEN set in the app's environment.");
518
      return html(newSpacePage(v), 200, v.clear);
519
    },
520
  },
521
  {
522
    method: "POST",
523
    path: "/spaces",
524
    handle: async (c) => {
525
      const form = await readForm(c.request);
526
      if (form === undefined) return text("too large", 413);
527
      const input = {
528
        name: String(form.get("name") ?? ""),
529
        repo: String(form.get("repo") ?? ""),
530
        branch: String(form.get("branch") ?? ""),
531
        folder: String(form.get("folder") ?? ""),
532
      };
533
      const again = (notice: string) => {
534
        const v = vaultOf(c);
535
        return v instanceof Response ? v : html(newSpacePage({ ...v, notice, form: input }), 422);
536
      };
537
      if (c.forge === undefined) return again("Shared folders need NOTES_REPO_TOKEN set in the app's environment.");
538
      const made = createSpace(c.db, c.who, input);
539
      if (!made.ok) return made.error === "invalid" ? again(made.message) : go("/", made.message);
540
      // Read it now, so it opens with its notes — and a wrong name is said at once.
541
      try {
542
        const read = await syncSpace(c.db, c.forge, made.value.id);
543
        return go(spaceHref(made.value.id), `Connected — ${read.pulled} ${read.pulled === 1 ? "note" : "notes"} read from ${made.value.repo}.`);
544
      } catch (e) {
545
        deleteSpace(c.db, c.who, { id: made.value.id });
546
        return again(`Couldn't read ${made.value.repo}: ${e instanceof Error ? e.message : String(e)}`);
547
      }
548
    },
549
  },
550
  {
551
    method: "GET",
552
    path: "/join/:code",
553
    handle: (c) => {
554
      const to = `/join/${c.params.code ?? ""}`;
555
      if (c.who.role === "anon")
556
        return html(landing("Sign in (or make an account) to join the shared folder.", safeTo(to)), 200);
557
      const joined = joinSpace(c.db, c.who, { code: c.params.code ?? "" });
558
      if (!joined.ok) return go("/", joined.message);
559
      return go(spaceHref(joined.value.id), `You're in ${joined.value.name}.`);
560
    },
561
  },
562
  {
563
    method: "GET",
564
    path: "/s/:sid/settings",
565
    handle: (c) => {
566
      const v = spaceAround(c);
567
      if (v instanceof Response) return v;
568
      return html(spacePage({ ...v, members: members(c.db, v.space.id) }), 200, v.clear);
569
    },
570
  },
571
  {
572
    method: "POST",
573
    path: "/s/:sid/sync",
574
    handle: async (c) => {
575
      const v = spaceAround(c);
576
      if (v instanceof Response) return v;
577
      const back = `${spaceHref(v.space.id)}/settings`;
578
      if (c.forge === undefined) return go(back, "Shared folders need NOTES_REPO_TOKEN set in the app's environment.");
579
      try {
580
        const r = await syncSpace(c.db, c.forge, v.space.id);
581
        const said = [
582
          r.pulled > 0 ? `${r.pulled} from the repository` : "",
583
          r.pushed > 0 ? `${r.pushed} ${r.pushed === 1 ? "file" : "files"} committed` : "",
584
          r.copies > 0 ? `${r.copies} kept as repo copies` : "",
585
        ].filter((x) => x !== "");
586
        return go(back, said.length === 0 ? "Already in step." : `Synced: ${said.join(", ")}.`);
587
      } catch (e) {
588
        return go(back, `Sync failed: ${e instanceof Error ? e.message : String(e)}`);
589
      }
590
    },
591
  },
592
  {
593
    method: "POST",
594
    path: "/s/:sid/link",
595
    handle: async (c) => {
596
      const form = await readForm(c.request);
597
      if (form === undefined) return text("too large", 413);
598
      const id = Number(c.params.sid);
599
      const on = form.get("on") === "1";
600
      return done(setLink(c.db, c.who, { id, on }), () => `${spaceHref(id)}/settings`, `${spaceHref(id)}/settings`);
601
    },
602
  },
603
  {
604
    method: "POST",
605
    path: "/s/:sid/remove",
606
    handle: async (c) => {
607
      const form = await readForm(c.request);
608
      if (form === undefined) return text("too large", 413);
609
      const id = Number(c.params.sid);
610
      return done(
611
        removeMember(c.db, c.who, { id, member: String(form.get("member") ?? "") }),
612
        () => `${spaceHref(id)}/settings`,
613
        `${spaceHref(id)}/settings`,
614
      );
615
    },
616
  },
617
  {
618
    method: "POST",
619
    path: "/s/:sid/leave",
620
    handle: (c) => {
621
      const id = Number(c.params.sid);
622
      if (c.who.role === "anon") return go("/");
623
      return done(removeMember(c.db, c.who, { id, member: c.who.id }), () => "/", `${spaceHref(id)}/settings`);
624
    },
625
  },
626
  {
627
    method: "POST",
628
    path: "/s/:sid/delete",
629
    handle: (c) => {
630
      const id = Number(c.params.sid);
631
      return done(deleteSpace(c.db, c.who, { id }), () => "/", `${spaceHref(id)}/settings`);
632
    },
633
  },
634
  {
635
    method: "POST",
636
    path: "/welcome",
637
    handle: (c) =>
638
      done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote),
639
  },
640
  {
641
    method: "GET",
642
    path: "/export",
643
    handle: (c) => {
644
      const notes = listNotes(c.db, c.who);
645
      if (!notes.ok) return answer(notes);
646
      const day = new Date().toISOString().slice(0, 10);
647
      return new Response(
648
        JSON.stringify(
649
          {
650
            exported_at: new Date().toISOString(),
651
            notes: notes.value.map(({ title, body, pinned, created_at, updated_at }) => ({
652
              title,
653
              body,
654
              pinned: pinned === 1,
655
              created_at,
656
              updated_at,
657
            })),
658
          },
659
          null,
660
          2,
661
        ),
662
        {
663
          headers: {
664
            "content-type": "application/json; charset=utf-8",
665
            "content-disposition": `attachment; filename="notes-${day}.json"`,
666
            "cache-control": "no-store",
667
            ...SECURITY,
668
          },
669
        },
670
      );
671
    },
672
  },
673
674
  // The app's own accounts: sign up, log in, log out, and the account page.
675
  {
676
    method: "GET",
677
    path: "/signup",
678
    handle: (c) => {
679
      if (c.who.role !== "anon") return go("/");
680
      const { notice, clear } = readNotice(c.request);
681
      return html(authPage({ mode: "signup", notice, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "", to: safeTo(c.url.searchParams.get("to")) }), 200, clear);
682
    },
683
  },
684
  {
685
    method: "POST",
686
    path: "/signup",
687
    handle: async (c) => {
688
      const form = await readForm(c.request);
689
      if (form === undefined) return text("too large", 413);
690
      const email = String(form.get("email") ?? "");
691
      const name = String(form.get("name") ?? "");
692
      const to = safeTo(String(form.get("to") ?? ""));
693
      const again = (notice: string, status = 422) =>
694
        html(authPage({ mode: "signup", notice, email, name, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "", to }), status);
695
      if (!allow("signup", SIGNUP_ALL)) return again("Lots of people are signing up right now — try again in a few minutes.", 429);
696
      const made = await signUp(c.db, {
697
        email,
698
        name,
699
        password: String(form.get("password") ?? ""),
700
        code: String(form.get("code") ?? ""),
701
      });
702
      if (!made.ok) return again(made.message, made.error === "forbidden" ? 403 : 422);
703
      return go(to, undefined, [session(made.value.token)]);
704
    },
705
  },
706
  {
707
    method: "GET",
708
    path: "/login",
709
    handle: (c) => {
710
      if (c.who.role !== "anon") return go("/");
711
      const { notice, clear } = readNotice(c.request);
712
      return html(authPage({ mode: "login", notice, needsCode: false, to: safeTo(c.url.searchParams.get("to")) }), 200, clear);
713
    },
714
  },
715
  {
716
    method: "POST",
717
    path: "/login",
718
    handle: async (c) => {
719
      const form = await readForm(c.request);
720
      if (form === undefined) return text("too large", 413);
721
      const email = String(form.get("email") ?? "");
722
      const to = safeTo(String(form.get("to") ?? ""));
723
      const again = (notice: string, status: number) =>
724
        html(authPage({ mode: "login", notice, email, needsCode: false, to }), status);
725
      if (!allow(`login:${email.trim().toLowerCase()}`, LOGIN_PER_EMAIL) || !allow("login", LOGIN_ALL))
726
        return again("Too many tries. Wait a minute and try again.", 429);
727
      const inside = await logIn(c.db, { email, password: String(form.get("password") ?? "") });
728
      if (!inside.ok) return again(inside.message, 401);
729
      return go(to, undefined, [session(inside.value.token)]);
730
    },
731
  },
732
  {
733
    method: "POST",
734
    path: "/logout",
735
    handle: (c) => {
736
      logOut(c.db, readCookie(c.request, SESSION_COOKIE));
737
      return go("/", "Logged out.", [ended()]);
738
    },
739
  },
740
  {
741
    method: "GET",
742
    path: "/account",
743
    handle: (c) => {
744
      const v = vaultOf(c);
745
      if (v instanceof Response) return v;
746
      const account = getAccount(c.db, c.who);
747
      if (account === undefined) return go("/", "Forge accounts are managed on the forge.");
748
      return html(accountPage({ ...v, account }), 200, v.clear);
749
    },
750
  },
751
  {
752
    method: "POST",
753
    path: "/account/password",
754
    handle: async (c) => {
755
      const form = await readForm(c.request);
756
      if (form === undefined) return text("too large", 413);
757
      if (!allow(`password:${c.who.role === "anon" ? "" : c.who.id}`, LOGIN_PER_EMAIL))
758
        return go("/account", "Too many tries. Wait a minute and try again.");
759
      const changed = await changePassword(c.db, c.who, {
760
        current: String(form.get("current") ?? ""),
761
        next: String(form.get("next") ?? ""),
762
      });
763
      if (!changed.ok) return go("/account", changed.message);
764
      return go("/account", "Password changed.", [session(changed.value.token)]);
765
    },
766
  },
767
  {
768
    method: "POST",
769
    path: "/account/delete",
770
    handle: async (c) => {
771
      const form = await readForm(c.request);
772
      if (form === undefined) return text("too large", 413);
773
      if (!allow(`password:${c.who.role === "anon" ? "" : c.who.id}`, LOGIN_PER_EMAIL))
774
        return go("/account", "Too many tries. Wait a minute and try again.");
775
      const gone = await deleteAccount(c.db, c.who, { password: String(form.get("password") ?? "") });
776
      if (!gone.ok) return go("/account", gone.message);
777
      return go("/", "Your account and notes are deleted.", [ended()]);
778
    },
779
  },
780
781
  // The JSON API: the same actions, the same identity, plain status codes.
782
  {
783
    method: "GET",
784
    path: "/api/notes",
785
    handle: (c) => {
786
      const q = c.url.searchParams.get("q");
787
      const limit = whole(c.url.searchParams.get("limit"));
788
      const space = whole(c.url.searchParams.get("space"));
789
      const scope = {
790
        ...(limit === undefined ? {} : { limit }),
791
        ...(space === undefined ? {} : { space }),
792
      };
793
      if (q === null || q.trim() === "") return answer(listNotes(c.db, c.who, scope));
794
      // A search answers the same notes, best match first.
795
      const hits = searchNotes(c.db, c.who, { q, ...scope });
796
      if (!hits.ok) return answer(hits);
797
      const notes = hits.value
798
        .map((h) => getNote(c.db, c.who, { id: h.id }))
799
        .flatMap((r) => (r.ok ? [r.value] : []));
800
      return json(notes);
801
    },
802
    api: {
803
      summary:
804
        "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first. With `space`, a shared folder's instead.",
805
      query: {
806
        limit: { type: "integer", minimum: 1, maximum: 5000 },
807
        space: { type: "integer" },
808
        q: { type: "string", maxLength: 500 },
809
      },
810
      answer: list(NOTE),
811
    },
812
  },
813
  {
814
    method: "POST",
815
    path: "/api/notes",
816
    handle: async (c) => {
817
      const body = await readJson(c.request, NOTE_BYTES);
818
      if (body === undefined) return json({ error: "too_large" }, 413);
819
      const f = fields(body);
820
      return answer(
821
        addNote(c.db, c.who, {
822
          ...(typeof f.title === "string" ? { title: f.title } : {}),
823
          ...(typeof f.body === "string" ? { body: f.body } : {}),
824
          ...(typeof f.space === "number" ? { space: f.space } : {}),
825
          ...(typeof f.folder === "string" ? { folder: f.folder } : {}),
826
        }),
827
        201,
828
      );
829
    },
830
    api: {
831
      summary: "Add a note to your vault.",
832
      body: NOTE_NEW,
833
      answer: NOTE,
834
      status: 201,
835
    },
836
  },
837
  {
838
    method: "GET",
839
    path: "/api/notes/:id",
840
    handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })),
841
    api: { summary: "One of your notes.", answer: NOTE },
842
  },
843
  {
844
    method: "PATCH",
845
    path: "/api/notes/:id",
846
    handle: async (c) => {
847
      const body = await readJson(c.request, NOTE_BYTES);
848
      if (body === undefined) return json({ error: "too_large" }, 413);
849
      return answer(updateNote(c.db, c.who, change(Number(c.params.id), fields(body))));
850
    },
851
    api: {
852
      summary:
853
        "Change a note's title, words or pin; a rename rewrites the links to it. `relinked` says how many notes that touched.",
854
      body: NOTE_CHANGE,
855
      answer: {
856
        allOf: [
857
          NOTE,
858
          {
859
            type: "object",
860
            properties: { relinked: { type: "integer" } },
861
            required: ["relinked"],
862
          },
863
        ],
864
      },
865
    },
866
  },
867
  {
868
    method: "DELETE",
869
    path: "/api/notes/:id",
870
    handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
871
    api: {
872
      summary: "Remove a note.",
873
      answer: {
874
        type: "object",
875
        properties: { id: { type: "integer" } },
876
        required: ["id"],
877
      },
878
    },
879
  },
880
881
  // Who tried to change what: the audit trail, for whoever the table lets read it.
882
  {
883
    method: "GET",
884
    path: "/api/audit",
885
    handle: (c) =>
886
      answer(
887
        listAudit(c.db, c.who, {
888
          limit: whole(c.url.searchParams.get("limit")) ?? 100,
889
          ...(whole(c.url.searchParams.get("before")) === undefined
890
            ? {}
891
            : { before: whole(c.url.searchParams.get("before"))! }),
892
        }),
893
      ),
894
    api: {
895
      summary: "The audit trail, newest first; `before` pages back by id.",
896
      query: { limit: LIMIT, before: { type: "integer" } },
897
      answer: list(AUDIT_ROW),
898
    },
899
  },
900
  {
901
    method: "GET",
902
    path: "/api/audit/:id",
903
    handle: (c) => answer(getAudit(c.db, c.who, { id: Number(c.params.id) })),
904
    api: { summary: "One row of the audit trail.", answer: AUDIT_ROW },
905
  },
906
907
  // Live events, and the MCP server (the tools are the actions too).
908
  { method: "GET", path: "/events", handle: (c) => events(c.request, c.who) },
909
  {
910
    method: "POST",
911
    path: "/mcp",
912
    handle: async (c) => {
913
      const message = await readJson(c.request);
914
      if (message === undefined) return json({ error: "too_large" }, 413);
915
      if (message === null)
916
        return json({
917
          jsonrpc: "2.0",
918
          id: null,
919
          error: { code: -32700, message: "parse error" },
920
        });
921
      const { status, body } = rpc(c.db, c.who, message);
922
      return body === undefined
923
        ? new Response(null, { status })
924
        : json(body, status);
925
    },
926
    api: {
927
      summary:
928
        "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.",
929
      body: {
930
        type: "object",
931
        properties: {
932
          jsonrpc: { const: "2.0" },
933
          id: {},
934
          method: { type: "string" },
935
          params: { type: "object" },
936
        },
937
        required: ["jsonrpc", "method"],
938
      },
939
      answer: { type: "object" },
940
    },
941
  },
942
  {
943
    method: "GET",
944
    path: "/mcp",
945
    handle: () => text("this endpoint speaks JSON-RPC over POST", 405),
946
  },
947
948
  // Up or not, for the forge and for anyone watching: a separate path from
949
  // the page, so a health check never renders it.
950
  {
951
    method: "GET",
952
    path: "/healthz",
953
    handle: (c) => {
954
      let ok = true;
955
      try {
956
        c.db.query("SELECT 1").get();
957
      } catch {
958
        ok = false;
959
      }
960
      const uptime = Math.floor((Date.now() - startedAt) / 1000);
961
      return new Response(
962
        JSON.stringify({ ok, version, commit, env, uptime }),
963
        {
964
          status: ok ? 200 : 503,
965
          headers: {
966
            "content-type": "application/json; charset=utf-8",
967
            "cache-control": "no-store",
968
            ...SECURITY,
969
          },
970
        },
971
      );
972
    },
973
    api: {
974
      summary:
975
        "Whether the app is up: its version, commit, environment and seconds since it started.",
976
      answer: {
977
        type: "object",
978
        properties: {
979
          ok: { type: "boolean" },
980
          version: { type: "string" },
981
          commit: { type: "string" },
982
          env: { type: "string" },
983
          uptime: { type: "integer" },
984
        },
985
        required: ["ok", "version", "uptime"],
986
      },
987
    },
988
  },
989
  {
990
    method: "GET",
991
    path: "/openapi.json",
992
    handle: () =>
993
      openapi === undefined
994
        ? text("not found", 404)
995
        : new Response(openapi, {
996
            headers: {
997
              "content-type": "application/json; charset=utf-8",
998
              "cache-control": "no-cache",
999
              ...SECURITY,
1000
            },
1001
          }),
1002
  },
1003
1004
  // The look: design/tokens.json, compiled at boot, cached for a year when linked by content.
1005
  {
1006
    method: "GET",
1007
    path: "/tokens.css",
1008
    handle: (c) =>
1009
      new Response(tokensCss, {
1010
        headers: {
1011
          "content-type": "text/css; charset=utf-8",
1012
          "cache-control": c.url.searchParams.has("v")
1013
            ? "public, max-age=31536000, immutable"
1014
            : "no-cache",
1015
          ...SECURITY,
1016
        },
1017
      }),
1018
  },
1019
1020
  // What the web expects of a site. Crawlers read the page, not the API;
1021
  // a preview is a draft and is read by nobody. Where to report a
1022
  // vulnerability is the forge's to say: it runs this app.
1023
  {
1024
    method: "GET",
1025
    path: "/robots.txt",
1026
    handle: () =>
1027
      text(
1028
        env === "production"
1029
          ? "User-agent: *\nDisallow: /api/\nDisallow: /mcp\nDisallow: /events\nDisallow: /__forge/\n"
1030
          : "User-agent: *\nDisallow: /\n",
1031
      ),
1032
  },
1033
  {
1034
    method: "GET",
1035
    path: "/.well-known/security.txt",
1036
    handle: () =>
1037
      forgeUrl === undefined
1038
        ? text("not found", 404)
1039
        : new Response(null, {
1040
            status: 302,
1041
            headers: {
1042
              location: new URL(
1043
                "/.well-known/security.txt",
1044
                forgeUrl,
1045
              ).toString(),
1046
              ...SECURITY,
1047
            },
1048
          }),
1049
  },
1050
1051
  // Files under ./static, cached for a year when linked by content (`asset`).
1052
  {
1053
    method: "GET",
1054
    path: "/static/:file",
1055
    handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")),
1056
  },
1057
];
1058
1059
/** The route for a method and path, with what its `:names` matched. */
1060
export function match(
1061
  method: string,
1062
  pathname: string,
1063
): { route: Route; params: Record<string, string> } | undefined {
1064
  const parts = pathname.split("/");
1065
  for (const route of routes) {
1066
    if (route.method !== method) continue;
1067
    const pattern = route.path.split("/");
1068
    if (pattern.length !== parts.length) continue;
1069
    const params: Record<string, string> = {};
1070
    const hit = pattern.every((p, i) => {
1071
      if (p.startsWith(":")) {
1072
        // A malformed escape is no match, never a thrown error.
1073
        try {
1074
          params[p.slice(1)] = decodeURIComponent(parts[i] ?? "");
1075
          return true;
1076
        } catch {
1077
          return false;
1078
        }
1079
      }
1080
      return p === parts[i];
1081
    });
1082
    if (hit) return { route, params };
1083
  }
1084
  return undefined;
1085
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts