pi-roundtable-web 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/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Console</title>
7
7
 
8
- <link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-2cm6wak5.js"></script></head>
8
+ <link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-0vqnvf92.js"></script></head>
9
9
  <body>
10
10
  <div id="root"></div>
11
11
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-roundtable-web",
3
- "version": "0.8.0",
4
- "description": "An owner-only web console for pi-roundtable: conversations, transcripts, and memory notes, with live updates",
3
+ "version": "0.9.0",
4
+ "description": "A web console for the owners of a pi-roundtable host: conversations, transcripts, and memory notes, with live updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -40,14 +40,14 @@
40
40
  "jose": "6.2.12"
41
41
  },
42
42
  "peerDependencies": {
43
- "pi-roundtable": ">=0.8.0 <0.9.0"
43
+ "pi-roundtable": ">=0.9.0 <0.10.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@biomejs/biome": "2.5.15",
47
47
  "@types/bun": "1.4.2",
48
48
  "@types/react": "19.3.0",
49
49
  "@types/react-dom": "19.3.0",
50
- "pi-roundtable": "0.8.0",
50
+ "pi-roundtable": "0.9.0",
51
51
  "react": "19.3.0",
52
52
  "react-dom": "19.3.0",
53
53
  "react-markdown": "10.1.0",
package/src/api-types.ts CHANGED
@@ -81,8 +81,10 @@ export interface DashboardView {
81
81
  }
82
82
 
83
83
  /**
84
- * Who a stored conversation belongs to; `plugin` is one a plugin runs through `context.turns`,
85
- * such as a web chat, as the host's registry records it.
84
+ * Where a stored conversation came from, for the page to group it by; it grants nothing. `owner`
85
+ * is a Discord channel no agent or group owns, where an owner talks to the assistant; `plugin` is
86
+ * one a plugin runs through `context.turns`, such as a web chat, as the host's registry records
87
+ * it. Whose a conversation is, the registry says: `ConversationView.principal`.
86
88
  */
87
89
  export type ConversationKind =
88
90
  | "agent"
@@ -110,10 +112,21 @@ export interface ConversationView {
110
112
  startedAt?: string;
111
113
  /** Plugin conversations only: the name the registry gives it. */
112
114
  title?: string;
115
+ /** Conversations the host's registry records: `private` to one principal, or `shared`. */
116
+ visibility?: "private" | "shared";
117
+ /** Conversations the registry records as someone's: whose, by their principal's name. */
118
+ principal?: PrincipalName;
113
119
  /** Turns running or waiting in the channel. */
114
120
  busy: number;
115
121
  }
116
122
 
123
+ /** A principal as the console names them. */
124
+ export interface PrincipalName {
125
+ id: string;
126
+ /** Their display name, or their id when the host no longer knows them. */
127
+ name: string;
128
+ }
129
+
117
130
  export interface ConversationsView {
118
131
  conversations: ConversationView[];
119
132
  }
@@ -151,6 +164,13 @@ export interface NoteView {
151
164
  eventDate: string | null;
152
165
  }
153
166
 
167
+ /** The people whose notes the notes pane can show: the visitor first, then the others, oldest first. */
168
+ export interface PrincipalsView {
169
+ /** The visitor's own principal, whose notes the pane shows unless asked for another's. */
170
+ self: string;
171
+ principals: (PrincipalName & { disabled?: true })[];
172
+ }
173
+
154
174
  export interface NoteInput {
155
175
  fact: string;
156
176
  kind: NoteKind;
@@ -1,12 +1,15 @@
1
1
  import { createRemoteJWKSet, type JWTVerifyGetKey, jwtVerify } from "jose";
2
- import { admit, type RequestVerifier, refuse } from "./verifier.ts";
2
+ import { admitAs, type RequestVerifier, refuse } from "./verifier.ts";
3
3
 
4
4
  export interface CloudflareAccessOptions {
5
5
  /** The Access team domain, such as `example.cloudflareaccess.com`. */
6
6
  teamDomain: string;
7
7
  /** The AUD tag of the Access application in front of the console. */
8
8
  audience: string;
9
- /** The email, or emails, Access may vouch for; compared without regard to case. */
9
+ /**
10
+ * The email, or emails, Access may vouch for; compared without regard to case. The console
11
+ * then admits only those whose identity is linked to an owner: see `cloudflareAccessIdentity`.
12
+ */
10
13
  email: string | readonly string[];
11
14
  /** Replaceable in tests; the team's published signing keys otherwise. */
12
15
  keys?: JWTVerifyGetKey;
@@ -14,11 +17,29 @@ export interface CloudflareAccessOptions {
14
17
 
15
18
  const HEADER = "cf-access-jwt-assertion";
16
19
 
20
+ /** The identity provider of an Access team's users: `oidc:<base64url(https://<teamDomain>)>`. */
21
+ const providerOf = (issuer: string) =>
22
+ `oidc:${Buffer.from(issuer).toString("base64url")}`;
23
+
24
+ /**
25
+ * The identity, as `access.owners[].identities` and `roundtable principal link` write it, of the
26
+ * Access user `sub` (the user id in the token) of a team: `oidc:<base64url(issuer)>:<sub>`, the
27
+ * form any OpenID issuer's users take.
28
+ */
29
+ export function cloudflareAccessIdentity(
30
+ teamDomain: string,
31
+ sub: string,
32
+ ): string {
33
+ return `${providerOf(`https://${teamDomain.trim()}`)}:${sub}`;
34
+ }
35
+
17
36
  /**
18
37
  * Admits a request only when Cloudflare Access vouches for an allowed email: its
19
38
  * `Cf-Access-Jwt-Assertion` header holds a token signed by the team's keys, issued by the team,
20
- * for this application, naming that email. The token is checked on every request, so the
21
- * console stays closed to a client that reaches it without passing through Access.
39
+ * for this application, naming that email and a user. The token is checked on every request, so
40
+ * the console stays closed to a client that reaches it without passing through Access. It reports
41
+ * the user as an OpenID identity of the team's issuer, their `sub` as the subject and their email
42
+ * as the name.
22
43
  */
23
44
  export function cloudflareAccess(
24
45
  options: CloudflareAccessOptions,
@@ -38,22 +59,27 @@ export function cloudflareAccess(
38
59
  const keys =
39
60
  options.keys ??
40
61
  createRemoteJWKSet(new URL(`https://${teamDomain}/cdn-cgi/access/certs`));
62
+ const issuer = `https://${teamDomain}`;
63
+ const provider = providerOf(issuer);
41
64
  return async (request) => {
42
65
  const token = request.headers.get(HEADER);
43
66
  if (!token) return refuse("no Access token");
67
+ let email: string;
68
+ let subject: string;
44
69
  try {
45
- const { payload } = await jwtVerify(token, keys, {
46
- issuer: `https://${teamDomain}`,
47
- audience,
48
- });
70
+ const { payload } = await jwtVerify(token, keys, { issuer, audience });
49
71
  if (
50
72
  typeof payload.email !== "string" ||
51
73
  !emails.includes(payload.email.toLowerCase())
52
74
  )
53
75
  return refuse("email not allowed");
76
+ if (typeof payload.sub !== "string" || !payload.sub)
77
+ return refuse("Access token names no user");
78
+ email = payload.email.toLowerCase();
79
+ subject = payload.sub;
54
80
  } catch {
55
81
  return refuse("invalid Access token");
56
82
  }
57
- return admit();
83
+ return admitAs({ provider, subject, name: email });
58
84
  };
59
85
  }
@@ -2,13 +2,15 @@ import { join } from "node:path";
2
2
  import type {
3
3
  AgentTeam,
4
4
  ChannelKey,
5
+ ConversationRecord,
5
6
  ConversationRegistry,
7
+ IdentityService,
6
8
  Logger,
7
9
  MemoryKind,
8
10
  QueuePort,
9
11
  SpeakerMemory,
10
12
  } from "pi-roundtable";
11
- import { MemoryError, parseChannelKey } from "pi-roundtable";
13
+ import { MemoryError, parseChannelKey, SYSTEM_PRINCIPAL } from "pi-roundtable";
12
14
  import { thinkingLabel } from "pi-roundtable/kit";
13
15
  import type {
14
16
  ApiError,
@@ -22,6 +24,8 @@ import type {
22
24
  NoteView,
23
25
  OverviewView,
24
26
  PaneName,
27
+ PrincipalName,
28
+ PrincipalsView,
25
29
  TranscriptView,
26
30
  } from "./api-types.ts";
27
31
  import {
@@ -41,6 +45,7 @@ import {
41
45
  message,
42
46
  safeAdminUrl,
43
47
  } from "./features.ts";
48
+ import type { Visitor } from "./visitors.ts";
44
49
 
45
50
  const NOTE_SEARCH_LIMIT = 500;
46
51
  /** The largest note body the console reads. */
@@ -57,11 +62,12 @@ export interface ConsolePorts {
57
62
  queue: Pick<QueuePort, "size">;
58
63
  /** Undefined when Discord no longer knows the channel; throws when it cannot be asked. */
59
64
  channelName?: (channelId: string) => Promise<ChannelName | undefined>;
60
- /** The owner's memory; present when the notes pane is served. */
61
- memory?: Pick<
62
- SpeakerMemory,
63
- "list" | "search" | "add" | "update" | "removeById"
64
- >;
65
+ /** A principal's memory; present when the notes pane is served. */
66
+ memoryOf?: (
67
+ principalId: string,
68
+ ) => Pick<SpeakerMemory, "list" | "search" | "add" | "update" | "removeById">;
69
+ /** The host's principals, read only: whose notes the pane may show, and the names of conversations' principals. */
70
+ people?: Pick<IdentityService, "principal" | "list">;
65
71
  /** Conversations the console must not list or read. */
66
72
  exclude?: (key: ChannelKey) => boolean;
67
73
  /** The host's conversation registry; without it only the conversations found by name are listed. */
@@ -105,10 +111,14 @@ export class ConsoleApi {
105
111
  this.#ports = ports;
106
112
  }
107
113
 
108
- /** `path` is what follows `<mount>/api/`. */
109
- async handle(request: Request, path: string): Promise<Response> {
114
+ /** `path` is what follows `<mount>/api/`; `visitor` is the owner the request comes from. */
115
+ async handle(
116
+ request: Request,
117
+ path: string,
118
+ visitor: Visitor,
119
+ ): Promise<Response> {
110
120
  try {
111
- return await this.#route(request, path);
121
+ return await this.#route(request, path, visitor);
112
122
  } catch (error) {
113
123
  if (error instanceof HttpError)
114
124
  return json(
@@ -143,7 +153,11 @@ export class ConsoleApi {
143
153
  throw new HttpError(404, "This pane is not served.");
144
154
  }
145
155
 
146
- async #route(request: Request, path: string): Promise<Response> {
156
+ async #route(
157
+ request: Request,
158
+ path: string,
159
+ visitor: Visitor,
160
+ ): Promise<Response> {
147
161
  const method = request.method;
148
162
  let parts: string[];
149
163
  try {
@@ -205,7 +219,11 @@ export class ConsoleApi {
205
219
  }
206
220
  if (head === "notes" && parts.length <= 2) {
207
221
  this.#pane("notes");
208
- return this.#notes(request, id);
222
+ return this.#notes(request, id, visitor);
223
+ }
224
+ if (method === "GET" && head === "principals" && !id) {
225
+ this.#pane("notes");
226
+ return json(await this.principals(visitor));
209
227
  }
210
228
  throw new HttpError(404, "There is no such API.");
211
229
  }
@@ -282,16 +300,19 @@ export class ConsoleApi {
282
300
  excluded: (key: ChannelKey) => this.#hidden(key),
283
301
  relayNotes,
284
302
  };
303
+ const records = registry ? await registry.list() : [];
285
304
  // The registry first; the directory scan reads the conversations from before it, by name.
286
- const recorded = registry
287
- ? registeredConversations(sessionsDir, await registry.list(), filter)
288
- : [];
305
+ const recorded = registeredConversations(sessionsDir, records, filter);
289
306
  const stored = [
290
307
  ...recorded,
291
308
  ...storedConversations(sessionsDir, filter),
292
309
  ].sort((a, b) => (b.lastActive ?? "").localeCompare(a.lastActive ?? ""));
310
+ const byKey = new Map(records.map((record) => [record.key, record]));
311
+ const names = new Map<string, Promise<PrincipalName>>();
293
312
  return {
294
- conversations: await Promise.all(stored.map((c) => this.#view(c))),
313
+ conversations: await Promise.all(
314
+ stored.map((c) => this.#view(c, byKey.get(c.key), names)),
315
+ ),
295
316
  };
296
317
  }
297
318
 
@@ -337,13 +358,16 @@ export class ConsoleApi {
337
358
  throw new HttpError(404, "There is no such archive.");
338
359
  const { entries, truncated } = readTranscript(dir, archive, relayNotes);
339
360
  return {
340
- conversation: await this.#view({
341
- key: key as ChannelKey,
342
- kind: parsed.kind,
343
- id: parsed.id,
344
- ...(parsed.member ? { member: parsed.member } : {}),
345
- ...files,
346
- }),
361
+ conversation: await this.#view(
362
+ {
363
+ key: key as ChannelKey,
364
+ kind: parsed.kind,
365
+ id: parsed.id,
366
+ ...(parsed.member ? { member: parsed.member } : {}),
367
+ ...files,
368
+ },
369
+ await this.#ports.registry?.get(key as ChannelKey),
370
+ ),
347
371
  archives,
348
372
  ...(archive !== undefined ? { archive } : {}),
349
373
  entries,
@@ -351,8 +375,22 @@ export class ConsoleApi {
351
375
  };
352
376
  }
353
377
 
354
- async #view(stored: StoredConversation): Promise<ConversationView> {
378
+ /**
379
+ * How the page shows a conversation. `record` is what the registry holds of it, which says whose
380
+ * it is; `names` gathers the principals' names one listing reads, so each is read once.
381
+ */
382
+ async #view(
383
+ stored: StoredConversation,
384
+ record: ConversationRecord | undefined,
385
+ names = new Map<string, Promise<PrincipalName>>(),
386
+ ): Promise<ConversationView> {
355
387
  const { team, queue } = this.#ports;
388
+ const owner = record?.principalId;
389
+ let principal: Promise<PrincipalName> | undefined;
390
+ if (owner !== undefined) {
391
+ principal = names.get(owner) ?? this.#principalName(owner);
392
+ names.set(owner, principal);
393
+ }
356
394
  const kind: ConversationKind =
357
395
  stored.kind === "mcp"
358
396
  ? "outside"
@@ -375,6 +413,8 @@ export class ConsoleApi {
375
413
  ...(stored.lastActive ? { lastActive: stored.lastActive } : {}),
376
414
  ...(stored.firstMessage ? { firstMessage: stored.firstMessage } : {}),
377
415
  ...(stored.startedAt ? { startedAt: stored.startedAt } : {}),
416
+ ...(record ? { visibility: record.visibility } : {}),
417
+ ...(principal ? { principal: await principal } : {}),
378
418
  // A group's turns queue under the group channel, not under each member's conversation.
379
419
  busy: queue.size(
380
420
  stored.kind === "group" ? `discord:${stored.id}` : stored.key,
@@ -382,6 +422,11 @@ export class ConsoleApi {
382
422
  };
383
423
  }
384
424
 
425
+ async #principalName(id: string): Promise<PrincipalName> {
426
+ const known = await this.#ports.people?.principal(id);
427
+ return { id, name: known?.displayName ?? id };
428
+ }
429
+
385
430
  async #name(channelId: string): Promise<ChannelName> {
386
431
  const lookup = this.#ports.channelName;
387
432
  if (!lookup) return { kind: "unknown" };
@@ -395,9 +440,43 @@ export class ConsoleApi {
395
440
 
396
441
  // ── Notes ──────────────────────────────────────────────────────────────
397
442
 
398
- async #notes(request: Request, id: string | undefined): Promise<Response> {
399
- const { memory } = this.#ports;
400
- if (!memory) throw new HttpError(404, "This pane is not served.");
443
+ /** The people whose notes the pane can show: every principal but the host's own, the visitor first. */
444
+ async principals(visitor: Visitor): Promise<PrincipalsView> {
445
+ const self = visitor.principal.id;
446
+ const everyone = (await this.#ports.people?.list()) ?? [visitor.principal];
447
+ const listed = [
448
+ ...everyone.filter((p) => p.id === self),
449
+ ...everyone.filter((p) => p.id !== self && p.id !== SYSTEM_PRINCIPAL),
450
+ ];
451
+ return {
452
+ self,
453
+ principals: listed.map((p) => ({
454
+ id: p.id,
455
+ name: p.displayName,
456
+ ...(p.disabled ? { disabled: true as const } : {}),
457
+ })),
458
+ };
459
+ }
460
+
461
+ /** The memory of the principal the request names, default the visitor's own. */
462
+ async #memoryFor(request: Request, visitor: Visitor) {
463
+ const { memoryOf, people } = this.#ports;
464
+ if (!memoryOf) throw new HttpError(404, "This pane is not served.");
465
+ const named = new URL(request.url).searchParams.get("principal");
466
+ if (named === null || named === visitor.principal.id)
467
+ return memoryOf(visitor.principal.id);
468
+ // The host's own principal keeps no notes; anyone else must be a principal the host knows.
469
+ if (named === SYSTEM_PRINCIPAL || !(await people?.principal(named)))
470
+ throw new HttpError(404, "There is no such person.");
471
+ return memoryOf(named);
472
+ }
473
+
474
+ async #notes(
475
+ request: Request,
476
+ id: string | undefined,
477
+ visitor: Visitor,
478
+ ): Promise<Response> {
479
+ const memory = await this.#memoryFor(request, visitor);
401
480
  const method = request.method;
402
481
  if (!id) {
403
482
  if (method === "GET") {
@@ -1,9 +1,10 @@
1
- import type { HttpRoute, Logger } from "pi-roundtable";
1
+ import type { ActorFacts, HttpRoute, Logger } from "pi-roundtable";
2
2
  import type { AssetBundle } from "./assets.ts";
3
3
  import type { ConsoleApi } from "./console-api.ts";
4
4
  import type { ConsolePresentation } from "./features.ts";
5
5
  import { presentPage } from "./page-presentation.ts";
6
6
  import type { RequestVerifier } from "./verifier.ts";
7
+ import type { Identified, Visitor } from "./visitors.ts";
7
8
 
8
9
  const encoder = new TextEncoder();
9
10
  /** Changes closer together than this reach the page as one event. */
@@ -33,6 +34,8 @@ export interface ConsoleServerOptions {
33
34
  mount: string;
34
35
  assets: AssetBundle;
35
36
  verifier: RequestVerifier;
37
+ /** Who a request the verifier admitted comes from, by the actor it reported; only an owner is a visitor. */
38
+ identify(actor: ActorFacts | undefined): Promise<Identified>;
36
39
  /** The console's own origin; requests that change data must come from it. */
37
40
  origin: string;
38
41
  api: Pick<ConsoleApi, "handle">;
@@ -48,8 +51,8 @@ export interface ConsoleServerOptions {
48
51
 
49
52
  /**
50
53
  * The console on the host's listener: `<mount>` redirects to `<mount>/`, and everything under
51
- * it needs the verifier to vouch for the owner. A request that changes data must also carry the
52
- * console's own `Origin`.
54
+ * it needs the verifier to vouch for the person and that person to be an owner. A request that
55
+ * changes data must also carry the console's own `Origin`.
53
56
  */
54
57
  export class ConsoleServer {
55
58
  readonly #options: ConsoleServerOptions;
@@ -113,9 +116,12 @@ export class ConsoleServer {
113
116
  const path = new URL(request.url).pathname;
114
117
  if (path !== this.#options.mount && !path.startsWith(this.#base))
115
118
  return new Response("Not found", { status: 404 });
116
- const refusal = await this.#refusal(request);
117
- if (refusal !== undefined) {
118
- this.#options.logger.warn({ refusal }, "console request refused");
119
+ const admission = await this.#admission(request);
120
+ if ("refusal" in admission) {
121
+ this.#options.logger.warn(
122
+ { refusal: admission.refusal },
123
+ "console request refused",
124
+ );
119
125
  return new Response("Forbidden", {
120
126
  status: 403,
121
127
  headers: { "cache-control": "no-store" },
@@ -130,7 +136,11 @@ export class ConsoleServer {
130
136
  if (rest === "api/events" && request.method === "GET")
131
137
  return this.#events();
132
138
  if (rest.startsWith("api/"))
133
- return this.#options.api.handle(request, rest.slice("api/".length));
139
+ return this.#options.api.handle(
140
+ request,
141
+ rest.slice("api/".length),
142
+ admission.visitor,
143
+ );
134
144
  if (!READ_METHODS.has(request.method))
135
145
  return new Response("Method not allowed", { status: 405 });
136
146
  const asset = this.#options.assets.get(rest);
@@ -167,21 +177,33 @@ export class ConsoleServer {
167
177
  return new Response("Not found", { status: 404 });
168
178
  }
169
179
 
170
- /** Undefined when the request may pass, otherwise why not; never throws. */
171
- async #refusal(request: Request): Promise<string | undefined> {
180
+ /** The owner the request comes from when it may pass, otherwise why not; never throws. */
181
+ async #admission(
182
+ request: Request,
183
+ ): Promise<{ visitor: Visitor } | { refusal: string }> {
184
+ let actor: ActorFacts | undefined;
172
185
  try {
173
186
  const verdict = await this.#options.verifier(request);
174
- if (!verdict.admitted) return String(verdict.reason);
187
+ if (!verdict.admitted) return { refusal: String(verdict.reason) };
188
+ actor = verdict.actor;
175
189
  } catch {
176
190
  // A verifier that fails admits no one.
177
- return "verifier failed";
191
+ return { refusal: "verifier failed" };
178
192
  }
193
+ let identified: Identified;
194
+ try {
195
+ identified = await this.#options.identify(actor);
196
+ } catch {
197
+ // Nor does a lookup of who they are that fails.
198
+ return { refusal: "identity failed" };
199
+ }
200
+ if ("refusal" in identified) return { refusal: String(identified.refusal) };
179
201
  if (
180
202
  !READ_METHODS.has(request.method) &&
181
203
  request.headers.get("origin") !== this.#options.origin
182
204
  )
183
- return "foreign origin";
184
- return undefined;
205
+ return { refusal: "foreign origin" };
206
+ return identified;
185
207
  }
186
208
 
187
209
  /** A server-sent event stream that says `changed` whenever the console may differ. */
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export type {
18
18
  OverviewView,
19
19
  PaneName,
20
20
  PartyView,
21
+ PrincipalName,
22
+ PrincipalsView,
21
23
  SkillDetailView,
22
24
  SkillView,
23
25
  TranscriptEntry,
@@ -26,12 +28,15 @@ export type {
26
28
  } from "./api-types.ts";
27
29
  export { PANES } from "./api-types.ts";
28
30
  export type { CloudflareAccessOptions } from "./cloudflare-access.ts";
29
- export { cloudflareAccess } from "./cloudflare-access.ts";
31
+ export {
32
+ cloudflareAccess,
33
+ cloudflareAccessIdentity,
34
+ } from "./cloudflare-access.ts";
30
35
  /** Summary for a trusted host's separately stored party sessions. */
31
36
  export { conversationFiles as sessionSummary } from "./conversations.ts";
32
37
  export type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
33
38
  export type { WebConsoleOptions } from "./options.ts";
34
39
  export { DEFAULT_RELAY_NOTE } from "./options.ts";
35
40
  export type { RequestVerifier, Verdict } from "./verifier.ts";
36
- export { admit, refuse } from "./verifier.ts";
41
+ export { admit, admitAs, refuse } from "./verifier.ts";
37
42
  export { webConsole } from "./web-plugin.ts";
package/src/options.ts CHANGED
@@ -14,8 +14,10 @@ export const DEFAULT_RELAY_NOTE =
14
14
 
15
15
  export interface WebConsoleOptions {
16
16
  /**
17
- * Decides whether a request comes from the owner; required. `cloudflareAccess(...)` is the
18
- * built-in one. Without it the plugin refuses to start rather than serve without authentication.
17
+ * Decides whether a request comes from someone the operator's proxy authenticated, and reports
18
+ * who; required. `cloudflareAccess(...)` is the built-in one. Without it the plugin refuses to
19
+ * start rather than serve without authentication. Only a person whose principal holds the owner
20
+ * role gets in.
19
21
  */
20
22
  verifier: RequestVerifier;
21
23
  /** Host-specific data and actions, resolved against public plugin setup context. */
@@ -31,7 +33,11 @@ export interface WebConsoleOptions {
31
33
  * must carry it as their `Origin`, and the dashboard line links to it.
32
34
  */
33
35
  origin: string;
34
- /** The id memory notes are kept under: the owner's. Required when the `notes` pane is served. */
36
+ /**
37
+ * Deprecated: the owner's principal that a verifier reporting no actor speaks for; default the
38
+ * primary owner (the first of `access.owners`). The notes pane shows each visitor their own
39
+ * notes, and any principal's on request.
40
+ */
35
41
  ownerId?: string;
36
42
  /** The host's data directory, whose `sessions/` holds the conversations; default `./data`. */
37
43
  dataDir?: string;
@@ -145,9 +151,9 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
145
151
  }
146
152
  const panes = checkPanes(options.panes);
147
153
  const ownerId = options.ownerId?.trim();
148
- if (panes.includes("notes") && !ownerId)
154
+ if (options.ownerId !== undefined && !ownerId)
149
155
  fail(
150
- "ownerId is required for the notes pane: it is the id the owner's memory is kept under. Set it, or leave `notes` out of panes",
156
+ "ownerId is empty. Leave it out: the console finds each visitor's principal, and a verifier that reports no actor speaks for the primary owner",
151
157
  );
152
158
  const title = options.title?.trim() ?? "Roundtable";
153
159
  if (!title) fail("title is empty");
package/src/verifier.ts CHANGED
@@ -1,6 +1,16 @@
1
+ import type { ActorFacts } from "pi-roundtable";
2
+
1
3
  /** The verdict on one request: admitted, or refused with a reason for the log. */
2
4
  export type Verdict =
3
- | { admitted: true }
5
+ | {
6
+ admitted: true;
7
+ /**
8
+ * Who signed in, as the proxy vouched for them; the console finds their principal through
9
+ * `IDENTITY` and admits only an owner. Without it, the request is taken as the primary
10
+ * owner's, as before 0.9, and the console warns once.
11
+ */
12
+ actor?: ActorFacts;
13
+ }
4
14
  | {
5
15
  admitted: false;
6
16
  /** Why, for the operator's log. It is never sent to the client, and it must not hold a secret. */
@@ -8,14 +18,22 @@ export type Verdict =
8
18
  };
9
19
 
10
20
  /**
11
- * Decides whether a request comes from the owner. The console asks it for every request under
12
- * its path, before anything else, and refuses with 403 when it says no, throws, or rejects.
13
- * A verifier reads the request's headers, which the operator's proxy set after authenticating the
14
- * owner, and must never trust a header a client can forge: see "Threat model" in the README.
21
+ * Decides whether a request comes from someone the operator's proxy authenticated, and who. The
22
+ * console asks it for every request under its path, before anything else, and refuses with 403
23
+ * when it says no, throws, or rejects; then it admits only a person whose principal holds the
24
+ * owner role. A verifier reads the request's headers, which the operator's proxy set after
25
+ * authenticating the person, and must never trust a header a client can forge: see "Threat model"
26
+ * in the README.
15
27
  */
16
28
  export type RequestVerifier = (request: Request) => Verdict | Promise<Verdict>;
17
29
 
30
+ /** Admits the request without saying who it is from, as a verifier before 0.9 did. */
18
31
  export const admit = (): Verdict => ({ admitted: true });
32
+ /** Admits the request as coming from the person these facts describe. */
33
+ export const admitAs = (actor: ActorFacts): Verdict => ({
34
+ admitted: true,
35
+ actor,
36
+ });
19
37
  export const refuse = (reason: string): Verdict => ({
20
38
  admitted: false,
21
39
  reason,