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.
@@ -9,8 +9,12 @@ import {
9
9
  statSync,
10
10
  } from "node:fs";
11
11
  import { join } from "node:path";
12
- import type { ChannelKey } from "pi-roundtable";
13
- import { textOf } from "pi-roundtable/kit";
12
+ import {
13
+ type ChannelKey,
14
+ type ConversationRecord,
15
+ parseChannelKey,
16
+ } from "pi-roundtable";
17
+ import { channelSegment, textOf } from "pi-roundtable/kit";
14
18
  import type { TranscriptEntry } from "./api-types.ts";
15
19
 
16
20
  const FIRST_MESSAGE_CHARS = 80;
@@ -41,8 +45,11 @@ export interface ConversationFiles {
41
45
  lastActive?: string;
42
46
  }
43
47
 
44
- /** The kinds of session directory the console reads. */
45
- export type StoredKind = "discord" | "group" | "mcp";
48
+ /**
49
+ * The kinds of session directory the console reads: the three it recognises by name, and any
50
+ * other conversation the host's registry records.
51
+ */
52
+ export type StoredKind = "discord" | "group" | "mcp" | "registered";
46
53
 
47
54
  /** An owner, agent, group-member, or outside-agent conversation found on disk. */
48
55
  export interface StoredConversation extends ConversationFiles {
@@ -52,9 +59,11 @@ export interface StoredConversation extends ConversationFiles {
52
59
  id: string;
53
60
  /** Group conversations only: the agent whose conversation inside the group this is. */
54
61
  member?: string;
55
- /** Outside-agent conversations only. */
62
+ /** Outside-agent and registered conversations only. */
56
63
  firstMessage?: string;
57
64
  startedAt?: string;
65
+ /** Registered conversations only: the name the registry gives it. */
66
+ title?: string;
58
67
  }
59
68
 
60
69
  /** The session directory a channel key names, or undefined when the key is not one the console reads. */
@@ -253,6 +262,41 @@ export function storedConversations(
253
262
  );
254
263
  }
255
264
 
265
+ /** The session directory of a conversation the registry records, by its key. */
266
+ export function registeredDir(key: ChannelKey): string {
267
+ return channelSegment(key);
268
+ }
269
+
270
+ /**
271
+ * The conversations the host's registry records and the directory scan does not recognise, with
272
+ * their files; one whose first turn left no session file has nothing to read and is left out.
273
+ */
274
+ export function registeredConversations(
275
+ sessionsDir: string,
276
+ records: readonly ConversationRecord[],
277
+ options: {
278
+ excluded: (key: ChannelKey) => boolean;
279
+ relayNotes: readonly string[];
280
+ },
281
+ ): StoredConversation[] {
282
+ const found: StoredConversation[] = [];
283
+ for (const record of records) {
284
+ if (parseKey(record.key) || options.excluded(record.key)) continue;
285
+ const dir = join(sessionsDir, registeredDir(record.key));
286
+ const files = conversationFiles(dir);
287
+ if (!files) continue;
288
+ found.push({
289
+ key: record.key,
290
+ kind: "registered",
291
+ id: parseChannelKey(record.key).id,
292
+ ...files,
293
+ ...opening(dir, options.relayNotes),
294
+ ...(record.title === undefined ? {} : { title: record.title }),
295
+ });
296
+ }
297
+ return found;
298
+ }
299
+
256
300
  /** The archive folders of a conversation, newest first. */
257
301
  export function archiveNames(dir: string): string[] {
258
302
  const archiveDir = join(dir, "archive");
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,
@@ -0,0 +1,89 @@
1
+ import type {
2
+ ActorFacts,
3
+ IdentityService,
4
+ Logger,
5
+ Principal,
6
+ } from "pi-roundtable";
7
+
8
+ /** Who a request the console admitted comes from: an owner's principal. */
9
+ export interface Visitor {
10
+ principal: Principal;
11
+ }
12
+
13
+ /** The visitor of a request, or why it is refused, for the operator's log. */
14
+ export type Identified = { visitor: Visitor } | { refusal: string };
15
+
16
+ /** The reads of `IDENTITY` the console uses; none of them makes or changes a principal. */
17
+ export type ConsoleIdentity = Pick<
18
+ IdentityService,
19
+ "principalOf" | "principal" | "tierOf" | "owners"
20
+ >;
21
+
22
+ export interface VisitorOptions {
23
+ /** Whom a request from a verifier that reports no actor stands for; default the primary owner. */
24
+ ownerId?: string;
25
+ logger: Logger;
26
+ }
27
+
28
+ /** The provider of the aliases 0.9 keeps for 0.8 ids; `IDENTITY.resolve` refuses it as no surface's. */
29
+ const LEGACY_PROVIDER = "legacy";
30
+
31
+ /** How many unlinked identities are remembered, so each is warned about once. */
32
+ const WARNED_MAX = 100;
33
+
34
+ /**
35
+ * Identifies the person behind each request the verifier admitted, by reading `IDENTITY` only, so
36
+ * a visitor never makes a principal: the principal their identity is linked to, who must hold the
37
+ * owner role. An identity linked to no one is refused on every host, however many owners it has:
38
+ * the verifier may vouch for members too, so only a link says whose an identity is. A verifier
39
+ * that reports no actor speaks for `ownerId`, default the primary owner, as in 0.8.
40
+ */
41
+ export function consoleVisitors(
42
+ identity: ConsoleIdentity,
43
+ options: VisitorOptions,
44
+ ): (actor: ActorFacts | undefined) => Promise<Identified> {
45
+ const { logger } = options;
46
+ const warned = new Set<string>();
47
+ const warnOnce = (key: string, message: string) => {
48
+ if (warned.has(key) || warned.size >= WARNED_MAX) return;
49
+ warned.add(key);
50
+ logger.warn(`web-console: ${message}`);
51
+ };
52
+
53
+ const owner = async (id: string): Promise<Identified> => {
54
+ const principal = await identity.principal(id);
55
+ if (!principal) return { refusal: `there is no principal ${id}` };
56
+ if (principal.disabled) return { refusal: `principal ${id} is disabled` };
57
+ if ((await identity.tierOf(id)) !== "owner")
58
+ return { refusal: `principal ${id} holds no owner role` };
59
+ return { visitor: { principal } };
60
+ };
61
+
62
+ return async (actor) => {
63
+ if (!actor) {
64
+ warnOnce(
65
+ "",
66
+ "the verifier reports no actor, so the console takes every request it admits as the primary owner's. Have it report who signed in (cloudflareAccess does)",
67
+ );
68
+ const id = options.ownerId ?? (await identity.owners())[0]?.id;
69
+ return id === undefined
70
+ ? { refusal: "the host has no owner" }
71
+ : owner(id);
72
+ }
73
+ const named = `${actor.provider}:${actor.subject}`;
74
+ if (actor.provider === LEGACY_PROVIDER)
75
+ return { refusal: `${named} is a 0.8 id, not an identity` };
76
+ let id: string | undefined;
77
+ try {
78
+ id = await identity.principalOf(named);
79
+ } catch {
80
+ return { refusal: "the verifier's actor is no identity" };
81
+ }
82
+ if (id !== undefined) return owner(id);
83
+ warnOnce(
84
+ named,
85
+ `${named} is linked to no principal, so the console refuses it. If it is an owner's, add "${named}" to that owner's access.owners[].identities, or run: roundtable principal link <owner's principal id> ${named}`,
86
+ );
87
+ return { refusal: `${named} is linked to no principal` };
88
+ };
89
+ }
package/src/web-plugin.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  AGENTS,
3
+ CONVERSATIONS,
3
4
  definePlugin,
5
+ IDENTITY,
4
6
  MEMORY,
5
7
  type RoundtablePlugin,
6
8
  } from "pi-roundtable";
@@ -13,17 +15,20 @@ import {
13
15
  resolveOptions,
14
16
  type WebConsoleOptions,
15
17
  } from "./options.ts";
18
+ import { consoleVisitors } from "./visitors.ts";
16
19
 
17
20
  /**
18
- * The owner-only web console: conversations, transcripts, memory notes, and the agent team,
19
- * served on a route of the host's listener and updated live. The options are checked here, so
20
- * a missing verifier or a bad setting throws when the config is loaded.
21
+ * The owners' web console: conversations, transcripts, memory notes, and the agent team, served
22
+ * on a route of the host's listener and updated live. Every request needs the verifier to vouch
23
+ * for the person and `IDENTITY` to name them an owner. The options are checked here, so a missing
24
+ * verifier or a bad setting throws when the config is loaded.
21
25
  */
22
26
  export function webConsole(options: WebConsoleOptions): RoundtablePlugin {
23
27
  const settings = resolveOptions(options);
24
28
  return definePlugin({
25
29
  name: "web-console",
26
30
  requires: [
31
+ IDENTITY,
27
32
  ...(settings.panes.includes("overview") ? [AGENTS] : []),
28
33
  ...(settings.panes.includes("notes") ? [MEMORY] : []),
29
34
  ],
@@ -45,6 +50,9 @@ async function build(settings: ResolvedOptions, context: Context) {
45
50
  }
46
51
  const team = services.find(AGENTS)?.team;
47
52
  const connection = services.find(DISCORD)?.connection;
53
+ // Absent on a host without the registry: the console then lists conversations found by name only.
54
+ const registry = services.find(CONVERSATIONS);
55
+ const identity = services.get(IDENTITY);
48
56
  const listeners: (() => void)[] = [];
49
57
  const changed = () => {
50
58
  for (const listener of listeners) listener();
@@ -61,10 +69,15 @@ async function build(settings: ResolvedOptions, context: Context) {
61
69
  ...(connection
62
70
  ? { channelName: (id: string) => connection.channelInfo(id) }
63
71
  : {}),
64
- ...(settings.ownerId && settings.panes.includes("notes")
65
- ? { memory: services.get(MEMORY).forSpeaker(settings.ownerId) }
72
+ ...(settings.panes.includes("notes")
73
+ ? {
74
+ memoryOf: (principalId: string) =>
75
+ services.get(MEMORY).forSpeaker(principalId),
76
+ }
66
77
  : {}),
78
+ people: identity,
67
79
  ...(settings.exclude ? { exclude: settings.exclude } : {}),
80
+ ...(registry ? { registry } : {}),
68
81
  relayNotes: settings.relayNotes,
69
82
  changed,
70
83
  logger,
@@ -76,6 +89,10 @@ async function build(settings: ResolvedOptions, context: Context) {
76
89
  mount: settings.mount,
77
90
  assets,
78
91
  verifier: settings.verifier,
92
+ identify: consoleVisitors(identity, {
93
+ logger,
94
+ ...(settings.ownerId ? { ownerId: settings.ownerId } : {}),
95
+ }),
79
96
  origin: settings.origin,
80
97
  pathRouting: settings.routing === "path",
81
98
  ...(settings.presentation ? { presentation: settings.presentation } : {}),