pi-roundtable-webchat 0.8.0 → 0.9.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 +18 -0
- package/README.md +55 -26
- package/package.json +3 -3
- package/src/chat.ts +169 -31
- package/src/connections.ts +4 -4
- package/src/direct-channel.ts +66 -0
- package/src/index.ts +2 -2
- package/src/notices.ts +131 -0
- package/src/oidc.ts +47 -4
- package/src/plugin.ts +25 -14
- package/src/prompts.ts +12 -12
- package/src/protocol.ts +4 -1
- package/src/rest.ts +30 -2
- package/src/surface.ts +11 -7
- package/src/tickets.ts +16 -8
- package/src/access.ts +0 -99
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.0] - 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Personal background turns and delegated reports run privately as the router-checked principal, with replies pushed only to that principal's connections; the claim rejects other targets, principals, and unregistered or shared conversations.
|
|
13
|
+
- A private inbox provider with offline `knows`, durable `webchat_notices` storage, additive `notice` frames, `GET <path>/notices` pagination, and idempotent `POST <path>/notices/<id>/read`. Inbox entries never become fake transcript turns.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- The private conversation claim declares `takesSystemReports: false`.
|
|
18
|
+
Configuring `ops.conversation` on the webchat surface now fails at startup with core `ConfigError`, even though personal background turns are supported; use a shared conversation on another surface or `ops.agent` for system error reports.
|
|
19
|
+
|
|
20
|
+
- Webchat resolves core `IDENTITY` at every token admission. Ownership, quotas, tickets, prompts and connection groups use `principalId`; `ready.speaker` adds it without changing `roundtable.webchat.v1`. A token switching principal closes with 4403, while linked identities of one principal can renew the same connection. Client frames recheck core admission and tier, so revoked admission or a relinked actor cannot keep acting on an old socket, and approvals do not use stale roles. Existing M1 conversations keep their OIDC principal ids unchanged.
|
|
21
|
+
- Removed the plugin's `access` option and `WebAccessMap` / `webAccess` exports. Configure the host's top-level `access` with surface-prefixed roles and linked identities instead; passing the removed option fails with migration guidance. OIDC verification also reports `ActorFacts` for the core resolver.
|
|
22
|
+
|
|
23
|
+
- A message the router drops after its claim admitted it (pi-roundtable 0.9's `Admission.dropped`, when the author's record finds them someone else) frees its place in the person's turn budget, as a message the claim drops does, and its person, told it was accepted, gets a `failed` frame for its conversation (`stopped: false`), as for a turn the host refused.
|
|
24
|
+
- The surface's `prompts` take the core's `PromptScope` (pi-roundtable 0.9): a prompt goes to the scope's principal, by `principalId`, when the conversation is theirs, and an approval above their `tier` expires at once without being shown, whatever the scope escalates to, since the owners are not on the web chat. Who answers is unchanged: the conversation's person only, at the tier the call needs.
|
|
25
|
+
|
|
8
26
|
## [0.8.0] - 2026-10-07
|
|
9
27
|
|
|
10
28
|
- 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.
|
package/README.md
CHANGED
|
@@ -8,11 +8,12 @@ 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 four 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
|
-
- a REST API and a WebSocket under one path of the host's HTTP listener
|
|
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
17
|
|
|
17
18
|
It needs no Discord: a host whose `roundtable.config.ts` has no `discord` key and lists this plugin is a web-only assistant.
|
|
18
19
|
`roundtable init --adapter web` creates such a project.
|
|
@@ -35,7 +36,11 @@ import type { RoundtableConfig } from "pi-roundtable";
|
|
|
35
36
|
import { oidcJwtVerifier, webChat } from "pi-roundtable-webchat";
|
|
36
37
|
|
|
37
38
|
export default {
|
|
38
|
-
|
|
39
|
+
access: {
|
|
40
|
+
owners: [{ principal: "operator", name: "Ada" }],
|
|
41
|
+
admins: { roles: ["web:role:Helpdesk.Admin"] },
|
|
42
|
+
members: { roles: ["web:role:Helpdesk.User"] },
|
|
43
|
+
},
|
|
39
44
|
database: { url: process.env.DATABASE_URL ?? "" },
|
|
40
45
|
dataDir: "./data",
|
|
41
46
|
model: "anthropic/claude-sonnet-5-5",
|
|
@@ -47,10 +52,6 @@ export default {
|
|
|
47
52
|
issuers: ["https://login.example.com/"],
|
|
48
53
|
audiences: ["api://helpdesk"],
|
|
49
54
|
}),
|
|
50
|
-
access: {
|
|
51
|
-
admins: { roles: ["Helpdesk.Admin"] },
|
|
52
|
-
members: { roles: ["Helpdesk.User"] },
|
|
53
|
-
},
|
|
54
55
|
origins: ["https://chat.example.com"],
|
|
55
56
|
personas: [
|
|
56
57
|
{
|
|
@@ -71,7 +72,6 @@ Every mistake in these options throws when the configuration loads, so `roundtab
|
|
|
71
72
|
| Option | What it sets |
|
|
72
73
|
|---|---|
|
|
73
74
|
| `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
75
|
| `personas` | The conversation kinds a person may open (`WebPersona`). |
|
|
76
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
77
|
| `listener` | The configured listener the route attaches to; default `public`, the one `http` names. |
|
|
@@ -85,16 +85,30 @@ A `WebPersona` is `{ kind, label?, prompt?, minTier?, selection? }`.
|
|
|
85
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
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
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
|
-
|
|
88
|
+
Schedules and delegated reports can run in the person's private web conversation, even after reconnect or restart.
|
|
89
|
+
Include their tools in `selection` only where intended: scheduling and delegation default to admin tier, and `notify` defaults to owner tier; top-level `toolTiers` can lower them deliberately.
|
|
90
|
+
Leave out `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 adds them to `agentSelection`.
|
|
89
91
|
The kinds `owner` and `agent` belong to the host and are refused.
|
|
90
92
|
|
|
91
93
|
### Access
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
The core [Migrating to 0.9](../../docs/migration-0.9.md) guide covers the removed access option, backfill, memory isolation, prompt differences, and rollback.
|
|
96
|
+
Use matching lockstep core and package releases; these principal contracts do not imply isolated per-person model credentials.
|
|
97
|
+
|
|
98
|
+
Use the host's top-level `access`, not a `webChat` option.
|
|
99
|
+
The removed `webChat({ access })` option fails at configuration load with migration guidance; `WebAccessMap` and `webAccess` are removed too.
|
|
100
|
+
Token roles become `web:role:<role>` (or `<surface>:role:<role>` for a custom surface), and identities are written as `oidcSpeakerId(issuer, subject)`.
|
|
101
|
+
Replace old `users` rules with `identities`, and old web owners with `access.owners: [{ name, principal?, identities: [oidcSpeakerId(...)] }]`.
|
|
102
|
+
Replace the old webchat `everyone: true` with `everyone: ["<surface>"]`, substituting this chat's configured surface (default `"web"`).
|
|
103
|
+
Core `everyone: true` opens every surface, including Discord, rather than only people this web verifier accepts.
|
|
104
|
+
No IdP role can create an owner.
|
|
105
|
+
A person the core policy gives no tier is not admitted: no ticket, no socket, no conversation, no turn.
|
|
106
|
+
|
|
107
|
+
Each successful contact resolves through `IDENTITY`.
|
|
108
|
+
An admitted new person gets an opaque `p_` principal; existing M1 users claim their backfilled OIDC principal and keep their private conversations without rewriting them.
|
|
109
|
+
Ownership, limits, tickets, approvals and connection groups use `principalId`, not the external actor id.
|
|
110
|
+
Link another identity with `roundtable principal link <principal> <identity>` to share memory across Discord and web; the CLI reports the host's cache delay.
|
|
111
|
+
A fresh token may name another linked actor of the same principal, but a changed principal closes the socket with 4403.
|
|
98
112
|
|
|
99
113
|
### `oidcJwtVerifier`
|
|
100
114
|
|
|
@@ -116,7 +130,10 @@ A map that admits no one throws.
|
|
|
116
130
|
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
131
|
A refused token throws `TokenRefused`, whose `reason` goes to the host's log and never to the client.
|
|
118
132
|
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
|
|
133
|
+
Use it in the host's `access.owners[].identities` or with `roundtable principal link`.
|
|
134
|
+
The verifier also reports `ActorFacts` with the canonical OIDC provider, subject, name, prefixed roles and legacy id.
|
|
135
|
+
A custom verifier may supply `WebIdentity.actor` only with a provider beginning `oidc:` or equal to this chat's configured surface; `token`, `discord`, or another surface's provider is refused with `TokenRefused` before core identity resolution.
|
|
136
|
+
Otherwise the chat derives these facts from its id.
|
|
120
137
|
|
|
121
138
|
### Provider settings
|
|
122
139
|
|
|
@@ -128,7 +145,7 @@ Microsoft Entra ID is the example below, and the same questions apply to any pro
|
|
|
128
145
|
- **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
146
|
- **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
147
|
- **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:
|
|
148
|
+
- **Mind who `everyone` admits.** `everyone: ["<surface>"]` admits every person the verifier accepts on that surface, guest accounts of your tenant included; core `everyone: true` also admits people on every other surface. 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
149
|
|
|
133
150
|
```ts
|
|
134
151
|
const tenant = process.env.ENTRA_TENANT_ID ?? "";
|
|
@@ -151,7 +168,7 @@ The token is never read from a URL.
|
|
|
151
168
|
- **Another client**, such as a service, sends `Authorization: Bearer <token>` on the upgrade and offers `roundtable.webchat.v1`.
|
|
152
169
|
|
|
153
170
|
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
|
|
171
|
+
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.
|
|
155
172
|
|
|
156
173
|
```js
|
|
157
174
|
const { ticket } = await (
|
|
@@ -187,7 +204,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
187
204
|
|
|
188
205
|
| Frame | Meaning |
|
|
189
206
|
|---|---|
|
|
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. |
|
|
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. |
|
|
191
208
|
| `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
|
|
192
209
|
| `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
|
|
193
210
|
| `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
|
|
@@ -196,6 +213,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
196
213
|
| `{ 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
214
|
| `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message }`, or `{ id, kind: "ask", title, question, options, multi, allowOther }`. |
|
|
198
215
|
| `{ type: "prompt_closed", conversation, prompt, outcome }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, or `cancelled` (the turn stopped). |
|
|
216
|
+
| `{ type: "notice", notice: { id, text, createdAt, readAt } }` | A durable private inbox entry; `readAt` is `null` until read. Fetch the REST inbox to recover entries missed while offline. |
|
|
199
217
|
| `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
|
|
200
218
|
| `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
|
|
201
219
|
|
|
@@ -206,7 +224,7 @@ The host's own limits close with `1008` (too many frames), `1009` (a frame too b
|
|
|
206
224
|
|
|
207
225
|
## REST API
|
|
208
226
|
|
|
209
|
-
Every call sends `Authorization: Bearer <token>`; a missing or refused token gets 401 with `WWW-Authenticate: Bearer`, and a person the
|
|
227
|
+
Every call sends `Authorization: Bearer <token>`; a missing or refused token gets 401 with `WWW-Authenticate: Bearer`, and a person the core policy does not admit 403.
|
|
210
228
|
A request from a browser origin not in `origins` gets 403; an allowed origin gets CORS headers and its preflight is answered.
|
|
211
229
|
|
|
212
230
|
| Call | Answer |
|
|
@@ -215,23 +233,34 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
|
|
|
215
233
|
| `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
|
|
216
234
|
| `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
|
|
217
235
|
| `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
|
|
236
|
+
| `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
|
+
| `POST <path>/notices/<id>/read` | `{ notice }` with `readAt` set. Idempotent; unknown ids or another principal's notice return 404. |
|
|
238
|
+
|
|
239
|
+
Inbox text is truncated with `…` to at most 4096 UTF-16 units, or `messageChars` if smaller.
|
|
240
|
+
The cap is reduced further for a small `maxBufferedBytes`, reserving 256 bytes for the notice frame and allowing six JSON bytes per text unit; configurations below 262 bytes are refused.
|
|
241
|
+
Each principal retains only its newest 100 notices per surface, pruning oldest entries atomically with each insert (including concurrent deliveries).
|
|
242
|
+
List, read acknowledgements, pagination cursors and retention are isolated by surface as well as principal.
|
|
243
|
+
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
|
+
A REST notice page therefore contains at most 100 bounded entries, less than 2.5 MiB at the default text cap.
|
|
218
245
|
|
|
219
246
|
## Security model
|
|
220
247
|
|
|
221
248
|
- **Private conversations.** A conversation belongs to the person who opened it.
|
|
222
|
-
The host's conversation registry records its
|
|
249
|
+
The host's conversation registry records its principal before runtime use, including transcript reads, 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
250
|
Conversation ids are random UUIDs, and the claim runs only messages this plugin accepted from a verified socket.
|
|
224
251
|
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
252
|
- **Tokens.** Tokens are checked on every REST call, every upgrade, and every `auth` frame, and never read from a URL.
|
|
226
253
|
By default only a person's access token passes: one without a scope or app roles, or an app-only one, is refused.
|
|
227
254
|
A socket is closed when its token expires.
|
|
255
|
+
Every valid client frame rechecks the stored verified facts against core identity: revoked admission or a changed principal closes with 4403, and approvals use the current tier, subject to the core's identity-cache delay.
|
|
228
256
|
- **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
257
|
- **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
|
|
258
|
+
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
|
+
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.
|
|
231
260
|
Each person opens at most `newConversationsPerHour` conversations an hour, over the socket or the REST API alike.
|
|
232
261
|
- **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
|
|
234
|
-
- **Owner.** Nobody becomes the owner through a token's claims;
|
|
262
|
+
A card whose tier the person lacks is never shown: its prompt resolves `expired`, without owner escalation.
|
|
263
|
+
- **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
|
|
235
264
|
Owner-tier tools stay out of web turns unless an owner is chatting.
|
|
236
265
|
|
|
237
266
|
## Limits
|
|
@@ -254,10 +283,10 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
|
|
|
254
283
|
## What it does not do yet
|
|
255
284
|
|
|
256
285
|
- Attachments: messages carry text only.
|
|
257
|
-
-
|
|
258
|
-
|
|
286
|
+
- 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
|
+
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
|
+
Use a shared conversation on another surface, such as a Discord channel, or `ops.agent`.
|
|
259
289
|
- 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
290
|
|
|
262
291
|
## Testing
|
|
263
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-webchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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",
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"jose": "6.2.12"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"pi-roundtable": ">=0.
|
|
39
|
+
"pi-roundtable": ">=0.9.0 <0.10.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "2.5.15",
|
|
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.
|
|
46
|
+
"pi-roundtable": "0.9.0",
|
|
47
47
|
"typebox": "1.3.34",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
package/src/chat.ts
CHANGED
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
type ConversationRegistry,
|
|
8
8
|
type ConversationTurns,
|
|
9
9
|
channelKey,
|
|
10
|
+
type IdentityService,
|
|
10
11
|
type Logger,
|
|
12
|
+
PERSONAL_TARGET,
|
|
11
13
|
type RouteSocket,
|
|
12
14
|
type Speaker,
|
|
13
15
|
TIERS,
|
|
@@ -16,10 +18,14 @@ import {
|
|
|
16
18
|
type TranscriptEntry,
|
|
17
19
|
type TurnResult,
|
|
18
20
|
} from "pi-roundtable";
|
|
19
|
-
import type { WebAccess } from "./access.ts";
|
|
20
21
|
import { RateWindow, TurnBudget } from "./budget.ts";
|
|
21
22
|
import { type Connection, Connections } from "./connections.ts";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
identityActor,
|
|
25
|
+
TokenRefused,
|
|
26
|
+
type TokenVerifier,
|
|
27
|
+
type WebIdentity,
|
|
28
|
+
} from "./oidc.ts";
|
|
23
29
|
import { PromptDesk } from "./prompts.ts";
|
|
24
30
|
import {
|
|
25
31
|
CLOSE_CODES,
|
|
@@ -58,7 +64,7 @@ export interface WebChatLimits {
|
|
|
58
64
|
/** New conversations one person may open in any hour, written in or not; default 60. */
|
|
59
65
|
newConversationsPerHour: number;
|
|
60
66
|
/**
|
|
61
|
-
*
|
|
67
|
+
* Interactive turns one person may have running or queued at once, across their conversations; default 2.
|
|
62
68
|
* A conversation also holds at most its running turn and one queued behind it.
|
|
63
69
|
*/
|
|
64
70
|
turnsPerPrincipal: number;
|
|
@@ -73,7 +79,7 @@ export interface WebChatLimits {
|
|
|
73
79
|
export interface WebChatDeps {
|
|
74
80
|
surface: string;
|
|
75
81
|
verifier: TokenVerifier;
|
|
76
|
-
|
|
82
|
+
identity(): IdentityService;
|
|
77
83
|
personas: readonly WebPersona[];
|
|
78
84
|
limits: WebChatLimits;
|
|
79
85
|
logger: Logger;
|
|
@@ -183,6 +189,8 @@ export class WebChat {
|
|
|
183
189
|
readonly #minted = new Map<string, Minted>();
|
|
184
190
|
/** Whose each conversation is, once its person wrote in it during this process. */
|
|
185
191
|
readonly #owners = new Map<string, string>();
|
|
192
|
+
/** Keys proved registered private before any direct runtime operation. */
|
|
193
|
+
readonly #registered = new Set<ChannelKey>();
|
|
186
194
|
/** Accepted messages waiting for the claim, by message id. */
|
|
187
195
|
readonly #pending = new Map<string, Pending>();
|
|
188
196
|
/** Each person's turns, running or queued. */
|
|
@@ -230,13 +238,12 @@ export class WebChat {
|
|
|
230
238
|
return this.admitIdentity(identity);
|
|
231
239
|
}
|
|
232
240
|
|
|
233
|
-
admitIdentity(identity: WebIdentity): Admitted {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
};
|
|
241
|
+
async admitIdentity(identity: WebIdentity): Promise<Admitted> {
|
|
242
|
+
const speaker = await this.#deps
|
|
243
|
+
.identity()
|
|
244
|
+
.resolve(identityActor(identity, this.#deps.surface));
|
|
245
|
+
if (!speaker) throw new Refusal("forbidden");
|
|
246
|
+
return { identity, speaker };
|
|
240
247
|
}
|
|
241
248
|
|
|
242
249
|
/** The personas a speaker may open. */
|
|
@@ -260,16 +267,16 @@ export class WebChat {
|
|
|
260
267
|
open(speaker: Speaker, kind: string, title?: string): string {
|
|
261
268
|
this.#persona(kind, speaker);
|
|
262
269
|
const unused = [...this.#minted.values()].filter(
|
|
263
|
-
(minted) => minted.principal === speaker.
|
|
270
|
+
(minted) => minted.principal === speaker.principalId,
|
|
264
271
|
).length;
|
|
265
272
|
if (unused >= this.#deps.limits.unusedConversationsPerPrincipal)
|
|
266
273
|
throw new Refusal("too_many_conversations");
|
|
267
|
-
if (!this.#opened.take(speaker.
|
|
274
|
+
if (!this.#opened.take(speaker.principalId))
|
|
268
275
|
throw new Refusal("too_many_conversations");
|
|
269
276
|
const id = crypto.randomUUID();
|
|
270
277
|
const cut = title === undefined ? undefined : titleOf(title);
|
|
271
278
|
this.#minted.set(id, {
|
|
272
|
-
principal: speaker.
|
|
279
|
+
principal: speaker.principalId,
|
|
273
280
|
persona: kind,
|
|
274
281
|
...(cut ? { title: cut } : {}),
|
|
275
282
|
});
|
|
@@ -292,24 +299,31 @@ export class WebChat {
|
|
|
292
299
|
.registry()
|
|
293
300
|
.get(channelKey(this.#deps.surface, conversation));
|
|
294
301
|
if (record) {
|
|
295
|
-
if (
|
|
302
|
+
if (
|
|
303
|
+
record.principalId !== speaker.principalId ||
|
|
304
|
+
record.visibility !== "private"
|
|
305
|
+
)
|
|
296
306
|
throw new Refusal("forbidden");
|
|
297
307
|
const persona = this.#persona(record.kind, speaker);
|
|
298
|
-
this.#owners.set(conversation, speaker.
|
|
308
|
+
this.#owners.set(conversation, speaker.principalId);
|
|
309
|
+
this.#registered.add(record.key);
|
|
299
310
|
return { persona, record };
|
|
300
311
|
}
|
|
301
312
|
const minted = this.#minted.get(conversation);
|
|
302
313
|
if (!minted) throw new Refusal("unknown_conversation");
|
|
303
|
-
if (minted.principal !== speaker.
|
|
314
|
+
if (minted.principal !== speaker.principalId)
|
|
315
|
+
throw new Refusal("forbidden");
|
|
304
316
|
const persona = this.#persona(minted.persona, speaker);
|
|
305
317
|
// The surface learns whose a conversation is from each check that proves it.
|
|
306
|
-
this.#owners.set(conversation, speaker.
|
|
318
|
+
this.#owners.set(conversation, speaker.principalId);
|
|
307
319
|
return { persona, minted };
|
|
308
320
|
}
|
|
309
321
|
|
|
310
322
|
/** The speaker's web conversations, the most recently active first. */
|
|
311
323
|
async list(speaker: Speaker): Promise<ConversationRecord[]> {
|
|
312
|
-
const records = await this.#deps
|
|
324
|
+
const records = await this.#deps
|
|
325
|
+
.registry()
|
|
326
|
+
.list({ principal: speaker.principalId });
|
|
313
327
|
return records.filter(
|
|
314
328
|
(record) =>
|
|
315
329
|
record.surface === this.#deps.surface &&
|
|
@@ -349,7 +363,7 @@ export class WebChat {
|
|
|
349
363
|
personas: this.personasFor(speaker),
|
|
350
364
|
expiresAt: identity.expiresAt.toISOString(),
|
|
351
365
|
});
|
|
352
|
-
for (const frame of this.desk.openFor(
|
|
366
|
+
for (const frame of this.desk.openFor(speaker.principalId))
|
|
353
367
|
this.connections.send(connection, frame);
|
|
354
368
|
this.#watchToken(connection);
|
|
355
369
|
}
|
|
@@ -419,6 +433,23 @@ export class WebChat {
|
|
|
419
433
|
}
|
|
420
434
|
|
|
421
435
|
async #handle(connection: Connection, frame: ClientFrame): Promise<void> {
|
|
436
|
+
// Token facts remain verified until expiry, but linked principals and persistent roles can
|
|
437
|
+
// change while the socket is open. In particular, an approval must use the current tier.
|
|
438
|
+
if (frame.type !== "auth") {
|
|
439
|
+
let current: Speaker;
|
|
440
|
+
try {
|
|
441
|
+
current = (await this.admitIdentity(connection.identity)).speaker;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (!(error instanceof Refusal)) throw error;
|
|
444
|
+
connection.socket?.close(CLOSE_CODES.notAdmitted, "not admitted");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (current.principalId !== connection.speaker.principalId) {
|
|
448
|
+
connection.socket?.close(CLOSE_CODES.notAdmitted, "another person");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
connection.speaker = current;
|
|
452
|
+
}
|
|
422
453
|
const { speaker } = connection;
|
|
423
454
|
switch (frame.type) {
|
|
424
455
|
case "auth":
|
|
@@ -472,7 +503,7 @@ export class WebChat {
|
|
|
472
503
|
throw error;
|
|
473
504
|
}
|
|
474
505
|
// A connection belongs to one person: a token for someone else ends it.
|
|
475
|
-
if (admitted.
|
|
506
|
+
if (admitted.speaker.principalId !== connection.speaker.principalId) {
|
|
476
507
|
connection.socket?.close(CLOSE_CODES.notAdmitted, "another person");
|
|
477
508
|
return;
|
|
478
509
|
}
|
|
@@ -490,12 +521,12 @@ export class WebChat {
|
|
|
490
521
|
if (!text.trim() || text.length > this.#deps.limits.messageChars)
|
|
491
522
|
throw new Refusal("bad_frame");
|
|
492
523
|
// Checked before a conversation is opened for it, so a refused message opens none.
|
|
493
|
-
if (!this.#turns.allows(speaker.
|
|
524
|
+
if (!this.#turns.allows(speaker.principalId, frame.conversation))
|
|
494
525
|
throw new Refusal("busy");
|
|
495
526
|
const conversation =
|
|
496
527
|
frame.conversation ?? this.open(speaker, frame.persona as string);
|
|
497
528
|
const { persona, record, minted } = await this.own(speaker, conversation);
|
|
498
|
-
const release = this.#turns.take(speaker.
|
|
529
|
+
const release = this.#turns.take(speaker.principalId, conversation);
|
|
499
530
|
if (!release) {
|
|
500
531
|
// Another message took the last place meanwhile.
|
|
501
532
|
if (!frame.conversation) this.#minted.delete(conversation);
|
|
@@ -518,6 +549,7 @@ export class WebChat {
|
|
|
518
549
|
this.surface.deliver({
|
|
519
550
|
channel: channelKey(this.#deps.surface, conversation),
|
|
520
551
|
messageId,
|
|
552
|
+
actor: identityActor(connection.identity, this.#deps.surface),
|
|
521
553
|
authorId: speaker.id,
|
|
522
554
|
authorName: speaker.name,
|
|
523
555
|
authorIsBot: false,
|
|
@@ -543,32 +575,122 @@ export class WebChat {
|
|
|
543
575
|
return {
|
|
544
576
|
name: `webchat:${surface}`,
|
|
545
577
|
priority: 10,
|
|
578
|
+
postsInPlace: true,
|
|
579
|
+
takesSystemReports: false,
|
|
546
580
|
owns: (channel) => channel.startsWith(`${surface}:`),
|
|
547
581
|
admit: (message) => {
|
|
548
582
|
const pending = this.#pending.get(message.messageId);
|
|
549
583
|
this.#pending.delete(message.messageId);
|
|
550
|
-
|
|
584
|
+
const speaker = message.speaker;
|
|
585
|
+
if (
|
|
586
|
+
!pending ||
|
|
587
|
+
pending.speaker.id !== message.authorId ||
|
|
588
|
+
!speaker ||
|
|
589
|
+
speaker.principalId !== pending.speaker.principalId
|
|
590
|
+
) {
|
|
551
591
|
pending?.release();
|
|
592
|
+
if (pending)
|
|
593
|
+
this.connections.sendTo(pending.speaker.principalId, {
|
|
594
|
+
type: "failed",
|
|
595
|
+
conversation: this.surface.conversationOf(message.channel),
|
|
596
|
+
stopped: false,
|
|
597
|
+
});
|
|
552
598
|
return undefined;
|
|
553
599
|
}
|
|
554
600
|
return {
|
|
555
601
|
kind: "turn",
|
|
556
602
|
run: async () => {
|
|
557
603
|
try {
|
|
558
|
-
await this.#turn(message.channel, message.text,
|
|
604
|
+
await this.#turn(message.channel, message.text, {
|
|
605
|
+
...pending,
|
|
606
|
+
speaker,
|
|
607
|
+
});
|
|
559
608
|
} finally {
|
|
560
609
|
pending.release();
|
|
561
610
|
}
|
|
562
611
|
},
|
|
612
|
+
// Dropped by the router after all: the message never runs, so its place is free, and its
|
|
613
|
+
// person, told it was accepted, is told it failed, as for a turn the host refused.
|
|
614
|
+
dropped: () => {
|
|
615
|
+
pending.release();
|
|
616
|
+
this.connections.sendTo(pending.speaker.principalId, {
|
|
617
|
+
type: "failed",
|
|
618
|
+
conversation: this.surface.conversationOf(message.channel),
|
|
619
|
+
stopped: false,
|
|
620
|
+
});
|
|
621
|
+
},
|
|
563
622
|
failure: "a web chat turn failed",
|
|
564
623
|
};
|
|
565
624
|
},
|
|
625
|
+
background: async (turn) => {
|
|
626
|
+
if (turn.target !== PERSONAL_TARGET.name)
|
|
627
|
+
return {
|
|
628
|
+
status: "skipped",
|
|
629
|
+
reason: "webchat takes only personal background turns",
|
|
630
|
+
};
|
|
631
|
+
const { speaker } = turn;
|
|
632
|
+
if (!speaker || speaker.principalId !== turn.author.principalId)
|
|
633
|
+
return {
|
|
634
|
+
status: "skipped",
|
|
635
|
+
reason: "webchat needs the router's checked principal",
|
|
636
|
+
};
|
|
637
|
+
const conversation = this.surface.conversationOf(turn.channel);
|
|
638
|
+
const record = await this.#deps.registry().get(turn.channel);
|
|
639
|
+
if (
|
|
640
|
+
record?.visibility !== "private" ||
|
|
641
|
+
record.principalId !== turn.author.principalId
|
|
642
|
+
)
|
|
643
|
+
return {
|
|
644
|
+
status: "skipped",
|
|
645
|
+
reason:
|
|
646
|
+
"the background author does not own this private conversation",
|
|
647
|
+
};
|
|
648
|
+
let persona: WebPersona;
|
|
649
|
+
try {
|
|
650
|
+
persona = (await this.own(speaker, conversation)).persona;
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (!(error instanceof Refusal)) throw error;
|
|
653
|
+
return { status: "skipped", reason: error.code };
|
|
654
|
+
}
|
|
655
|
+
// Background work has already been admitted by core. Its runtime queue is
|
|
656
|
+
// separate from interactive admission: a busy browser must not discard
|
|
657
|
+
// a completed delegation or scheduled reminder. Runtime owns stop/shutdown.
|
|
658
|
+
const result = await this.#deps.turns().run({
|
|
659
|
+
channel: turn.channel,
|
|
660
|
+
kind: persona.kind,
|
|
661
|
+
text: turn.text,
|
|
662
|
+
speaker,
|
|
663
|
+
interactive: turn.report === true,
|
|
664
|
+
conversation: { visibility: "private" },
|
|
665
|
+
...(persona.selection
|
|
666
|
+
? {
|
|
667
|
+
selection: {
|
|
668
|
+
id: `webchat:${persona.kind}`,
|
|
669
|
+
...persona.selection,
|
|
670
|
+
},
|
|
671
|
+
}
|
|
672
|
+
: {}),
|
|
673
|
+
reply: async (result) =>
|
|
674
|
+
this.#reply(speaker.principalId, conversation, result),
|
|
675
|
+
});
|
|
676
|
+
return result.ok
|
|
677
|
+
? { status: "ran" }
|
|
678
|
+
: { status: "failed", error: "a webchat background turn failed" };
|
|
679
|
+
},
|
|
566
680
|
startFresh: async (channel) => {
|
|
567
681
|
const record = await this.#deps.registry().get(channel);
|
|
682
|
+
if (record?.visibility !== "private" || !record.principalId)
|
|
683
|
+
throw new Refusal("unknown_conversation");
|
|
684
|
+
this.#owners.set(
|
|
685
|
+
this.surface.conversationOf(channel),
|
|
686
|
+
record.principalId,
|
|
687
|
+
);
|
|
688
|
+
this.#registered.add(channel);
|
|
568
689
|
await this.#deps.runtime().startFresh(channel);
|
|
569
|
-
return record
|
|
690
|
+
return record.kind;
|
|
570
691
|
},
|
|
571
|
-
stop: (channel) =>
|
|
692
|
+
stop: (channel) =>
|
|
693
|
+
this.#registered.has(channel) && this.#deps.runtime().stop(channel),
|
|
572
694
|
};
|
|
573
695
|
}
|
|
574
696
|
|
|
@@ -580,15 +702,16 @@ export class WebChat {
|
|
|
580
702
|
): Promise<void> {
|
|
581
703
|
const { speaker, persona } = pending;
|
|
582
704
|
const conversation = this.surface.conversationOf(channel);
|
|
705
|
+
let registered = false;
|
|
583
706
|
try {
|
|
584
|
-
await this.own(speaker, conversation);
|
|
707
|
+
registered = (await this.own(speaker, conversation)).record !== undefined;
|
|
585
708
|
} catch (error) {
|
|
586
709
|
if (!(error instanceof Refusal)) throw error;
|
|
587
710
|
this.#deps.logger.warn(
|
|
588
711
|
{ channel, code: error.code },
|
|
589
712
|
"a web chat message was refused before its turn",
|
|
590
713
|
);
|
|
591
|
-
this.connections.sendTo(speaker.
|
|
714
|
+
this.connections.sendTo(speaker.principalId, {
|
|
592
715
|
type: "error",
|
|
593
716
|
code: error.code,
|
|
594
717
|
});
|
|
@@ -596,6 +719,21 @@ export class WebChat {
|
|
|
596
719
|
}
|
|
597
720
|
let answered = false;
|
|
598
721
|
try {
|
|
722
|
+
if (!registered) {
|
|
723
|
+
const kept = await this.#deps.registry().register({
|
|
724
|
+
key: channel,
|
|
725
|
+
kind: persona.kind,
|
|
726
|
+
visibility: "private",
|
|
727
|
+
principalId: speaker.principalId,
|
|
728
|
+
...(pending.title ? { title: pending.title } : {}),
|
|
729
|
+
});
|
|
730
|
+
if (
|
|
731
|
+
kept.visibility !== "private" ||
|
|
732
|
+
kept.principalId !== speaker.principalId
|
|
733
|
+
)
|
|
734
|
+
throw new Refusal("forbidden");
|
|
735
|
+
}
|
|
736
|
+
this.#registered.add(channel);
|
|
599
737
|
await this.#deps.turns().run({
|
|
600
738
|
channel,
|
|
601
739
|
kind: persona.kind,
|
|
@@ -616,14 +754,14 @@ export class WebChat {
|
|
|
616
754
|
},
|
|
617
755
|
reply: async (result) => {
|
|
618
756
|
answered = true;
|
|
619
|
-
this.#reply(speaker.
|
|
757
|
+
this.#reply(speaker.principalId, conversation, result);
|
|
620
758
|
},
|
|
621
759
|
});
|
|
622
760
|
} catch (error) {
|
|
623
761
|
// A turn the host refused before it ran, such as one whose conversation could not be
|
|
624
762
|
// recorded, has no reply: the person is told it failed, and the router logs the cause.
|
|
625
763
|
if (!answered)
|
|
626
|
-
this.connections.sendTo(speaker.
|
|
764
|
+
this.connections.sendTo(speaker.principalId, {
|
|
627
765
|
type: "failed",
|
|
628
766
|
conversation,
|
|
629
767
|
stopped: false,
|