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.
- package/CHANGELOG.md +41 -1
- package/README.md +427 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-6vdshaj9.js +1 -0
- package/dist/chunk-connectors-s33pc7b8.js +1 -0
- package/dist/chunk-conversations-zpztvesk.js +1 -0
- package/dist/chunk-entry-th44mrfe.js +1 -0
- package/dist/chunk-index-4w1rf52n.js +47 -0
- package/dist/chunk-index-5pnpf9v5.js +41 -0
- package/dist/chunk-index-dkvf85c7.css +1 -0
- package/dist/chunk-index-kkydgsrs.js +2 -0
- package/dist/chunk-index-mtcervy3.js +2 -0
- package/dist/chunk-index-x3cf4dsz.js +1 -0
- package/dist/chunk-notes-ytw8qcfk.js +1 -0
- package/dist/chunk-overview-fbea32dq.js +1 -0
- package/dist/chunk-skills-58f4qz3b.js +1 -0
- package/dist/chunk-table-ypqdgf66.js +1 -0
- package/dist/chunk-table2d-bk1khqzq.js +1 -0
- package/dist/chunk-table3d-5vpp4bzh.js +4229 -0
- package/dist/chunk-transcript-nabrrayp.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/http.ts +8 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- 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;
|
|
19
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
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
|
-
"
|
|
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
|
-
|
|
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;
|
package/src/page-presentation.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
let head =
|
|
19
|
-
if (mount) head += `<base href="${
|
|
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="${
|
|
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}${
|
|
32
|
+
`${prefix}${escapeHtml(presentation.locale ?? "en")}${suffix}`,
|
|
28
33
|
);
|
|
29
34
|
return html;
|
|
30
35
|
}
|
package/src/push/api.ts
ADDED
|
@@ -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
|
+
}
|