pi-roundtable-webchat 0.9.8 → 0.9.10

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,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.10] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - A turn run for a message carries the `Origin` header its connection's upgrade was accepted with, as `origin` on `context.turns.run`, so a tool reads it as `ToolTurn.origin`. It is trimmed and lower-cased and kept only when it is a serialized `scheme://host[:port]` of at most 256 characters; a connection that sent none, or an `Origin` of another shape, leaves it undefined. It is read from the transport at the upgrade, never from a frame or the message text. Needs pi-roundtable 0.9.10 or later to reach tools; on an older core the extra field is ignored.
13
+
14
+ ## [0.9.9] - 2026-10-10
15
+
16
+ ### Added
17
+
18
+ - A privacy note in the README for the core's `attachments.retention`: a host can have the files messages used removed after a period, which also gives the person's `usedAttachmentBytesPerPrincipal` allowance back. The plugin needs no setting of its own for it. `memoryAttachments` (the test double) implements the port's new optional `expireUsed`.
19
+
8
20
  ## [0.9.8] - 2026-10-10
9
21
 
10
22
  - Release in lockstep with pi-roundtable 0.9.8; no package-specific behavior changes.
package/README.md CHANGED
@@ -169,6 +169,9 @@ The token is never read from a URL.
169
169
  - **Another client**, such as a service, sends `Authorization: Bearer <token>` on the upgrade and offers `roundtable.webchat.v1`.
170
170
 
171
171
  The server echoes `roundtable.webchat.v1`.
172
+ The `Origin` an accepted upgrade carried is kept on the connection, trimmed and lower-cased, and each message's turn passes it to the core as `origin`, so a tool reads it as `turn.origin` (for example to write where an issue was filed from).
173
+ It is read from the transport only, never from a frame or the message text.
174
+ A client that sent none, which only a route with `origins: "any"` admits, or a header that is not a serialized `scheme://host[:port]`, leaves `turn.origin` undefined; a tool that needs it should fail closed then.
172
175
  An upgrade from an origin not in `origins`, or without one, is refused with 403; without a valid ticket or token with 401; for a person the core policy does not admit with 403; past the person's connection limit with 429.
173
176
 
174
177
  ```js
@@ -297,6 +300,12 @@ An upload no message used is deleted after `unsentUploadTtlMs` (24 hours): the p
297
300
  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.
298
301
  This plugin has no way to delete a conversation itself; a host surface that deletes one does it through the core.
299
302
 
303
+ **Privacy: removing used files after a period.** A host that must not keep uploads sets the core's `attachments.retention` (`{ maxAgeMs, sweepEveryMs? }` in the host configuration; see the core's [plugin guide](https://github.com/wayne930242/pi-roundtable/blob/master/docs/plugins.md)), for example `{ maxAgeMs: 7 * 24 * 60 * 60_000 }` to keep no upload longer than seven days after the message that used it.
304
+ The host then deletes the files and their records, and takes their bytes off the person's `usedAttachmentBytesPerPrincipal` allowance, so a person who sends files for months is not refused with `attachment_quota` for good.
305
+ A model or tool that asks for a removed file is told it was removed after its retention period.
306
+ The transcript is a separate record: this removes the files, not the messages that named them or what the model said about them. Uploads that no message used are deleted after `unsentUploadTtlMs` as before, whatever the retention.
307
+ This needs a pi-roundtable release that has `attachments.retention`; on an earlier core the plugin keeps working and files simply live as long as their conversation.
308
+
300
309
  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.
301
310
  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.
302
311
 
@@ -343,7 +352,7 @@ Only the person who uploaded a file can use it, and only in the conversation it
343
352
  | `attachmentsPerMessage` | 8 files named by one message |
344
353
  | `uploadsPerHour` | 60 uploads per person in any hour |
345
354
  | `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 |
346
- | `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 |
355
+ | `usedAttachmentBytesPerPrincipal` | 1 GiB per person kept by their messages across all conversations until a conversation is deleted or the host's `attachments.retention` removes the files; needs pi-roundtable 0.9.3 to be enforced |
347
356
  | `attachmentTypes` | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain`, `application/pdf` |
348
357
  | `unsentUploadTtlMs` | 24 hours before an unused upload is deleted |
349
358
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-webchat",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
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.8",
46
+ "pi-roundtable": "0.9.10",
47
47
  "typebox": "1.3.34",
48
48
  "typescript": "7.0.2"
49
49
  }
package/src/chat.ts CHANGED
@@ -112,6 +112,8 @@ interface Minted {
112
112
 
113
113
  interface Pending {
114
114
  speaker: Speaker;
115
+ /** The origin of the connection the message arrived on. */
116
+ origin?: string;
115
117
  persona: WebPersona;
116
118
  title: string;
117
119
  /** The files the message references, moved into the conversation when it was accepted. */
@@ -600,6 +602,7 @@ export class WebChat {
600
602
  const messageId = crypto.randomUUID();
601
603
  this.#pending.set(messageId, {
602
604
  speaker,
605
+ ...(connection.origin ? { origin: connection.origin } : {}),
603
606
  persona,
604
607
  title: minted?.title ?? titleOf(text),
605
608
  fresh: record === undefined,
@@ -805,6 +808,7 @@ export class WebChat {
805
808
  kind: persona.kind,
806
809
  text,
807
810
  speaker,
811
+ ...(pending.origin ? { origin: pending.origin } : {}),
808
812
  interactive: true,
809
813
  ...(pending.attachments ? { attachments: pending.attachments } : {}),
810
814
  ...(persona.selection
@@ -6,6 +6,12 @@ import type { ServerFrame } from "./protocol.ts";
6
6
  export interface Connection {
7
7
  identity: WebIdentity;
8
8
  speaker: Speaker;
9
+ /**
10
+ * The normalized `Origin` the upgrade carried and the route accepted; undefined when it sent none
11
+ * (a client that is not a browser, on a route that admits `"any"` origin). Fixed for the
12
+ * connection's life: it comes from the transport, never from a frame.
13
+ */
14
+ origin?: string;
9
15
  socket?: RouteSocket<Connection>;
10
16
  /** Asks for a fresh token, then closes the socket when the token expires. */
11
17
  timers: ReturnType<typeof setTimeout>[];
package/src/origin.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** The longest origin kept; a longer header is not a browser's. */
2
+ const MAX_ORIGIN_LENGTH = 256;
3
+
4
+ /** A serialized origin: a scheme, `://`, and a host with an optional port, with no path, query or space. */
5
+ const ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^\s/?#@]+$/;
6
+
7
+ /**
8
+ * The connection's `Origin` header as a tool may label a record with it: trimmed and lower-cased,
9
+ * or undefined when the header is absent, the opaque `null`, over-long, or not a serialized origin.
10
+ * Browsers always send one; a client that is not a browser sends any text it likes, so the shape
11
+ * is checked here and nothing else is trusted about it.
12
+ */
13
+ export function normalizeOrigin(header: string | null): string | undefined {
14
+ const value = header?.trim().toLowerCase();
15
+ if (!value || value.length > MAX_ORIGIN_LENGTH) return undefined;
16
+ return ORIGIN.test(value) ? value : undefined;
17
+ }
package/src/plugin.ts CHANGED
@@ -21,6 +21,7 @@ import type { Connection } from "./connections.ts";
21
21
  import { webDirectChannel } from "./direct-channel.ts";
22
22
  import { PgNotices } from "./notices.ts";
23
23
  import { TokenRefused, type TokenVerifier } from "./oidc.ts";
24
+ import { normalizeOrigin } from "./origin.ts";
24
25
  import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
25
26
  import { restHandler } from "./rest.ts";
26
27
  import { TicketBook } from "./tickets.ts";
@@ -240,7 +241,12 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
240
241
  if (error instanceof Refusal) return refuse(403, "Forbidden");
241
242
  throw error;
242
243
  }
243
- const connection: Connection = { ...admitted, timers: [] };
244
+ const origin = normalizeOrigin(request.headers.get("origin"));
245
+ const connection: Connection = {
246
+ ...admitted,
247
+ ...(origin ? { origin } : {}),
248
+ timers: [],
249
+ };
244
250
  if (!chat.connections.reserve(connection))
245
251
  return refuse(429, "Too Many Connections");
246
252
  return {