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,363 @@
1
+ import { extname } from "node:path";
2
+ import type { AttachmentFile, AttachmentRef } from "pi-roundtable";
3
+ import type { RoomFile, UploadView } from "../api-types.ts";
4
+ import { FILE_RETENTION_MS, type FileDir, safeName } from "./files.ts";
5
+ import {
6
+ boundaryOf,
7
+ MultipartError,
8
+ type PartHeaders,
9
+ type PartSink,
10
+ readMultipart,
11
+ } from "./multipart.ts";
12
+
13
+ /** The most bytes of one uploaded file when the options say nothing else: 100 MiB. */
14
+ export const DEFAULT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
15
+ /** What the multipart framing of one file may add to a body: 64 KiB. */
16
+ export const UPLOAD_FRAMING_BYTES = 64 * 1024;
17
+ /** How long an upload no message used waits: 24 hours. */
18
+ const UPLOAD_WAIT_MS = 24 * 3_600_000;
19
+ /** The most files one person may have waiting for a message, or on their way. */
20
+ const PENDING_PER_PRINCIPAL = 16;
21
+ /** The most bytes one person may have waiting for a message, or on their way: 400 MiB. */
22
+ const PENDING_BYTES_PER_PRINCIPAL = 400 * 1024 * 1024;
23
+ /** How much of the start of a file is kept to tell what it is. */
24
+ const HEAD_BYTES = 16;
25
+
26
+ /** The scheme of the references a message carries to its uploads: `app-upload:<id>`. */
27
+ const REF_PREFIX = "app-upload:";
28
+
29
+ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
30
+ bytes.byteLength >= at + signature.length &&
31
+ signature.every((byte, index) => bytes[at + index] === byte);
32
+ const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
33
+
34
+ /** The image type the first bytes say, if any; these four are the ones the page shows itself. */
35
+ function imageTypeOf(head: Uint8Array): string | undefined {
36
+ if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
37
+ return "image/png";
38
+ if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg";
39
+ if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a")))
40
+ return "image/gif";
41
+ if (startsWith(head, ascii("RIFF")) && startsWith(head, ascii("WEBP"), 8))
42
+ return "image/webp";
43
+ return undefined;
44
+ }
45
+
46
+ /** The image types a file's declared type may name; any other declared type says nothing the bytes must match. */
47
+ const CHECKED_IMAGE_TYPES = new Set([
48
+ "image/png",
49
+ "image/jpeg",
50
+ "image/gif",
51
+ "image/webp",
52
+ ]);
53
+
54
+ /** The type kept for a text file: what its name says among the text types, otherwise plain text. */
55
+ function textTypeOf(name: string, declared: string): string {
56
+ switch (extname(name).toLowerCase()) {
57
+ case ".md":
58
+ case ".markdown":
59
+ return "text/markdown";
60
+ case ".csv":
61
+ return "text/csv";
62
+ case ".json":
63
+ return "application/json";
64
+ default:
65
+ return declared === "application/json" ? declared : "text/plain";
66
+ }
67
+ }
68
+
69
+ /**
70
+ * What kind of file the bytes are, learned as they stream past: the first bytes, and whether
71
+ * everything so far is UTF-8 text without a NUL. Nothing is held but the first few bytes.
72
+ */
73
+ class Sniffer {
74
+ #head: Uint8Array = new Uint8Array(0);
75
+ #text = true;
76
+ readonly #decoder = new TextDecoder("utf-8", { fatal: true });
77
+
78
+ feed(chunk: Uint8Array): void {
79
+ if (this.#head.byteLength < HEAD_BYTES) {
80
+ const kept = new Uint8Array(
81
+ Math.min(HEAD_BYTES, this.#head.byteLength + chunk.byteLength),
82
+ );
83
+ kept.set(this.#head, 0);
84
+ kept.set(
85
+ chunk.subarray(0, kept.byteLength - this.#head.byteLength),
86
+ this.#head.byteLength,
87
+ );
88
+ this.#head = kept;
89
+ }
90
+ if (!this.#text) return;
91
+ if (chunk.includes(0)) {
92
+ this.#text = false;
93
+ return;
94
+ }
95
+ try {
96
+ this.#decoder.decode(chunk, { stream: true });
97
+ } catch {
98
+ this.#text = false;
99
+ }
100
+ }
101
+
102
+ /** The verdict at the end of the file: the type it is kept as, or undefined when its declared image type is not what the bytes are. */
103
+ verdict(name: string, declared: string): string | undefined {
104
+ let complete = this.#text;
105
+ if (complete) {
106
+ try {
107
+ this.#decoder.decode();
108
+ } catch {
109
+ complete = false;
110
+ }
111
+ }
112
+ const image = imageTypeOf(this.#head);
113
+ if (CHECKED_IMAGE_TYPES.has(declared) && image !== declared)
114
+ return undefined;
115
+ if (image) return image;
116
+ if (complete) return textTypeOf(name, declared);
117
+ if (startsWith(this.#head, ascii("%PDF-"))) return "application/pdf";
118
+ return "application/octet-stream";
119
+ }
120
+ }
121
+
122
+ /** Why an upload was refused; `status` is the HTTP answer. */
123
+ export class UploadRefusal extends Error {
124
+ override name = "UploadRefusal";
125
+ constructor(
126
+ readonly status: 400 | 413 | 415 | 429,
127
+ message: string,
128
+ ) {
129
+ super(message);
130
+ }
131
+ }
132
+
133
+ interface Waiting {
134
+ principalId: string;
135
+ at: number;
136
+ size: number;
137
+ }
138
+
139
+ /**
140
+ * The files the owner uploads for a message, kept on disk until a message uses them. A file is
141
+ * its owner's alone: a message may only name uploads of its own sender. When a message takes
142
+ * its uploads they move to the stored files, which keep 30 days, and the agent reads them
143
+ * through the surface as `app-upload:<id>`; an upload no message uses is deleted after a day.
144
+ * An upload is read as a stream and written to disk as it arrives, so its size costs disk, not
145
+ * memory; what a person may have waiting is bounded in files and in bytes.
146
+ */
147
+ export class Uploads {
148
+ readonly #waiting: FileDir;
149
+ readonly #stored: FileDir;
150
+ readonly #pending = new Map<string, Waiting>();
151
+ /** What uploads under way will take at most, by person: their declared lengths. */
152
+ readonly #inflight = new Map<string, { files: number; bytes: number }>();
153
+ readonly #now: () => number;
154
+ readonly maxBytes: number;
155
+ readonly #pendingBytes: number;
156
+
157
+ constructor(
158
+ waiting: FileDir,
159
+ stored: FileDir,
160
+ options: {
161
+ maxBytes?: number;
162
+ /** The most bytes one person may have waiting; default 400 MiB. */
163
+ pendingBytes?: number;
164
+ now?: () => number;
165
+ } = {},
166
+ ) {
167
+ this.#waiting = waiting;
168
+ this.#stored = stored;
169
+ this.#now = options.now ?? Date.now;
170
+ this.maxBytes = options.maxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
171
+ this.#pendingBytes = options.pendingBytes ?? PENDING_BYTES_PER_PRINCIPAL;
172
+ }
173
+
174
+ #waitingOf(principalId: string): { files: number; bytes: number } {
175
+ let files = 0;
176
+ let bytes = 0;
177
+ for (const w of this.#pending.values())
178
+ if (w.principalId === principalId) {
179
+ files += 1;
180
+ bytes += w.size;
181
+ }
182
+ const flying = this.#inflight.get(principalId);
183
+ return {
184
+ files: files + (flying?.files ?? 0),
185
+ bytes: bytes + (flying?.bytes ?? 0),
186
+ };
187
+ }
188
+
189
+ /** Takes one file from a multipart body (field `file`); throws `UploadRefusal` when it is not accepted. */
190
+ async receive(request: Request, principalId: string): Promise<UploadView> {
191
+ const length = Number(request.headers.get("content-length"));
192
+ if (!Number.isFinite(length) || length <= 0)
193
+ throw new UploadRefusal(400, "The upload has no length.");
194
+ if (length > this.maxBytes + UPLOAD_FRAMING_BYTES)
195
+ throw new UploadRefusal(413, "The file is too large.");
196
+ const waiting = this.#waitingOf(principalId);
197
+ if (waiting.files >= PENDING_PER_PRINCIPAL)
198
+ throw new UploadRefusal(429, "Too many files are waiting for a message.");
199
+ if (waiting.bytes + length > this.#pendingBytes)
200
+ throw new UploadRefusal(
201
+ 429,
202
+ "Too much is waiting for a message. Send it, or wait a day for the rest to clear.",
203
+ );
204
+ const boundary = boundaryOf(request.headers.get("content-type"));
205
+ if (!boundary || !request.body)
206
+ throw new UploadRefusal(400, "The upload is not a form.");
207
+ this.#inflight.set(principalId, {
208
+ files: (this.#inflight.get(principalId)?.files ?? 0) + 1,
209
+ bytes: (this.#inflight.get(principalId)?.bytes ?? 0) + length,
210
+ });
211
+ let kept: RoomFile | undefined;
212
+ try {
213
+ await readMultipart(request.body, boundary, (part) => {
214
+ if (kept || part.name !== "file" || part.filename === undefined)
215
+ return undefined;
216
+ return this.#sink(part, (file) => {
217
+ kept = file;
218
+ });
219
+ });
220
+ } catch (error) {
221
+ // A file part may have finished before the body failed; the form as a whole did not arrive.
222
+ if (kept) this.#waiting.remove(kept.id);
223
+ if (error instanceof MultipartError)
224
+ throw new UploadRefusal(400, "The upload is not a form.");
225
+ throw error;
226
+ } finally {
227
+ const flying = this.#inflight.get(principalId);
228
+ if (flying && flying.files > 1)
229
+ this.#inflight.set(principalId, {
230
+ files: flying.files - 1,
231
+ bytes: flying.bytes - length,
232
+ });
233
+ else this.#inflight.delete(principalId);
234
+ }
235
+ if (!kept) throw new UploadRefusal(400, "There is no file.");
236
+ this.#pending.set(kept.id, {
237
+ principalId,
238
+ at: this.#now(),
239
+ size: kept.size,
240
+ });
241
+ return {
242
+ id: kept.id,
243
+ name: kept.name,
244
+ type: kept.type,
245
+ size: kept.size,
246
+ };
247
+ }
248
+
249
+ /** The sink that streams one file part to the waiting directory and decides what it is as it goes. */
250
+ #sink(part: PartHeaders, done: (file: RoomFile) => void): PartSink {
251
+ const sniffer = new Sniffer();
252
+ const name = safeName(part.filename ?? "file");
253
+ let writer: Awaited<ReturnType<FileDir["begin"]>> | undefined;
254
+ const open = async () => {
255
+ writer ??= await this.#waiting.begin();
256
+ return writer;
257
+ };
258
+ return {
259
+ write: async (chunk) => {
260
+ const file = await open();
261
+ if (file.size + chunk.byteLength > this.maxBytes)
262
+ throw new UploadRefusal(413, "The file is too large.");
263
+ sniffer.feed(chunk);
264
+ await file.write(chunk);
265
+ },
266
+ end: async () => {
267
+ const file = await open();
268
+ if (file.size === 0) throw new UploadRefusal(400, "The file is empty.");
269
+ const type = sniffer.verdict(name, part.contentType);
270
+ if (!type)
271
+ throw new UploadRefusal(415, "The file is not what its type says.");
272
+ done(await file.finish(name, type));
273
+ },
274
+ abort: async () => {
275
+ await writer?.abort();
276
+ },
277
+ };
278
+ }
279
+
280
+ /** Deletes a waiting upload of the person's own; false when there is none such, or a message already took it. */
281
+ discard(id: string, principalId: string): boolean {
282
+ const waiting = this.#pending.get(id);
283
+ if (!waiting || waiting.principalId !== principalId) return false;
284
+ this.#pending.delete(id);
285
+ this.#waiting.remove(id);
286
+ return true;
287
+ }
288
+
289
+ /**
290
+ * Checks that these are waiting uploads of the person and returns them; undefined when one is
291
+ * unknown, someone else's, or named twice. Nothing moves yet.
292
+ */
293
+ claim(ids: readonly string[], principalId: string): RoomFile[] | undefined {
294
+ if (new Set(ids).size !== ids.length) return undefined;
295
+ const files: RoomFile[] = [];
296
+ for (const id of ids) {
297
+ const waiting = this.#pending.get(id);
298
+ const meta = this.#waiting.meta(id);
299
+ if (!waiting || waiting.principalId !== principalId || !meta)
300
+ return undefined;
301
+ files.push(meta);
302
+ }
303
+ return files;
304
+ }
305
+
306
+ /** Moves claimed uploads into the stored files, once their message is accepted. */
307
+ use(ids: readonly string[]): void {
308
+ for (const id of ids) {
309
+ this.#pending.delete(id);
310
+ this.#waiting.moveTo(id, this.#stored);
311
+ }
312
+ }
313
+
314
+ /** The reference a message carries for a stored upload. */
315
+ refOf(file: RoomFile): AttachmentRef {
316
+ return {
317
+ url: `${REF_PREFIX}${file.id}`,
318
+ name: file.name,
319
+ contentType: file.type,
320
+ size: file.size,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * The file behind a reference this store made, as its path: the host copies it from there, so
326
+ * a large file is never held in memory. Rejects for any other reference.
327
+ */
328
+ async read(ref: AttachmentRef): Promise<AttachmentFile> {
329
+ if (!ref.url.startsWith(REF_PREFIX))
330
+ throw new Error("not a file of this app");
331
+ const id = ref.url.slice(REF_PREFIX.length);
332
+ const path = this.#stored.pathOf(id) ?? this.#waiting.pathOf(id);
333
+ if (!path) throw new Error("the file is gone");
334
+ return { path };
335
+ }
336
+
337
+ /** Deletes the waiting uploads older than a day and the stored files older than 30 days; returns how many. */
338
+ sweep(): number {
339
+ const now = this.#now();
340
+ let dropped = 0;
341
+ for (const id of this.#waiting.older(UPLOAD_WAIT_MS, now)) {
342
+ this.#waiting.remove(id);
343
+ this.#pending.delete(id);
344
+ dropped += 1;
345
+ }
346
+ for (const id of this.#stored.older(FILE_RETENTION_MS, now)) {
347
+ this.#stored.remove(id);
348
+ dropped += 1;
349
+ }
350
+ return dropped;
351
+ }
352
+
353
+ /** Remembers the uploads left on disk by an earlier run, to the principal who made them: none, so they are swept. */
354
+ adopt(): void {
355
+ for (const id of this.#waiting.ids())
356
+ if (!this.#pending.has(id))
357
+ this.#pending.set(id, {
358
+ principalId: "",
359
+ at: this.#now(),
360
+ size: 0,
361
+ });
362
+ }
363
+ }
package/src/assets.ts CHANGED
@@ -12,9 +12,7 @@ export interface Asset {
12
12
  export type AssetBundle = ReadonlyMap<string, Asset>;
13
13
 
14
14
  /** Where `bun run build` leaves the page; the published package ships it ready-made. */
15
- export const BUILT_PAGE_DIR = fileURLToPath(
16
- new URL("../dist/", import.meta.url),
17
- );
15
+ const BUILT_PAGE_DIR = fileURLToPath(new URL("../dist/", import.meta.url));
18
16
 
19
17
  /**
20
18
  * Reads the built page into memory. A missing page throws, so a checkout that was never built
@@ -0,0 +1,167 @@
1
+ import type { ActorFacts, ListenerAddress, Logger } from "pi-roundtable";
2
+ import type { ConsolePresentation } from "../features.ts";
3
+ import type { ResolvedAuth } from "../options.ts";
4
+ import type { Verdict } from "../verifier.ts";
5
+ import type { Identified, Visitor } from "../visitors.ts";
6
+ import { checkNoneListener } from "./none.ts";
7
+ import { checkPassword, PasswordCredential } from "./password.ts";
8
+ import { loadSessionKey } from "./session-key.ts";
9
+
10
+ /** The plugin identity `none` and `password` speak through; `RoundtablePlugin.identities` links it to the owner. */
11
+ export const WEB_CONSOLE_IDENTITY = "token:web-console";
12
+
13
+ /** The actor a request of mode `none` or `password` comes from. */
14
+ export const WEB_CONSOLE_ACTOR: ActorFacts = {
15
+ provider: "token",
16
+ subject: "web-console",
17
+ name: "Web console",
18
+ };
19
+
20
+ /** A request that may go on: who it comes from, and when that lapses, when the credential says. */
21
+ export interface Authenticated {
22
+ visitor: Visitor;
23
+ actor?: ActorFacts;
24
+ expiresAt?: Date;
25
+ }
26
+
27
+ /** A request that may not, with the status that says so; `login` when signing in would help. */
28
+ export interface Unauthenticated {
29
+ /** Why, for the operator's log. It is never sent to the client, and it must not hold a secret. */
30
+ refusal: string;
31
+ status: 401 | 403;
32
+ login?: true;
33
+ }
34
+
35
+ /**
36
+ * The one check every request to the console passes, whatever carries it: a page, the API, the
37
+ * event stream, and the WebSocket upgrade. It never throws.
38
+ */
39
+ export interface Authenticator {
40
+ authenticate(request: Request): Promise<Authenticated | Unauthenticated>;
41
+ /**
42
+ * Answers the paths below the mount that need no session, such as the login page; undefined
43
+ * for any other, and absent for a mode with none.
44
+ */
45
+ open?(request: Request, rest: string): Promise<Response | undefined>;
46
+ }
47
+
48
+ export interface AuthenticatorOptions {
49
+ auth: ResolvedAuth;
50
+ /** Who an actor the credential vouched for is; only an owner is a visitor. */
51
+ identify(actor: ActorFacts | undefined): Promise<Identified>;
52
+ /** The console's own origin; requests that change data must come from it. */
53
+ origin: string;
54
+ /** The path the console is served under, without a trailing slash. */
55
+ mount: string;
56
+ title: string;
57
+ presentation?: ConsolePresentation | undefined;
58
+ logger: Logger;
59
+ /** The host's data directory, whose `web/` holds the session key. */
60
+ dataDir: string;
61
+ /** The listener the console is served on and its address, for the check of `none`. */
62
+ listener: { id: string; address: Readonly<ListenerAddress> | undefined };
63
+ /** Where the password is read from; default `process.env`. */
64
+ env?: Readonly<Record<string, string | undefined>>;
65
+ /** How long each failed login waits; default 1000 ms. Tests set it lower. */
66
+ loginDelayMs?: number;
67
+ /** The clock, in milliseconds; default `Date.now`. Tests set it. */
68
+ now?: () => number;
69
+ }
70
+
71
+ /** What a credential says about a request, before the person is identified. */
72
+ type Credential = (request: Request) => Promise<Verdict | Unauthenticated>;
73
+
74
+ const READ_METHODS = new Set(["GET", "HEAD"]);
75
+
76
+ const denied = (refusal: string): Unauthenticated => ({
77
+ refusal,
78
+ status: 403,
79
+ });
80
+
81
+ /** Admits as the verifier says; one that throws or rejects admits no one. */
82
+ function verifierCredential(
83
+ verifier: (request: Request) => Verdict | Promise<Verdict>,
84
+ ): Credential {
85
+ return async (request) => {
86
+ try {
87
+ const verdict = await verifier(request);
88
+ return verdict.admitted ? verdict : denied(String(verdict.reason));
89
+ } catch {
90
+ return denied("verifier failed");
91
+ }
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Builds the authenticator for the configured mode, throwing at once for what must stop startup:
97
+ * `none` on an address that reaches beyond the machine, a missing or short password, a bad
98
+ * session key.
99
+ */
100
+ export function createAuthenticator(
101
+ options: AuthenticatorOptions,
102
+ ): Authenticator {
103
+ const { auth, logger } = options;
104
+ let credential: Credential;
105
+ let open: Authenticator["open"];
106
+ if (auth.mode === "verifier") credential = verifierCredential(auth.verifier);
107
+ else if (auth.mode === "none") {
108
+ checkNoneListener(
109
+ options.listener.id,
110
+ options.listener.address,
111
+ auth.trustNetwork,
112
+ logger,
113
+ );
114
+ credential = async () => ({ admitted: true, actor: WEB_CONSOLE_ACTOR });
115
+ } else {
116
+ const password = new PasswordCredential({
117
+ password: checkPassword(options.env ?? process.env, auth.passwordEnv),
118
+ key: loadSessionKey(options.dataDir, logger),
119
+ actor: WEB_CONSOLE_ACTOR,
120
+ mount: options.mount,
121
+ origin: options.origin,
122
+ sessionDays: auth.sessionDays,
123
+ title: options.title,
124
+ presentation: options.presentation,
125
+ logger,
126
+ loginDelayMs: options.loginDelayMs ?? 1000,
127
+ now: options.now ?? Date.now,
128
+ });
129
+ credential = async (request) => {
130
+ const session = password.check(request);
131
+ return session.valid
132
+ ? {
133
+ admitted: true,
134
+ actor: WEB_CONSOLE_ACTOR,
135
+ expiresAt: session.expiresAt,
136
+ }
137
+ : { refusal: session.reason, status: 401, login: true };
138
+ };
139
+ open = (request, rest) => password.open(request, rest);
140
+ }
141
+ return {
142
+ ...(open ? { open } : {}),
143
+ async authenticate(request) {
144
+ const checked = await credential(request);
145
+ if ("refusal" in checked) return checked;
146
+ if (!checked.admitted) return denied(String(checked.reason));
147
+ let identified: Identified;
148
+ try {
149
+ identified = await options.identify(checked.actor);
150
+ } catch {
151
+ // A lookup of who they are that fails admits no one.
152
+ return denied("identity failed");
153
+ }
154
+ if ("refusal" in identified) return denied(String(identified.refusal));
155
+ if (
156
+ !READ_METHODS.has(request.method) &&
157
+ request.headers.get("origin") !== options.origin
158
+ )
159
+ return denied("foreign origin");
160
+ return {
161
+ visitor: identified.visitor,
162
+ ...(checked.actor ? { actor: checked.actor } : {}),
163
+ ...(checked.expiresAt ? { expiresAt: checked.expiresAt } : {}),
164
+ };
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,44 @@
1
+ import { type ConsolePresentation, message } from "../features.ts";
2
+ import { escapeHtml } from "../http.ts";
3
+
4
+ /** The login page's CSP: no script at all, one inline style, and the form posts only to this origin. */
5
+ export const LOGIN_CSP =
6
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
7
+
8
+ /** The login page, as one server-rendered document with no script; `note` is a line above the field. */
9
+ export function loginPage(
10
+ mount: string,
11
+ title: string,
12
+ presentation: ConsolePresentation | undefined,
13
+ note?: string,
14
+ ): string {
15
+ const say = (source: string) => escapeHtml(message(presentation, source));
16
+ return `<!doctype html>
17
+ <html lang="${escapeHtml(presentation?.locale ?? "en")}">
18
+ <head>
19
+ <meta charset="utf-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1">
21
+ <meta name="robots" content="noindex">
22
+ <title>${escapeHtml(title)}</title>
23
+ <style>
24
+ body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
25
+ @media(prefers-color-scheme:dark){body{background:#151514;color:#ecece8}input{background:#222220;color:inherit}}
26
+ form{width:min(22rem,calc(100vw - 2rem));display:grid;gap:1rem}
27
+ h1{font-size:1.25rem;margin:0}
28
+ label{display:grid;gap:.25rem}
29
+ input,button{font:inherit;padding:.6rem .75rem;border-radius:.5rem;border:1px solid #8886}
30
+ button{cursor:pointer;background:#2d5be3;color:#fff;border-color:transparent}
31
+ .note{margin:0;color:#b3261e}
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <form method="post" action="${escapeHtml(mount)}/auth/login">
36
+ <h1>${escapeHtml(title)}</h1>
37
+ ${note ? `<p class="note" role="alert">${escapeHtml(note)}</p>` : ""}
38
+ <label>${say("Password")}<input type="password" name="password" autocomplete="current-password" autofocus required></label>
39
+ <button type="submit">${say("Sign in")}</button>
40
+ </form>
41
+ </body>
42
+ </html>
43
+ `;
44
+ }
@@ -0,0 +1,38 @@
1
+ import type { ListenerAddress, Logger } from "pi-roundtable";
2
+ import { PluginError } from "pi-roundtable";
3
+
4
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
5
+
6
+ /**
7
+ * Refuses an address `auth: none` must not serve on, and warns about the rest, at every start.
8
+ * A unix socket passes, a TCP listener only on a loopback hostname unless `trustNetwork`: no
9
+ * hostname means every interface.
10
+ */
11
+ export function checkNoneListener(
12
+ listener: string,
13
+ address: Readonly<ListenerAddress> | undefined,
14
+ trustNetwork: boolean,
15
+ logger: Logger,
16
+ ): void {
17
+ if (!address)
18
+ throw new PluginError(
19
+ `pi-roundtable-web: auth none needs the listener ${listener} to read its address, and the host has no such listener`,
20
+ );
21
+ if ("socketPath" in address) {
22
+ if ((address.mode ?? 0o660) & 0o002)
23
+ logger.warn(
24
+ { listener, socketPath: address.socketPath },
25
+ "web-console: the listener's socket is writable by every user of this machine, and auth none makes each of them the owner",
26
+ );
27
+ } else if (
28
+ !trustNetwork &&
29
+ !LOOPBACK.has((address.hostname ?? "").toLowerCase())
30
+ )
31
+ throw new PluginError(
32
+ `pi-roundtable-web: auth none on listener ${listener} would serve the owner's console to ${address.hostname ?? "every interface"}. Bind a unix socket or 127.0.0.1, or set auth.trustNetwork: true if a network in front of it keeps everyone else out`,
33
+ );
34
+ logger.warn(
35
+ { listener },
36
+ "web-console: auth none, so whoever reaches the listener is the owner, including an agent whose shell runs as this user; only a verifier resists that (README, Threat model)",
37
+ );
38
+ }