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

Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
464 lines · 12.9 KB · ~3.8k 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
  updateNote,
25
  type Failure,
26
  type Result,
27
} from "./actions.ts";
28
import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
29
import {
30
  back,
31
  fields,
32
  html,
33
  json,
34
  readForm,
35
  readJson,
36
  readNotice,
37
  SECURITY,
38
  staticFile,
39
  text,
40
} from "./http.ts";
41
import { events } from "./live.ts";
42
import { rpc } from "./mcp.ts";
43
import { home } from "./page.ts";
44
import { tokensCss } from "./tokens.ts";
45
import type { Who } from "./who.ts";
46
47
export interface Context {
48
  readonly request: Request;
49
  readonly url: URL;
50
  readonly db: Database;
51
  readonly who: Who;
52
  readonly params: Record<string, string>;
53
}
54
55
/** A JSON Schema, as plain data. */
56
export type Schema = Readonly<Record<string, unknown>>;
57
58
/** What an API route is, for the description: never consulted to decide anything. */
59
export interface Api {
60
  readonly summary: string;
61
  /** The body it reads. */
62
  readonly body?: Schema;
63
  /** What it answers when it succeeds, and with which status (200). */
64
  readonly answer: Schema;
65
  readonly status?: number;
66
  /** The query parameters it reads. */
67
  readonly query?: Readonly<Record<string, Schema>>;
68
}
69
70
export interface Route {
71
  readonly method: "GET" | "POST" | "PATCH" | "DELETE";
72
  readonly path: string;
73
  readonly handle: (ctx: Context) => Response | Promise<Response>;
74
  readonly api?: Api;
75
}
76
77
// The shapes the API speaks in, named once.
78
export const NOTE: Schema = {
79
  type: "object",
80
  properties: {
81
    id: { type: "integer" },
82
    body: { type: "string", minLength: 1, maxLength: BODY_MAX },
83
    author_id: { type: "string" },
84
    author_name: { type: "string" },
85
    created_at: { type: "string", format: "date-time" },
86
    updated_at: { type: "string", format: "date-time" },
87
  },
88
  required: [
89
    "id",
90
    "body",
91
    "author_id",
92
    "author_name",
93
    "created_at",
94
    "updated_at",
95
  ],
96
};
97
const NOTE_BODY: Schema = {
98
  type: "object",
99
  properties: { body: { type: "string", minLength: 1, maxLength: BODY_MAX } },
100
  required: ["body"],
101
};
102
export const AUDIT_ROW: Schema = {
103
  type: "object",
104
  properties: {
105
    id: { type: "integer" },
106
    at: { type: "string", format: "date-time" },
107
    who_id: { type: ["string", "null"] },
108
    who_name: { type: ["string", "null"] },
109
    role: { enum: ["anon", "user", "admin"] },
110
    resource: { type: "string" },
111
    verb: { type: "string" },
112
    target: { type: ["string", "null"] },
113
    allowed: { enum: [0, 1] },
114
    outcome: { type: "string" },
115
  },
116
  required: ["id", "at", "role", "resource", "verb", "allowed", "outcome"],
117
};
118
export const ERROR: Schema = {
119
  type: "object",
120
  properties: { error: { type: "string" }, message: { type: "string" } },
121
  required: ["error"],
122
};
123
const LIMIT: Schema = {
124
  type: "integer",
125
  minimum: 1,
126
  maximum: 500,
127
  default: 100,
128
};
129
const list = (items: Schema): Schema => ({ type: "array", items });
130
131
const STATUS: Record<Failure, number> = {
132
  signin_required: 401,
133
  forbidden: 403,
134
  not_found: 404,
135
  invalid: 422,
136
};
137
138
/** A result as JSON: the value, or `{ error, message }` with the matching status. */
139
const answer = <T>(result: Result<T>, status = 200): Response =>
140
  result.ok
141
    ? json(result.value, status)
142
    : json(
143
        { error: result.error, message: result.message },
144
        STATUS[result.error],
145
      );
146
147
/** A result for a form: back to the page, with what went wrong if it did. */
148
const done = <T>(result: Result<T>): Response =>
149
  result.ok ? back() : back(result.message);
150
151
/** A whole number from the query, or undefined. */
152
const whole = (value: string | null): number | undefined =>
153
  value !== null && /^\d+$/.test(value) ? Number(value) : undefined;
154
155
// The API's description, read from git once: what the tests hold equal to this table.
156
const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
157
const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
158
159
export const routes: readonly Route[] = [
160
  // The page. Forms post and come back; the list stays live over /events.
161
  {
162
    method: "GET",
163
    path: "/",
164
    handle: (c) => {
165
      const { notice, clear } = readNotice(c.request);
166
      const notes = listNotes(c.db, c.who);
167
      return html(
168
        home({ who: c.who, notes: notes.ok ? notes.value : [], notice }),
169
        200,
170
        clear,
171
      );
172
    },
173
  },
174
  {
175
    method: "POST",
176
    path: "/notes",
177
    handle: async (c) => {
178
      const form = await readForm(c.request);
179
      if (form === undefined) return text("too large", 413);
180
      return done(
181
        addNote(c.db, c.who, { body: String(form.get("body") ?? "") }),
182
      );
183
    },
184
  },
185
  {
186
    method: "POST",
187
    path: "/notes/:id/delete",
188
    handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
189
  },
190
191
  // The JSON API: the same actions, the same identity, plain status codes.
192
  {
193
    method: "GET",
194
    path: "/api/notes",
195
    handle: (c) =>
196
      answer(
197
        listNotes(c.db, c.who, {
198
          limit: Number(c.url.searchParams.get("limit") ?? 100),
199
        }),
200
      ),
201
    api: {
202
      summary: "The notes, newest first.",
203
      query: { limit: LIMIT },
204
      answer: list(NOTE),
205
    },
206
  },
207
  {
208
    method: "POST",
209
    path: "/api/notes",
210
    handle: async (c) => {
211
      const body = await readJson(c.request);
212
      if (body === undefined) return json({ error: "too_large" }, 413);
213
      return answer(
214
        addNote(c.db, c.who, { body: String(fields(body).body ?? "") }),
215
        201,
216
      );
217
    },
218
    api: {
219
      summary: "Add a note as the person asking.",
220
      body: NOTE_BODY,
221
      answer: NOTE,
222
      status: 201,
223
    },
224
  },
225
  {
226
    method: "GET",
227
    path: "/api/notes/:id",
228
    handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })),
229
    api: { summary: "One note.", answer: NOTE },
230
  },
231
  {
232
    method: "PATCH",
233
    path: "/api/notes/:id",
234
    handle: async (c) => {
235
      const body = await readJson(c.request);
236
      if (body === undefined) return json({ error: "too_large" }, 413);
237
      return answer(
238
        updateNote(c.db, c.who, {
239
          id: Number(c.params.id),
240
          body: String(fields(body).body ?? ""),
241
        }),
242
      );
243
    },
244
    api: { summary: "Change a note's words.", body: NOTE_BODY, answer: NOTE },
245
  },
246
  {
247
    method: "DELETE",
248
    path: "/api/notes/:id",
249
    handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
250
    api: {
251
      summary: "Remove a note.",
252
      answer: {
253
        type: "object",
254
        properties: { id: { type: "integer" } },
255
        required: ["id"],
256
      },
257
    },
258
  },
259
260
  // Who tried to change what: the audit trail, for whoever the table lets read it.
261
  {
262
    method: "GET",
263
    path: "/api/audit",
264
    handle: (c) =>
265
      answer(
266
        listAudit(c.db, c.who, {
267
          limit: whole(c.url.searchParams.get("limit")) ?? 100,
268
          ...(whole(c.url.searchParams.get("before")) === undefined
269
            ? {}
270
            : { before: whole(c.url.searchParams.get("before"))! }),
271
        }),
272
      ),
273
    api: {
274
      summary: "The audit trail, newest first; `before` pages back by id.",
275
      query: { limit: LIMIT, before: { type: "integer" } },
276
      answer: list(AUDIT_ROW),
277
    },
278
  },
279
  {
280
    method: "GET",
281
    path: "/api/audit/:id",
282
    handle: (c) => answer(getAudit(c.db, c.who, { id: Number(c.params.id) })),
283
    api: { summary: "One row of the audit trail.", answer: AUDIT_ROW },
284
  },
285
286
  // Live events, and the MCP server (the tools are the actions too).
287
  { method: "GET", path: "/events", handle: (c) => events(c.request) },
288
  {
289
    method: "POST",
290
    path: "/mcp",
291
    handle: async (c) => {
292
      const message = await readJson(c.request);
293
      if (message === undefined) return json({ error: "too_large" }, 413);
294
      if (message === null)
295
        return json({
296
          jsonrpc: "2.0",
297
          id: null,
298
          error: { code: -32700, message: "parse error" },
299
        });
300
      const { status, body } = rpc(c.db, c.who, message);
301
      return body === undefined
302
        ? new Response(null, { status })
303
        : json(body, status);
304
    },
305
    api: {
306
      summary:
307
        "One JSON-RPC 2.0 message to the MCP server; its tools are the actions.",
308
      body: {
309
        type: "object",
310
        properties: {
311
          jsonrpc: { const: "2.0" },
312
          id: {},
313
          method: { type: "string" },
314
          params: { type: "object" },
315
        },
316
        required: ["jsonrpc", "method"],
317
      },
318
      answer: { type: "object" },
319
    },
320
  },
321
  {
322
    method: "GET",
323
    path: "/mcp",
324
    handle: () => text("this endpoint speaks JSON-RPC over POST", 405),
325
  },
326
327
  // Up or not, for the forge and for anyone watching: a separate path from
328
  // the page, so a health check never renders it.
329
  {
330
    method: "GET",
331
    path: "/healthz",
332
    handle: (c) => {
333
      let ok = true;
334
      try {
335
        c.db.query("SELECT 1").get();
336
      } catch {
337
        ok = false;
338
      }
339
      const uptime = Math.floor((Date.now() - startedAt) / 1000);
340
      return new Response(
341
        JSON.stringify({ ok, version, commit, env, uptime }),
342
        {
343
          status: ok ? 200 : 503,
344
          headers: {
345
            "content-type": "application/json; charset=utf-8",
346
            "cache-control": "no-store",
347
            ...SECURITY,
348
          },
349
        },
350
      );
351
    },
352
    api: {
353
      summary:
354
        "Whether the app is up: its version, commit, environment and seconds since it started.",
355
      answer: {
356
        type: "object",
357
        properties: {
358
          ok: { type: "boolean" },
359
          version: { type: "string" },
360
          commit: { type: "string" },
361
          env: { type: "string" },
362
          uptime: { type: "integer" },
363
        },
364
        required: ["ok", "version", "uptime"],
365
      },
366
    },
367
  },
368
  {
369
    method: "GET",
370
    path: "/openapi.json",
371
    handle: () =>
372
      openapi === undefined
373
        ? text("not found", 404)
374
        : new Response(openapi, {
375
            headers: {
376
              "content-type": "application/json; charset=utf-8",
377
              "cache-control": "no-cache",
378
              ...SECURITY,
379
            },
380
          }),
381
  },
382
383
  // The look: design/tokens.json, compiled at boot, cached for a year when linked by content.
384
  {
385
    method: "GET",
386
    path: "/tokens.css",
387
    handle: (c) =>
388
      new Response(tokensCss, {
389
        headers: {
390
          "content-type": "text/css; charset=utf-8",
391
          "cache-control": c.url.searchParams.has("v")
392
            ? "public, max-age=31536000, immutable"
393
            : "no-cache",
394
          ...SECURITY,
395
        },
396
      }),
397
  },
398
399
  // What the web expects of a site. Crawlers read the page, not the API;
400
  // a preview is a draft and is read by nobody. Where to report a
401
  // vulnerability is the forge's to say: it runs this app.
402
  {
403
    method: "GET",
404
    path: "/robots.txt",
405
    handle: () =>
406
      text(
407
        env === "production"
408
          ? "User-agent: *\nDisallow: /api/\nDisallow: /mcp\nDisallow: /events\nDisallow: /__forge/\n"
409
          : "User-agent: *\nDisallow: /\n",
410
      ),
411
  },
412
  {
413
    method: "GET",
414
    path: "/.well-known/security.txt",
415
    handle: () =>
416
      forgeUrl === undefined
417
        ? text("not found", 404)
418
        : new Response(null, {
419
            status: 302,
420
            headers: {
421
              location: new URL(
422
                "/.well-known/security.txt",
423
                forgeUrl,
424
              ).toString(),
425
              ...SECURITY,
426
            },
427
          }),
428
  },
429
430
  // Files under ./static, cached for a year when linked by content (`asset`).
431
  {
432
    method: "GET",
433
    path: "/static/:file",
434
    handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")),
435
  },
436
];
437
438
/** The route for a method and path, with what its `:names` matched. */
439
export function match(
440
  method: string,
441
  pathname: string,
442
): { route: Route; params: Record<string, string> } | undefined {
443
  const parts = pathname.split("/");
444
  for (const route of routes) {
445
    if (route.method !== method) continue;
446
    const pattern = route.path.split("/");
447
    if (pattern.length !== parts.length) continue;
448
    const params: Record<string, string> = {};
449
    const hit = pattern.every((p, i) => {
450
      if (p.startsWith(":")) {
451
        // A malformed escape is no match, never a thrown error.
452
        try {
453
          params[p.slice(1)] = decodeURIComponent(parts[i] ?? "");
454
          return true;
455
        } catch {
456
          return false;
457
        }
458
      }
459
      return p === parts[i];
460
    });
461
    if (hit) return { route, params };
462
  }
463
  return undefined;
464
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts