pi-roundtable-webchat 0.9.1 → 0.9.3

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 CHANGED
@@ -5,6 +5,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.3] - 2026-10-10
9
+
10
+ ### Fixed
11
+
12
+ - Uploads that arrive together can no longer pass `unsentUploadBytesPerPrincipal`: the room an upload may fill is reserved per person before its body is read (its declared `Content-Length`, or the room left when none is declared), so ten slow uploads against a 64 MiB allowance take at most 64 MiB, and their request buffers are bounded by it. A body longer than its declared length is cut off and refused (413).
13
+ - A file name loses line and paragraph separators and bidi controls too (they become underscores), and a request `Content-Type` that is not a plain `type/subtype` is refused (415) even under a `/*` entry of `attachmentTypes`, so neither can add to the model's prompt.
14
+ - Long non-ASCII file names no longer fail on Linux (see pi-roundtable 0.9.3).
15
+
16
+ ### Added
17
+
18
+ - The limit `usedAttachmentBytesPerPrincipal` (1 GiB): what one person's messages may keep across all their conversations. A `send` that would pass it is refused whole with the error code `attachment_quota` (REST 429), and its files stay waiting. Deleting a conversation through the core gives its bytes back. It is enforced by the core of pi-roundtable 0.9.3; with 0.9.2 the limit is ignored.
19
+
20
+ ## [0.9.2] - 2026-10-10
21
+
22
+ ### Added
23
+
24
+ - File attachments. A person uploads a file to one of their conversations with `POST <path>/conversations/<id>/files?name=<file name>` (the body is the file, its `Content-Type` the type; answers 201 `{ file, name, contentType, size }`), then names it in a `send` frame's new `attachments` field. The turn runs with the files as the core's attachments: images reach the model, every file is listed under `## Attachments`, and `read_attachment` and a tool's `turn.attachment()` read them. An rrweb recording is an ordinary `application/json` file.
25
+ - `ready` carries `attachments: { maxBytes, perMessage, types }`, so a client shows an upload control only when the server takes files; the subprotocol stays `roundtable.webchat.v1`, and a client that sends no `attachments` sees no change.
26
+ - The error code `unknown_attachment` (a `send` named a file that is not waiting for the person in that conversation; the whole message is refused) and the REST errors `payload_too_large` (413), `unsupported_media_type` (415) and `too_many_uploads` (429).
27
+ - The limits `attachmentBytes` (10 MiB, at most the core's 25 MiB), `attachmentsPerMessage` (8), `uploadsPerHour` (60), `unsentUploadBytesPerPrincipal` (64 MiB), `attachmentTypes` (PNG, JPEG, WebP, GIF, JSON, plain text, PDF; an entry may end in `/*`) and `unsentUploadTtlMs` (24 hours). The first bytes of an image or PDF must match its type. An upload no message used is deleted after its time; a swept file, a file of another conversation or person, or a file a message already used is `unknown_attachment`.
28
+ - Nothing needs configuring. The plugin needs `context.attachments` from pi-roundtable 0.9.2 and refuses to set up without it, and a host that has no `dataDir` fails at startup with a message naming it.
29
+
30
+ ### Fixed
31
+
32
+ - The README named `>=0.8.0 <0.9.0` as the peer range of pi-roundtable; it is `>=0.9.0 <0.10.0`.
33
+
8
34
  ## [0.9.1] - 2026-10-10
9
35
 
10
36
  - Release in lockstep with pi-roundtable 0.9.1; no package-specific behavior changes.
package/README.md CHANGED
@@ -8,19 +8,20 @@ Source lives in [`packages/webchat`][source] in the pi-roundtable repository and
8
8
  [roundtable]: https://www.npmjs.com/package/pi-roundtable
9
9
  [source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/webchat
10
10
 
11
- The plugin adds four things to a host:
11
+ The plugin adds five things to a host:
12
12
 
13
13
  - a chat surface whose conversations have keys `web:<conversation>`;
14
14
  - the claim that runs each message as a turn of its conversation's persona, through `context.turns` and the host's runtime;
15
15
  - a REST API and a WebSocket under one path of the host's HTTP listener;
16
- - a durable private inbox for `notify`, with live `notice` frames and an authenticated REST inbox.
16
+ - a durable private inbox for `notify`, with live `notice` frames and an authenticated REST inbox;
17
+ - file uploads to a conversation (screenshots, a JSON recording, a PDF), which a message then references; see [Attachments](#attachments).
17
18
 
18
19
  It needs no Discord: a host whose `roundtable.config.ts` has no `discord` key and lists this plugin is a web-only assistant.
19
20
  `roundtable init --adapter web` creates such a project.
20
21
 
21
22
  ## Requirements
22
23
 
23
- - Bun 1.3 or later, and pi-roundtable `>=0.8.0 <0.9.0` as a peer dependency.
24
+ - Bun 1.3 or later, and pi-roundtable `>=0.9.0 <0.10.0` as a peer dependency. Uploading files needs `context.attachments`, which pi-roundtable added in 0.9.2: the plugin refuses to set up on an earlier core and names the version to install.
24
25
  - An HTTP listener on the host (`http` in the configuration), behind a reverse proxy that serves it over HTTPS.
25
26
  - An OpenID Connect provider that issues access tokens for this API, with signing keys published as a JWKS.
26
27
 
@@ -194,7 +195,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
194
195
  | Frame | Meaning |
195
196
  |---|---|
196
197
  | `{ type: "send", id, persona, text }` | Opens a new conversation of `persona` with this message. `id` is your reference, echoed by `accepted` or `error`. |
197
- | `{ type: "send", id, conversation, text }` | A message in one of your conversations. |
198
+ | `{ type: "send", id, conversation, text, attachments? }` | A message in one of your conversations. `attachments` lists files you [uploaded](#attachments) to this conversation, by the `file` each upload returned. |
198
199
  | `{ type: "stop", conversation }` | Stops the conversation's running turn. |
199
200
  | `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
200
201
  | `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
@@ -204,7 +205,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
204
205
 
205
206
  | Frame | Meaning |
206
207
  |---|---|
207
- | `{ type: "ready", protocol, speaker, personas, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), and when the token expires. Open prompts follow it. |
208
+ | `{ type: "ready", protocol, speaker, personas, attachments, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), and when the token expires. Open prompts follow it. |
208
209
  | `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
209
210
  | `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
210
211
  | `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
@@ -217,7 +218,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
217
218
  | `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
218
219
  | `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
219
220
 
220
- Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
221
+ Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
221
222
 
222
223
  Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
223
224
  The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
@@ -232,6 +233,7 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
232
233
  | `POST <path>/tickets` | 201 `{ ticket, expiresAt }`. |
233
234
  | `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
234
235
  | `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
236
+ | `POST <path>/conversations/<conversation>/files?name=<file name>` with the file's bytes as the body and its type as `Content-Type` | 201 `{ file, name, contentType, size }`: the file waits for your next message that names `file`. 400 `bad_request` (no name, or a name over 255 characters), 403 (not your conversation), 404 (unknown conversation), 413 `payload_too_large`, 415 `unsupported_media_type`, 429 `too_many_uploads`. See [Attachments](#attachments). |
235
237
  | `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
236
238
  | `GET <path>/notices?limit=50&before=<id>` | `{ notices: [{ id, text, createdAt, readAt }] }`: only your principal's entries on this surface, newest first, at most 100. Omit `before` for the first page; use its last id to fetch the next. |
237
239
  | `POST <path>/notices/<id>/read` | `{ notice }` with `readAt` set. Idempotent; unknown ids or another principal's notice return 404. |
@@ -243,6 +245,45 @@ List, read acknowledgements, pagination cursors and retention are isolated by su
243
245
  Offline inbox discovery requires a private conversation on this surface or a linked identity whose provider equals this surface; a generic `oidc:` link alone does not establish membership in every webchat inbox.
244
246
  A REST notice page therefore contains at most 100 bounded entries, less than 2.5 MiB at the default text cap.
245
247
 
248
+ ## Attachments
249
+
250
+ A person sends a file in two steps, because the socket carries JSON text only: they upload it over REST to a conversation they own, then name it in a `send` frame.
251
+ Open the conversation first (`POST <path>/conversations`, or an earlier message), since a message that opens a conversation cannot reference files that have no conversation to wait in.
252
+ `ready.attachments` tells the client the limits (`maxBytes`, `perMessage`, `types`) before it shows an upload control.
253
+
254
+ ```js
255
+ const open = await fetch("/chat/conversations", {
256
+ method: "POST",
257
+ headers: { authorization: `Bearer ${token}` },
258
+ body: JSON.stringify({ persona: "helpdesk" }),
259
+ });
260
+ const { conversation } = await open.json();
261
+ const response = await fetch(`/chat/conversations/${conversation}/files?name=${encodeURIComponent(file.name)}`, {
262
+ method: "POST",
263
+ headers: { authorization: `Bearer ${token}`, "content-type": file.type },
264
+ body: file,
265
+ });
266
+ const { file: id } = await response.json();
267
+ socket.send(JSON.stringify({ type: "send", id: "2", conversation, text: "Why did this fail?", attachments: [id] }));
268
+ ```
269
+
270
+ What the server checks, in this order: the conversation is the caller's (403, 404); the file name is present and at most 255 characters, with control characters, line and paragraph separators and bidi controls replaced and quotes made apostrophes so a name cannot add lines to the model's prompt (400); the `Content-Type` is a plain `type/subtype` and one of `limits.attachmentTypes` (415); the person has not made `uploadsPerHour` uploads in the last hour (429); the file is within `attachmentBytes`, by `Content-Length` and again while the body streams, so a body that lies about its length is cut off (413); the person's uploads waiting for a message stay within `unsentUploadBytesPerPrincipal` (429), the bytes of uploads still arriving counted too, so a person who starts many at once cannot pass it (an upload declares its `Content-Length` to keep the room it needs small); and for PNG, JPEG, GIF, WebP and PDF the first bytes match the type (415), whatever the file name says.
271
+ JSON, plain text and other types have no such check, since any bytes may be labelled so.
272
+ The default types are `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain` and `application/pdf`; an entry ending in `/*` admits every type under it.
273
+ An rrweb recording is an ordinary `application/json` file.
274
+
275
+ A `send` that names files runs the turn with them: the core moves each file into the conversation's attachment directory, shows up to four images to the model, lists every file under `## Attachments` in the turn's prompt, and lets the model read text, JSON and PDF files with `read_attachment`.
276
+ A plugin's tool passes a file on with `turn.attachment(file)`, which returns the bytes (see the core's [plugin guide](https://github.com/wayne930242/pi-roundtable/blob/master/docs/plugins.md)).
277
+ A message may name at most `attachmentsPerMessage` files, a file belongs to one message, and a message that names a file that is not waiting for the person is refused whole with `unknown_attachment`.
278
+ A message that would take the files its person's messages keep, across all their conversations, past `usedAttachmentBytesPerPrincipal` is refused whole with `attachment_quota` and its files stay waiting.
279
+
280
+ An upload no message used is deleted after `unsentUploadTtlMs` (24 hours): the plugin sweeps every ten minutes, and once when the host starts.
281
+ Files a message used stay with their conversation, in the host's data directory, as long as the conversation does: when the conversation is deleted, the core removes them, the uploads waiting for it, and their share of the person's allowance.
282
+ This plugin has no way to delete a conversation itself; a host surface that deletes one does it through the core.
283
+
284
+ Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
285
+ Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
286
+
246
287
  ## Security model
247
288
 
248
289
  - **Private conversations.** A conversation belongs to the person who opened it.
@@ -258,6 +299,9 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
258
299
  Each person has at most `turnsPerPrincipal` interactive turns running or queued at once, however many conversations or sockets they use, and a conversation at most its running interactive turn and one queued behind it; a browser message over either limit is refused with `busy` and never queued.
259
300
  Personal schedules and delegated reports use the core runtime's conversation queue separately from this interactive admission budget, so a busy browser cannot discard them; runtime stop and shutdown still apply.
260
301
  Each person opens at most `newConversationsPerHour` conversations an hour, over the socket or the REST API alike.
302
+ - **Uploads.** Only a conversation's own person may upload to it, a file waits for that person and conversation alone, and a message names it by an id nobody can guess.
303
+ Type, size, rate and the waiting total are limited per person, and the bytes of an image or PDF are checked against the type.
304
+ See [Attachments](#attachments).
261
305
  - **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
262
306
  A card whose tier the person lacks is never shown: its prompt resolves `expired`, without owner escalation.
263
307
  - **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
@@ -279,10 +323,16 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
279
323
  | `rate` | 60 frames a minute per socket |
280
324
  | `maxBufferedBytes` | 4 MiB waiting for a slow client |
281
325
  | `ticketTtlMs` | 30 seconds |
326
+ | `attachmentBytes` | 10 MiB per uploaded file; never above the core's 25 MiB |
327
+ | `attachmentsPerMessage` | 8 files named by one message |
328
+ | `uploadsPerHour` | 60 uploads per person in any hour |
329
+ | `unsentUploadBytesPerPrincipal` | 64 MiB per person uploaded and not yet used by a message; uploads arriving together are counted per web chat plugin instance, so two web chats on one host each hold a person to it separately |
330
+ | `usedAttachmentBytesPerPrincipal` | 1 GiB per person kept by their messages across all conversations until a conversation is deleted; needs pi-roundtable 0.9.3 to be enforced |
331
+ | `attachmentTypes` | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain`, `application/pdf` |
332
+ | `unsentUploadTtlMs` | 24 hours before an unused upload is deleted |
282
333
 
283
334
  ## What it does not do yet
284
335
 
285
- - Attachments: messages carry text only.
286
336
  - System error reports into a person's web conversation: the background claim accepts only `PERSONAL_TARGET` turns checked by core, whose author principal is the private conversation's principal.
287
337
  The claim declares `takesSystemReports: false`: configuring `ops.conversation` on this surface (for example, `web:ops`) fails at startup with core `ConfigError`, rather than silently skipping every report.
288
338
  Use a shared conversation on another surface, such as a Discord channel, or `ops.agent`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-webchat",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "@earendil-works/pi-ai": ">=1.0.0 <2",
44
44
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
45
45
  "@types/bun": "1.4.2",
46
- "pi-roundtable": "0.9.1",
46
+ "pi-roundtable": "0.9.3",
47
47
  "typebox": "1.3.34",
48
48
  "typescript": "7.0.2"
49
49
  }
package/src/chat.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  type AgentRuntime,
3
+ type AttachmentPort,
4
+ AttachmentRefusal,
3
5
  type ChannelClaim,
4
6
  type ChannelKey,
5
7
  type ConversationPort,
@@ -16,6 +18,7 @@ import {
16
18
  type Tier,
17
19
  type ToolSelection,
18
20
  type TranscriptEntry,
21
+ type TurnAttachments,
19
22
  type TurnResult,
20
23
  } from "pi-roundtable";
21
24
  import { RateWindow, TurnBudget } from "./budget.ts";
@@ -37,6 +40,7 @@ import {
37
40
  WEBCHAT_PROTOCOL_VERSION,
38
41
  } from "./protocol.ts";
39
42
  import { WebSurface } from "./surface.ts";
43
+ import { type UploadedFile, type UploadLimits, Uploads } from "./uploads.ts";
40
44
 
41
45
  /** A conversation kind a client may open. */
42
46
  export interface WebPersona {
@@ -55,8 +59,8 @@ export interface WebPersona {
55
59
  selection?: ToolSelection;
56
60
  }
57
61
 
58
- /** The limits of one web chat. */
59
- export interface WebChatLimits {
62
+ /** The limits of one web chat, those of uploads and attachments included. */
63
+ export interface WebChatLimits extends UploadLimits {
60
64
  /** Connections one person may hold open at once; default 5. */
61
65
  connectionsPerPrincipal: number;
62
66
  /** New conversations one person may hold before writing in them; default 20. */
@@ -86,6 +90,8 @@ export interface WebChatDeps {
86
90
  /** Read when used, after the host linked every plugin. */
87
91
  registry(): ConversationRegistry;
88
92
  conversations(): ConversationPort;
93
+ /** Where uploaded files wait for the message that uses them. */
94
+ attachments(): AttachmentPort;
89
95
  turns(): ConversationTurns;
90
96
  runtime(): AgentRuntime;
91
97
  /** The clock, in milliseconds; default `Date.now`. */
@@ -108,6 +114,8 @@ interface Pending {
108
114
  speaker: Speaker;
109
115
  persona: WebPersona;
110
116
  title: string;
117
+ /** The files the message references, moved into the conversation when it was accepted. */
118
+ attachments?: TurnAttachments;
111
119
  /** Whether the conversation was new when the message was accepted. */
112
120
  fresh: boolean;
113
121
  /** Frees the message's place in the person's turn budget; called once its turn ends or never runs. */
@@ -181,6 +189,7 @@ export function checkPersonas(
181
189
  */
182
190
  export class WebChat {
183
191
  readonly surface: WebSurface;
192
+ readonly uploads: Uploads;
184
193
  readonly connections: Connections;
185
194
  readonly desk: PromptDesk;
186
195
  readonly #deps: WebChatDeps;
@@ -200,6 +209,11 @@ export class WebChat {
200
209
 
201
210
  constructor(deps: WebChatDeps) {
202
211
  this.#deps = deps;
212
+ this.uploads = new Uploads({
213
+ limits: deps.limits,
214
+ attachments: deps.attachments,
215
+ ...(deps.now ? { now: deps.now } : {}),
216
+ });
203
217
  this.#personas = checkPersonas(deps.personas);
204
218
  this.#turns = new TurnBudget(deps.limits.turnsPerPrincipal);
205
219
  this.#opened = new RateWindow(
@@ -319,6 +333,26 @@ export class WebChat {
319
333
  return { persona, minted };
320
334
  }
321
335
 
336
+ /**
337
+ * Keeps the request's body as a file for one of the speaker's conversations until a message
338
+ * uses it; a Refusal when the conversation is not theirs, an UploadRefusal when the file is not
339
+ * accepted.
340
+ */
341
+ async upload(
342
+ speaker: Speaker,
343
+ conversation: string,
344
+ request: Request,
345
+ name: string | null,
346
+ ): Promise<UploadedFile> {
347
+ await this.own(speaker, conversation);
348
+ return this.uploads.receive(
349
+ request,
350
+ channelKey(this.#deps.surface, conversation),
351
+ speaker.principalId,
352
+ name,
353
+ );
354
+ }
355
+
322
356
  /** The speaker's web conversations, the most recently active first. */
323
357
  async list(speaker: Speaker): Promise<ConversationRecord[]> {
324
358
  const records = await this.#deps
@@ -361,6 +395,7 @@ export class WebChat {
361
395
  protocol: WEBCHAT_PROTOCOL_VERSION,
362
396
  speaker: { ...speaker },
363
397
  personas: this.personasFor(speaker),
398
+ attachments: this.uploads.advertised,
364
399
  expiresAt: identity.expiresAt.toISOString(),
365
400
  });
366
401
  for (const frame of this.desk.openFor(speaker.principalId))
@@ -520,6 +555,9 @@ export class WebChat {
520
555
  const text = frame.text;
521
556
  if (!text.trim() || text.length > this.#deps.limits.messageChars)
522
557
  throw new Refusal("bad_frame");
558
+ const files = frame.attachments ?? [];
559
+ if (files.length > this.#deps.limits.attachmentsPerMessage)
560
+ throw new Refusal("bad_frame");
523
561
  // Checked before a conversation is opened for it, so a refused message opens none.
524
562
  if (!this.#turns.allows(speaker.principalId, frame.conversation))
525
563
  throw new Refusal("busy");
@@ -532,12 +570,40 @@ export class WebChat {
532
570
  if (!frame.conversation) this.#minted.delete(conversation);
533
571
  throw new Refusal("busy");
534
572
  }
573
+ let attachments: TurnAttachments | undefined;
574
+ if (files.length > 0) {
575
+ try {
576
+ attachments = await this.#deps
577
+ .attachments()
578
+ .turnAttachments(
579
+ channelKey(this.#deps.surface, conversation),
580
+ speaker.principalId,
581
+ files,
582
+ {
583
+ usedBytesLimit: this.#deps.limits.usedAttachmentBytesPerPrincipal,
584
+ },
585
+ );
586
+ } catch (error) {
587
+ // The refused message holds no place and opens no conversation.
588
+ release();
589
+ if (!frame.conversation) this.#minted.delete(conversation);
590
+ if (!(error instanceof AttachmentRefusal)) throw error;
591
+ throw new Refusal(
592
+ error.code === "unknown_file"
593
+ ? "unknown_attachment"
594
+ : error.code === "quota_exceeded"
595
+ ? "attachment_quota"
596
+ : "forbidden",
597
+ );
598
+ }
599
+ }
535
600
  const messageId = crypto.randomUUID();
536
601
  this.#pending.set(messageId, {
537
602
  speaker,
538
603
  persona,
539
604
  title: minted?.title ?? titleOf(text),
540
605
  fresh: record === undefined,
606
+ ...(attachments ? { attachments } : {}),
541
607
  release,
542
608
  });
543
609
  this.connections.send(connection, {
@@ -740,6 +806,7 @@ export class WebChat {
740
806
  text,
741
807
  speaker,
742
808
  interactive: true,
809
+ ...(pending.attachments ? { attachments: pending.attachments } : {}),
743
810
  ...(persona.selection
744
811
  ? {
745
812
  selection: {
@@ -0,0 +1,50 @@
1
+ /** The content type without parameters, lower case; undefined for a missing or empty header. */
2
+ export function baseType(header: string | null): string | undefined {
3
+ const type = header?.split(";")[0]?.trim().toLowerCase();
4
+ return type ? type : undefined;
5
+ }
6
+
7
+ /** Whether `type` is one of `allowed`; an entry ending in `/*` admits every type under it. */
8
+ export function isAllowedType(
9
+ allowed: readonly string[],
10
+ type: string,
11
+ ): boolean {
12
+ return allowed.some((entry) =>
13
+ entry.endsWith("/*")
14
+ ? type.startsWith(entry.slice(0, -1)) && type.length > entry.length - 1
15
+ : entry === type,
16
+ );
17
+ }
18
+
19
+ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
20
+ signature.every((byte, index) => bytes[at + index] === byte);
21
+
22
+ const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
23
+
24
+ /**
25
+ * Whether the bytes are what the type says, for the types whose first bytes say so: the four
26
+ * image formats the model reads, and PDF. Any other type has nothing to check and passes.
27
+ */
28
+ export function bytesMatchType(type: string, bytes: Uint8Array): boolean {
29
+ switch (type) {
30
+ case "image/png":
31
+ return startsWith(
32
+ bytes,
33
+ [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
34
+ );
35
+ case "image/jpeg":
36
+ return startsWith(bytes, [0xff, 0xd8, 0xff]);
37
+ case "image/gif":
38
+ return (
39
+ startsWith(bytes, ascii("GIF87a")) || startsWith(bytes, ascii("GIF89a"))
40
+ );
41
+ case "image/webp":
42
+ return (
43
+ startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8)
44
+ );
45
+ case "application/pdf":
46
+ return startsWith(bytes, ascii("%PDF-"));
47
+ default:
48
+ return true;
49
+ }
50
+ }
package/src/plugin.ts CHANGED
@@ -24,6 +24,7 @@ import { TokenRefused, type TokenVerifier } from "./oidc.ts";
24
24
  import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
25
25
  import { restHandler } from "./rest.ts";
26
26
  import { TicketBook } from "./tickets.ts";
27
+ import { DEFAULT_ATTACHMENT_TYPES } from "./uploads.ts";
27
28
 
28
29
  /** Limits of the WebSocket route, besides the chat's own. */
29
30
  export interface WebChatRouteLimits {
@@ -68,6 +69,13 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
68
69
  messageChars: 32_000,
69
70
  promptTimeoutMs: 30 * 60_000,
70
71
  reauthLeadMs: 60_000,
72
+ attachmentBytes: 10 * 1024 * 1024,
73
+ attachmentsPerMessage: 8,
74
+ uploadsPerHour: 60,
75
+ unsentUploadBytesPerPrincipal: 64 * 1024 * 1024,
76
+ usedAttachmentBytesPerPrincipal: 1024 * 1024 * 1024,
77
+ attachmentTypes: DEFAULT_ATTACHMENT_TYPES,
78
+ unsentUploadTtlMs: 24 * 60 * 60_000,
71
79
  maxConnections: 256,
72
80
  maxMessageBytes: 64 * 1024,
73
81
  rate: { messages: 60, perMs: 60_000 },
@@ -100,6 +108,42 @@ function checkOptions(options: WebChatOptions): {
100
108
  return { path, surface };
101
109
  }
102
110
 
111
+ /** The core keeps no file over this; a web chat's own limit may be lower, never higher. */
112
+ const CORE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
113
+ /** The longest wait between two sweeps of uploads no message used. */
114
+ const SWEEP_EVERY_MS = 10 * 60_000;
115
+
116
+ /** Refuses an attachment limit that is not a positive whole number, or a size the core cannot keep. */
117
+ function checkAttachmentLimits(limits: WebChatLimits): void {
118
+ for (const key of [
119
+ "attachmentBytes",
120
+ "attachmentsPerMessage",
121
+ "uploadsPerHour",
122
+ "unsentUploadBytesPerPrincipal",
123
+ "usedAttachmentBytesPerPrincipal",
124
+ "unsentUploadTtlMs",
125
+ ] as const)
126
+ if (!Number.isInteger(limits[key]) || limits[key] < 1)
127
+ throw new Error(
128
+ `webChat: limits.${key} must be a positive whole number; got ${String(limits[key])}`,
129
+ );
130
+ if (limits.attachmentBytes > CORE_ATTACHMENT_BYTES)
131
+ throw new Error(
132
+ `webChat: limits.attachmentBytes may be at most ${CORE_ATTACHMENT_BYTES} (25 MiB), the most the core keeps; got ${limits.attachmentBytes}`,
133
+ );
134
+ if (
135
+ !Array.isArray(limits.attachmentTypes) ||
136
+ limits.attachmentTypes.some(
137
+ (type) =>
138
+ typeof type !== "string" ||
139
+ !/^[a-z0-9.+-]+\/(\*|[a-z0-9.+-]+)$/.test(type),
140
+ )
141
+ )
142
+ throw new Error(
143
+ 'webChat: limits.attachmentTypes must list content types in lower case, such as "image/png" or "image/*"',
144
+ );
145
+ }
146
+
103
147
  /** The subprotocols a WebSocket upgrade offers. */
104
148
  function offered(request: Request): string[] {
105
149
  return (request.headers.get("sec-websocket-protocol") ?? "")
@@ -122,11 +166,16 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
122
166
  const limits = { ...DEFAULT_LIMITS, ...options.limits };
123
167
  // The personas are checked now, so a broken list stops the configuration rather than the boot.
124
168
  checkPersonas(options.personas);
169
+ checkAttachmentLimits(limits);
125
170
  return definePlugin({
126
171
  name: surface === "web" ? "webchat" : `webchat-${surface}`,
127
172
  requires: [IDENTITY, CONVERSATIONS, RUNTIME],
128
173
  migrations: [PgNotices.migration],
129
174
  setup: (context: PluginContext) => {
175
+ if (!context.attachments)
176
+ throw new Error(
177
+ "webChat: this version keeps uploaded files with context.attachments, which pi-roundtable 0.9.2 added. Update pi-roundtable to 0.9.2 or later.",
178
+ );
130
179
  const chat = new WebChat({
131
180
  surface,
132
181
  verifier: options.verifier,
@@ -136,6 +185,7 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
136
185
  logger: context.logger,
137
186
  registry: () => context.services.get(CONVERSATIONS),
138
187
  conversations: () => context.conversations,
188
+ attachments: () => context.attachments,
139
189
  turns: () => context.turns,
140
190
  runtime: () => context.services.get(RUNTIME),
141
191
  });
@@ -216,7 +266,41 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
216
266
  handle: rest,
217
267
  websocket,
218
268
  };
269
+ // Uploads no message used are deleted once they are older than the limit's time.
270
+ let sweeper: ReturnType<typeof setInterval> | undefined;
271
+ const sweep = async () => {
272
+ try {
273
+ const dropped = await chat.uploads.sweep();
274
+ if (dropped > 0)
275
+ context.logger.info({ dropped }, "unused web chat uploads deleted");
276
+ } catch (error) {
277
+ context.logger.warn(
278
+ { err: error },
279
+ "unused web chat uploads not deleted",
280
+ );
281
+ }
282
+ };
219
283
  return {
284
+ services: [
285
+ {
286
+ name: `${surface}-uploads`,
287
+ start: async () => {
288
+ // Not caught: a host without a `dataDir` stops here, naming it, instead of failing at the first upload.
289
+ await chat.uploads.sweep();
290
+ sweeper = setInterval(
291
+ sweep,
292
+ Math.min(
293
+ SWEEP_EVERY_MS,
294
+ Math.max(limits.unsentUploadTtlMs, 100),
295
+ ),
296
+ );
297
+ },
298
+ stop: () => {
299
+ clearInterval(sweeper);
300
+ sweeper = undefined;
301
+ },
302
+ },
303
+ ],
220
304
  surfaces: [chat.surface],
221
305
  channels: [chat.claim()],
222
306
  directChannels: [
package/src/protocol.ts CHANGED
@@ -65,7 +65,11 @@ export type ErrorCode =
65
65
  | "unknown_persona"
66
66
  | "unknown_prompt"
67
67
  | "too_many_conversations"
68
- | "busy";
68
+ | "busy"
69
+ /** A `send` named a file that is not waiting for this person in this conversation; the whole message is refused. */
70
+ | "unknown_attachment"
71
+ /** A `send` would take the person past `usedAttachmentBytesPerPrincipal`; the whole message is refused and its files stay waiting. */
72
+ | "attachment_quota";
69
73
 
70
74
  /** What a client sends: one JSON object per WebSocket text message. */
71
75
  export type ClientFrame =
@@ -81,6 +85,11 @@ export type ClientFrame =
81
85
  conversation?: string;
82
86
  persona?: string;
83
87
  text: string;
88
+ /**
89
+ * Files uploaded to the conversation (`POST <path>/conversations/<id>/files`) for this
90
+ * message, by the `file` each upload returned; at most `ready.attachments.perMessage`.
91
+ */
92
+ attachments?: string[];
84
93
  }
85
94
  /** Stops the conversation's running turn. */
86
95
  | { type: "stop"; conversation: string }
@@ -97,6 +106,16 @@ export type ServerFrame =
97
106
  protocol: typeof WEBCHAT_PROTOCOL_VERSION;
98
107
  speaker: { id: string; name: string; tier: Tier; principalId: string };
99
108
  personas: readonly PersonaSummary[];
109
+ /**
110
+ * What an upload may be: the most bytes in a file, the most files in a message, and the
111
+ * content types accepted, each exactly or as `<type>/*`. Absent from a server before 0.9.2,
112
+ * which takes no attachments: show no upload control then.
113
+ */
114
+ attachments: {
115
+ maxBytes: number;
116
+ perMessage: number;
117
+ types: readonly string[];
118
+ };
100
119
  /** When the token expires, as an ISO time. */
101
120
  expiresAt: string;
102
121
  }
@@ -135,6 +154,10 @@ export type ServerFrame =
135
154
 
136
155
  /** The longest client reference, conversation id, or prompt id accepted. */
137
156
  const ID_CHARS = 128;
157
+ /** The longest uploaded file reference accepted: a 36-character id, a dash, and a name cut to 120. */
158
+ const FILE_ID_CHARS = 200;
159
+ /** The most file references one frame may carry, whatever the server's own limit is. */
160
+ const MAX_FRAME_FILES = 32;
138
161
 
139
162
  const isRecord = (value: unknown): value is Record<string, unknown> =>
140
163
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -142,6 +165,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
142
165
  const isId = (value: unknown): value is string =>
143
166
  typeof value === "string" && value.length > 0 && value.length <= ID_CHARS;
144
167
 
168
+ const isFileList = (value: unknown): value is string[] =>
169
+ Array.isArray(value) &&
170
+ value.length <= MAX_FRAME_FILES &&
171
+ value.every(
172
+ (item) =>
173
+ typeof item === "string" &&
174
+ item.length > 0 &&
175
+ item.length <= FILE_ID_CHARS,
176
+ );
177
+
145
178
  const optional = <T>(value: unknown, check: (v: unknown) => v is T) =>
146
179
  value === undefined || check(value);
147
180
 
@@ -169,12 +202,17 @@ export function parseClientFrame(
169
202
  if (!optional(conversation, isId) || !optional(persona, isId))
170
203
  return undefined;
171
204
  if (conversation === undefined && persona === undefined) return undefined;
205
+ if (!optional(value.attachments, isFileList)) return undefined;
206
+ const { attachments } = value;
172
207
  return {
173
208
  type: "send",
174
209
  id,
175
210
  text,
176
211
  ...(conversation === undefined ? {} : { conversation }),
177
212
  ...(persona === undefined ? {} : { persona }),
213
+ ...(attachments === undefined || attachments.length === 0
214
+ ? {}
215
+ : { attachments }),
178
216
  };
179
217
  }
180
218
  case "stop":
package/src/rest.ts CHANGED
@@ -3,6 +3,7 @@ import { type Admitted, Refusal, type WebChat } from "./chat.ts";
3
3
  import { MAX_NOTICES, type PgNotices } from "./notices.ts";
4
4
  import { TokenRefused } from "./oidc.ts";
5
5
  import type { TicketBook } from "./tickets.ts";
6
+ import { UploadRefusal } from "./uploads.ts";
6
7
 
7
8
  export interface RestOptions {
8
9
  chat: WebChat;
@@ -62,13 +63,18 @@ const STATUS: Record<string, number> = {
62
63
  unknown_conversation: 404,
63
64
  unknown_persona: 404,
64
65
  too_many_conversations: 429,
66
+ unknown_attachment: 400,
67
+ attachment_quota: 429,
65
68
  };
66
69
 
67
70
  /**
68
71
  * The web chat's REST API, under its path, every call with `Authorization: Bearer <token>`:
69
72
  * `POST tickets` (a one-time ticket for the WebSocket), `GET conversations` (the caller's own),
70
73
  * `POST conversations` (`{ persona, title? }` opens one), and `GET conversations/<id>/messages`
71
- * (`?limit=`, its last messages). A browser on another origin gets CORS headers when its origin
74
+ * (`?limit=`, its last messages), and `POST conversations/<id>/files?name=<file name>` (the body is
75
+ * the file's bytes, its `Content-Type` the file's type; answers 201 with `{ file, name, contentType,
76
+ * size }`, 413 over the size limit, 415 for a type that is not accepted, 429 over the person's
77
+ * allowance). A browser on another origin gets CORS headers when its origin
72
78
  * is allowed and 403 otherwise.
73
79
  */
74
80
  export function restHandler(options: RestOptions) {
@@ -143,6 +149,16 @@ export function restHandler(options: RestOptions) {
143
149
  const entries = await chat.transcript(speaker, messages[1], limit);
144
150
  return json({ messages: entries }, 200, headers);
145
151
  }
152
+ const files = /^conversations\/([A-Za-z0-9-]{1,128})\/files$/.exec(rest);
153
+ if (files?.[1] && request.method === "POST") {
154
+ const uploaded = await chat.upload(
155
+ speaker,
156
+ files[1],
157
+ request,
158
+ url.searchParams.get("name"),
159
+ );
160
+ return json(uploaded, 201, headers);
161
+ }
146
162
  return json({ error: "not_found" }, 404, headers);
147
163
  }
148
164
  return async (request: Request): Promise<Response> => {
@@ -179,6 +195,8 @@ export function restHandler(options: RestOptions) {
179
195
  try {
180
196
  return await route(request, url, who, headers);
181
197
  } catch (error) {
198
+ if (error instanceof UploadRefusal)
199
+ return json({ error: error.code }, error.status, headers);
182
200
  if (!(error instanceof Refusal)) throw error;
183
201
  return json({ error: error.code }, STATUS[error.code] ?? 400, headers);
184
202
  }
package/src/uploads.ts ADDED
@@ -0,0 +1,274 @@
1
+ import type { AttachmentPort, ChannelKey } from "pi-roundtable";
2
+ import { RateWindow } from "./budget.ts";
3
+ import { baseType, bytesMatchType, isAllowedType } from "./file-types.ts";
4
+
5
+ /** The types a web chat takes unless its `limits.attachmentTypes` says otherwise. */
6
+ export const DEFAULT_ATTACHMENT_TYPES: readonly string[] = [
7
+ "image/png",
8
+ "image/jpeg",
9
+ "image/webp",
10
+ "image/gif",
11
+ "application/json",
12
+ "text/plain",
13
+ "application/pdf",
14
+ ];
15
+
16
+ /** The limits of uploads and of the attachments a message carries. */
17
+ export interface UploadLimits {
18
+ /** The largest file in bytes; default 10 MiB, and never over the core's 25 MiB. */
19
+ attachmentBytes: number;
20
+ /** The most files one message may reference; default 8. */
21
+ attachmentsPerMessage: number;
22
+ /** Uploads one person may make in any hour; default 60. */
23
+ uploadsPerHour: number;
24
+ /** The bytes one person may have uploaded and not yet sent in a message; default 64 MiB. */
25
+ unsentUploadBytesPerPrincipal: number;
26
+ /**
27
+ * The bytes one person's messages may keep in all their conversations, until a conversation is
28
+ * deleted and gives its bytes back; default 1 GiB.
29
+ */
30
+ usedAttachmentBytesPerPrincipal: number;
31
+ /** The content types accepted, such as `image/png`; an entry ending in `/*` admits every type under it. */
32
+ attachmentTypes: readonly string[];
33
+ /** How long an upload no message referenced is kept before it is deleted; default 24 hours. */
34
+ unsentUploadTtlMs: number;
35
+ }
36
+
37
+ /** Why an upload was refused, as the HTTP status and error code the client reads. */
38
+ export class UploadRefusal extends Error {
39
+ override name = "UploadRefusal";
40
+ readonly status: number;
41
+ readonly code:
42
+ | "bad_request"
43
+ | "payload_too_large"
44
+ | "unsupported_media_type"
45
+ | "too_many_uploads";
46
+
47
+ constructor(status: number, code: UploadRefusal["code"]) {
48
+ super(code);
49
+ this.status = status;
50
+ this.code = code;
51
+ }
52
+ }
53
+
54
+ /** What an upload returns: `file` is what a `send` frame's `attachments` names. */
55
+ export interface UploadedFile {
56
+ file: string;
57
+ name: string;
58
+ contentType: string;
59
+ size: number;
60
+ }
61
+
62
+ export interface UploadsDeps {
63
+ limits: UploadLimits;
64
+ /** Read when used, after the host linked every plugin. */
65
+ attachments(): AttachmentPort;
66
+ /** The clock, in milliseconds; default `Date.now`. */
67
+ now?(): number;
68
+ }
69
+
70
+ const HOUR_MS = 60 * 60_000;
71
+ /** The longest file name accepted, in characters. */
72
+ const NAME_CHARS = 255;
73
+
74
+ /** Control characters, line and paragraph separators, and bidi controls: none reaches a prompt or a screen as it is. */
75
+ const UNSAFE_NAME_CHARS =
76
+ /[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
77
+ /** A plain type/subtype, the grammar a configured `attachmentTypes` entry has too. */
78
+ const PLAIN_TYPE = /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/;
79
+
80
+ /**
81
+ * A name the model can read in its prompt: control and bidi characters and line separators become
82
+ * underscores and quotes become apostrophes, so a name cannot add lines or close the quotes
83
+ * around it.
84
+ */
85
+ function safeName(name: string | null): string {
86
+ const cleaned = (name ?? "")
87
+ .replace(UNSAFE_NAME_CHARS, "_")
88
+ .replaceAll('"', "'")
89
+ .trim();
90
+ if (cleaned === "" || cleaned.length > NAME_CHARS)
91
+ throw new UploadRefusal(400, "bad_request");
92
+ return cleaned;
93
+ }
94
+
95
+ /** The request body, or undefined once it passes `max` bytes; the rest of the stream is not read. */
96
+ async function readLimited(
97
+ request: Request,
98
+ max: number,
99
+ ): Promise<Uint8Array | undefined> {
100
+ const reader = request.body?.getReader();
101
+ if (!reader) return new Uint8Array();
102
+ const chunks: Uint8Array[] = [];
103
+ let size = 0;
104
+ try {
105
+ for (;;) {
106
+ const { done, value } = await reader.read();
107
+ if (done) break;
108
+ size += value.byteLength;
109
+ if (size > max) {
110
+ await reader.cancel();
111
+ return undefined;
112
+ }
113
+ chunks.push(value);
114
+ }
115
+ } finally {
116
+ reader.releaseLock();
117
+ }
118
+ const bytes = new Uint8Array(size);
119
+ let offset = 0;
120
+ for (const chunk of chunks) {
121
+ bytes.set(chunk, offset);
122
+ offset += chunk.byteLength;
123
+ }
124
+ return bytes;
125
+ }
126
+
127
+ /**
128
+ * The files people upload to their conversations before they send a message: it checks the
129
+ * content type, the bytes, the size and the person's allowance, and keeps the file with the
130
+ * core's attachment port until a message uses it. Who may upload to which conversation is the
131
+ * chat's to decide before it calls `receive`.
132
+ */
133
+ export class Uploads {
134
+ readonly #deps: UploadsDeps;
135
+ readonly #rate: RateWindow;
136
+ /** The bytes each person's uploads in flight may still write, counted against their allowance. */
137
+ readonly #reserved = new Map<string, number>();
138
+ /** The last admission queued for each person, which the next one waits for. */
139
+ readonly #admissions = new Map<string, Promise<void>>();
140
+
141
+ constructor(deps: UploadsDeps) {
142
+ this.#deps = deps;
143
+ this.#rate = new RateWindow(
144
+ deps.limits.uploadsPerHour,
145
+ HOUR_MS,
146
+ deps.now ?? Date.now,
147
+ );
148
+ }
149
+
150
+ /** What `ready` tells a client about attachments. */
151
+ get advertised(): {
152
+ maxBytes: number;
153
+ perMessage: number;
154
+ types: readonly string[];
155
+ } {
156
+ const { limits } = this.#deps;
157
+ return {
158
+ maxBytes: limits.attachmentBytes,
159
+ perMessage: limits.attachmentsPerMessage,
160
+ types: limits.attachmentTypes,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Reserves the bytes an upload may write against the person's allowance, one admission at a
166
+ * time per person, so uploads in flight together count against it and not only the ones saved.
167
+ * Returns the bytes reserved, and the most the body may be.
168
+ */
169
+ #admit(
170
+ principalId: string,
171
+ declared: number | undefined,
172
+ ): Promise<{ reserved: number; cap: number }> {
173
+ const { limits } = this.#deps;
174
+ const run = async () => {
175
+ const holding = this.#reserved.get(principalId) ?? 0;
176
+ const waiting = await this.#deps.attachments().pendingBytes(principalId);
177
+ const room = Math.max(
178
+ limits.unsentUploadBytesPerPrincipal - waiting - holding,
179
+ 0,
180
+ );
181
+ const cap = Math.min(limits.attachmentBytes, room);
182
+ if (declared !== undefined && declared > room)
183
+ throw new UploadRefusal(429, "too_many_uploads");
184
+ const reserved = declared === undefined ? cap : Math.min(declared, cap);
185
+ this.#reserved.set(principalId, holding + reserved);
186
+ return { reserved, cap };
187
+ };
188
+ const result = (
189
+ this.#admissions.get(principalId) ?? Promise.resolve()
190
+ ).then(run);
191
+ const tail = result.then(
192
+ () => undefined,
193
+ () => undefined,
194
+ );
195
+ this.#admissions.set(principalId, tail);
196
+ void tail.then(() => {
197
+ if (this.#admissions.get(principalId) === tail)
198
+ this.#admissions.delete(principalId);
199
+ });
200
+ return result;
201
+ }
202
+
203
+ #release(principalId: string, reserved: number): void {
204
+ const left = (this.#reserved.get(principalId) ?? 0) - reserved;
205
+ if (left > 0) this.#reserved.set(principalId, left);
206
+ else this.#reserved.delete(principalId);
207
+ }
208
+
209
+ /** Takes the request's body as a file for `principalId` in `channel`; throws an UploadRefusal. */
210
+ async receive(
211
+ request: Request,
212
+ channel: ChannelKey,
213
+ principalId: string,
214
+ name: string | null,
215
+ ): Promise<UploadedFile> {
216
+ const { limits } = this.#deps;
217
+ const fileName = safeName(name);
218
+ const type = baseType(request.headers.get("content-type"));
219
+ if (
220
+ !type ||
221
+ !PLAIN_TYPE.test(type) ||
222
+ !isAllowedType(limits.attachmentTypes, type)
223
+ )
224
+ throw new UploadRefusal(415, "unsupported_media_type");
225
+ const header = request.headers.get("content-length");
226
+ const length = header === null ? Number.NaN : Number(header);
227
+ // A header that is no length is no declaration; the body is held to the room left instead.
228
+ const declared =
229
+ Number.isFinite(length) && length >= 0 ? length : undefined;
230
+ if (declared !== undefined && declared > limits.attachmentBytes)
231
+ throw new UploadRefusal(413, "payload_too_large");
232
+ if (!this.#rate.take(principalId))
233
+ throw new UploadRefusal(429, "too_many_uploads");
234
+ const { reserved, cap } = await this.#admit(principalId, declared);
235
+ let held = reserved;
236
+ const release = () => {
237
+ this.#release(principalId, held);
238
+ held = 0;
239
+ };
240
+ try {
241
+ const bytes = await readLimited(request, reserved);
242
+ if (!bytes)
243
+ // A body past its declared length lied; one past the room left is the allowance's.
244
+ throw declared === undefined && cap < limits.attachmentBytes
245
+ ? new UploadRefusal(429, "too_many_uploads")
246
+ : new UploadRefusal(413, "payload_too_large");
247
+ if (!bytesMatchType(type, bytes))
248
+ throw new UploadRefusal(415, "unsupported_media_type");
249
+ const stored = await this.#deps.attachments().save(channel, principalId, {
250
+ name: fileName,
251
+ contentType: type,
252
+ data: bytes,
253
+ });
254
+ // The saved file counts as waiting from here on.
255
+ release();
256
+ return {
257
+ file: stored.file,
258
+ name: stored.name,
259
+ contentType: stored.contentType,
260
+ size: stored.size,
261
+ };
262
+ } finally {
263
+ release();
264
+ }
265
+ }
266
+
267
+ /** Deletes the uploads no message used within the time to live; returns how many. */
268
+ sweep(): Promise<number> {
269
+ const now = (this.#deps.now ?? Date.now)();
270
+ return this.#deps
271
+ .attachments()
272
+ .discardPending(new Date(now - this.#deps.limits.unsentUploadTtlMs));
273
+ }
274
+ }