pi-roundtable-web 0.22.5 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +427 -104
  3. package/assets/icons/192.png +0 -0
  4. package/assets/icons/512.png +0 -0
  5. package/assets/icons/apple-180.png +0 -0
  6. package/assets/icons/badge-96.png +0 -0
  7. package/assets/icons/mark-maskable.svg +1 -0
  8. package/assets/icons/mark.svg +1 -0
  9. package/assets/icons/maskable-512.png +0 -0
  10. package/dist/chunk-character-creator-6vdshaj9.js +1 -0
  11. package/dist/chunk-connectors-s33pc7b8.js +1 -0
  12. package/dist/chunk-conversations-zpztvesk.js +1 -0
  13. package/dist/chunk-entry-th44mrfe.js +1 -0
  14. package/dist/chunk-index-4w1rf52n.js +47 -0
  15. package/dist/chunk-index-5pnpf9v5.js +41 -0
  16. package/dist/chunk-index-dkvf85c7.css +1 -0
  17. package/dist/chunk-index-kkydgsrs.js +2 -0
  18. package/dist/chunk-index-mtcervy3.js +2 -0
  19. package/dist/chunk-index-x3cf4dsz.js +1 -0
  20. package/dist/chunk-notes-ytw8qcfk.js +1 -0
  21. package/dist/chunk-overview-fbea32dq.js +1 -0
  22. package/dist/chunk-skills-58f4qz3b.js +1 -0
  23. package/dist/chunk-table-ypqdgf66.js +1 -0
  24. package/dist/chunk-table2d-bk1khqzq.js +1 -0
  25. package/dist/chunk-table3d-5vpp4bzh.js +4229 -0
  26. package/dist/chunk-transcript-nabrrayp.js +1 -0
  27. package/dist/index.html +6 -2
  28. package/dist/sw.js +1 -0
  29. package/package.json +8 -3
  30. package/src/api-types.ts +448 -1
  31. package/src/app/app.ts +351 -0
  32. package/src/app/card-log.ts +119 -0
  33. package/src/app/desk.ts +533 -0
  34. package/src/app/files.ts +262 -0
  35. package/src/app/frames.ts +132 -0
  36. package/src/app/multipart.ts +172 -0
  37. package/src/app/presence.ts +73 -0
  38. package/src/app/protocol.ts +144 -0
  39. package/src/app/room-log.ts +137 -0
  40. package/src/app/rooms.ts +189 -0
  41. package/src/app/routes.ts +434 -0
  42. package/src/app/socket.ts +406 -0
  43. package/src/app/sockets.ts +84 -0
  44. package/src/app/surface-threads.ts +92 -0
  45. package/src/app/surface.ts +384 -0
  46. package/src/app/threads.ts +99 -0
  47. package/src/app/uploads.ts +363 -0
  48. package/src/assets.ts +1 -3
  49. package/src/auth/authenticator.ts +167 -0
  50. package/src/auth/login-page.ts +44 -0
  51. package/src/auth/none.ts +38 -0
  52. package/src/auth/password.ts +250 -0
  53. package/src/auth/session-key.ts +65 -0
  54. package/src/cleanup-api.ts +1 -1
  55. package/src/cloudflare-access.ts +5 -2
  56. package/src/console-api.ts +13 -15
  57. package/src/console-server.ts +77 -49
  58. package/src/http.ts +8 -0
  59. package/src/index.ts +1 -1
  60. package/src/options.ts +308 -12
  61. package/src/page-colors.ts +9 -0
  62. package/src/page-presentation.ts +17 -12
  63. package/src/push/api.ts +190 -0
  64. package/src/push/channel.ts +50 -0
  65. package/src/push/encrypt.ts +136 -0
  66. package/src/push/endpoint.ts +54 -0
  67. package/src/push/sender.ts +109 -0
  68. package/src/push/subscriptions.ts +140 -0
  69. package/src/push/test-fetch.ts +17 -0
  70. package/src/push/triggers.ts +155 -0
  71. package/src/push/vapid.ts +124 -0
  72. package/src/pwa.ts +130 -0
  73. package/src/request-body.ts +20 -0
  74. package/src/verifier.ts +7 -1
  75. package/src/web-plugin.ts +123 -11
  76. package/dist/chunk-82frzte9.js +0 -36
  77. package/dist/chunk-e8z9q554.css +0 -1
package/src/options.ts CHANGED
@@ -2,7 +2,13 @@ import { join } from "node:path";
2
2
  import type { ChannelKey, RoundtablePlugin } from "pi-roundtable";
3
3
  import { PluginError } from "pi-roundtable";
4
4
  import { PANES, type PaneName } from "./api-types.ts";
5
+ import {
6
+ DEFAULT_UPLOAD_MAX_BYTES,
7
+ UPLOAD_FRAMING_BYTES,
8
+ } from "./app/uploads.ts";
5
9
  import type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
10
+ import { isHostPattern } from "./push/endpoint.ts";
11
+ import { pushFetchOf } from "./push/test-fetch.ts";
6
12
  import type { RequestVerifier } from "./verifier.ts";
7
13
 
8
14
  /**
@@ -12,14 +18,78 @@ import type { RequestVerifier } from "./verifier.ts";
12
18
  export const DEFAULT_RELAY_NOTE =
13
19
  "(The owner wrote this in a personal agent that relays it over MCP, not on Discord. Answer the owner directly, just as you would on Discord; the agent passes your reply back.)";
14
20
 
21
+ /** The default environment variable `auth: { mode: "password" }` reads the password from. */
22
+ const DEFAULT_PASSWORD_ENV = "ROUNDTABLE_WEB_PASSWORD";
23
+ /** How many days a password session lasts by default. */
24
+ const DEFAULT_SESSION_DAYS = 30;
25
+ /** The shortest password the console accepts, in characters. */
26
+ export const MIN_PASSWORD_LENGTH = 12;
27
+ /** The key prefix of the app's rooms by default. */
28
+ const DEFAULT_SURFACE = "app";
29
+ /** How many seconds a turn waits on its card before it goes on without the answer, by default. */
30
+ const DEFAULT_CARD_GRACE_SECONDS = 120;
31
+
32
+ /**
33
+ * How the console knows who is asking. Every mode but `verifier` speaks for one principal through
34
+ * the plugin identity `token:web-console`, which the plugin declares; read "Authentication" and
35
+ * "Threat model" in the README before choosing `none` or `password`.
36
+ */
37
+ export type WebAuthOptions =
38
+ | {
39
+ /**
40
+ * No authentication: whoever reaches the listener is the owner. Written explicitly, never
41
+ * a default, and only for a listener a person cannot reach but the owner: a unix socket,
42
+ * or TCP on `127.0.0.1`, `::1` or `localhost`.
43
+ */
44
+ mode: "none";
45
+ /** Allows `none` on a TCP listener that reaches beyond this machine, when a network in front of it keeps everyone else out. */
46
+ trustNetwork?: boolean;
47
+ /** The principal it speaks for; default the primary owner (the first of `access.owners`). */
48
+ principal?: string;
49
+ }
50
+ | {
51
+ /**
52
+ * A password from the environment, asked on a login page, then a signed session cookie.
53
+ * It resists the network, not an agent whose shell runs as the same user.
54
+ */
55
+ mode: "password";
56
+ /** The environment variable that holds the password, at least 12 characters; default `ROUNDTABLE_WEB_PASSWORD`. */
57
+ passwordEnv?: string;
58
+ /** How long a login lasts, 1 to 365 days; default 30. */
59
+ sessionDays?: number;
60
+ /** The principal it speaks for; default the primary owner. */
61
+ principal?: string;
62
+ }
63
+ | {
64
+ /** A request verifier, such as `cloudflareAccess(...)`; the same as the `verifier` option. */
65
+ mode: "verifier";
66
+ verifier: RequestVerifier;
67
+ };
68
+
69
+ /** `auth` checked and filled in. */
70
+ export type ResolvedAuth =
71
+ | { mode: "none"; trustNetwork: boolean; principal?: string }
72
+ | {
73
+ mode: "password";
74
+ passwordEnv: string;
75
+ sessionDays: number;
76
+ principal?: string;
77
+ }
78
+ | { mode: "verifier"; verifier: RequestVerifier };
79
+
15
80
  export interface WebConsoleOptions {
81
+ /**
82
+ * How requests are authenticated; one of `auth` or `verifier` is required, and without either
83
+ * the plugin refuses to start rather than serve without authentication. Only a person whose
84
+ * principal holds the owner role gets in.
85
+ */
86
+ auth?: WebAuthOptions;
16
87
  /**
17
88
  * Decides whether a request comes from someone the operator's proxy authenticated, and reports
18
- * who; required. `cloudflareAccess(...)` is the built-in one. Without it the plugin refuses to
19
- * start rather than serve without authentication. Only a person whose principal holds the owner
20
- * role gets in.
89
+ * who; the same as `auth: { mode: "verifier", verifier }`, and giving both throws.
90
+ * `cloudflareAccess(...)` is the built-in one.
21
91
  */
22
- verifier: RequestVerifier;
92
+ verifier?: RequestVerifier;
23
93
  /** Host-specific data and actions, resolved against public plugin setup context. */
24
94
  features?:
25
95
  | ConsoleFeatures
@@ -47,10 +117,65 @@ export interface WebConsoleOptions {
47
117
  listener?: string;
48
118
  /** Hash routes by default; path routes retain links such as /console/skills. */
49
119
  routing?: "hash" | "path";
50
- /** The panes to serve, in the order the page lists them; default all of `overview`, `conversations`, `notes`. */
120
+ /**
121
+ * The panes to serve, in the order the page lists them; default `rooms`, `table`, `overview`,
122
+ * `schedules`, `conversations`, `notes`. `rooms` is the owner app: the same agent team as on
123
+ * Discord, in rooms of its own; `table` seats its agents at the round table and needs `rooms`.
124
+ * An explicit list is kept as written, so a host that listed the panes before 0.23 adds
125
+ * `rooms`, `table` and `schedules` by hand.
126
+ */
51
127
  panes?: readonly PaneName[];
128
+ /**
129
+ * The key prefix of the app's rooms: `<surface>:agent.<name>` and `<surface>:group.<name>`.
130
+ * A lower-case word that is not `discord`; default `app`. Rooms keep their conversations under
131
+ * it, so changing it starts every room over.
132
+ */
133
+ surface?: string;
134
+ /**
135
+ * How long a turn waits on its approval or question card before it goes on without the answer,
136
+ * in seconds; default 120, as on Discord. The card stays open for a day, and an answer given
137
+ * then starts a new turn in the room.
138
+ */
139
+ cardGraceSeconds?: number;
140
+ /**
141
+ * The most bytes of one file a person may upload in the app, 100 MiB by default. The upload
142
+ * route raises the listener's request body limit to this plus the form's framing, and the
143
+ * agent reads files up to it. At most 4 GiB. The listener has one body limit, so this raises
144
+ * it for every route on the listener, not only the upload route. A reverse proxy in front has a limit of its own, and
145
+ * Cloudflare's free plan caps request bodies at 100 MB. Files are streamed to disk, not held
146
+ * in memory.
147
+ */
148
+ uploadMaxBytes?: number;
149
+ /**
150
+ * Web Push to the owner's devices, sent when a card waits, a long-running cap is reached, an
151
+ * agent replies on its own, or a notice is sent, and no page of the owner is in view. The
152
+ * payload is encrypted for the device. Only the `rooms` pane has push.
153
+ */
154
+ push?: {
155
+ /**
156
+ * Whether a notification shows the start of the message (the first 120 characters); default
157
+ * true. With `false` it says only that something is waiting and where.
158
+ */
159
+ preview?: boolean;
160
+ /**
161
+ * Push services the server may post to besides the browsers' own (Google, Mozilla, Apple,
162
+ * Microsoft), as host names; a leading `*.` allows every subdomain. Their endpoints must
163
+ * still be `https`.
164
+ */
165
+ endpointHosts?: readonly string[];
166
+ /**
167
+ * Who a push service may contact about this server (the VAPID `sub`): a `mailto:` or
168
+ * `https:` URL. Default `origin` when that is `https`; with an `http` origin push stays off
169
+ * until this is set.
170
+ */
171
+ subject?: string;
172
+ };
52
173
  /** The page's title and heading; default `Roundtable`. */
53
174
  title?: string;
175
+ /** Where the install icons come from; default the ones this package ships. */
176
+ icons?: { dir: string };
177
+ /** How the page looks: `modern` (default) or `camelot`. The page's owner can switch it in the app. */
178
+ appearance?: { look?: "modern" | "camelot" };
54
179
  /** Text a relayed message begins with, taken off the owner's words; default the remote MCP note. */
55
180
  relayNotes?: readonly string[];
56
181
  /** Conversations the console neither lists nor reads. */
@@ -61,15 +186,28 @@ export interface WebConsoleOptions {
61
186
 
62
187
  /** Options checked and filled in. */
63
188
  export interface ResolvedOptions {
64
- verifier: RequestVerifier;
189
+ auth: ResolvedAuth;
65
190
  origin: string;
66
191
  ownerId: string | undefined;
192
+ dataDir: string;
67
193
  sessionsDir: string;
68
194
  mount: string;
69
195
  listener: string;
70
196
  routing: "hash" | "path";
71
197
  panes: PaneName[];
198
+ surface: string;
199
+ cardGraceSeconds: number;
200
+ uploadMaxBytes: number;
201
+ push: {
202
+ preview: boolean;
203
+ endpointHosts: readonly string[];
204
+ /** The VAPID contact; undefined = push is off. */
205
+ subject: string | undefined;
206
+ fetch: typeof fetch | undefined;
207
+ };
72
208
  title: string;
209
+ iconsDir: string | undefined;
210
+ look: "modern" | "camelot";
73
211
  relayNotes: readonly string[];
74
212
  exclude: ((key: ChannelKey) => boolean) | undefined;
75
213
  assetDir: string | undefined;
@@ -119,7 +257,15 @@ function checkOrigin(origin: string): string {
119
257
  }
120
258
 
121
259
  function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
122
- if (panes === undefined) return ["overview", "conversations", "notes"];
260
+ if (panes === undefined)
261
+ return [
262
+ "rooms",
263
+ "table",
264
+ "overview",
265
+ "schedules",
266
+ "conversations",
267
+ "notes",
268
+ ];
123
269
  if (panes.length === 0) fail("panes is empty; serve at least one pane");
124
270
  for (const pane of panes)
125
271
  if (!PANES.includes(pane))
@@ -127,15 +273,155 @@ function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
127
273
  `unknown pane ${JSON.stringify(pane)}; the panes are ${PANES.join(", ")}`,
128
274
  );
129
275
  if (new Set(panes).size !== panes.length) fail("panes lists a pane twice");
276
+ if (panes.includes("schedules") && !panes.includes("rooms"))
277
+ fail("the schedules pane needs the rooms pane, which serves its API");
278
+ if (panes.includes("table") && !panes.includes("rooms"))
279
+ fail("the table pane needs the rooms pane, whose agents it seats");
130
280
  return [...panes];
131
281
  }
132
282
 
133
- /** Checks the options at the point the plugin is created, so a bad setting stops the host before anything starts. */
134
- export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
135
- if (typeof options.verifier !== "function")
283
+ function checkAuth(options: WebConsoleOptions): ResolvedAuth {
284
+ const { auth, verifier } = options;
285
+ if (auth !== undefined && verifier !== undefined)
286
+ fail(
287
+ 'both auth and verifier are given. `verifier` is shorthand for `auth: { mode: "verifier", verifier }`; pass one',
288
+ );
289
+ if (auth === undefined) {
290
+ if (typeof verifier !== "function")
291
+ fail(
292
+ 'no verifier or auth. The console serves the owner\'s conversations and notes, so it will not start without a way to authenticate every request; pass `auth: { mode: "password" }`, `auth: { mode: "none" }` for a listener only the owner reaches, or `verifier: cloudflareAccess({...})` or your own',
293
+ );
294
+ return { mode: "verifier", verifier };
295
+ }
296
+ const mode = (auth as { mode?: unknown } | null)?.mode;
297
+ const principal = (auth as { principal?: unknown }).principal;
298
+ if (
299
+ principal !== undefined &&
300
+ (typeof principal !== "string" || !principal.trim())
301
+ )
302
+ fail(
303
+ "auth.principal is empty. Leave it out to speak for the primary owner",
304
+ );
305
+ const named =
306
+ typeof principal === "string" ? { principal: principal.trim() } : {};
307
+ switch (mode) {
308
+ case "none": {
309
+ const { trustNetwork } = auth as { trustNetwork?: unknown };
310
+ if (trustNetwork !== undefined && typeof trustNetwork !== "boolean")
311
+ fail("auth.trustNetwork must be true or false");
312
+ return { mode, trustNetwork: trustNetwork === true, ...named };
313
+ }
314
+ case "password": {
315
+ const {
316
+ passwordEnv = DEFAULT_PASSWORD_ENV,
317
+ sessionDays = DEFAULT_SESSION_DAYS,
318
+ } = auth as { passwordEnv?: unknown; sessionDays?: unknown };
319
+ if (typeof passwordEnv !== "string" || !passwordEnv.trim())
320
+ fail("auth.passwordEnv is empty");
321
+ if (
322
+ typeof sessionDays !== "number" ||
323
+ !Number.isInteger(sessionDays) ||
324
+ sessionDays < 1 ||
325
+ sessionDays > 365
326
+ )
327
+ fail("auth.sessionDays must be a whole number of days from 1 to 365");
328
+ return {
329
+ mode,
330
+ passwordEnv: passwordEnv.trim(),
331
+ sessionDays,
332
+ ...named,
333
+ };
334
+ }
335
+ case "verifier": {
336
+ const given = (auth as { verifier?: unknown }).verifier;
337
+ if (typeof given !== "function")
338
+ fail(
339
+ "auth.verifier is not a function; pass `cloudflareAccess({...})` or your own",
340
+ );
341
+ return { mode, verifier: given as RequestVerifier };
342
+ }
343
+ default:
344
+ fail(
345
+ `auth.mode ${JSON.stringify(mode)} is unknown; the modes are none, password and verifier`,
346
+ );
347
+ }
348
+ }
349
+
350
+ function checkSurface(surface: string | undefined): string {
351
+ if (surface === undefined) return DEFAULT_SURFACE;
352
+ if (typeof surface !== "string" || !/^[a-z][a-z0-9-]*$/.test(surface))
353
+ fail(
354
+ `surface ${JSON.stringify(surface)} must be a lower-case word such as app`,
355
+ );
356
+ if (surface === "discord")
357
+ fail("surface cannot be discord, whose channels the Discord plugin serves");
358
+ return surface;
359
+ }
360
+
361
+ function checkGrace(seconds: number | undefined): number {
362
+ if (seconds === undefined) return DEFAULT_CARD_GRACE_SECONDS;
363
+ if (typeof seconds !== "number" || !(seconds > 0) || seconds > 3600)
364
+ fail(
365
+ "cardGraceSeconds must be a number of seconds above 0 and at most 3600",
366
+ );
367
+ return seconds;
368
+ }
369
+
370
+ function checkPush(
371
+ options: WebConsoleOptions,
372
+ origin: string,
373
+ ): ResolvedOptions["push"] {
374
+ const push = options.push;
375
+ if (push?.preview !== undefined && typeof push.preview !== "boolean")
376
+ fail("push.preview must be true or false");
377
+ const hosts = push?.endpointHosts ?? [];
378
+ if (!Array.isArray(hosts))
379
+ fail("push.endpointHosts must be a list of host names");
380
+ for (const host of hosts)
381
+ if (typeof host !== "string" || !isHostPattern(host))
382
+ fail(
383
+ `push.endpointHosts has ${JSON.stringify(host)}, which is not a host name such as push.example.com or *.push.example.com`,
384
+ );
385
+ const given = push?.subject;
386
+ if (
387
+ given !== undefined &&
388
+ !(
389
+ typeof given === "string" &&
390
+ (/^mailto:[^\s@]+@[^\s@]+$/.test(given) ||
391
+ (URL.parse(given)?.protocol === "https:" && !/\s/.test(given)))
392
+ )
393
+ )
394
+ fail(
395
+ "push.subject must be a mailto: address or an https: URL, such as mailto:ops@example.com",
396
+ );
397
+ return {
398
+ preview: push?.preview ?? true,
399
+ endpointHosts: [...hosts],
400
+ subject: given ?? (origin.startsWith("https:") ? origin : undefined),
401
+ fetch: pushFetchOf(options),
402
+ };
403
+ }
404
+
405
+ /** The largest `uploadMaxBytes`: the limit is also the listener's body limit. */
406
+ const MAX_UPLOAD_MAX_BYTES = 4 * 1024 ** 3;
407
+
408
+ function checkUploadMax(bytes: number | undefined): number {
409
+ if (bytes === undefined) return DEFAULT_UPLOAD_MAX_BYTES;
410
+ if (
411
+ typeof bytes !== "number" ||
412
+ !Number.isSafeInteger(bytes + UPLOAD_FRAMING_BYTES) ||
413
+ bytes < 1 ||
414
+ bytes > MAX_UPLOAD_MAX_BYTES
415
+ )
136
416
  fail(
137
- "no verifier. The console serves the owner's conversations and notes, so it will not start without one that authenticates every request; pass `verifier: cloudflareAccess({...})` or your own",
417
+ "uploadMaxBytes must be a whole number of bytes, from 1 to 4 GiB, that the upload route can ask the listener to take with the form's framing added",
138
418
  );
419
+ return bytes;
420
+ }
421
+
422
+ /** Checks the options at the point the plugin is created, so a bad setting stops the host before anything starts. */
423
+ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
424
+ const auth = checkAuth(options);
139
425
  if (
140
426
  options.routing !== undefined &&
141
427
  options.routing !== "path" &&
@@ -155,18 +441,28 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
155
441
  fail(
156
442
  "ownerId is empty. Leave it out: the console finds each visitor's principal, and a verifier that reports no actor speaks for the primary owner",
157
443
  );
444
+ const look = options.appearance?.look ?? "modern";
445
+ if (look !== "modern" && look !== "camelot")
446
+ fail("appearance.look must be modern or camelot");
158
447
  const title = options.title?.trim() ?? "Roundtable";
159
448
  if (!title) fail("title is empty");
160
449
  return {
161
- verifier: options.verifier,
450
+ auth,
162
451
  origin: checkOrigin(options.origin),
163
452
  ownerId,
453
+ dataDir: options.dataDir ?? "data",
164
454
  sessionsDir: join(options.dataDir ?? "data", "sessions"),
165
455
  mount: checkMount(options.mountPath ?? "/console"),
166
456
  listener: options.listener ?? "public",
167
457
  routing: options.routing ?? "hash",
168
458
  panes,
459
+ surface: checkSurface(options.surface),
460
+ cardGraceSeconds: checkGrace(options.cardGraceSeconds),
461
+ uploadMaxBytes: checkUploadMax(options.uploadMaxBytes),
462
+ push: checkPush(options, checkOrigin(options.origin)),
169
463
  title,
464
+ iconsDir: options.icons?.dir,
465
+ look,
170
466
  relayNotes: options.relayNotes ?? [DEFAULT_RELAY_NOTE],
171
467
  exclude: options.exclude,
172
468
  assetDir: options.assetDir,
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The page's background in each look and scheme, which the browser's toolbar takes (`--bg` in
3
+ * tokens.css and in styles/camelot.css). Pure data: the server writes it into the page, and the
4
+ * page itself reads it when the look or the scheme changes.
5
+ */
6
+ export const PAGE_COLORS = {
7
+ modern: { light: "#f5f6f8", dark: "#0f1115" },
8
+ camelot: { light: "#f4ead5", dark: "#0f1726" },
9
+ } as const;
@@ -1,30 +1,35 @@
1
1
  import type { ConsolePresentation } from "./features.ts";
2
+ import { escapeHtml } from "./http.ts";
3
+ import { PAGE_COLORS } from "./page-colors.ts";
2
4
 
3
- const attribute = (value: string): string =>
4
- value
5
- .replaceAll("&", "&amp;")
6
- .replaceAll('"', "&quot;")
7
- .replaceAll("<", "&lt;")
8
- .replaceAll(">", "&gt;")
9
- .replaceAll("'", "&#39;");
5
+ /** The tags that make the page installable; the URLs resolve against the page, so the mount does not matter. */
6
+ const pwaTags = (look: "modern" | "camelot"): string =>
7
+ '<link rel="manifest" href="./manifest.webmanifest" crossorigin="use-credentials">' +
8
+ `<meta name="theme-color" content="${PAGE_COLORS[look].light}" media="(prefers-color-scheme: light)">` +
9
+ `<meta name="theme-color" content="${PAGE_COLORS[look].dark}" media="(prefers-color-scheme: dark)">` +
10
+ '<link rel="apple-touch-icon" href="./icons/apple-180.png">' +
11
+ '<meta name="apple-mobile-web-app-capable" content="yes">';
10
12
 
11
13
  /** Authenticated, inert bootstrap data localizes even a failed first API request without inline scripts. */
12
14
  export function presentPage(
13
15
  body: Uint8Array,
14
16
  mount: string | undefined,
15
17
  presentation: ConsolePresentation | undefined,
18
+ look: "modern" | "camelot" = "modern",
16
19
  ): Uint8Array<ArrayBuffer> | string {
17
- if (!mount && !presentation) return new Uint8Array(body);
18
- let head = "<head>";
19
- if (mount) head += `<base href="${attribute(`${mount}/`)}">`;
20
+ // Not in web/index.html: the bundler would try to resolve and rewrite these URLs.
21
+ let head = `<head>${pwaTags(look)}`;
22
+ if (mount) head += `<base href="${escapeHtml(`${mount}/`)}">`;
20
23
  if (presentation)
21
- head += `<meta name="roundtable-console-presentation" content="${attribute(JSON.stringify(presentation))}">`;
24
+ head += `<meta name="roundtable-console-presentation" content="${escapeHtml(JSON.stringify(presentation))}">`;
25
+ // After the base, so it resolves under the mount; a script of the page's own origin, which its policy allows.
26
+ head += '<script src="./theme-init.js"></script>';
22
27
  let html = new TextDecoder().decode(body).replace("<head>", () => head);
23
28
  if (presentation?.locale)
24
29
  html = html.replace(
25
30
  /(<html[^>]*lang=")[^"]*(")/,
26
31
  (_all, prefix: string, suffix: string) =>
27
- `${prefix}${attribute(presentation.locale ?? "en")}${suffix}`,
32
+ `${prefix}${escapeHtml(presentation.locale ?? "en")}${suffix}`,
28
33
  );
29
34
  return html;
30
35
  }
@@ -0,0 +1,190 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { IdentityService } from "pi-roundtable";
3
+ import type {
4
+ PushDevicesView,
5
+ PushDeviceView,
6
+ PushKeyView,
7
+ } from "../api-types.ts";
8
+ import type { Authenticated } from "../auth/authenticator.ts";
9
+ import { ConsoleHttpError as HttpError, json } from "../http.ts";
10
+ import { readLimited } from "../request-body.ts";
11
+ import type { EndpointPolicy } from "./endpoint.ts";
12
+ import type { PushSubscriptions } from "./subscriptions.ts";
13
+ import type { Vapid } from "./vapid.ts";
14
+
15
+ /** The most devices one person may have subscribed. */
16
+ export const MAX_DEVICES = 20;
17
+ /** The largest JSON body a subscription is sent in. */
18
+ const MAX_BODY_BYTES = 8 * 1024;
19
+ const MAX_LABEL_CHARS = 80;
20
+ /** An uncompressed P-256 point. */
21
+ const P256DH_BYTES = 65;
22
+ const AUTH_BYTES = 16;
23
+
24
+ export interface PushApiOptions {
25
+ vapid: Pick<Vapid, "publicKey">;
26
+ policy: EndpointPolicy;
27
+ subscriptions: Pick<PushSubscriptions, "of" | "save" | "remove">;
28
+ identity: Pick<IdentityService, "tierOf">;
29
+ /** False when the server has no VAPID contact: devices cannot be added, since nothing would reach them. */
30
+ enabled: boolean;
31
+ }
32
+
33
+ /** What identifies a device in a list without handing its endpoint around: the endpoint's SHA-256, hex. */
34
+ const deviceId = (endpoint: string): string =>
35
+ createHash("sha256").update(endpoint).digest("hex");
36
+
37
+ const decode = (value: unknown, bytes: number, name: string): string => {
38
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value))
39
+ throw new HttpError(400, `${name} must be base64url.`);
40
+ const raw = Buffer.from(value, "base64url");
41
+ if (raw.byteLength !== bytes || (bytes === P256DH_BYTES && raw[0] !== 4))
42
+ throw new HttpError(400, `${name} is not a valid key.`);
43
+ return value;
44
+ };
45
+
46
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
47
+ const raw = await readLimited(request, MAX_BODY_BYTES);
48
+ if (!raw) throw new HttpError(413, "The body is too large.");
49
+ let value: unknown;
50
+ try {
51
+ value = JSON.parse(raw.toString("utf8"));
52
+ } catch {
53
+ throw new HttpError(400, "The body is not JSON.");
54
+ }
55
+ if (typeof value !== "object" || value === null || Array.isArray(value))
56
+ throw new HttpError(400, "The body is not an object.");
57
+ return value as Record<string, unknown>;
58
+ }
59
+
60
+ /**
61
+ * The push routes under `<mount>/api/push/`: the key a browser subscribes against, and the
62
+ * signed-in owner's own devices: list, add, remove. A device is only ever read or removed by the
63
+ * person who added it, and an endpoint is accepted only on a push service the policy allows.
64
+ */
65
+ export class PushApi {
66
+ readonly #options: PushApiOptions;
67
+
68
+ constructor(options: PushApiOptions) {
69
+ this.#options = options;
70
+ }
71
+
72
+ /** Answers `api/push/...`; undefined for any other path. */
73
+ async handle(
74
+ request: Request,
75
+ parts: readonly string[],
76
+ admission: Authenticated,
77
+ ): Promise<Response | undefined> {
78
+ const [head, area, name, id] = parts;
79
+ if (head !== "api" || area !== "push") return undefined;
80
+ try {
81
+ const principalId = await this.#owner(admission);
82
+ const { method } = request;
83
+ if (name === "key" && !id && method === "GET")
84
+ return json({
85
+ key: this.#options.vapid.publicKey,
86
+ } satisfies PushKeyView);
87
+ if (name !== "subscriptions")
88
+ throw new HttpError(404, "There is no such API.");
89
+ if (!id && method === "GET")
90
+ return json(await this.#devices(principalId));
91
+ if (!id && method === "POST")
92
+ return await this.#add(request, principalId);
93
+ if (!id && method === "DELETE")
94
+ return await this.#removeEndpoint(request, principalId);
95
+ if (id && method === "DELETE")
96
+ return await this.#removeId(id, principalId);
97
+ throw new HttpError(405, "Method not allowed.");
98
+ } catch (error) {
99
+ if (error instanceof HttpError)
100
+ return json({ error: error.message }, error.status);
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ async #owner(admission: Authenticated): Promise<string> {
106
+ const { principal } = admission.visitor;
107
+ if ((await this.#options.identity.tierOf(principal.id)) !== "owner")
108
+ throw new HttpError(403, "Only an owner may do this.");
109
+ return principal.id;
110
+ }
111
+
112
+ async #devices(principalId: string): Promise<PushDevicesView> {
113
+ const devices = await this.#options.subscriptions.of(principalId);
114
+ return {
115
+ devices: devices.map(
116
+ (device): PushDeviceView => ({
117
+ id: deviceId(device.endpoint),
118
+ ...(device.label ? { label: device.label } : {}),
119
+ createdAt: device.createdAt.toISOString(),
120
+ ...(device.lastOkAt
121
+ ? { lastOkAt: device.lastOkAt.toISOString() }
122
+ : {}),
123
+ }),
124
+ ),
125
+ };
126
+ }
127
+
128
+ async #add(request: Request, principalId: string): Promise<Response> {
129
+ if (!this.#options.enabled)
130
+ throw new HttpError(
131
+ 503,
132
+ "Push is not set up: set push.subject on the server.",
133
+ );
134
+ const body = await bodyOf(request);
135
+ const checked = this.#options.policy.check(body.endpoint);
136
+ if ("refusal" in checked) throw new HttpError(400, checked.refusal);
137
+ const keys = body.keys;
138
+ if (typeof keys !== "object" || keys === null)
139
+ throw new HttpError(400, "keys must hold p256dh and auth.");
140
+ const { p256dh, auth } = keys as Record<string, unknown>;
141
+ const label = body.label;
142
+ if (label !== undefined && typeof label !== "string")
143
+ throw new HttpError(400, "label must be text.");
144
+ const endpoint = checked.url.href;
145
+ const saved = await this.#options.subscriptions.save(
146
+ {
147
+ endpoint,
148
+ principalId,
149
+ p256dh: decode(p256dh, P256DH_BYTES, "p256dh"),
150
+ auth: decode(auth, AUTH_BYTES, "auth"),
151
+ ...(label?.trim()
152
+ ? { label: label.trim().slice(0, MAX_LABEL_CHARS) }
153
+ : {}),
154
+ },
155
+ MAX_DEVICES,
156
+ );
157
+ if (saved === "taken")
158
+ throw new HttpError(
159
+ 409,
160
+ "That device is already subscribed by someone else.",
161
+ );
162
+ if (saved === "full")
163
+ throw new HttpError(
164
+ 400,
165
+ "Too many devices. Remove one in the settings first.",
166
+ );
167
+ return json({ id: deviceId(endpoint) });
168
+ }
169
+
170
+ async #removeEndpoint(
171
+ request: Request,
172
+ principalId: string,
173
+ ): Promise<Response> {
174
+ const body = await bodyOf(request);
175
+ const checked = this.#options.policy.check(body.endpoint);
176
+ if ("refusal" in checked) throw new HttpError(400, checked.refusal);
177
+ // Removing a device that is already gone is as good as removing it.
178
+ await this.#options.subscriptions.remove(checked.url.href, principalId);
179
+ return json({ ok: true });
180
+ }
181
+
182
+ async #removeId(id: string, principalId: string): Promise<Response> {
183
+ const device = (await this.#options.subscriptions.of(principalId)).find(
184
+ (candidate) => deviceId(candidate.endpoint) === id,
185
+ );
186
+ if (!device) throw new HttpError(404, "There is no such device.");
187
+ await this.#options.subscriptions.remove(device.endpoint, principalId);
188
+ return json({ ok: true });
189
+ }
190
+ }