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

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

Keyboard shortcuts