pi-roundtable-webchat 0.8.0 → 0.9.1

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,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.1] - 2026-10-10
9
+
10
+ - Release in lockstep with pi-roundtable 0.9.1; no package-specific behavior changes.
11
+
12
+ ## [0.9.0] - 2026-10-09
13
+
14
+ ### Added
15
+
16
+ - 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.
17
+ - 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.
18
+
19
+ ### Changed
20
+
21
+ - The private conversation claim declares `takesSystemReports: false`.
22
+ 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.
23
+
24
+ - 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.
25
+ - 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.
26
+
27
+ - 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.
28
+ - 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.
29
+
8
30
  ## [0.8.0] - 2026-10-07
9
31
 
10
32
  - 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 three things to a host:
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
- owner: { id: "operator", name: "Ada" },
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
- 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`.
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
- 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.
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 to name owners in the access map.
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: 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.
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 access map does not admit with 403; past the person's connection limit with 429.
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 access map does not admit 403.
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 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.
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, 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.
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, so the call stays held.
234
- - **Owner.** Nobody becomes the owner through a token's claims; list owners by speaker id.
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
- - 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.
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.8.0",
3
+ "version": "0.9.1",
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.8.0 <0.9.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.8.0",
46
+ "pi-roundtable": "0.9.1",
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 { TokenRefused, type TokenVerifier, type WebIdentity } from "./oidc.ts";
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
- * Turns one person may have running or queued at once, across their conversations; default 2.
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
- access: WebAccess;
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 tier = this.#deps.access.tierOf(identity);
235
- if (!tier) throw new Refusal("forbidden");
236
- return {
237
- identity,
238
- speaker: { id: identity.id, name: identity.name, tier },
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.id,
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.id))
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.id,
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 (record.principalId !== speaker.id || record.visibility !== "private")
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.id);
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.id) throw new Refusal("forbidden");
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.id);
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.registry().list({ principal: speaker.id });
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(identity.id))
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.identity.id !== connection.identity.id) {
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.id, frame.conversation))
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.id, conversation);
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
- if (!pending || pending.speaker.id !== message.authorId) {
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, pending);
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?.kind ?? "";
690
+ return record.kind;
570
691
  },
571
- stop: (channel) => this.#deps.runtime().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.id, {
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.id, conversation, result);
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.id, {
764
+ this.connections.sendTo(speaker.principalId, {
627
765
  type: "failed",
628
766
  conversation,
629
767
  stopped: false,