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/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-mr32cg74.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.7.19",
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.7.0 <0.8.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.7.19",
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
@@ -80,8 +80,18 @@ export interface DashboardView {
80
80
  party: PartyView[];
81
81
  }
82
82
 
83
- /** Who a stored conversation belongs to. */
84
- export type ConversationKind = "agent" | "group" | "owner" | "outside";
83
+ /**
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`.
88
+ */
89
+ export type ConversationKind =
90
+ | "agent"
91
+ | "group"
92
+ | "owner"
93
+ | "outside"
94
+ | "plugin";
85
95
 
86
96
  export interface ConversationView {
87
97
  /** The channel key: `discord:<id>`, `agentgroup:<channel>.<agent>`, or `mcp:<session>`. */
@@ -97,13 +107,26 @@ export interface ConversationView {
97
107
  liveBytes: number;
98
108
  archives: number;
99
109
  lastActive?: string;
100
- /** Outside-agent conversations only: up to 80 characters of the first message. */
110
+ /** Outside-agent and plugin conversations only: up to 80 characters of the first message. */
101
111
  firstMessage?: string;
102
112
  startedAt?: string;
113
+ /** Plugin conversations only: the name the registry gives it. */
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;
103
119
  /** Turns running or waiting in the channel. */
104
120
  busy: number;
105
121
  }
106
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
+
107
130
  export interface ConversationsView {
108
131
  conversations: ConversationView[];
109
132
  }
@@ -141,6 +164,13 @@ export interface NoteView {
141
164
  eventDate: string | null;
142
165
  }
143
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
+
144
174
  export interface NoteInput {
145
175
  fact: string;
146
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,12 +2,15 @@ import { join } from "node:path";
2
2
  import type {
3
3
  AgentTeam,
4
4
  ChannelKey,
5
+ ConversationRecord,
6
+ ConversationRegistry,
7
+ IdentityService,
5
8
  Logger,
6
9
  MemoryKind,
7
10
  QueuePort,
8
11
  SpeakerMemory,
9
12
  } from "pi-roundtable";
10
- import { MemoryError } from "pi-roundtable";
13
+ import { MemoryError, parseChannelKey, SYSTEM_PRINCIPAL } from "pi-roundtable";
11
14
  import { thinkingLabel } from "pi-roundtable/kit";
12
15
  import type {
13
16
  ApiError,
@@ -21,6 +24,8 @@ import type {
21
24
  NoteView,
22
25
  OverviewView,
23
26
  PaneName,
27
+ PrincipalName,
28
+ PrincipalsView,
24
29
  TranscriptView,
25
30
  } from "./api-types.ts";
26
31
  import {
@@ -28,16 +33,19 @@ import {
28
33
  conversationFiles,
29
34
  parseKey,
30
35
  readTranscript,
36
+ registeredConversations,
37
+ registeredDir,
31
38
  type StoredConversation,
39
+ type StoredKind,
32
40
  storedConversations,
33
41
  } from "./conversations.ts";
34
-
35
42
  import {
36
43
  type ConsoleFeatures,
37
44
  type ConsolePresentation,
38
45
  message,
39
46
  safeAdminUrl,
40
47
  } from "./features.ts";
48
+ import type { Visitor } from "./visitors.ts";
41
49
 
42
50
  const NOTE_SEARCH_LIMIT = 500;
43
51
  /** The largest note body the console reads. */
@@ -54,13 +62,16 @@ export interface ConsolePorts {
54
62
  queue: Pick<QueuePort, "size">;
55
63
  /** Undefined when Discord no longer knows the channel; throws when it cannot be asked. */
56
64
  channelName?: (channelId: string) => Promise<ChannelName | undefined>;
57
- /** The owner's memory; present when the notes pane is served. */
58
- memory?: Pick<
59
- SpeakerMemory,
60
- "list" | "search" | "add" | "update" | "removeById"
61
- >;
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">;
62
71
  /** Conversations the console must not list or read. */
63
72
  exclude?: (key: ChannelKey) => boolean;
73
+ /** The host's conversation registry; without it only the conversations found by name are listed. */
74
+ registry?: Pick<ConversationRegistry, "list" | "get">;
64
75
  /** Text a relayed message begins with, which is not the owner's words. */
65
76
  relayNotes: readonly string[];
66
77
  /** Called after a note is written, so every open page refreshes. */
@@ -100,10 +111,14 @@ export class ConsoleApi {
100
111
  this.#ports = ports;
101
112
  }
102
113
 
103
- /** `path` is what follows `<mount>/api/`. */
104
- 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> {
105
120
  try {
106
- return await this.#route(request, path);
121
+ return await this.#route(request, path, visitor);
107
122
  } catch (error) {
108
123
  if (error instanceof HttpError)
109
124
  return json(
@@ -138,7 +153,11 @@ export class ConsoleApi {
138
153
  throw new HttpError(404, "This pane is not served.");
139
154
  }
140
155
 
141
- async #route(request: Request, path: string): Promise<Response> {
156
+ async #route(
157
+ request: Request,
158
+ path: string,
159
+ visitor: Visitor,
160
+ ): Promise<Response> {
142
161
  const method = request.method;
143
162
  let parts: string[];
144
163
  try {
@@ -200,7 +219,11 @@ export class ConsoleApi {
200
219
  }
201
220
  if (head === "notes" && parts.length <= 2) {
202
221
  this.#pane("notes");
203
- 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));
204
227
  }
205
228
  throw new HttpError(404, "There is no such API.");
206
229
  }
@@ -272,29 +295,59 @@ export class ConsoleApi {
272
295
  // ── Conversations ──────────────────────────────────────────────────────
273
296
 
274
297
  async conversations(): Promise<ConversationsView> {
275
- const { sessionsDir, relayNotes, exclude } = this.#ports;
276
- const stored = storedConversations(sessionsDir, {
277
- excluded: (key) =>
278
- (exclude?.(key) ?? false) ||
279
- (this.#ports.features?.party?.contains(key) ?? false),
298
+ const { sessionsDir, relayNotes, registry } = this.#ports;
299
+ const filter = {
300
+ excluded: (key: ChannelKey) => this.#hidden(key),
280
301
  relayNotes,
281
- });
302
+ };
303
+ const records = registry ? await registry.list() : [];
304
+ // The registry first; the directory scan reads the conversations from before it, by name.
305
+ const recorded = registeredConversations(sessionsDir, records, filter);
306
+ const stored = [
307
+ ...recorded,
308
+ ...storedConversations(sessionsDir, filter),
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>>();
282
312
  return {
283
- 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
+ ),
284
316
  };
285
317
  }
286
318
 
319
+ #hidden(key: ChannelKey): boolean {
320
+ return (
321
+ (this.#ports.exclude?.(key) ?? false) ||
322
+ (this.#ports.features?.party?.contains(key) ?? false)
323
+ );
324
+ }
325
+
326
+ /** The session directory and identity of a conversation the console may read, or undefined. */
327
+ async #located(
328
+ key: string,
329
+ ): Promise<
330
+ { dir: string; kind: StoredKind; id: string; member?: string } | undefined
331
+ > {
332
+ const parsed = parseKey(key);
333
+ if (parsed) return parsed;
334
+ const record = await this.#ports.registry?.get(key as ChannelKey);
335
+ return record
336
+ ? {
337
+ dir: registeredDir(record.key),
338
+ kind: "registered",
339
+ id: parseChannelKey(record.key).id,
340
+ }
341
+ : undefined;
342
+ }
343
+
287
344
  async transcript(
288
345
  key: string,
289
346
  archive: string | undefined,
290
347
  ): Promise<TranscriptView> {
291
- const { sessionsDir, relayNotes, exclude } = this.#ports;
292
- const parsed = parseKey(key);
293
- if (
294
- !parsed ||
295
- exclude?.(key as ChannelKey) ||
296
- this.#ports.features?.party?.contains(key as ChannelKey)
297
- )
348
+ const { sessionsDir, relayNotes } = this.#ports;
349
+ const parsed = await this.#located(key);
350
+ if (!parsed || this.#hidden(key as ChannelKey))
298
351
  throw new HttpError(404, "There is no such conversation.");
299
352
  const dir = join(sessionsDir, parsed.dir);
300
353
  const files = conversationFiles(dir);
@@ -305,13 +358,16 @@ export class ConsoleApi {
305
358
  throw new HttpError(404, "There is no such archive.");
306
359
  const { entries, truncated } = readTranscript(dir, archive, relayNotes);
307
360
  return {
308
- conversation: await this.#view({
309
- key: key as ChannelKey,
310
- kind: parsed.kind,
311
- id: parsed.id,
312
- ...(parsed.member ? { member: parsed.member } : {}),
313
- ...files,
314
- }),
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
+ ),
315
371
  archives,
316
372
  ...(archive !== undefined ? { archive } : {}),
317
373
  entries,
@@ -319,27 +375,46 @@ export class ConsoleApi {
319
375
  };
320
376
  }
321
377
 
322
- 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> {
323
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
+ }
324
394
  const kind: ConversationKind =
325
395
  stored.kind === "mcp"
326
396
  ? "outside"
327
- : stored.kind === "group"
328
- ? "group"
329
- : (team?.owns(stored.key) ?? "owner");
397
+ : stored.kind === "registered"
398
+ ? "plugin"
399
+ : stored.kind === "group"
400
+ ? "group"
401
+ : (team?.owns(stored.key) ?? "owner");
330
402
  return {
331
403
  key: stored.key,
332
404
  kind,
333
405
  id: stored.id,
334
406
  ...(stored.member ? { member: stored.member } : {}),
335
- ...(stored.kind === "mcp"
407
+ ...(stored.kind === "mcp" || stored.kind === "registered"
336
408
  ? {}
337
409
  : { channel: await this.#name(stored.id) }),
410
+ ...(stored.title === undefined ? {} : { title: stored.title }),
338
411
  liveBytes: stored.liveBytes,
339
412
  archives: stored.archives,
340
413
  ...(stored.lastActive ? { lastActive: stored.lastActive } : {}),
341
414
  ...(stored.firstMessage ? { firstMessage: stored.firstMessage } : {}),
342
415
  ...(stored.startedAt ? { startedAt: stored.startedAt } : {}),
416
+ ...(record ? { visibility: record.visibility } : {}),
417
+ ...(principal ? { principal: await principal } : {}),
343
418
  // A group's turns queue under the group channel, not under each member's conversation.
344
419
  busy: queue.size(
345
420
  stored.kind === "group" ? `discord:${stored.id}` : stored.key,
@@ -347,6 +422,11 @@ export class ConsoleApi {
347
422
  };
348
423
  }
349
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
+
350
430
  async #name(channelId: string): Promise<ChannelName> {
351
431
  const lookup = this.#ports.channelName;
352
432
  if (!lookup) return { kind: "unknown" };
@@ -360,9 +440,43 @@ export class ConsoleApi {
360
440
 
361
441
  // ── Notes ──────────────────────────────────────────────────────────────
362
442
 
363
- async #notes(request: Request, id: string | undefined): Promise<Response> {
364
- const { memory } = this.#ports;
365
- 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);
366
480
  const method = request.method;
367
481
  if (!id) {
368
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. */