pi-roundtable-webchat 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.8.0] - 2026-10-07
9
+
10
+ - First release. `webChat(options)` adds a WebSocket chat to a host's HTTP listener for people an OpenID Connect provider signs in: a `web:` chat surface, the claim that runs each message as a turn of its conversation's persona through `context.turns`, and a REST API (`POST tickets`, `GET`/`POST conversations`, `GET conversations/<id>/messages`) under one path. Every conversation is private to the person who opened it: the claim checks the host's conversation registry inside the conversation's queue before each turn.
11
+ - Protocol version 1 (`roundtable.webchat.v1`): client frames `send`, `stop`, `approval`, `answer`, and `auth`; server frames `ready`, `accepted`, `typing`, `stoppable`, `progress`, `reply`, `failed` (also when the host refuses a turn before it runs, such as when its conversation cannot be recorded), `prompt`, `prompt_closed`, `reauth`, and `error`; close codes 4401 and 4403. A browser connects with a one-time ticket in the `ticket.<ticket>` subprotocol, another client with a bearer header; the token is never read from a URL.
12
+ - `oidcJwtVerifier(options)`: JWKS fetched and cached, `iss`, `aud`, `exp`, `nbf` with clock skew, an asymmetric algorithm allowlist, configurable subject, name, and roles claims, and a further `check`. By default it refuses a token with no scope (`scp` or `scope`) or app roles, so an ID token cannot pass for an access token (`requireScopeOrRoles`), and an app-only token (`idtyp: "app"`, `rejectAppOnly`). The README's provider settings cover a stable subject claim, pinning the tenant, merging issuers of one tenant only, and who `everyone` admits. Speaker ids are `oidc:<base64url(issuer)>:<subject>` (`oidcSpeakerId`, `parseOidcSpeakerId`).
13
+ - `webAccess(map)`: tiers from the token's roles or speaker ids; owners only by speaker id; a person with no tier is not admitted.
14
+ - Secure defaults: `origins` required, connections per person and per route, frame size and rate limits, prompts that only the conversation's person answers at the tier the call needs.
15
+ - Turn and conversation limits per person: `turnsPerPrincipal` (default 2) turns running or queued at once across their conversations, and at most one turn queued behind a conversation's running one; a message over either is refused with the `busy` error and never queued. `newConversationsPerHour` (default 60) bounds the conversations a person opens an hour, over the socket or `POST conversations`, past which they get `too_many_conversations` (429 over REST).
16
+ - Tickets per person: a person holds at most `connectionsPerPrincipal` unspent WebSocket tickets, and asking for one more drops their own oldest, so one person cannot push out everyone else's tickets (`TicketBookOptions.perPrincipal`, default 5).
17
+ - Configuration checked at load, for JavaScript configurations too: an access map whose `owners`, `users`, or `roles` is not a list of strings, or whose `everyone` is not a boolean, and a persona whose `kind` is not a non-empty string or whose `minTier` is not a tier, throw. A string where a list belongs would have matched its substrings, and an unknown `minTier` would have let every tier open the persona.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wayne930242
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # pi-roundtable-webchat
2
+
3
+ A WebSocket chat adapter for [pi-roundtable][roundtable].
4
+ People an OpenID Connect provider signs in chat with the assistant from a web page or a browser extension, each in private conversations of their own.
5
+ A turn's text and tools show as it runs, and a held tool call asks the person on an approval card.
6
+ Source lives in [`packages/webchat`][source] in the pi-roundtable repository and releases in lockstep with the core.
7
+
8
+ [roundtable]: https://www.npmjs.com/package/pi-roundtable
9
+ [source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/webchat
10
+
11
+ The plugin adds three things to a host:
12
+
13
+ - a chat surface whose conversations have keys `web:<conversation>`;
14
+ - the claim that runs each message as a turn of its conversation's persona, through `context.turns` and the host's runtime;
15
+ - a REST API and a WebSocket under one path of the host's HTTP listener.
16
+
17
+ It needs no Discord: a host whose `roundtable.config.ts` has no `discord` key and lists this plugin is a web-only assistant.
18
+ `roundtable init --adapter web` creates such a project.
19
+
20
+ ## Requirements
21
+
22
+ - Bun 1.3 or later, and pi-roundtable `>=0.8.0 <0.9.0` as a peer dependency.
23
+ - An HTTP listener on the host (`http` in the configuration), behind a reverse proxy that serves it over HTTPS.
24
+ - An OpenID Connect provider that issues access tokens for this API, with signing keys published as a JWKS.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ bun add pi-roundtable-webchat
30
+ ```
31
+
32
+ ```ts
33
+ import { readFileSync } from "node:fs";
34
+ import type { RoundtableConfig } from "pi-roundtable";
35
+ import { oidcJwtVerifier, webChat } from "pi-roundtable-webchat";
36
+
37
+ export default {
38
+ owner: { id: "operator", name: "Ada" },
39
+ database: { url: process.env.DATABASE_URL ?? "" },
40
+ dataDir: "./data",
41
+ model: "anthropic/claude-sonnet-5-5",
42
+ http: { port: 3000, hostname: "127.0.0.1" },
43
+ plugins: [
44
+ webChat({
45
+ verifier: oidcJwtVerifier({
46
+ jwksUrl: "https://login.example.com/keys",
47
+ issuers: ["https://login.example.com/"],
48
+ audiences: ["api://helpdesk"],
49
+ }),
50
+ access: {
51
+ admins: { roles: ["Helpdesk.Admin"] },
52
+ members: { roles: ["Helpdesk.User"] },
53
+ },
54
+ origins: ["https://chat.example.com"],
55
+ personas: [
56
+ {
57
+ kind: "helpdesk",
58
+ label: "Helpdesk",
59
+ prompt: () => readFileSync("./persona/helpdesk.md", "utf8"),
60
+ },
61
+ ],
62
+ }),
63
+ ],
64
+ } satisfies RoundtableConfig;
65
+ ```
66
+
67
+ Every mistake in these options throws when the configuration loads, so `roundtable doctor` names it before the host starts.
68
+
69
+ ## Options
70
+
71
+ | Option | What it sets |
72
+ |---|---|
73
+ | `verifier` | Checks each bearer token and names the person: `oidcJwtVerifier({ ... })`, or your own `TokenVerifier`. |
74
+ | `access` | Who may chat, and at which tier: a `WebAccessMap`, or `webAccess(map)`. |
75
+ | `personas` | The conversation kinds a person may open (`WebPersona`). |
76
+ | `origins` | Required. The browser origins allowed to open the socket and call the API, each exactly `scheme://host[:port]`, such as `https://chat.example.com` or `chrome-extension://<id>`. `"any"` admits every origin, for clients that are not browsers; never use it where a browser holds the token. |
77
+ | `listener` | The configured listener the route attaches to; default `public`, the one `http` names. |
78
+ | `path` | Where the API and the socket live; default `/chat`. |
79
+ | `surface` | The key prefix of the conversations; default `web`. Two web chats on one host need two prefixes, and two paths. |
80
+ | `limits` | Overrides of the limits below. |
81
+
82
+ ### Personas
83
+
84
+ A `WebPersona` is `{ kind, label?, prompt?, minTier?, selection? }`.
85
+ `prompt()` is the system prompt of its conversations, contributed by this plugin; leave it out when another plugin contributes the persona of that kind.
86
+ `minTier` (default `member`) is the lowest tier that may see and open it; a person whose tier falls below it later can no longer write in its conversations.
87
+ `selection` (`{ tools, groups }`) names the tools its turns get; without it a turn gets the plugins' `agentSelection`, which grows with every plugin you add, so name the tools.
88
+ Leave out `schedule_*` and `delegate_task` (see [what it does not do yet](#what-it-does-not-do-yet)), and `web_search` and `fetch_content` unless people may make the server fetch any address, internal ones included: they are member-tier tools, and a plugin that loads pi-web-access, such as one `roundtable add package pi-web-access` writes, adds them to `agentSelection`.
89
+ The kinds `owner` and `agent` belong to the host and are refused.
90
+
91
+ ### Access
92
+
93
+ A `WebAccessMap` is `{ owners?, admins?, members? }`.
94
+ `admins` and `members` name people by `users` (speaker ids), by `roles` (the names in the token's roles claim), or `everyone` the verifier accepts, guest accounts included; the highest tier a person qualifies for wins.
95
+ `owners` is a list of speaker ids only: no claim a provider issues can make anyone the owner.
96
+ A person the map gives no tier is not admitted: no ticket, no socket, no conversation, no turn.
97
+ A map that admits no one throws.
98
+
99
+ ### `oidcJwtVerifier`
100
+
101
+ | Option | What it sets |
102
+ |---|---|
103
+ | `jwksUrl` | The provider's signing keys (`jwks_uri`): https, or http on a loopback address. Fetched when first needed, cached for `cacheMaxAgeMs` (10 minutes), and fetched again for a key id it lacks at most every `refetchCooldownMs` (30 seconds). |
104
+ | `issuers` | The accepted `iss` values. |
105
+ | `audiences` | The accepted `aud` values. |
106
+ | `speakerIssuer` | The issuer speaker ids are made from; required when `issuers` lists several issuers of one provider, so one person keeps one id. |
107
+ | `subjectClaim` | The claim naming the person; default `sub`. Use a claim that stays the same across your app registrations, such as Entra's `oid`; see [provider settings](#provider-settings). |
108
+ | `nameClaim` | The claim to show them by; default `name`, then `preferred_username`, then the subject. |
109
+ | `rolesClaim` | The claim holding their roles or groups, an array of strings; default `roles`. |
110
+ | `algorithms` | Accepted signature algorithms; default `RS256` and `ES256`. Symmetric algorithms and `none` are refused. |
111
+ | `clockSkewSeconds` | How far `exp` and `nbf` may be off the local clock; default 60. |
112
+ | `check` | A further check on the verified claims, such as a tenant claim; `false` refuses the token. |
113
+ | `requireScopeOrRoles` | Refuse a token without a scope (`scp` or `scope`) or app roles (`roles`), the marks of an access token; default `true`, so an ID token cannot pass for an access token. Set `false` only for a provider whose access tokens carry neither. |
114
+ | `rejectAppOnly` | Refuse an app-only token, which names a service rather than a person (`idtyp: "app"`); default `true`. |
115
+
116
+ It checks the signature, `iss`, `aud`, `exp` (required), and `nbf`, then a scope or roles and no app-only token, then the subject claim and `check`.
117
+ A refused token throws `TokenRefused`, whose `reason` goes to the host's log and never to the client.
118
+ The speaker id of a person is `oidc:<base64url(issuer)>:<subject>`: `oidcSpeakerId(issuer, subject)` makes it, and `parseOidcSpeakerId(id)` turns it back into the pair.
119
+ Use it to name owners in the access map.
120
+
121
+ ### Provider settings
122
+
123
+ The verifier is generic; what makes it safe is how you point it at your provider.
124
+ Microsoft Entra ID is the example below, and the same questions apply to any provider.
125
+
126
+ - **Name people by a claim that is stable across app registrations.** Many providers make `sub` pairwise: it differs for each application, so a web page and a browser extension registered as two apps would give one person two speaker ids, and two memories. Entra's `oid` is the same for a person in every app of the tenant: set `subjectClaim: "oid"`.
127
+ - **Pin the tenant in `check`.** An object id is unique only within its tenant, and a multi-tenant app takes tokens from every tenant. With Entra, check `claims.tid` against your tenant id.
128
+ - **Accept access tokens only.** `requireScopeOrRoles` refuses a token with no scope or app roles, as an ID token is. An Entra ID token may still carry `roles` when you assign app roles, so also require the delegated scope in `check` (`typeof claims.scp === "string"`); where you can, register the API apart from the web page's sign-in app, so a page's ID token never has the API's audience.
129
+ - **Refuse app-only tokens.** `rejectAppOnly` refuses `idtyp: "app"`. Entra writes `idtyp` only when the app asks for that optional claim; requiring `scp` refuses app-only tokens either way, since they carry roles and no scope.
130
+ - **Merge issuers of one tenant only.** `speakerIssuer` makes one person's id the same whichever listed issuer signed the token, such as Entra's v1 `https://sts.windows.net/<tenant>/` and v2 `https://login.microsoftonline.com/<tenant>/v2.0`. Never list issuers of different tenants or providers together: their subjects are separate namespaces, and two people could get one id.
131
+ - **Mind who `everyone` admits.** `everyone: true` admits every person the verifier accepts, guest accounts of your tenant included. Prefer roles assigned to the users and groups who should chat; with Entra, the optional `acct` claim (`0` for a member of the tenant, `1` for a guest) lets `check` refuse guests.
132
+
133
+ ```ts
134
+ const tenant = process.env.ENTRA_TENANT_ID ?? "";
135
+ const verifier = oidcJwtVerifier({
136
+ jwksUrl: `https://login.microsoftonline.com/${tenant}/discovery/v2.0/keys`,
137
+ issuers: [`https://login.microsoftonline.com/${tenant}/v2.0`],
138
+ audiences: [process.env.ENTRA_API_CLIENT_ID ?? ""],
139
+ subjectClaim: "oid",
140
+ check: (claims) => claims.tid === tenant && typeof claims.scp === "string",
141
+ });
142
+ ```
143
+
144
+ ## Connecting
145
+
146
+ The token is never read from a URL.
147
+
148
+ - **A browser** asks for a one-time ticket with `POST <path>/tickets` and `Authorization: Bearer <token>`, then opens `<path>/socket` offering two subprotocols: `roundtable.webchat.v1` and `ticket.<ticket>`.
149
+ A ticket is spent by the first upgrade, lasts 30 seconds, and never outlives its token.
150
+ A person holds at most `connectionsPerPrincipal` unspent tickets; asking for one more drops their own oldest, never anyone else's.
151
+ - **Another client**, such as a service, sends `Authorization: Bearer <token>` on the upgrade and offers `roundtable.webchat.v1`.
152
+
153
+ The server echoes `roundtable.webchat.v1`.
154
+ 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 access map does not admit with 403; past the person's connection limit with 429.
155
+
156
+ ```js
157
+ const { ticket } = await (
158
+ await fetch("/chat/tickets", { method: "POST", headers: { authorization: `Bearer ${token}` } })
159
+ ).json();
160
+ const socket = new WebSocket("wss://chat.example.com/chat/socket", [
161
+ "roundtable.webchat.v1",
162
+ `ticket.${ticket}`,
163
+ ]);
164
+ socket.onmessage = (event) => console.log(JSON.parse(event.data));
165
+ socket.onopen = () =>
166
+ socket.send(JSON.stringify({ type: "send", id: "1", persona: "helpdesk", text: "Hello" }));
167
+ ```
168
+
169
+ ## Protocol, version 1
170
+
171
+ Each WebSocket message is one JSON object with a `type`.
172
+ `WEBCHAT_PROTOCOL` and `WEBCHAT_PROTOCOL_VERSION` name the version; an incompatible change gets a new subprotocol name.
173
+ The TypeScript types are `ClientFrame` and `ServerFrame`.
174
+
175
+ ### Client frames
176
+
177
+ | Frame | Meaning |
178
+ |---|---|
179
+ | `{ type: "send", id, persona, text }` | Opens a new conversation of `persona` with this message. `id` is your reference, echoed by `accepted` or `error`. |
180
+ | `{ type: "send", id, conversation, text }` | A message in one of your conversations. |
181
+ | `{ type: "stop", conversation }` | Stops the conversation's running turn. |
182
+ | `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
183
+ | `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
184
+ | `{ type: "auth", token }` | A fresh token for the same person, sent after `reauth`. |
185
+
186
+ ### Server frames
187
+
188
+ | Frame | Meaning |
189
+ |---|---|
190
+ | `{ type: "ready", protocol, speaker, personas, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier }`), the personas you may open (`{ kind, label }`), and when the token expires. Open prompts follow it. |
191
+ | `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
192
+ | `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
193
+ | `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
194
+ | `{ type: "progress", conversation, event }` | What the running turn writes and which tools it runs: `{ type: "text", delta }`, `{ type: "tool_start", id, tool, preview? }`, or `{ type: "tool_end", id, tool, ok }`. Never the thinking, never a tool's full arguments. |
195
+ | `{ type: "reply", conversation, text, thinking?, files? }` | The turn's answer in full markdown, with its files inline as `{ name, data }` (base64). |
196
+ | `{ type: "failed", conversation, stopped }` | The turn ended without an answer: it failed, the host refused it before it ran (for example when its conversation could not be recorded), or it was stopped. The cause stays in the host's log. |
197
+ | `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message }`, or `{ id, kind: "ask", title, question, options, multi, allowOther }`. |
198
+ | `{ type: "prompt_closed", conversation, prompt, outcome }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, or `cancelled` (the turn stopped). |
199
+ | `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
200
+ | `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
201
+
202
+ 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).
203
+
204
+ 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.
205
+ The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
206
+
207
+ ## REST API
208
+
209
+ Every call sends `Authorization: Bearer <token>`; a missing or refused token gets 401 with `WWW-Authenticate: Bearer`, and a person the access map does not admit 403.
210
+ A request from a browser origin not in `origins` gets 403; an allowed origin gets CORS headers and its preflight is answered.
211
+
212
+ | Call | Answer |
213
+ |---|---|
214
+ | `POST <path>/tickets` | 201 `{ ticket, expiresAt }`. |
215
+ | `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
216
+ | `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
217
+ | `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
218
+
219
+ ## Security model
220
+
221
+ - **Private conversations.** A conversation belongs to the person who opened it.
222
+ The host's conversation registry records its person at its first turn, and the claim checks that record inside the conversation's queue before every turn; only that person may list it, read it, write in it, stop it, or answer its prompts.
223
+ Conversation ids are random UUIDs, and the claim runs only messages this plugin accepted from a verified socket.
224
+ The owner can still read every conversation through the owner console, pi-roundtable-web, and the operator through the database and the data directory.
225
+ - **Tokens.** Tokens are checked on every REST call, every upgrade, and every `auth` frame, and never read from a URL.
226
+ By default only a person's access token passes: one without a scope or app roles, or an app-only one, is refused.
227
+ A socket is closed when its token expires.
228
+ - **Origins.** `origins` is required and checked on every upgrade and every browser request, so another site cannot open a socket or call the API with a browser's credentials.
229
+ - **Limits.** Each person holds at most `connectionsPerPrincipal` sockets, and the route at most `maxConnections`; frames are limited in size and rate.
230
+ Each person has at most `turnsPerPrincipal` turns running or queued at once, however many conversations or sockets they use, and a conversation at most its running turn and one queued behind it, so one account cannot spend a shared model subscription on many turns at once; a message over either limit is refused with `busy` and never queued.
231
+ Each person opens at most `newConversationsPerHour` conversations an hour, over the socket or the REST API alike.
232
+ - **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
233
+ A card whose tier the person lacks is never shown, so the call stays held.
234
+ - **Owner.** Nobody becomes the owner through a token's claims; list owners by speaker id.
235
+ Owner-tier tools stay out of web turns unless an owner is chatting.
236
+
237
+ ## Limits
238
+
239
+ | Limit | Default |
240
+ |---|---|
241
+ | `connectionsPerPrincipal` | 5 sockets per person |
242
+ | `unusedConversationsPerPrincipal` | 20 conversations opened but not written in |
243
+ | `newConversationsPerHour` | 60 conversations opened per person in any hour |
244
+ | `turnsPerPrincipal` | 2 turns running or queued per person, across conversations; a conversation holds its running turn and one queued |
245
+ | `messageChars` | 32 000 characters per message |
246
+ | `promptTimeoutMs` | 30 minutes before a prompt expires |
247
+ | `reauthLeadMs` | `reauth` 60 seconds before the token expires |
248
+ | `maxConnections` | 256 sockets on the route |
249
+ | `maxMessageBytes` | 64 KiB per client frame |
250
+ | `rate` | 60 frames a minute per socket |
251
+ | `maxBufferedBytes` | 4 MiB waiting for a slow client |
252
+ | `ticketTtlMs` | 30 seconds |
253
+
254
+ ## What it does not do yet
255
+
256
+ - Attachments: messages carry text only.
257
+ - Schedules and delegated reports: the claim takes no background turns, so a schedule or a report aimed at a web conversation is skipped. Leave the `schedule_*` and `delegate_task` tools out of a web persona's `selection`.
258
+ - Error reports: `ops: { conversation: "web:<id>" }` stops the host at startup with a `config ops.conversation` error, since the surface posts only to a conversation a signed-in person opened and the claim takes no background turns. Report errors to another surface's conversation, or to an agent with Discord.
259
+ - Agent teams: the web chat has no agent rooms.
260
+ - A person signed in on two providers, or on Discord and the web, has two speaker ids, and so two memories, until principals arrive in pi-roundtable 0.9.
261
+
262
+ ## Testing
263
+
264
+ `pi-roundtable/testing`'s `describeSurfaceContract` runs the chat surface contract on this surface in this package's tests, and the end-to-end test runs a host with a self-made JWKS and a scripted model.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "pi-roundtable-webchat",
3
+ "version": "0.8.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "bun": ">=1.3.0"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts",
16
+ "!src/testing",
17
+ "LICENSE",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/wayne930242/pi-roundtable.git",
24
+ "directory": "packages/webchat"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": true
29
+ },
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit",
32
+ "lint": "biome check .",
33
+ "test": "bun test"
34
+ },
35
+ "dependencies": {
36
+ "jose": "6.2.12"
37
+ },
38
+ "peerDependencies": {
39
+ "pi-roundtable": ">=0.8.0 <0.9.0"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "2.5.15",
43
+ "@earendil-works/pi-ai": ">=1.0.0 <2",
44
+ "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
45
+ "@types/bun": "1.4.2",
46
+ "pi-roundtable": "0.8.0",
47
+ "typebox": "1.3.34",
48
+ "typescript": "7.0.2"
49
+ }
50
+ }
package/src/access.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { Tier } from "pi-roundtable";
2
+ import type { WebIdentity } from "./oidc.ts";
3
+
4
+ /** Who holds a tier: speaker ids, role or group names from the token, or everyone the provider signs in. */
5
+ export interface WebTierMembers {
6
+ users?: readonly string[];
7
+ roles?: readonly string[];
8
+ everyone?: boolean;
9
+ }
10
+
11
+ /**
12
+ * Who may chat, and at which tier. Owners are named only by speaker id, so no claim a provider
13
+ * issues can make someone the owner; admins and members may be named by role as well. A person
14
+ * the map gives no tier is not admitted: no connection, no conversation, no turn.
15
+ */
16
+ export interface WebAccessMap {
17
+ /** Speaker ids, such as `oidcSpeakerId(issuer, subject)`; never roles. */
18
+ owners?: readonly string[];
19
+ admins?: WebTierMembers;
20
+ members?: WebTierMembers;
21
+ }
22
+
23
+ /** The tier a verified person chats at, or undefined when they are not admitted. */
24
+ export interface WebAccess {
25
+ tierOf(identity: WebIdentity): Tier | undefined;
26
+ }
27
+
28
+ const isStrings = (value: unknown): value is readonly string[] =>
29
+ Array.isArray(value) && value.every((item) => typeof item === "string");
30
+
31
+ /**
32
+ * Throws unless `tier` is a `WebTierMembers` whose `users` and `roles` are lists of strings and
33
+ * whose `everyone` is a boolean: a JavaScript configuration is not type-checked, and a string
34
+ * where a list belongs would match its substrings.
35
+ */
36
+ function checkTier(name: string, tier: unknown): void {
37
+ if (tier === undefined) return;
38
+ if (typeof tier !== "object" || tier === null || Array.isArray(tier))
39
+ throw new Error(
40
+ `webAccess: ${name} is { users?, roles?, everyone? }; got ${JSON.stringify(tier)}`,
41
+ );
42
+ const { users, roles, everyone } = tier as Record<string, unknown>;
43
+ for (const [field, value] of [
44
+ ["users", users],
45
+ ["roles", roles],
46
+ ] as const)
47
+ if (value !== undefined && !isStrings(value))
48
+ throw new Error(
49
+ `webAccess: ${name}.${field} must be a list of strings; got ${JSON.stringify(value)}`,
50
+ );
51
+ if (everyone !== undefined && typeof everyone !== "boolean")
52
+ throw new Error(
53
+ `webAccess: ${name}.everyone must be true or false; got ${JSON.stringify(everyone)}`,
54
+ );
55
+ }
56
+
57
+ function admitsSomeone(tier: WebTierMembers | undefined): boolean {
58
+ return (
59
+ tier !== undefined &&
60
+ (tier.everyone === true ||
61
+ (tier.users?.length ?? 0) > 0 ||
62
+ (tier.roles?.length ?? 0) > 0)
63
+ );
64
+ }
65
+
66
+ /**
67
+ * The policy of an access map: the highest tier a person qualifies for wins. A map that admits
68
+ * no one is a configuration mistake and throws.
69
+ */
70
+ export function webAccess(map: WebAccessMap): WebAccess {
71
+ if (map.owners !== undefined && !isStrings(map.owners))
72
+ throw new Error(
73
+ "webAccess: owners is a list of speaker ids; a provider's roles cannot name an owner",
74
+ );
75
+ checkTier("admins", map.admins);
76
+ checkTier("members", map.members);
77
+ const owners = new Set(map.owners ?? []);
78
+ if (
79
+ owners.size === 0 &&
80
+ !admitsSomeone(map.admins) &&
81
+ !admitsSomeone(map.members)
82
+ )
83
+ throw new Error(
84
+ "webAccess: the map admits no one; name owners, or admins or members by user, role, or everyone",
85
+ );
86
+ const holds = (tier: WebTierMembers | undefined, identity: WebIdentity) =>
87
+ tier !== undefined &&
88
+ (tier.everyone === true ||
89
+ tier.users?.includes(identity.id) === true ||
90
+ identity.roles.some((role) => tier.roles?.includes(role)));
91
+ return {
92
+ tierOf(identity) {
93
+ if (owners.has(identity.id)) return "owner";
94
+ if (holds(map.admins, identity)) return "admin";
95
+ if (holds(map.members, identity)) return "member";
96
+ return undefined;
97
+ },
98
+ };
99
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,74 @@
1
+ /** Turns one conversation may hold at once: the running one and one queued behind it. */
2
+ export const TURNS_PER_CONVERSATION = 2;
3
+
4
+ /**
5
+ * The turns each person has running or queued, by conversation. A person holds at most
6
+ * `perPrincipal` across their conversations, and a conversation at most `TURNS_PER_CONVERSATION`,
7
+ * so one person cannot keep the shared model busy with many turns at once.
8
+ */
9
+ export class TurnBudget {
10
+ readonly #perPrincipal: number;
11
+ readonly #held = new Map<string, Map<string, number>>();
12
+
13
+ constructor(perPrincipal: number) {
14
+ this.#perPrincipal = perPrincipal;
15
+ }
16
+
17
+ /** Whether the person may start one more turn, in `conversation` when it is named. */
18
+ allows(principal: string, conversation?: string): boolean {
19
+ const mine = this.#held.get(principal);
20
+ let total = 0;
21
+ for (const count of mine?.values() ?? []) total += count;
22
+ if (total >= this.#perPrincipal) return false;
23
+ return (
24
+ conversation === undefined ||
25
+ (mine?.get(conversation) ?? 0) < TURNS_PER_CONVERSATION
26
+ );
27
+ }
28
+
29
+ /** Takes a place for one turn: a function that frees it, once, or undefined over a limit. */
30
+ take(principal: string, conversation: string): (() => void) | undefined {
31
+ if (!this.allows(principal, conversation)) return undefined;
32
+ const mine = this.#held.get(principal) ?? new Map<string, number>();
33
+ mine.set(conversation, (mine.get(conversation) ?? 0) + 1);
34
+ this.#held.set(principal, mine);
35
+ let held = true;
36
+ return () => {
37
+ if (!held) return;
38
+ held = false;
39
+ const left = (mine.get(conversation) ?? 1) - 1;
40
+ if (left > 0) mine.set(conversation, left);
41
+ else mine.delete(conversation);
42
+ if (mine.size === 0) this.#held.delete(principal);
43
+ };
44
+ }
45
+ }
46
+
47
+ /** At most `limit` events per key in any `perMs` window, as a log of each key's recent events. */
48
+ export class RateWindow {
49
+ readonly #limit: number;
50
+ readonly #perMs: number;
51
+ readonly #now: () => number;
52
+ readonly #events = new Map<string, number[]>();
53
+
54
+ constructor(limit: number, perMs: number, now: () => number) {
55
+ this.#limit = limit;
56
+ this.#perMs = perMs;
57
+ this.#now = now;
58
+ }
59
+
60
+ /** Records one event for `key`; false, recording nothing, when the window is full. */
61
+ take(key: string): boolean {
62
+ const now = this.#now();
63
+ const recent = (this.#events.get(key) ?? []).filter(
64
+ (at) => at > now - this.#perMs,
65
+ );
66
+ if (recent.length >= this.#limit) {
67
+ this.#events.set(key, recent);
68
+ return false;
69
+ }
70
+ recent.push(now);
71
+ this.#events.set(key, recent);
72
+ return true;
73
+ }
74
+ }