pi-roundtable-web 0.7.19 → 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 CHANGED
@@ -5,6 +5,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.0] - 2026-10-09
9
+
10
+ - Admit only owners, each as their own principal: the verifier reports who signed in (`Verdict.actor`, built with `admitAs`), and the console reads the host's `IDENTITY`, never making or claiming a principal. An identity linked to anyone but an owner is refused, and so is one linked to no one, on a single-owner host too, with one warning per identity that names the line to add to `access.owners[].identities` and the `roundtable principal link` command. An identity of the `legacy` provider, an alias 0.9 keeps for a 0.8 id, is refused. A verifier that reports no actor is taken as the primary owner, or `ownerId`, with one warning. The plugin now requires `IDENTITY`.
11
+ **Upgrading:** add your Access identity, `cloudflareAccessIdentity(teamDomain, sub)`, to your entry in `access.owners[].identities`, or the console answers 403 and logs the identity to add; see the README's "Upgrading from 0.8".
12
+ - `cloudflareAccess` reports the user as `oidc:<base64url(https://<teamDomain>)>:<sub>`, named by their email, and refuses a token that names no user; `cloudflareAccessIdentity(teamDomain, sub)` writes that identity for `access.owners[].identities`.
13
+ - The Notes pane shows the signed-in owner's own notes, and any principal's through a picker (`GET api/principals`, `?principal=<id>` on the notes API). `ownerId` is no longer required and is deprecated.
14
+ - A conversation the host's registry records shows whose it is, by display name, and whether it is private or shared (`ConversationView.principal`, `visibility`).
15
+
16
+ ## [0.8.0] - 2026-10-07
17
+
18
+ - List the conversations the host's registry (`CONVERSATIONS`) records beside the ones found by their directory names, under a new "Plugin conversations" section with their title and first message, and read their transcripts. A conversation from before the registry is still found by name.
19
+
8
20
  ## [0.7.18] - 2026-10-05
9
21
 
10
22
  - Release in lockstep with pi-roundtable 0.7.18; no package-specific behavior changes.
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # pi-roundtable-web
2
2
 
3
- An owner-only web console for [pi-roundtable][roundtable].
4
- It serves one page on a route of the host's HTTP listener, where the owner can browse conversations, read their transcripts, view and edit the assistant's memory notes, and watch the agent team, with live updates.
3
+ A web console for the owners of a [pi-roundtable][roundtable] host.
4
+ It serves one page on a route of the host's HTTP listener, where an owner can browse conversations, read their transcripts, view and edit the assistant's memory notes, and watch the agent team, with live updates.
5
5
 
6
6
  The console reads what the host already stores: the conversation files under the data directory, the memory addon, and the agent team.
7
- It adds no database, no cookie, and no account system.
8
- Every request must pass an authentication check that you configure, and the plugin refuses to start without one.
7
+ It adds no database, no cookie, and no account system of its own: who may enter is the host's `access` configuration.
8
+ Every request must pass an authentication check that you configure, and the plugin refuses to start without one; then only a person whose principal holds the owner role gets in.
9
9
 
10
10
  ## What the console shows
11
11
 
@@ -15,8 +15,8 @@ The `panes` option chooses which ones a host serves.
15
15
  | Pane | What it shows |
16
16
  |---|---|
17
17
  | Overview | Every active agent with its model, thinking level, state (working where, waiting, or idle), context use, schedule count, and last activity; every group with its members, host, and busy count. An agent links to its Discord channel and its transcript, and a group to its Discord channel. |
18
- | Conversations | Every conversation stored on disk, in four sections: owner channels (direct messages and channels where the owner talks to the assistant), agent channels, group conversations (each member's conversation inside a group), and outside-agent conversations opened over MCP. Each row shows the channel's name (when the host has a Discord connection), when it was last active, its size, and how many archives it has. An outside-agent conversation shows its first message, when it appears in the first 256 KB of the file, and when it began. |
19
- | Notes | The owner's memory in three tabs, core, notes, and events, with search. The owner can add a note, edit its text, kind, and date, and delete it after a confirmation. The memory addon's own validation applies, so a refusal shows its reason and saves nothing. |
18
+ | Conversations | Every conversation stored on disk, in four sections: owner channels (direct messages and channels where the owner talks to the assistant), agent channels, group conversations (each member's conversation inside a group), and outside-agent conversations opened over MCP. Each row shows the channel's name (when the host has a Discord connection), when it was last active, its size, and how many archives it has. An outside-agent conversation shows its first message, when it appears in the first 256 KB of the file, and when it began. A conversation the host's registry records also shows whose it is, by their display name, and whether it is private to them or shared. Every owner sees every conversation. |
19
+ | Notes | A principal's memory in three tabs, core, notes, and events, with search: the signed-in owner's own by default, and any principal's the host knows through a picker that appears when there is more than one. The owner can add a note, edit its text, kind, and date, and delete it after a confirmation, in whichever memory is shown. The memory addon's own validation applies, so a refusal shows its reason and saves nothing. |
20
20
  | Skills | The full host catalog, source, groups and carriers; each available skill opens its ordered frontmatter and Markdown body. |
21
21
  | Connectors | Upstream gateway enabled/reachable/tool status, virtual-server tool lists, and the agents or profiles that use them; an optional administration link. |
22
22
 
@@ -39,10 +39,12 @@ Without the cleanup hook, conversations remain read-only.
39
39
 
40
40
  ## Requirements
41
41
 
42
- - Bun 1.3 or later, and pi-roundtable `>=0.7.0 <0.8.0` as a peer dependency.
42
+ - Bun 1.3 or later, and pi-roundtable `>=0.8.0 <0.9.0` as a peer dependency.
43
43
  - The host must run on the machine that holds its data directory: the console reads `<dataDir>/sessions` from disk.
44
44
  - The `memory` addon for the Notes pane (it is on by default; switch the pane off with `panes` when you run without memory).
45
- - A way to authenticate the owner in front of the listener, such as Cloudflare Access, or a verifier of your own (see [Authentication](#authentication)).
45
+ - A way to authenticate the owners in front of the listener, such as Cloudflare Access, or a verifier of your own (see [Authentication](#authentication)).
46
+ - Each person who uses the console is an owner in the host's `access` configuration, with the identity the verifier reports for them among their `identities` or linked to them with `roundtable principal link`.
47
+ This holds on a host with a single owner too: the console refuses an identity linked to no one.
46
48
  - A Discord connection is optional: with the Discord entry's `DISCORD` service the console names channels, and without one it shows channel ids.
47
49
 
48
50
  The package ships its page prebuilt in `dist/`, so installing it needs no build step and no bundler on the host.
@@ -70,22 +72,23 @@ export default {
70
72
  email: "owner@example.com",
71
73
  }),
72
74
  origin: "https://console.example.com",
73
- ownerId: "<the owner's Discord user id>",
74
75
  dataDir: "./data",
75
76
  }),
76
77
  ],
77
78
  } satisfies RoundtableConfig;
78
79
  ```
79
80
 
81
+ The owner signs in with the identity `cloudflareAccess` reports for them, so add it to their entry in `access.owners`, next to their Discord identity: see [Cloudflare Access](#cloudflare-access) for how to find it.
82
+
80
83
  The plugin adds the route `/console` (a redirect to `/console/`) and `/console/…` to the `public` listener, and a line with the console's address to the dashboard message.
81
84
 
82
85
  ## Options
83
86
 
84
87
  | Option | Default | What it does |
85
88
  |---|---|---|
86
- | `verifier` | none, required | Decides whether a request comes from the owner. `cloudflareAccess(...)` is built in; pass your own function to use another proxy. |
89
+ | `verifier` | none, required | Decides whether a request comes from someone your proxy authenticated, and reports who. `cloudflareAccess(...)` is built in; pass your own function to use another proxy. |
87
90
  | `origin` | none, required | The console's own origin as the browser sees it, such as `https://console.example.com`: scheme and host (and port), no path. Requests that change data must carry it as their `Origin`, and the dashboard line links to it. |
88
- | `ownerId` | none | The id the owner's memory is kept under. Required when the Notes pane is served. |
91
+ | `ownerId` | the primary owner | Deprecated. The owner's principal id that a request from a verifier reporting no actor stands for. The Notes pane no longer needs it: it shows each owner their own notes. |
89
92
  | `dataDir` | `./data` | The host's data directory; the console reads `<dataDir>/sessions`. Set it to the `dataDir` of your configuration. |
90
93
  | `mountPath` | `/console` | The path the console is served under: one or more segments of letters, digits, `.`, `_`, `~`, and `-`. |
91
94
  | `listener` | `public` | The id of the listener whose address serves the console. |
@@ -97,7 +100,7 @@ The plugin adds the route `/console` (a redirect to `/console/`) and `/console/
97
100
  | `relayNotes` | the remote MCP note | Text that an outside agent's relayed message begins with, taken off before the owner's words are shown. Set it to the same value as the `relayNote` of [pi-roundtable-mcp][mcp] when you changed that one. |
98
101
  | `exclude` | none | A function from a channel key to `true` for conversations the console must neither list nor read, such as the channels of another plugin that keeps its own sessions in the same directory. |
99
102
 
100
- The options are checked when the plugin is created, so a bad setting stops the host at startup: no verifier, an `origin` with a path, a `mountPath` of `/` or with `..` in it, an unknown or repeated pane, or Notes without an `ownerId`.
103
+ The options are checked when the plugin is created, so a bad setting stops the host at startup: no verifier, an `origin` with a path, a `mountPath` of `/` or with `..` in it, an unknown or repeated pane, or an empty `ownerId`.
101
104
 
102
105
  The console also needs the agent server for the Overview pane and the memory addon for the Notes pane.
103
106
  The host refuses to start with a message naming the missing service when one of them is absent for a pane you serve.
@@ -142,18 +145,48 @@ When serving Skills and Connectors, provide those hooks as well; a missing port
142
145
 
143
146
  ## Authentication
144
147
 
145
- The console never decides who the owner is.
146
- It asks the `verifier` about every request under its path, before it reads anything, and answers `403 Forbidden` with no detail when the verifier refuses, throws, or rejects.
148
+ The console never decides who someone is, and never makes an account.
149
+ It asks the `verifier` about every request under its path, before it reads anything; the verifier says whether your proxy authenticated the person and reports who they are.
150
+ Then the console looks them up in the host's `IDENTITY`, reading only, and admits them only when their principal holds the owner role.
151
+ It answers `403 Forbidden` with no detail when the verifier refuses, throws, or rejects, when the lookup fails, and when the person is no owner.
147
152
 
148
- A verifier is a function that receives the `Request` and returns `{ admitted: true }` or `{ admitted: false, reason }`.
153
+ A verifier is a function that receives the `Request` and returns `{ admitted: true, actor }` or `{ admitted: false, reason }`.
154
+ `actor` describes the person as an identity, `{ provider, subject, name }`, written `<provider>:<subject>` in `access.owners[].identities` and by `roundtable principal link`.
149
155
  The reason goes to the host's log and is never sent to the client, so keep secrets out of it.
150
- `admit()` and `refuse(reason)` build the two answers.
156
+ `admitAs(actor)` and `refuse(reason)` build the two answers.
157
+
158
+ The console decides who the request comes from by what the verifier reported:
159
+
160
+ | The verifier reports | The console |
161
+ |---|---|
162
+ | An identity linked to an owner's principal | Admits them as that owner. |
163
+ | An identity linked to anyone else, such as a member, or to a disabled principal | Refuses. |
164
+ | An identity linked to no one, however many owners the host has | Refuses, and warns once with the full identity, the line to add to its owner's `access.owners[].identities`, and the `roundtable principal link <owner> <identity>` command that links it instead. |
165
+ | No actor (`admit()`, a verifier written for 0.8) | Admits the request as the primary owner, the first of `access.owners`, or as `ownerId` when set, and warns once. |
166
+
167
+ The console never resolves an identity the way a chat surface does: a visitor is never admitted as a new principal and never claims a 0.8 one.
168
+ It refuses an identity of the `legacy` provider too, as resolving refuses it: that is the alias 0.9 keeps for a 0.8 id, not anyone's sign-in.
169
+ A verifier may vouch for members as well as owners, through a proxy that lets the whole company in or an allowlist that grows, so the console never guesses whose an identity is: only a link says so.
170
+
171
+ ### Upgrading from 0.8
172
+
173
+ The core [Migrating to 0.9](../../docs/migration-0.9.md) guide covers backfill, rollback, and the principal isolation boundary.
174
+ This console remains an owner administration surface, not a principal-private view: every admitted owner can inspect every conversation and principal's notes.
175
+
176
+ In 0.8 the console took everyone the verifier admitted as the owner.
177
+ It now admits a person only when the identity the verifier reports is linked to an owner, so link yours before you upgrade, or the console answers 403 after it:
178
+
179
+ 1. Find your Access user id, the token's `sub`: sign in once after the upgrade, and copy the identity from the warning in the host's log, which gives it whole.
180
+ 2. Add `cloudflareAccessIdentity(teamDomain, sub)` to your entry in `access.owners[].identities`, or run `roundtable principal link <your principal id> <identity>` on the host.
181
+
182
+ A verifier of your own that still answers `admit()` without an actor keeps working as before: its requests are the primary owner's, with a warning.
151
183
 
152
184
  ### Cloudflare Access
153
185
 
154
186
  `cloudflareAccess(options)` is the built-in verifier.
155
187
  Cloudflare Access sits in front of your tunnel or proxy, signs in the owner, and adds a signed token to each request it forwards as the `Cf-Access-Jwt-Assertion` header.
156
- The verifier admits a request only when the token is signed by your team's published keys, was issued by your team, is addressed to this application, and carries an email you allow (compared without regard to case).
188
+ The verifier admits a request only when the token is signed by your team's published keys, was issued by your team, is addressed to this application, names a user, and carries an email you allow (compared without regard to case).
189
+ It reports the user as an OpenID identity of the team's issuer: `oidc:<base64url of https://<teamDomain>>:<sub>`, where `sub` is the user's Access id in the token, with their email as the name.
157
190
  It checks the token on every request and fetches the team's signing keys from `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`, caching them and following their rotation.
158
191
  If the keys cannot be fetched and none are cached, requests are refused.
159
192
 
@@ -161,7 +194,7 @@ If the keys cannot be fetched and none are cached, requests are refused.
161
194
  |---|---|
162
195
  | `teamDomain` | Your Access team domain, `<team-name>.cloudflareaccess.com`, with no scheme or path. |
163
196
  | `audience` | The Application Audience (AUD) tag of the Access application that protects the console. |
164
- | `email` | The email, or a list of emails, that may use the console. |
197
+ | `email` | The email, or a list of emails, that may use the console. The console still admits only those whose identity is an owner's. |
165
198
 
166
199
  To set it up:
167
200
 
@@ -169,12 +202,15 @@ To set it up:
169
202
  Use a hostname you do not share with anything that must stay open.
170
203
  2. In the Cloudflare dashboard, open Zero Trust, then Access controls, then Applications, and add a **Self-hosted** application for that hostname.
171
204
  Add the path `console` to the application's public hostname to protect only the console, or leave the path empty to protect the whole hostname.
172
- 3. Give the application one **Allow** policy that includes only the owner's email.
205
+ 3. Give the application one **Allow** policy that includes only the owners' emails.
173
206
  Do not add a Bypass or an Everyone policy.
174
207
  4. Open the application's configuration, and under Additional settings copy the **Application Audience (AUD) Tag**.
175
208
  Keep it in an environment variable and pass it as `audience`.
176
209
  5. Take your team name from Zero Trust settings, the `<team-name>` of `<team-name>.cloudflareaccess.com`, and pass the full domain as `teamDomain`.
177
210
  6. Set the plugin's `origin` to the address you opened in step 1, `https://console.example.com`.
211
+ 7. Add each owner's identity to their entry in `access.owners`.
212
+ Build it with `cloudflareAccessIdentity(teamDomain, sub)` from the user id shown in Zero Trust, or sign in once: the console refuses you and the host's log names the identity it saw (`oidc:…:<sub>`) with the line to add.
213
+ Until you do, the console refuses that owner, on a single-owner host as well.
178
214
 
179
215
  A request to the console from a browser that has not signed in is sent by Access to its login page and never reaches the host.
180
216
  The host still refuses any request that arrives without a valid token, so a listener that becomes reachable without Access, through a wrong route or a second tunnel, does not open the console.
@@ -182,20 +218,20 @@ The host still refuses any request that arrives without a valid token, so a list
182
218
  ### A verifier of your own
183
219
 
184
220
  Behind another authenticating proxy, write the verifier for what that proxy sends.
185
- This one trusts a header that a reverse proxy sets after it has authenticated the owner, which is safe only when the proxy removes the header from client requests and the listener cannot be reached except through the proxy, for example on a unix socket (`http.socketPath`) that only the proxy's user can open:
221
+ This one trusts a header that a reverse proxy sets after it has authenticated the person, which is safe only when the proxy removes the header from client requests and the listener cannot be reached except through the proxy, for example on a unix socket (`http.socketPath`) that only the proxy's user can open.
222
+ It reports the person as the identity `proxy:<email>`, which `access.owners[].identities` then lists for each owner:
186
223
 
187
224
  ```ts
188
- import { admit, refuse, webConsole } from "pi-roundtable-web";
189
-
190
- const OWNER = "owner@example.com";
225
+ import { admitAs, refuse, webConsole } from "pi-roundtable-web";
191
226
 
192
227
  export const web = webConsole({
193
- verifier: (request) =>
194
- request.headers.get("x-authenticated-email")?.toLowerCase() === OWNER
195
- ? admit()
196
- : refuse("not the owner"),
228
+ verifier: (request) => {
229
+ const email = request.headers.get("x-authenticated-email")?.toLowerCase();
230
+ return email
231
+ ? admitAs({ provider: "proxy", subject: email, name: email })
232
+ : refuse("no authenticated email");
233
+ },
197
234
  origin: "https://console.example.com",
198
- ownerId: "<the owner's Discord user id>",
199
235
  });
200
236
  ```
201
237
 
@@ -205,11 +241,12 @@ It may be asynchronous; a thrown error or a rejected promise counts as a refusal
205
241
  ## Threat model
206
242
 
207
243
  **What the console protects.**
208
- The conversations hold everything the owner said to the assistant and what the assistant answered, including tool calls and their results, and the memory notes shape every later turn.
209
- The console serves that to one owner and nobody else.
210
- It has no roles, no per-user views, and no audit trail beyond the host's log, which records only that a request was refused and why.
244
+ The conversations hold everything the host's people said to the assistant and what the assistant answered, including tool calls and their results, and the memory notes shape every later turn.
245
+ The console serves that to the host's owners and nobody else.
246
+ Every owner sees every conversation and can read and change every principal's notes; it has no per-user views for members, and no audit trail beyond the host's log, which records only that a request was refused and why.
211
247
 
212
- **Authentication is the verifier, and only the verifier.**
248
+ **Authentication is the verifier; authorization is the owner role.**
249
+ Who holds the owner role is the host's `access` configuration or the `roundtable principal` CLI, never something a verifier reports, so a verifier that admits a member admits no one.
213
250
  The console sets no cookie and keeps no session.
214
251
  The built-in Cloudflare Access verifier checks a signature, so a client that can reach the listener directly still cannot forge a token.
215
252
  A custom verifier that trusts a plain header is exactly as strong as the guarantee that clients cannot set that header: use a unix socket or a firewall rule so that only the proxy reaches the listener, and have the proxy strip the header from inbound requests.
@@ -246,8 +283,8 @@ If your tools handle secrets, they can appear there.
246
283
  Serve only the `notes` pane, or use `exclude`, when that is a concern.
247
284
 
248
285
  **Out of scope.**
249
- Transport security (terminate TLS at the proxy), protecting the data directory on disk, and other users of the host are not the console's job.
250
- The console is for one owner on one host.
286
+ Transport security (terminate TLS at the proxy), protecting the data directory on disk, and what members see are not the console's job.
287
+ The console is for the owners of one host; members talk to the host through its chat surfaces.
251
288
 
252
289
  ## Development
253
290
 
@@ -265,6 +302,7 @@ bun run --cwd packages/web fixture # a local host with fixture data
265
302
 
266
303
  `bun run fixture` starts the plugin on pi-roundtable's test harness over fixture conversations on port 4173, behind a stand-in for the authenticating proxy that signs a test Access token, and the same listener without it on port 4174, which must answer 403.
267
304
  `/fixture/message` appends a message to the owner's conversation and `/fixture/work` toggles the agent between working and idle, so the live updates can be watched.
305
+ The fixture host has two owners, so the Notes pane shows its principal picker.
268
306
 
269
307
  The page is React, bundled with Bun's bundler into relative URLs, so it works under any `mountPath`.
270
308
  Pages use URL fragments by default (`#/notes`, `#/conversations/<key>`); `routing: "path"` uses mount-relative page paths instead.