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
@@ -0,0 +1,250 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import type { ActorFacts, Logger } from "pi-roundtable";
3
+ import { PluginError } from "pi-roundtable";
4
+ import { type ConsolePresentation, message } from "../features.ts";
5
+ import { MIN_PASSWORD_LENGTH } from "../options.ts";
6
+ import { readLimited } from "../request-body.ts";
7
+ import { LOGIN_CSP, loginPage } from "./login-page.ts";
8
+
9
+ /** The session cookie's name. */
10
+ const SESSION_COOKIE = "rt_session";
11
+ /** How many failed logins in the window lock the login page. */
12
+ const MAX_FAILURES = 10;
13
+ const WINDOW_MS = 15 * 60 * 1000;
14
+ /** A login form is a password; anything bigger is not one. */
15
+ const MAX_BODY_BYTES = 4096;
16
+
17
+ export interface PasswordOptions {
18
+ /** The secret, already read from the environment and checked. */
19
+ password: string;
20
+ /** The session key from the data directory. */
21
+ key: Buffer;
22
+ /** Who a session speaks for: the plugin identity's subject and name. */
23
+ actor: ActorFacts;
24
+ mount: string;
25
+ origin: string;
26
+ sessionDays: number;
27
+ title: string;
28
+ presentation: ConsolePresentation | undefined;
29
+ logger: Logger;
30
+ /** How long each failed login waits before answering; default 1000. */
31
+ loginDelayMs: number;
32
+ now: () => number;
33
+ }
34
+
35
+ /** A valid session: when it ends. */
36
+ export type SessionCheck =
37
+ | { valid: true; expiresAt: Date }
38
+ | { valid: false; reason: string };
39
+
40
+ const sha256 = (value: string | Buffer) =>
41
+ createHash("sha256").update(value).digest();
42
+
43
+ const sign = (key: Buffer, payload: string) =>
44
+ createHmac("sha256", key).update(payload).digest();
45
+
46
+ /** Whether two buffers are the same, in time that does not depend on where they differ. */
47
+ const same = (a: Buffer, b: Buffer) =>
48
+ a.length === b.length && timingSafeEqual(a, b);
49
+
50
+ /** Whether a cookie part is the one base64url spelling of its bytes; unused trailing bits would let several spellings pass. */
51
+ const canonical = (part: string) =>
52
+ Buffer.from(part, "base64url").toString("base64url") === part;
53
+
54
+ /** Checks the secret's length and returns it; the message never holds it. */
55
+ export function checkPassword(
56
+ env: Readonly<Record<string, string | undefined>>,
57
+ name: string,
58
+ ): string {
59
+ const value = env[name];
60
+ if (value === undefined || value === "")
61
+ throw new PluginError(
62
+ `pi-roundtable-web: auth password reads the password from the environment variable ${name}, which is not set. Set it to a secret of at least ${MIN_PASSWORD_LENGTH} characters`,
63
+ );
64
+ if (Array.from(value).length < MIN_PASSWORD_LENGTH)
65
+ throw new PluginError(
66
+ `pi-roundtable-web: the password in ${name} is shorter than ${MIN_PASSWORD_LENGTH} characters; use at least ${MIN_PASSWORD_LENGTH}`,
67
+ );
68
+ return value;
69
+ }
70
+
71
+ const cookieValue = (
72
+ header: string | null,
73
+ name: string,
74
+ ): string | undefined => {
75
+ for (const part of (header ?? "").split(";")) {
76
+ const at = part.indexOf("=");
77
+ if (at > 0 && part.slice(0, at).trim() === name)
78
+ return part.slice(at + 1).trim();
79
+ }
80
+ return undefined;
81
+ };
82
+
83
+ /** The body of a login form, or undefined when it is larger than a password can be. */
84
+ async function readForm(
85
+ request: Request,
86
+ ): Promise<URLSearchParams | undefined> {
87
+ const raw = await readLimited(request, MAX_BODY_BYTES);
88
+ return raw && new URLSearchParams(raw.toString("utf8"));
89
+ }
90
+
91
+ /**
92
+ * The password credential: a login page, a signed session cookie, a global rate limit.
93
+ *
94
+ * The cookie is `b64url({p, e, v: 1}).b64url(HMAC-SHA256)`. Its key is the HMAC of the session key
95
+ * over the password's digest, so a new password logs everyone out, and the session key alone
96
+ * (a file in the data directory) signs nothing a host with another password would accept.
97
+ */
98
+ export class PasswordCredential {
99
+ readonly #options: PasswordOptions;
100
+ readonly #secret: Buffer;
101
+ readonly #signing: Buffer;
102
+ readonly #failures: number[] = [];
103
+ readonly #secure: boolean;
104
+ readonly #path: string;
105
+
106
+ constructor(options: PasswordOptions) {
107
+ this.#options = options;
108
+ this.#secret = sha256(options.password);
109
+ this.#signing = sign(options.key, this.#secret.toString("base64url"));
110
+ this.#secure = options.origin.startsWith("https:");
111
+ this.#path = `${options.mount}/`;
112
+ }
113
+
114
+ /** Whether the request carries a session this host made and that has not ended. */
115
+ check(request: Request): SessionCheck {
116
+ const value = cookieValue(request.headers.get("cookie"), SESSION_COOKIE);
117
+ if (!value) return { valid: false, reason: "no session" };
118
+ const parts = value.split(".");
119
+ const [payload, signature] = parts;
120
+ if (
121
+ parts.length !== 2 ||
122
+ !payload ||
123
+ !signature ||
124
+ !canonical(payload) ||
125
+ !canonical(signature)
126
+ )
127
+ return { valid: false, reason: "malformed session" };
128
+ if (
129
+ !same(sign(this.#signing, payload), Buffer.from(signature, "base64url"))
130
+ )
131
+ return { valid: false, reason: "invalid session" };
132
+ let claims: { p?: unknown; e?: unknown; v?: unknown };
133
+ try {
134
+ claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
135
+ } catch {
136
+ return { valid: false, reason: "invalid session" };
137
+ }
138
+ if (
139
+ claims.v !== 1 ||
140
+ claims.p !== this.#options.actor.subject ||
141
+ typeof claims.e !== "number"
142
+ )
143
+ return { valid: false, reason: "invalid session" };
144
+ if (claims.e * 1000 <= this.#options.now())
145
+ return { valid: false, reason: "session ended" };
146
+ return { valid: true, expiresAt: new Date(claims.e * 1000) };
147
+ }
148
+
149
+ /**
150
+ * The routes that need no session below the mount: the login page, `auth/login` and
151
+ * `auth/logout`. Undefined for any other path.
152
+ */
153
+ async open(request: Request, rest: string): Promise<Response | undefined> {
154
+ if (rest === "login")
155
+ return request.method === "GET" || request.method === "HEAD"
156
+ ? this.#page(200)
157
+ : new Response("Method not allowed", { status: 405 });
158
+ if (rest !== "auth/login" && rest !== "auth/logout") return undefined;
159
+ if (request.method !== "POST")
160
+ return new Response("Method not allowed", { status: 405 });
161
+ if (request.headers.get("origin") !== this.#options.origin)
162
+ return new Response("Forbidden", {
163
+ status: 403,
164
+ headers: { "cache-control": "no-store" },
165
+ });
166
+ return rest === "auth/login" ? this.#login(request) : this.#logout();
167
+ }
168
+
169
+ #page(
170
+ status: number,
171
+ note?: string,
172
+ headers: Record<string, string> = {},
173
+ ): Response {
174
+ const { mount, title, presentation } = this.#options;
175
+ return new Response(loginPage(mount, title, presentation, note), {
176
+ status,
177
+ headers: {
178
+ "content-type": "text/html;charset=utf-8",
179
+ "cache-control": "no-store",
180
+ "content-security-policy": LOGIN_CSP,
181
+ ...headers,
182
+ },
183
+ });
184
+ }
185
+
186
+ #redirect(to: string, cookie: string): Response {
187
+ return new Response(null, {
188
+ status: 303,
189
+ headers: {
190
+ location: to,
191
+ "set-cookie": cookie,
192
+ "cache-control": "no-store",
193
+ },
194
+ });
195
+ }
196
+
197
+ #cookie(value: string, maxAge: number): string {
198
+ return `${SESSION_COOKIE}=${value}; Path=${this.#path}; Max-Age=${maxAge}; HttpOnly; SameSite=Strict${this.#secure ? "; Secure" : ""}`;
199
+ }
200
+
201
+ /** Seconds until the login page opens again, or 0 when it is open. */
202
+ #lockedFor(): number {
203
+ const cutoff = this.#options.now() - WINDOW_MS;
204
+ while (this.#failures.length > 0 && (this.#failures[0] ?? 0) <= cutoff)
205
+ this.#failures.shift();
206
+ if (this.#failures.length < MAX_FAILURES) return 0;
207
+ return Math.max(
208
+ 1,
209
+ Math.ceil(
210
+ ((this.#failures[0] ?? 0) + WINDOW_MS - this.#options.now()) / 1000,
211
+ ),
212
+ );
213
+ }
214
+
215
+ async #login(request: Request): Promise<Response> {
216
+ const { logger, presentation } = this.#options;
217
+ const form = await readForm(request);
218
+ if (!form) return new Response("Payload too large", { status: 413 });
219
+ const retry = this.#lockedFor();
220
+ if (retry > 0) {
221
+ logger.warn({ retryAfter: retry }, "web-console: login is locked");
222
+ return this.#page(
223
+ 429,
224
+ message(presentation, "Too many failed attempts. Try again later."),
225
+ { "retry-after": String(retry) },
226
+ );
227
+ }
228
+ const given = form.get("password") ?? "";
229
+ if (!same(sha256(given), this.#secret)) {
230
+ // Counted before the wait, so a burst of attempts at once cannot get past the limit.
231
+ this.#failures.push(this.#options.now());
232
+ logger.warn("web-console: a login failed");
233
+ if (this.#options.loginDelayMs > 0)
234
+ await Bun.sleep(this.#options.loginDelayMs);
235
+ return this.#page(401, message(presentation, "Wrong password."));
236
+ }
237
+ const { sessionDays, actor } = this.#options;
238
+ const expires =
239
+ Math.floor(this.#options.now() / 1000) + sessionDays * 86400;
240
+ const payload = Buffer.from(
241
+ JSON.stringify({ p: actor.subject, e: expires, v: 1 }),
242
+ ).toString("base64url");
243
+ const value = `${payload}.${sign(this.#signing, payload).toString("base64url")}`;
244
+ return this.#redirect(this.#path, this.#cookie(value, sessionDays * 86400));
245
+ }
246
+
247
+ #logout(): Response {
248
+ return this.#redirect(`${this.#options.mount}/login`, this.#cookie("", 0));
249
+ }
250
+ }
@@ -0,0 +1,65 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ linkSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { Logger } from "pi-roundtable";
13
+ import { PluginError } from "pi-roundtable";
14
+
15
+ const KEY_BYTES = 32;
16
+
17
+ const readKey = (file: string): Buffer => {
18
+ const key = readFileSync(file);
19
+ if (key.length !== KEY_BYTES)
20
+ throw new PluginError(
21
+ `pi-roundtable-web: ${file} holds ${key.length} bytes, not ${KEY_BYTES}. It is the console's session key: delete it to sign everyone out and make a new one, or restore it`,
22
+ );
23
+ return key;
24
+ };
25
+
26
+ /**
27
+ * The console's session key, `<dataDir>/web/session.key`: 32 random bytes, readable by its owner
28
+ * only, made at the first start and kept so a restart does not sign anyone out. A file of the
29
+ * wrong size stops startup instead of being used, since a short key would be a weak one.
30
+ */
31
+ export function loadSessionKey(dataDir: string, logger: Logger): Buffer {
32
+ const dir = join(dataDir, "web");
33
+ const file = join(dir, "session.key");
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ let key: Buffer;
36
+ try {
37
+ key = readKey(file);
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
40
+ // Written whole under a temporary name, then linked into place: a second start never
41
+ // reads a half-written key, and the first link wins.
42
+ const temp = join(
43
+ dir,
44
+ `.session.key.${process.pid}.${randomBytes(4).toString("hex")}`,
45
+ );
46
+ writeFileSync(temp, randomBytes(KEY_BYTES), { flag: "wx", mode: 0o600 });
47
+ try {
48
+ linkSync(temp, file);
49
+ } catch (linkError) {
50
+ if ((linkError as NodeJS.ErrnoException).code !== "EEXIST")
51
+ throw linkError;
52
+ } finally {
53
+ unlinkSync(temp);
54
+ }
55
+ key = readKey(file);
56
+ }
57
+ if (statSync(file).mode & 0o077) {
58
+ chmodSync(file, 0o600);
59
+ logger.warn(
60
+ { file },
61
+ "web-console: the session key was readable by others; its mode is now 0600",
62
+ );
63
+ }
64
+ return key;
65
+ }
@@ -28,7 +28,7 @@ export async function cleanupConversation(
28
28
  Boolean(team?.owns(key)) || Boolean(features?.party?.contains(key));
29
29
  const group =
30
30
  (await team?.status())?.groups.some(
31
- (g) => `discord:${g.channelId}` === key,
31
+ (g) => g.channelId !== undefined && `discord:${g.channelId}` === key,
32
32
  ) ?? false;
33
33
  const stored =
34
34
  !owned &&
@@ -39,7 +39,7 @@ export function cloudflareAccessIdentity(
39
39
  * for this application, naming that email and a user. The token is checked on every request, so
40
40
  * the console stays closed to a client that reaches it without passing through Access. It reports
41
41
  * the user as an OpenID identity of the team's issuer, their `sub` as the subject and their email
42
- * as the name.
42
+ * as the name, and the token's expiry as when the admission lapses.
43
43
  */
44
44
  export function cloudflareAccess(
45
45
  options: CloudflareAccessOptions,
@@ -66,6 +66,7 @@ export function cloudflareAccess(
66
66
  if (!token) return refuse("no Access token");
67
67
  let email: string;
68
68
  let subject: string;
69
+ let expiresAt: Date | undefined;
69
70
  try {
70
71
  const { payload } = await jwtVerify(token, keys, { issuer, audience });
71
72
  if (
@@ -77,9 +78,11 @@ export function cloudflareAccess(
77
78
  return refuse("Access token names no user");
78
79
  email = payload.email.toLowerCase();
79
80
  subject = payload.sub;
81
+ if (typeof payload.exp === "number")
82
+ expiresAt = new Date(payload.exp * 1000);
80
83
  } catch {
81
84
  return refuse("invalid Access token");
82
85
  }
83
- return admitAs({ provider, subject, name: email });
86
+ return admitAs({ provider, subject, name: email }, expiresAt);
84
87
  };
85
88
  }
@@ -80,11 +80,15 @@ export interface ConsolePorts {
80
80
  features?: ConsoleFeatures;
81
81
  presentation?: ConsolePresentation;
82
82
  mountPath?: string;
83
+ look?: "modern" | "camelot";
84
+ /** Present when the owner app is served: its rooms' key prefix, for the page. */
85
+ app?: { surface: string; protocol: string };
83
86
  }
84
87
 
85
88
  import { cleanupConversation } from "./cleanup-api.ts";
86
89
  import { connectorsView } from "./connectors-api.ts";
87
90
  import { ConsoleHttpError as HttpError, json } from "./http.ts";
91
+ import { readLimited } from "./request-body.ts";
88
92
  import { skillCatalog, skillDetail } from "./skills-api.ts";
89
93
 
90
94
  /** Memory refusals as the Notes pane shows them. */
@@ -184,7 +188,7 @@ export class ConsoleApi {
184
188
  })),
185
189
  groups: view.groups.map((group) => ({
186
190
  ...group,
187
- key: `discord:${group.channelId}`,
191
+ key: group.channelId ? `discord:${group.channelId}` : "",
188
192
  })),
189
193
  workspaces: (view.workspaces ?? []).map((c) => ({
190
194
  ...c,
@@ -240,7 +244,9 @@ export class ConsoleApi {
240
244
  ...(presentation?.messages ? { messages: presentation.messages } : {}),
241
245
  ...(features?.cleanup ? { cleanup: true } : {}),
242
246
  ...(connectorAdminUrl ? { connectorAdminUrl } : {}),
247
+ look: this.#ports.look ?? "modern",
243
248
  ...(this.#ports.mountPath ? { mountPath: this.#ports.mountPath } : {}),
249
+ ...(this.#ports.app ? { app: this.#ports.app } : {}),
244
250
  };
245
251
  }
246
252
 
@@ -283,7 +289,8 @@ export class ConsoleApi {
283
289
  groups: status.groups.map((group) => ({
284
290
  name: group.name,
285
291
  displayName: group.displayName,
286
- channelId: group.channelId,
292
+ // A group of a team without Discord has no channel; the pane then links none.
293
+ ...(group.channelId ? { channelId: group.channelId } : {}),
287
294
  members: group.members,
288
295
  host: group.host,
289
296
  busy: group.busy,
@@ -513,21 +520,12 @@ export class ConsoleApi {
513
520
  }
514
521
  }
515
522
 
516
- /** The JSON body, read in chunks so a body past the limit is refused without being held whole. */
523
+ /** The JSON body; undefined when it is not JSON. */
517
524
  async function readBody(request: Request): Promise<unknown> {
518
- const tooLarge = () => new HttpError(413, "The request is too large.");
519
- if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES)
520
- throw tooLarge();
521
- const chunks: Uint8Array[] = [];
522
- let size = 0;
523
- if (request.body)
524
- for await (const chunk of request.body) {
525
- size += chunk.byteLength;
526
- if (size > MAX_BODY_BYTES) throw tooLarge();
527
- chunks.push(chunk);
528
- }
525
+ const raw = await readLimited(request, MAX_BODY_BYTES);
526
+ if (!raw) throw new HttpError(413, "The request is too large.");
529
527
  try {
530
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
528
+ return JSON.parse(raw.toString("utf8"));
531
529
  } catch {
532
530
  return undefined;
533
531
  }
@@ -1,10 +1,15 @@
1
- import type { ActorFacts, HttpRoute, Logger } from "pi-roundtable";
1
+ import type { HttpRoute, Logger, WebSocketRoute } from "pi-roundtable";
2
+ import type { AppSocket } from "./app/sockets.ts";
2
3
  import type { AssetBundle } from "./assets.ts";
4
+ import type {
5
+ Authenticated,
6
+ Authenticator,
7
+ Unauthenticated,
8
+ } from "./auth/authenticator.ts";
3
9
  import type { ConsoleApi } from "./console-api.ts";
4
10
  import type { ConsolePresentation } from "./features.ts";
5
11
  import { presentPage } from "./page-presentation.ts";
6
- import type { RequestVerifier } from "./verifier.ts";
7
- import type { Identified, Visitor } from "./visitors.ts";
12
+ import { servePwa } from "./pwa.ts";
8
13
 
9
14
  const encoder = new TextEncoder();
10
15
  /** Changes closer together than this reach the page as one event. */
@@ -33,12 +38,21 @@ export interface ConsoleServerOptions {
33
38
  /** The path the console is served under, without a trailing slash: `/console`. */
34
39
  mount: string;
35
40
  assets: AssetBundle;
36
- verifier: RequestVerifier;
37
- /** Who a request the verifier admitted comes from, by the actor it reported; only an owner is a visitor. */
38
- identify(actor: ActorFacts | undefined): Promise<Identified>;
39
- /** The console's own origin; requests that change data must come from it. */
40
- origin: string;
41
+ /** Checks every request under the mount, and answers the few paths that need no session. */
42
+ authenticator: Authenticator;
41
43
  api: Pick<ConsoleApi, "handle">;
44
+ /**
45
+ * The owner app, when the `rooms` pane is served: it answers its own paths below the mount
46
+ * before the console's API does, and its WebSocket takes the upgrades of the console's route.
47
+ */
48
+ app?: {
49
+ handle(
50
+ request: Request,
51
+ rest: string,
52
+ admission: Authenticated,
53
+ ): Promise<Response | undefined>;
54
+ websocket: WebSocketRoute<AppSocket>;
55
+ };
42
56
  /** Registers the listener with every source whose change the console shows. */
43
57
  subscribe(listener: () => void): void;
44
58
  logger: Logger;
@@ -47,12 +61,18 @@ export interface ConsoleServerOptions {
47
61
  /** Serve client page paths as the same authenticated document. */
48
62
  pathRouting?: boolean;
49
63
  presentation?: ConsolePresentation;
64
+ /** The manifest's name; default `Roundtable`. */
65
+ title?: string;
66
+ /** Where the install icons come from; default the ones this package ships. */
67
+ iconsDir?: string;
68
+ look?: "modern" | "camelot";
50
69
  }
51
70
 
52
71
  /**
53
72
  * The console on the host's listener: `<mount>` redirects to `<mount>/`, and everything under
54
- * it needs the verifier to vouch for the person and that person to be an owner. A request that
55
- * changes data must also carry the console's own `Origin`.
73
+ * it needs the authenticator to admit the request as an owner's, apart from what it answers
74
+ * openly, such as a login page. A request that changes data must also carry the console's own
75
+ * `Origin`.
56
76
  */
57
77
  export class ConsoleServer {
58
78
  readonly #options: ConsoleServerOptions;
@@ -67,7 +87,11 @@ export class ConsoleServer {
67
87
  options.subscribe(() => this.#changed());
68
88
  }
69
89
 
70
- routes(listener: string): HttpRoute[] {
90
+ /**
91
+ * The routes on `listener`. `maxRequestBodyBytes` is the largest body the console takes, for
92
+ * the owner app's uploads; without it the listener's default stands.
93
+ */
94
+ routes(listener: string, maxRequestBodyBytes?: number): HttpRoute[] {
71
95
  const handle = (request: Request) => this.handle(request);
72
96
  return [
73
97
  {
@@ -81,6 +105,10 @@ export class ConsoleServer {
81
105
  listener,
82
106
  path: { prefix: this.#base },
83
107
  handle,
108
+ ...(maxRequestBodyBytes === undefined ? {} : { maxRequestBodyBytes }),
109
+ ...(this.#options.app
110
+ ? { websocket: this.#options.app.websocket }
111
+ : {}),
84
112
  },
85
113
  ];
86
114
  }
@@ -116,25 +144,20 @@ export class ConsoleServer {
116
144
  const path = new URL(request.url).pathname;
117
145
  if (path !== this.#options.mount && !path.startsWith(this.#base))
118
146
  return new Response("Not found", { status: 404 });
119
- const admission = await this.#admission(request);
120
- if ("refusal" in admission) {
121
- this.#options.logger.warn(
122
- { refusal: admission.refusal },
123
- "console request refused",
124
- );
125
- return new Response("Forbidden", {
126
- status: 403,
127
- headers: { "cache-control": "no-store" },
128
- });
129
- }
147
+ const rest = path.slice(this.#base.length);
148
+ const open = await this.#options.authenticator.open?.(request, rest);
149
+ if (open) return open;
150
+ const admission = await this.#options.authenticator.authenticate(request);
151
+ if ("refusal" in admission) return this.#refused(request, rest, admission);
130
152
  if (path === this.#options.mount)
131
153
  return new Response(null, {
132
154
  status: 302,
133
155
  headers: { location: this.#base },
134
156
  });
135
- const rest = path.slice(this.#base.length);
136
157
  if (rest === "api/events" && request.method === "GET")
137
158
  return this.#events();
159
+ const owned = await this.#options.app?.handle(request, rest, admission);
160
+ if (owned) return owned;
138
161
  if (rest.startsWith("api/"))
139
162
  return this.#options.api.handle(
140
163
  request,
@@ -143,8 +166,13 @@ export class ConsoleServer {
143
166
  );
144
167
  if (!READ_METHODS.has(request.method))
145
168
  return new Response("Method not allowed", { status: 405 });
169
+ const installable = servePwa(rest, {
170
+ ...this.#options,
171
+ title: this.#options.title ?? "Roundtable",
172
+ });
173
+ if (installable) return installable;
146
174
  const asset = this.#options.assets.get(rest);
147
- if (asset && rest !== "index.html")
175
+ if (asset && rest !== "index.html" && rest !== "sw.js")
148
176
  return new Response(new Uint8Array(asset.body), {
149
177
  headers: {
150
178
  "content-type": asset.type,
@@ -159,6 +187,7 @@ export class ConsoleServer {
159
187
  page.body,
160
188
  this.#options.pathRouting ? this.#options.mount : undefined,
161
189
  this.#options.presentation,
190
+ this.#options.look,
162
191
  ),
163
192
  {
164
193
  headers: {
@@ -177,33 +206,32 @@ export class ConsoleServer {
177
206
  return new Response("Not found", { status: 404 });
178
207
  }
179
208
 
180
- /** The owner the request comes from when it may pass, otherwise why not; never throws. */
181
- async #admission(
182
- request: Request,
183
- ): Promise<{ visitor: Visitor } | { refusal: string }> {
184
- let actor: ActorFacts | undefined;
185
- try {
186
- const verdict = await this.#options.verifier(request);
187
- if (!verdict.admitted) return { refusal: String(verdict.reason) };
188
- actor = verdict.actor;
189
- } catch {
190
- // A verifier that fails admits no one.
191
- return { refusal: "verifier failed" };
192
- }
193
- let identified: Identified;
194
- try {
195
- identified = await this.#options.identify(actor);
196
- } catch {
197
- // Nor does a lookup of who they are that fails.
198
- return { refusal: "identity failed" };
199
- }
200
- if ("refusal" in identified) return { refusal: String(identified.refusal) };
209
+ /**
210
+ * The answer to a request the authenticator refused: a page navigation that signing in would
211
+ * fix goes to the login page, everything else gets the status and a fixed body.
212
+ */
213
+ #refused(request: Request, rest: string, refusal: Unauthenticated): Response {
201
214
  if (
202
- !READ_METHODS.has(request.method) &&
203
- request.headers.get("origin") !== this.#options.origin
215
+ refusal.login &&
216
+ READ_METHODS.has(request.method) &&
217
+ !rest.startsWith("api/")
204
218
  )
205
- return { refusal: "foreign origin" };
206
- return identified;
219
+ return new Response(null, {
220
+ status: 303,
221
+ headers: {
222
+ location: `${this.#options.mount}/login`,
223
+ "cache-control": "no-store",
224
+ },
225
+ });
226
+ // A missing session is the normal way to meet a login page, not worth a line each time.
227
+ const { logger } = this.#options;
228
+ const fields = { refusal: refusal.refusal };
229
+ if (refusal.login) logger.debug(fields, "console request refused");
230
+ else logger.warn(fields, "console request refused");
231
+ return new Response(refusal.status === 401 ? "Unauthorized" : "Forbidden", {
232
+ status: refusal.status,
233
+ headers: { "cache-control": "no-store" },
234
+ });
207
235
  }
208
236
 
209
237
  /** A server-sent event stream that says `changed` whenever the console may differ. */
package/src/http.ts CHANGED
@@ -9,5 +9,13 @@ export class ConsoleHttpError extends Error {
9
9
  super(message);
10
10
  }
11
11
  }
12
+ /** Text safe in element content and in a quoted attribute value. */
13
+ export const escapeHtml = (value: string): string =>
14
+ value
15
+ .replaceAll("&", "&amp;")
16
+ .replaceAll('"', "&quot;")
17
+ .replaceAll("<", "&lt;")
18
+ .replaceAll(">", "&gt;")
19
+ .replaceAll("'", "&#39;");
12
20
  export const json = (body: unknown, status = 200): Response =>
13
21
  Response.json(body, { status, headers: { "cache-control": "no-store" } });
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ export {
35
35
  /** Summary for a trusted host's separately stored party sessions. */
36
36
  export { conversationFiles as sessionSummary } from "./conversations.ts";
37
37
  export type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
38
- export type { WebConsoleOptions } from "./options.ts";
38
+ export type { WebAuthOptions, WebConsoleOptions } from "./options.ts";
39
39
  export { DEFAULT_RELAY_NOTE } from "./options.ts";
40
40
  export type { RequestVerifier, Verdict } from "./verifier.ts";
41
41
  export { admit, admitAs, refuse } from "./verifier.ts";