pi-roundtable-webchat 0.9.1 → 0.9.2
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 +14 -0
- package/README.md +55 -7
- package/package.json +2 -2
- package/src/chat.ts +62 -2
- package/src/file-types.ts +50 -0
- package/src/plugin.ts +82 -0
- package/src/protocol.ts +37 -1
- package/src/rest.ts +18 -1
- package/src/uploads.ts +198 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.2] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- `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.
|
|
14
|
+
- 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).
|
|
15
|
+
- 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`.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- The README named `>=0.8.0 <0.9.0` as the peer range of pi-roundtable; it is `>=0.9.0 <0.10.0`.
|
|
21
|
+
|
|
8
22
|
## [0.9.1] - 2026-10-10
|
|
9
23
|
|
|
10
24
|
- 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
|
|
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.
|
|
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), 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,44 @@ 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 replaced and quotes made apostrophes so a name cannot add lines to the model's prompt (400); the `Content-Type` is 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); 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
|
+
|
|
279
|
+
An upload no message used is deleted after `unsentUploadTtlMs` (24 hours): the plugin sweeps every ten minutes, and once when the host starts.
|
|
280
|
+
Files a message used stay with their conversation, in the host's data directory, as long as the conversation does.
|
|
281
|
+
This plugin has no way to delete a conversation, so none of its files go with it.
|
|
282
|
+
|
|
283
|
+
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.
|
|
284
|
+
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.
|
|
285
|
+
|
|
246
286
|
## Security model
|
|
247
287
|
|
|
248
288
|
- **Private conversations.** A conversation belongs to the person who opened it.
|
|
@@ -258,6 +298,9 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
|
|
|
258
298
|
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
299
|
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
300
|
Each person opens at most `newConversationsPerHour` conversations an hour, over the socket or the REST API alike.
|
|
301
|
+
- **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.
|
|
302
|
+
Type, size, rate and the waiting total are limited per person, and the bytes of an image or PDF are checked against the type.
|
|
303
|
+
See [Attachments](#attachments).
|
|
261
304
|
- **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
|
|
262
305
|
A card whose tier the person lacks is never shown: its prompt resolves `expired`, without owner escalation.
|
|
263
306
|
- **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
|
|
@@ -279,10 +322,15 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
|
|
|
279
322
|
| `rate` | 60 frames a minute per socket |
|
|
280
323
|
| `maxBufferedBytes` | 4 MiB waiting for a slow client |
|
|
281
324
|
| `ticketTtlMs` | 30 seconds |
|
|
325
|
+
| `attachmentBytes` | 10 MiB per uploaded file; never above the core's 25 MiB |
|
|
326
|
+
| `attachmentsPerMessage` | 8 files named by one message |
|
|
327
|
+
| `uploadsPerHour` | 60 uploads per person in any hour |
|
|
328
|
+
| `unsentUploadBytesPerPrincipal` | 64 MiB per person uploaded and not yet used by a message |
|
|
329
|
+
| `attachmentTypes` | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain`, `application/pdf` |
|
|
330
|
+
| `unsentUploadTtlMs` | 24 hours before an unused upload is deleted |
|
|
282
331
|
|
|
283
332
|
## What it does not do yet
|
|
284
333
|
|
|
285
|
-
- Attachments: messages carry text only.
|
|
286
334
|
- 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
335
|
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
336
|
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.
|
|
3
|
+
"version": "0.9.2",
|
|
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.
|
|
46
|
+
"pi-roundtable": "0.9.2",
|
|
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,33 @@ 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
|
+
} catch (error) {
|
|
584
|
+
// The refused message holds no place and opens no conversation.
|
|
585
|
+
release();
|
|
586
|
+
if (!frame.conversation) this.#minted.delete(conversation);
|
|
587
|
+
if (!(error instanceof AttachmentRefusal)) throw error;
|
|
588
|
+
throw new Refusal(
|
|
589
|
+
error.code === "unknown_file" ? "unknown_attachment" : "forbidden",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
535
593
|
const messageId = crypto.randomUUID();
|
|
536
594
|
this.#pending.set(messageId, {
|
|
537
595
|
speaker,
|
|
538
596
|
persona,
|
|
539
597
|
title: minted?.title ?? titleOf(text),
|
|
540
598
|
fresh: record === undefined,
|
|
599
|
+
...(attachments ? { attachments } : {}),
|
|
541
600
|
release,
|
|
542
601
|
});
|
|
543
602
|
this.connections.send(connection, {
|
|
@@ -740,6 +799,7 @@ export class WebChat {
|
|
|
740
799
|
text,
|
|
741
800
|
speaker,
|
|
742
801
|
interactive: true,
|
|
802
|
+
...(pending.attachments ? { attachments: pending.attachments } : {}),
|
|
743
803
|
...(persona.selection
|
|
744
804
|
? {
|
|
745
805
|
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,12 @@ 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
|
+
attachmentTypes: DEFAULT_ATTACHMENT_TYPES,
|
|
77
|
+
unsentUploadTtlMs: 24 * 60 * 60_000,
|
|
71
78
|
maxConnections: 256,
|
|
72
79
|
maxMessageBytes: 64 * 1024,
|
|
73
80
|
rate: { messages: 60, perMs: 60_000 },
|
|
@@ -100,6 +107,41 @@ function checkOptions(options: WebChatOptions): {
|
|
|
100
107
|
return { path, surface };
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
/** The core keeps no file over this; a web chat's own limit may be lower, never higher. */
|
|
111
|
+
const CORE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
112
|
+
/** The longest wait between two sweeps of uploads no message used. */
|
|
113
|
+
const SWEEP_EVERY_MS = 10 * 60_000;
|
|
114
|
+
|
|
115
|
+
/** Refuses an attachment limit that is not a positive whole number, or a size the core cannot keep. */
|
|
116
|
+
function checkAttachmentLimits(limits: WebChatLimits): void {
|
|
117
|
+
for (const key of [
|
|
118
|
+
"attachmentBytes",
|
|
119
|
+
"attachmentsPerMessage",
|
|
120
|
+
"uploadsPerHour",
|
|
121
|
+
"unsentUploadBytesPerPrincipal",
|
|
122
|
+
"unsentUploadTtlMs",
|
|
123
|
+
] as const)
|
|
124
|
+
if (!Number.isInteger(limits[key]) || limits[key] < 1)
|
|
125
|
+
throw new Error(
|
|
126
|
+
`webChat: limits.${key} must be a positive whole number; got ${String(limits[key])}`,
|
|
127
|
+
);
|
|
128
|
+
if (limits.attachmentBytes > CORE_ATTACHMENT_BYTES)
|
|
129
|
+
throw new Error(
|
|
130
|
+
`webChat: limits.attachmentBytes may be at most ${CORE_ATTACHMENT_BYTES} (25 MiB), the most the core keeps; got ${limits.attachmentBytes}`,
|
|
131
|
+
);
|
|
132
|
+
if (
|
|
133
|
+
!Array.isArray(limits.attachmentTypes) ||
|
|
134
|
+
limits.attachmentTypes.some(
|
|
135
|
+
(type) =>
|
|
136
|
+
typeof type !== "string" ||
|
|
137
|
+
!/^[a-z0-9.+-]+\/(\*|[a-z0-9.+-]+)$/.test(type),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
throw new Error(
|
|
141
|
+
'webChat: limits.attachmentTypes must list content types in lower case, such as "image/png" or "image/*"',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
/** The subprotocols a WebSocket upgrade offers. */
|
|
104
146
|
function offered(request: Request): string[] {
|
|
105
147
|
return (request.headers.get("sec-websocket-protocol") ?? "")
|
|
@@ -122,11 +164,16 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
122
164
|
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
123
165
|
// The personas are checked now, so a broken list stops the configuration rather than the boot.
|
|
124
166
|
checkPersonas(options.personas);
|
|
167
|
+
checkAttachmentLimits(limits);
|
|
125
168
|
return definePlugin({
|
|
126
169
|
name: surface === "web" ? "webchat" : `webchat-${surface}`,
|
|
127
170
|
requires: [IDENTITY, CONVERSATIONS, RUNTIME],
|
|
128
171
|
migrations: [PgNotices.migration],
|
|
129
172
|
setup: (context: PluginContext) => {
|
|
173
|
+
if (!context.attachments)
|
|
174
|
+
throw new Error(
|
|
175
|
+
"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.",
|
|
176
|
+
);
|
|
130
177
|
const chat = new WebChat({
|
|
131
178
|
surface,
|
|
132
179
|
verifier: options.verifier,
|
|
@@ -136,6 +183,7 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
136
183
|
logger: context.logger,
|
|
137
184
|
registry: () => context.services.get(CONVERSATIONS),
|
|
138
185
|
conversations: () => context.conversations,
|
|
186
|
+
attachments: () => context.attachments,
|
|
139
187
|
turns: () => context.turns,
|
|
140
188
|
runtime: () => context.services.get(RUNTIME),
|
|
141
189
|
});
|
|
@@ -216,7 +264,41 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
216
264
|
handle: rest,
|
|
217
265
|
websocket,
|
|
218
266
|
};
|
|
267
|
+
// Uploads no message used are deleted once they are older than the limit's time.
|
|
268
|
+
let sweeper: ReturnType<typeof setInterval> | undefined;
|
|
269
|
+
const sweep = async () => {
|
|
270
|
+
try {
|
|
271
|
+
const dropped = await chat.uploads.sweep();
|
|
272
|
+
if (dropped > 0)
|
|
273
|
+
context.logger.info({ dropped }, "unused web chat uploads deleted");
|
|
274
|
+
} catch (error) {
|
|
275
|
+
context.logger.warn(
|
|
276
|
+
{ err: error },
|
|
277
|
+
"unused web chat uploads not deleted",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
219
281
|
return {
|
|
282
|
+
services: [
|
|
283
|
+
{
|
|
284
|
+
name: `${surface}-uploads`,
|
|
285
|
+
start: async () => {
|
|
286
|
+
// Not caught: a host without a `dataDir` stops here, naming it, instead of failing at the first upload.
|
|
287
|
+
await chat.uploads.sweep();
|
|
288
|
+
sweeper = setInterval(
|
|
289
|
+
sweep,
|
|
290
|
+
Math.min(
|
|
291
|
+
SWEEP_EVERY_MS,
|
|
292
|
+
Math.max(limits.unsentUploadTtlMs, 100),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
stop: () => {
|
|
297
|
+
clearInterval(sweeper);
|
|
298
|
+
sweeper = undefined;
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
220
302
|
surfaces: [chat.surface],
|
|
221
303
|
channels: [chat.claim()],
|
|
222
304
|
directChannels: [
|
package/src/protocol.ts
CHANGED
|
@@ -65,7 +65,9 @@ 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";
|
|
69
71
|
|
|
70
72
|
/** What a client sends: one JSON object per WebSocket text message. */
|
|
71
73
|
export type ClientFrame =
|
|
@@ -81,6 +83,11 @@ export type ClientFrame =
|
|
|
81
83
|
conversation?: string;
|
|
82
84
|
persona?: string;
|
|
83
85
|
text: string;
|
|
86
|
+
/**
|
|
87
|
+
* Files uploaded to the conversation (`POST <path>/conversations/<id>/files`) for this
|
|
88
|
+
* message, by the `file` each upload returned; at most `ready.attachments.perMessage`.
|
|
89
|
+
*/
|
|
90
|
+
attachments?: string[];
|
|
84
91
|
}
|
|
85
92
|
/** Stops the conversation's running turn. */
|
|
86
93
|
| { type: "stop"; conversation: string }
|
|
@@ -97,6 +104,16 @@ export type ServerFrame =
|
|
|
97
104
|
protocol: typeof WEBCHAT_PROTOCOL_VERSION;
|
|
98
105
|
speaker: { id: string; name: string; tier: Tier; principalId: string };
|
|
99
106
|
personas: readonly PersonaSummary[];
|
|
107
|
+
/**
|
|
108
|
+
* What an upload may be: the most bytes in a file, the most files in a message, and the
|
|
109
|
+
* content types accepted, each exactly or as `<type>/*`. Absent from a server before 0.9.2,
|
|
110
|
+
* which takes no attachments: show no upload control then.
|
|
111
|
+
*/
|
|
112
|
+
attachments: {
|
|
113
|
+
maxBytes: number;
|
|
114
|
+
perMessage: number;
|
|
115
|
+
types: readonly string[];
|
|
116
|
+
};
|
|
100
117
|
/** When the token expires, as an ISO time. */
|
|
101
118
|
expiresAt: string;
|
|
102
119
|
}
|
|
@@ -135,6 +152,10 @@ export type ServerFrame =
|
|
|
135
152
|
|
|
136
153
|
/** The longest client reference, conversation id, or prompt id accepted. */
|
|
137
154
|
const ID_CHARS = 128;
|
|
155
|
+
/** The longest uploaded file reference accepted: a 36-character id, a dash, and a name cut to 120. */
|
|
156
|
+
const FILE_ID_CHARS = 200;
|
|
157
|
+
/** The most file references one frame may carry, whatever the server's own limit is. */
|
|
158
|
+
const MAX_FRAME_FILES = 32;
|
|
138
159
|
|
|
139
160
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
140
161
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -142,6 +163,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
142
163
|
const isId = (value: unknown): value is string =>
|
|
143
164
|
typeof value === "string" && value.length > 0 && value.length <= ID_CHARS;
|
|
144
165
|
|
|
166
|
+
const isFileList = (value: unknown): value is string[] =>
|
|
167
|
+
Array.isArray(value) &&
|
|
168
|
+
value.length <= MAX_FRAME_FILES &&
|
|
169
|
+
value.every(
|
|
170
|
+
(item) =>
|
|
171
|
+
typeof item === "string" &&
|
|
172
|
+
item.length > 0 &&
|
|
173
|
+
item.length <= FILE_ID_CHARS,
|
|
174
|
+
);
|
|
175
|
+
|
|
145
176
|
const optional = <T>(value: unknown, check: (v: unknown) => v is T) =>
|
|
146
177
|
value === undefined || check(value);
|
|
147
178
|
|
|
@@ -169,12 +200,17 @@ export function parseClientFrame(
|
|
|
169
200
|
if (!optional(conversation, isId) || !optional(persona, isId))
|
|
170
201
|
return undefined;
|
|
171
202
|
if (conversation === undefined && persona === undefined) return undefined;
|
|
203
|
+
if (!optional(value.attachments, isFileList)) return undefined;
|
|
204
|
+
const { attachments } = value;
|
|
172
205
|
return {
|
|
173
206
|
type: "send",
|
|
174
207
|
id,
|
|
175
208
|
text,
|
|
176
209
|
...(conversation === undefined ? {} : { conversation }),
|
|
177
210
|
...(persona === undefined ? {} : { persona }),
|
|
211
|
+
...(attachments === undefined || attachments.length === 0
|
|
212
|
+
? {}
|
|
213
|
+
: { attachments }),
|
|
178
214
|
};
|
|
179
215
|
}
|
|
180
216
|
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,17 @@ 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,
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
70
|
* The web chat's REST API, under its path, every call with `Authorization: Bearer <token>`:
|
|
69
71
|
* `POST tickets` (a one-time ticket for the WebSocket), `GET conversations` (the caller's own),
|
|
70
72
|
* `POST conversations` (`{ persona, title? }` opens one), and `GET conversations/<id>/messages`
|
|
71
|
-
* (`?limit=`, its last messages)
|
|
73
|
+
* (`?limit=`, its last messages), and `POST conversations/<id>/files?name=<file name>` (the body is
|
|
74
|
+
* the file's bytes, its `Content-Type` the file's type; answers 201 with `{ file, name, contentType,
|
|
75
|
+
* size }`, 413 over the size limit, 415 for a type that is not accepted, 429 over the person's
|
|
76
|
+
* allowance). A browser on another origin gets CORS headers when its origin
|
|
72
77
|
* is allowed and 403 otherwise.
|
|
73
78
|
*/
|
|
74
79
|
export function restHandler(options: RestOptions) {
|
|
@@ -143,6 +148,16 @@ export function restHandler(options: RestOptions) {
|
|
|
143
148
|
const entries = await chat.transcript(speaker, messages[1], limit);
|
|
144
149
|
return json({ messages: entries }, 200, headers);
|
|
145
150
|
}
|
|
151
|
+
const files = /^conversations\/([A-Za-z0-9-]{1,128})\/files$/.exec(rest);
|
|
152
|
+
if (files?.[1] && request.method === "POST") {
|
|
153
|
+
const uploaded = await chat.upload(
|
|
154
|
+
speaker,
|
|
155
|
+
files[1],
|
|
156
|
+
request,
|
|
157
|
+
url.searchParams.get("name"),
|
|
158
|
+
);
|
|
159
|
+
return json(uploaded, 201, headers);
|
|
160
|
+
}
|
|
146
161
|
return json({ error: "not_found" }, 404, headers);
|
|
147
162
|
}
|
|
148
163
|
return async (request: Request): Promise<Response> => {
|
|
@@ -179,6 +194,8 @@ export function restHandler(options: RestOptions) {
|
|
|
179
194
|
try {
|
|
180
195
|
return await route(request, url, who, headers);
|
|
181
196
|
} catch (error) {
|
|
197
|
+
if (error instanceof UploadRefusal)
|
|
198
|
+
return json({ error: error.code }, error.status, headers);
|
|
182
199
|
if (!(error instanceof Refusal)) throw error;
|
|
183
200
|
return json({ error: error.code }, STATUS[error.code] ?? 400, headers);
|
|
184
201
|
}
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
/** The content types accepted, such as `image/png`; an entry ending in `/*` admits every type under it. */
|
|
27
|
+
attachmentTypes: readonly string[];
|
|
28
|
+
/** How long an upload no message referenced is kept before it is deleted; default 24 hours. */
|
|
29
|
+
unsentUploadTtlMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Why an upload was refused, as the HTTP status and error code the client reads. */
|
|
33
|
+
export class UploadRefusal extends Error {
|
|
34
|
+
override name = "UploadRefusal";
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly code:
|
|
37
|
+
| "bad_request"
|
|
38
|
+
| "payload_too_large"
|
|
39
|
+
| "unsupported_media_type"
|
|
40
|
+
| "too_many_uploads";
|
|
41
|
+
|
|
42
|
+
constructor(status: number, code: UploadRefusal["code"]) {
|
|
43
|
+
super(code);
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What an upload returns: `file` is what a `send` frame's `attachments` names. */
|
|
50
|
+
export interface UploadedFile {
|
|
51
|
+
file: string;
|
|
52
|
+
name: string;
|
|
53
|
+
contentType: string;
|
|
54
|
+
size: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UploadsDeps {
|
|
58
|
+
limits: UploadLimits;
|
|
59
|
+
/** Read when used, after the host linked every plugin. */
|
|
60
|
+
attachments(): AttachmentPort;
|
|
61
|
+
/** The clock, in milliseconds; default `Date.now`. */
|
|
62
|
+
now?(): number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const HOUR_MS = 60 * 60_000;
|
|
66
|
+
/** The longest file name accepted, in characters. */
|
|
67
|
+
const NAME_CHARS = 255;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A name the model can read in its prompt: control characters become underscores and quotes
|
|
71
|
+
* become apostrophes, so a name cannot add lines or close the quotes around it.
|
|
72
|
+
*/
|
|
73
|
+
function safeName(name: string | null): string {
|
|
74
|
+
const cleaned = (name ?? "")
|
|
75
|
+
.replace(/\p{Cc}/gu, "_")
|
|
76
|
+
.replaceAll('"', "'")
|
|
77
|
+
.trim();
|
|
78
|
+
if (cleaned === "" || cleaned.length > NAME_CHARS)
|
|
79
|
+
throw new UploadRefusal(400, "bad_request");
|
|
80
|
+
return cleaned;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The request body, or undefined once it passes `max` bytes; the rest of the stream is not read. */
|
|
84
|
+
async function readLimited(
|
|
85
|
+
request: Request,
|
|
86
|
+
max: number,
|
|
87
|
+
): Promise<Uint8Array | undefined> {
|
|
88
|
+
const reader = request.body?.getReader();
|
|
89
|
+
if (!reader) return new Uint8Array();
|
|
90
|
+
const chunks: Uint8Array[] = [];
|
|
91
|
+
let size = 0;
|
|
92
|
+
try {
|
|
93
|
+
for (;;) {
|
|
94
|
+
const { done, value } = await reader.read();
|
|
95
|
+
if (done) break;
|
|
96
|
+
size += value.byteLength;
|
|
97
|
+
if (size > max) {
|
|
98
|
+
await reader.cancel();
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
chunks.push(value);
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
reader.releaseLock();
|
|
105
|
+
}
|
|
106
|
+
const bytes = new Uint8Array(size);
|
|
107
|
+
let offset = 0;
|
|
108
|
+
for (const chunk of chunks) {
|
|
109
|
+
bytes.set(chunk, offset);
|
|
110
|
+
offset += chunk.byteLength;
|
|
111
|
+
}
|
|
112
|
+
return bytes;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The files people upload to their conversations before they send a message: it checks the
|
|
117
|
+
* content type, the bytes, the size and the person's allowance, and keeps the file with the
|
|
118
|
+
* core's attachment port until a message uses it. Who may upload to which conversation is the
|
|
119
|
+
* chat's to decide before it calls `receive`.
|
|
120
|
+
*/
|
|
121
|
+
export class Uploads {
|
|
122
|
+
readonly #deps: UploadsDeps;
|
|
123
|
+
readonly #rate: RateWindow;
|
|
124
|
+
|
|
125
|
+
constructor(deps: UploadsDeps) {
|
|
126
|
+
this.#deps = deps;
|
|
127
|
+
this.#rate = new RateWindow(
|
|
128
|
+
deps.limits.uploadsPerHour,
|
|
129
|
+
HOUR_MS,
|
|
130
|
+
deps.now ?? Date.now,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What `ready` tells a client about attachments. */
|
|
135
|
+
get advertised(): {
|
|
136
|
+
maxBytes: number;
|
|
137
|
+
perMessage: number;
|
|
138
|
+
types: readonly string[];
|
|
139
|
+
} {
|
|
140
|
+
const { limits } = this.#deps;
|
|
141
|
+
return {
|
|
142
|
+
maxBytes: limits.attachmentBytes,
|
|
143
|
+
perMessage: limits.attachmentsPerMessage,
|
|
144
|
+
types: limits.attachmentTypes,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Takes the request's body as a file for `principalId` in `channel`; throws an UploadRefusal. */
|
|
149
|
+
async receive(
|
|
150
|
+
request: Request,
|
|
151
|
+
channel: ChannelKey,
|
|
152
|
+
principalId: string,
|
|
153
|
+
name: string | null,
|
|
154
|
+
): Promise<UploadedFile> {
|
|
155
|
+
const { limits } = this.#deps;
|
|
156
|
+
const fileName = safeName(name);
|
|
157
|
+
const type = baseType(request.headers.get("content-type"));
|
|
158
|
+
if (!type || !isAllowedType(limits.attachmentTypes, type))
|
|
159
|
+
throw new UploadRefusal(415, "unsupported_media_type");
|
|
160
|
+
const declared = Number(request.headers.get("content-length"));
|
|
161
|
+
if (Number.isFinite(declared) && declared > limits.attachmentBytes)
|
|
162
|
+
throw new UploadRefusal(413, "payload_too_large");
|
|
163
|
+
if (!this.#rate.take(principalId))
|
|
164
|
+
throw new UploadRefusal(429, "too_many_uploads");
|
|
165
|
+
const attachments = this.#deps.attachments();
|
|
166
|
+
const waiting = await attachments.pendingBytes(principalId);
|
|
167
|
+
const room = limits.unsentUploadBytesPerPrincipal - waiting;
|
|
168
|
+
const bytes = await readLimited(
|
|
169
|
+
request,
|
|
170
|
+
Math.min(limits.attachmentBytes, Math.max(room, 0)),
|
|
171
|
+
);
|
|
172
|
+
if (!bytes)
|
|
173
|
+
throw room < limits.attachmentBytes
|
|
174
|
+
? new UploadRefusal(429, "too_many_uploads")
|
|
175
|
+
: new UploadRefusal(413, "payload_too_large");
|
|
176
|
+
if (!bytesMatchType(type, bytes))
|
|
177
|
+
throw new UploadRefusal(415, "unsupported_media_type");
|
|
178
|
+
const stored = await attachments.save(channel, principalId, {
|
|
179
|
+
name: fileName,
|
|
180
|
+
contentType: type,
|
|
181
|
+
data: bytes,
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
file: stored.file,
|
|
185
|
+
name: stored.name,
|
|
186
|
+
contentType: stored.contentType,
|
|
187
|
+
size: stored.size,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Deletes the uploads no message used within the time to live; returns how many. */
|
|
192
|
+
sweep(): Promise<number> {
|
|
193
|
+
const now = (this.#deps.now ?? Date.now)();
|
|
194
|
+
return this.#deps
|
|
195
|
+
.attachments()
|
|
196
|
+
.discardPending(new Date(now - this.#deps.limits.unsentUploadTtlMs));
|
|
197
|
+
}
|
|
198
|
+
}
|