pi-roundtable-webchat 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.
@@ -35,7 +35,7 @@ export class Connections {
35
35
 
36
36
  /** Takes a place for an upgrade that is about to open; false when the person holds every place. */
37
37
  reserve(connection: Connection): boolean {
38
- const id = connection.identity.id;
38
+ const id = connection.speaker.principalId;
39
39
  const held = this.#held.get(id) ?? 0;
40
40
  if (held >= this.#perPrincipal) return false;
41
41
  this.#held.set(id, held + 1);
@@ -53,7 +53,7 @@ export class Connections {
53
53
  clearTimeout(connection.pending);
54
54
  connection.pending = undefined;
55
55
  connection.socket = socket;
56
- const id = connection.identity.id;
56
+ const id = connection.speaker.principalId;
57
57
  const set = this.#byPrincipal.get(id) ?? new Set();
58
58
  set.add(connection);
59
59
  this.#byPrincipal.set(id, set);
@@ -63,7 +63,7 @@ export class Connections {
63
63
  closed(connection: Connection): void {
64
64
  for (const timer of connection.timers) clearTimeout(timer);
65
65
  connection.timers = [];
66
- const id = connection.identity.id;
66
+ const id = connection.speaker.principalId;
67
67
  const set = this.#byPrincipal.get(id);
68
68
  set?.delete(connection);
69
69
  if (set?.size === 0) this.#byPrincipal.delete(id);
@@ -75,7 +75,7 @@ export class Connections {
75
75
  connection.pending = undefined;
76
76
  if (!connection.holding) return;
77
77
  connection.holding = false;
78
- const id = connection.identity.id;
78
+ const id = connection.speaker.principalId;
79
79
  const held = (this.#held.get(id) ?? 1) - 1;
80
80
  if (held > 0) this.#held.set(id, held);
81
81
  else this.#held.delete(id);
@@ -0,0 +1,66 @@
1
+ import {
2
+ type ConversationRegistry,
3
+ channelKey,
4
+ type DirectChannelProvider,
5
+ type IdentityService,
6
+ } from "pi-roundtable";
7
+ import { Refusal, type WebChat } from "./chat.ts";
8
+ import type { PgNotices } from "./notices.ts";
9
+
10
+ export interface WebDirectChannelOptions {
11
+ chat: WebChat;
12
+ identity(): IdentityService;
13
+ registry(): ConversationRegistry;
14
+ notices: PgNotices;
15
+ }
16
+
17
+ /** A private inbox, known offline through a same-surface identity or private conversation. */
18
+ export function webDirectChannel(
19
+ options: WebDirectChannelOptions,
20
+ ): DirectChannelProvider {
21
+ const { chat, identity, registry, notices } = options;
22
+ const surface = chat.surface.surface;
23
+ const knows = async (principalId: string) => {
24
+ const principal = await identity().principal(principalId);
25
+ if (!principal || principal.disabled) return false;
26
+ const linked = await identity().identities(principalId);
27
+ // OIDC links carry an issuer, not a chat surface. They alone cannot prove
28
+ // membership in this inbox: another webchat might use that same issuer.
29
+ if (linked.some((link) => link.provider === surface)) return true;
30
+ return (await registry().list({ principal: principalId })).some(
31
+ (record) => record.surface === surface && record.visibility === "private",
32
+ );
33
+ };
34
+ return {
35
+ name: `webchat:${surface}`,
36
+ label: "a notice in the web inbox",
37
+ knows,
38
+ reaches: async (principalId) => {
39
+ if (!(await knows(principalId))) return undefined;
40
+ const speaker = await identity().speakerFor(principalId);
41
+ const available = chat.personasFor(speaker);
42
+ const persona = available[0];
43
+ if (!persona) return undefined;
44
+ const records = await chat.list(speaker);
45
+ const existing = records.find((record) =>
46
+ available.some((p) => p.kind === record.kind),
47
+ );
48
+ const key = existing?.key ?? channelKey(surface, crypto.randomUUID());
49
+ if (!existing)
50
+ await registry().register({
51
+ key,
52
+ kind: persona.kind,
53
+ visibility: "private",
54
+ principalId,
55
+ });
56
+ // Proves ownership, teaches the surface the recipient, and never changes visibility.
57
+ await chat.own(speaker, chat.surface.conversationOf(key));
58
+ return key;
59
+ },
60
+ deliver: async (principalId, text) => {
61
+ if (!(await knows(principalId))) throw new Refusal("forbidden");
62
+ const notice = await notices.add(principalId, text);
63
+ chat.connections.sendTo(principalId, { type: "notice", notice });
64
+ },
65
+ };
66
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- export type { WebAccess, WebAccessMap, WebTierMembers } from "./access.ts";
2
- export { webAccess } from "./access.ts";
3
1
  export type { WebChatLimits, WebPersona } from "./chat.ts";
2
+ export type { Notice } from "./notices.ts";
4
3
  export type {
5
4
  OidcJwtVerifierOptions,
6
5
  TokenVerifier,
7
6
  WebIdentity,
8
7
  } from "./oidc.ts";
9
8
  export {
9
+ identityActor,
10
10
  oidcJwtVerifier,
11
11
  oidcSpeakerId,
12
12
  parseOidcSpeakerId,
package/src/notices.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+
4
+ /** A private inbox entry, independent of conversation transcripts. */
5
+ export interface Notice {
6
+ id: string;
7
+ text: string;
8
+ createdAt: string;
9
+ readAt: string | null;
10
+ }
11
+
12
+ interface Row {
13
+ id: string;
14
+ text: string;
15
+ created_at: Date;
16
+ read_at: Date | null;
17
+ }
18
+
19
+ const noticeOf = (row: Row): Notice => ({
20
+ id: row.id,
21
+ text: row.text,
22
+ createdAt: row.created_at.toISOString(),
23
+ readAt: row.read_at?.toISOString() ?? null,
24
+ });
25
+
26
+ export interface NoticeLimits {
27
+ messageChars?: number;
28
+ maxBufferedBytes?: number;
29
+ /** Inbox namespace; default web, matching the plugin's default surface. */
30
+ surface?: string;
31
+ }
32
+
33
+ /** Fixed retention and text caps bound storage and REST response sizes. */
34
+ export const MAX_NOTICES = 100;
35
+ const MAX_NOTICE_CHARS = 4096;
36
+ // ASCII UUID and timestamps plus JSON field names fit within this reserve.
37
+ const FRAME_OVERHEAD = 256;
38
+
39
+ /** The first `length` UTF-16 units of `text`, one shorter when that would split a surrogate pair. */
40
+ const cut = (text: string, length: number): string => {
41
+ const last = text.charCodeAt(length - 1);
42
+ return text.slice(0, last >= 0xd800 && last <= 0xdbff ? length - 1 : length);
43
+ };
44
+
45
+ /** Notices scoped by surface and principal in every query, including marking one read. */
46
+ export class PgNotices {
47
+ readonly #sql: SQL;
48
+ readonly #textChars: number;
49
+ readonly #surface: string;
50
+
51
+ constructor(sql: SQL, limits: NoticeLimits = {}) {
52
+ this.#sql = sql;
53
+ this.#surface = limits.surface ?? "web";
54
+ const bytes = limits.maxBufferedBytes ?? 4 * 1024 * 1024;
55
+ const chars = limits.messageChars ?? 32_000;
56
+ if (!Number.isSafeInteger(bytes) || bytes < FRAME_OVERHEAD + 6)
57
+ throw new Error(
58
+ "webChat: maxBufferedBytes must be an integer of at least 262 bytes to carry a notice frame",
59
+ );
60
+ if (!Number.isSafeInteger(chars) || chars < 1)
61
+ throw new Error("webChat: messageChars must be a positive integer");
62
+ // One UTF-16 unit costs at most six bytes after JSON escaping, including
63
+ // control characters and lone surrogates. Reserve space for the envelope.
64
+ this.#textChars = Math.min(
65
+ chars,
66
+ MAX_NOTICE_CHARS,
67
+ Math.floor((bytes - FRAME_OVERHEAD) / 6),
68
+ );
69
+ }
70
+
71
+ static readonly migration: Migration = {
72
+ name: "notices",
73
+ up: async (sql) => {
74
+ await sql`CREATE TABLE IF NOT EXISTS webchat_notices (
75
+ surface TEXT NOT NULL, principal_id TEXT NOT NULL, id UUID PRIMARY KEY, text TEXT NOT NULL,
76
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), read_at TIMESTAMPTZ
77
+ )`;
78
+ await sql`CREATE INDEX IF NOT EXISTS webchat_notices_surface_principal_created
79
+ ON webchat_notices (surface, principal_id, created_at DESC, id)`;
80
+ },
81
+ };
82
+
83
+ async add(principalId: string, text: string): Promise<Notice> {
84
+ // A cut between a surrogate pair would store U+FFFD, so it moves before the pair.
85
+ const bounded =
86
+ text.length > this.#textChars
87
+ ? `${cut(text, this.#textChars - 1)}…`
88
+ : text;
89
+ return this.#sql.begin(async (sql) => {
90
+ // Serialize insert/prune for one inbox even across pools/processes.
91
+ await sql`SELECT pg_advisory_xact_lock(hashtextextended(${`webchat-notices:${this.#surface}:${principalId}`}, 0))`;
92
+ const rows: Row[] =
93
+ await sql`INSERT INTO webchat_notices (surface, principal_id, id, text, created_at)
94
+ VALUES (${this.#surface}, ${principalId}, ${crypto.randomUUID()}, ${bounded}, clock_timestamp()) RETURNING *`;
95
+ if (!rows[0]) throw new Error("webchat notice insert returned no row");
96
+ await sql`DELETE FROM webchat_notices WHERE surface = ${this.#surface} AND principal_id = ${principalId} AND id IN (
97
+ SELECT id FROM webchat_notices WHERE surface = ${this.#surface} AND principal_id = ${principalId}
98
+ ORDER BY created_at DESC, id DESC OFFSET ${MAX_NOTICES}
99
+ )`;
100
+ return noticeOf(rows[0]);
101
+ });
102
+ }
103
+
104
+ /** Latest entries, with a bounded page; before is the last id of the previous page. */
105
+ async list(
106
+ principalId: string,
107
+ limit = 50,
108
+ before?: string,
109
+ ): Promise<Notice[]> {
110
+ limit = Number.isInteger(limit)
111
+ ? Math.min(Math.max(limit, 1), MAX_NOTICES)
112
+ : 50;
113
+ const rows: Row[] = before
114
+ ? await this
115
+ .#sql`SELECT id, text, created_at, read_at FROM webchat_notices
116
+ WHERE surface = ${this.#surface} AND principal_id = ${principalId} AND (created_at, id) < (
117
+ SELECT created_at, id FROM webchat_notices WHERE id = ${before}::uuid AND surface = ${this.#surface} AND principal_id = ${principalId}
118
+ ) ORDER BY created_at DESC, id DESC LIMIT ${limit}`
119
+ : await this
120
+ .#sql`SELECT id, text, created_at, read_at FROM webchat_notices
121
+ WHERE surface = ${this.#surface} AND principal_id = ${principalId} ORDER BY created_at DESC, id DESC LIMIT ${limit}`;
122
+ return rows.map(noticeOf);
123
+ }
124
+
125
+ async read(principalId: string, id: string): Promise<Notice | undefined> {
126
+ const rows: Row[] = await this
127
+ .#sql`UPDATE webchat_notices SET read_at = COALESCE(read_at, NOW())
128
+ WHERE surface = ${this.#surface} AND principal_id = ${principalId} AND id = ${id}::uuid RETURNING *`;
129
+ return rows[0] ? noticeOf(rows[0]) : undefined;
130
+ }
131
+ }
package/src/oidc.ts CHANGED
@@ -5,9 +5,12 @@ import {
5
5
  type JWTVerifyGetKey,
6
6
  jwtVerify,
7
7
  } from "jose";
8
+ import type { ActorFacts } from "pi-roundtable";
8
9
 
9
10
  /** Who a verified token names, and until when it may be trusted. */
10
11
  export interface WebIdentity {
12
+ /** Verified actor facts for core; provider must start with `oidc:` or equal this chat's surface. */
13
+ actor?: ActorFacts;
11
14
  /** The speaker id: reversible and unique across issuers, such as `oidc:<base64url(issuer)>:<sub>`. */
12
15
  id: string;
13
16
  /** How the person is shown, from the token's name claim. */
@@ -59,6 +62,35 @@ export function parseOidcSpeakerId(
59
62
  return { issuer, subject };
60
63
  }
61
64
 
65
+ /** Core facts for a verified identity, with roles scoped to this chat's surface. */
66
+ export function identityActor(
67
+ identity: WebIdentity,
68
+ surface = "web",
69
+ ): ActorFacts {
70
+ const provider = identity.actor?.provider;
71
+ if (
72
+ provider !== undefined &&
73
+ !provider.startsWith("oidc:") &&
74
+ provider !== surface
75
+ )
76
+ throw new TokenRefused(
77
+ `custom actor provider must start with "oidc:" or equal this webchat surface ${JSON.stringify(surface)}`,
78
+ );
79
+ const oidc = parseOidcSpeakerId(identity.id);
80
+ return {
81
+ ...(identity.actor ?? {
82
+ provider: oidc
83
+ ? `oidc:${Buffer.from(oidc.issuer).toString("base64url")}`
84
+ : surface,
85
+ subject: oidc?.subject ?? identity.id,
86
+ legacyId: identity.id,
87
+ }),
88
+ name: identity.name,
89
+ surface,
90
+ roles: identity.roles.map((role) => `${surface}:role:${role}`),
91
+ };
92
+ }
93
+
62
94
  export interface OidcJwtVerifierOptions {
63
95
  /** The provider's published signing keys (`jwks_uri`): https, or http on a loopback address. */
64
96
  jwksUrl: string;
@@ -225,12 +257,23 @@ export function oidcJwtVerifier(
225
257
  text(options.nameClaim ? claims[options.nameClaim] : claims.name) ??
226
258
  text(claims.preferred_username) ??
227
259
  subject;
260
+ const issuer = speakerIssuer ?? (claims.iss as string);
261
+ const id = oidcSpeakerId(issuer, subject);
262
+ const roleNames = Array.isArray(roles)
263
+ ? roles.filter((role): role is string => typeof role === "string")
264
+ : [];
228
265
  return {
229
- id: oidcSpeakerId(speakerIssuer ?? (claims.iss as string), subject),
266
+ actor: {
267
+ provider: `oidc:${Buffer.from(issuer).toString("base64url")}`,
268
+ subject,
269
+ name,
270
+ surface: "web",
271
+ legacyId: id,
272
+ roles: roleNames.map((role) => `web:role:${role}`),
273
+ },
274
+ id,
230
275
  name,
231
- roles: Array.isArray(roles)
232
- ? roles.filter((role): role is string => typeof role === "string")
233
- : [],
276
+ roles: roleNames,
234
277
  expiresAt: new Date((claims.exp as number) * 1000),
235
278
  };
236
279
  };
package/src/plugin.ts CHANGED
@@ -2,13 +2,13 @@ import {
2
2
  CONVERSATIONS,
3
3
  definePlugin,
4
4
  type HttpRoute,
5
+ IDENTITY,
5
6
  type PluginContext,
6
7
  type RoundtablePlugin,
7
8
  RUNTIME,
8
9
  type WebSocketAccept,
9
10
  type WebSocketRoute,
10
11
  } from "pi-roundtable";
11
- import { type WebAccess, type WebAccessMap, webAccess } from "./access.ts";
12
12
  import {
13
13
  type Admitted,
14
14
  checkPersonas,
@@ -18,6 +18,8 @@ import {
18
18
  type WebPersona,
19
19
  } from "./chat.ts";
20
20
  import type { Connection } from "./connections.ts";
21
+ import { webDirectChannel } from "./direct-channel.ts";
22
+ import { PgNotices } from "./notices.ts";
21
23
  import { TokenRefused, type TokenVerifier } from "./oidc.ts";
22
24
  import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
23
25
  import { restHandler } from "./rest.ts";
@@ -40,8 +42,6 @@ export interface WebChatRouteLimits {
40
42
  export interface WebChatOptions {
41
43
  /** Checks each bearer token, such as `oidcJwtVerifier({ ... })`. */
42
44
  verifier: TokenVerifier;
43
- /** Who may chat and at which tier: an access map, or `webAccess(map)`. */
44
- access: WebAccessMap | WebAccess;
45
45
  /** The conversation kinds a person may open. */
46
46
  personas: readonly WebPersona[];
47
47
  /**
@@ -75,13 +75,14 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
75
75
  ticketTtlMs: 30_000,
76
76
  };
77
77
 
78
- const isAccess = (access: WebAccessMap | WebAccess): access is WebAccess =>
79
- typeof (access as WebAccess).tierOf === "function";
80
-
81
78
  function checkOptions(options: WebChatOptions): {
82
79
  path: string;
83
80
  surface: string;
84
81
  } {
82
+ if ("access" in options)
83
+ throw new Error(
84
+ 'webChat: access was removed; use top-level access on RoundtableConfig: members/admins roles become "<surface>:role:<role>", users become identities, and owners become access.owners[].identities. Replace everyone: true with everyone: ["<surface>"] (substitute this chat\'s surface, default "web"); core everyone: true opens every surface.',
85
+ );
85
86
  const path = options.path ?? "/chat";
86
87
  if (!/^\/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$/.test(path))
87
88
  throw new Error(
@@ -119,19 +120,17 @@ function offered(request: Request): string[] {
119
120
  export function webChat(options: WebChatOptions): RoundtablePlugin {
120
121
  const { path, surface } = checkOptions(options);
121
122
  const limits = { ...DEFAULT_LIMITS, ...options.limits };
122
- const access = isAccess(options.access)
123
- ? options.access
124
- : webAccess(options.access);
125
123
  // The personas are checked now, so a broken list stops the configuration rather than the boot.
126
124
  checkPersonas(options.personas);
127
125
  return definePlugin({
128
126
  name: surface === "web" ? "webchat" : `webchat-${surface}`,
129
- requires: [CONVERSATIONS, RUNTIME],
127
+ requires: [IDENTITY, CONVERSATIONS, RUNTIME],
128
+ migrations: [PgNotices.migration],
130
129
  setup: (context: PluginContext) => {
131
130
  const chat = new WebChat({
132
131
  surface,
133
132
  verifier: options.verifier,
134
- access,
133
+ identity: () => context.services.get(IDENTITY),
135
134
  personas: options.personas,
136
135
  limits,
137
136
  logger: context.logger,
@@ -145,8 +144,10 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
145
144
  ttlMs: limits.ticketTtlMs,
146
145
  perPrincipal: limits.connectionsPerPrincipal,
147
146
  });
147
+ const notices = new PgNotices(context.database(), { ...limits, surface });
148
148
  const rest = restHandler({
149
149
  chat,
150
+ notices,
150
151
  tickets,
151
152
  path,
152
153
  origins: options.origins,
@@ -172,9 +173,11 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
172
173
  try {
173
174
  if (token) admitted = await chat.admit(token);
174
175
  else if (ticket) {
175
- const identity = tickets.redeem(ticket);
176
- if (!identity) return refuse(401, "Unauthorized");
177
- admitted = chat.admitIdentity(identity);
176
+ const held = tickets.redeem(ticket);
177
+ if (!held) return refuse(401, "Unauthorized");
178
+ admitted = await chat.admitIdentity(held.identity);
179
+ if (admitted.speaker.principalId !== held.principalId)
180
+ return refuse(403, "Forbidden");
178
181
  } else return refuse(401, "Unauthorized");
179
182
  } catch (error) {
180
183
  if (error instanceof TokenRefused) {
@@ -216,6 +219,14 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
216
219
  return {
217
220
  surfaces: [chat.surface],
218
221
  channels: [chat.claim()],
222
+ directChannels: [
223
+ webDirectChannel({
224
+ chat,
225
+ notices,
226
+ identity: () => context.services.get(IDENTITY),
227
+ registry: () => context.services.get(CONVERSATIONS),
228
+ }),
229
+ ],
219
230
  personas: chat.contributedPersonas,
220
231
  http: [route],
221
232
  };
package/src/prompts.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  type Approval,
3
3
  type OwnerAnswer,
4
- type OwnerPrompts,
5
4
  type OwnerQuestion,
6
- type Speaker,
5
+ type PromptScope,
6
+ type Prompts,
7
7
  TIERS,
8
8
  type Tier,
9
9
  } from "pi-roundtable";
@@ -16,7 +16,7 @@ import type {
16
16
 
17
17
  /** Who answers: the conversation's person, at their tier as of the answer. */
18
18
  export interface Answerer {
19
- id: string;
19
+ principalId: string;
20
20
  tier: Tier;
21
21
  }
22
22
 
@@ -54,17 +54,17 @@ export class PromptDesk {
54
54
  }
55
55
 
56
56
  /**
57
- * The prompts of a conversation for the speaker whose turn runs there. An approval whose tier
58
- * the speaker lacks expires at once without being shown: no one else can answer in a private
59
- * conversation, so the call stays held, as an unanswered card leaves it.
57
+ * The prompts of a conversation for the scope's speaker, whose turn runs there. An approval
58
+ * whose tier the speaker lacks expires at once without being shown: no one else can answer in
59
+ * a private conversation, so the call stays held, as an unanswered card leaves it.
60
60
  */
61
- prompts(conversation: string, speaker: Speaker): OwnerPrompts {
61
+ prompts(conversation: string, scope: PromptScope): Prompts {
62
62
  return {
63
63
  confirm: async (title, message, signal, minTier = "owner") => {
64
- if (!atLeast(speaker.tier, minTier)) return "expired";
64
+ if (!atLeast(scope.tier, minTier)) return "expired";
65
65
  const outcome = await this.#ask(
66
66
  conversation,
67
- speaker.id,
67
+ scope.principalId,
68
68
  (id) => ({ id, kind: "approval", title, message }),
69
69
  signal,
70
70
  minTier,
@@ -74,7 +74,7 @@ export class PromptDesk {
74
74
  ask: async (title, question, signal) => {
75
75
  const { outcome, answer } = await this.#ask(
76
76
  conversation,
77
- speaker.id,
77
+ scope.principalId,
78
78
  (id) => questionFrame(id, title, question),
79
79
  signal,
80
80
  );
@@ -102,7 +102,7 @@ export class PromptDesk {
102
102
  ): ErrorCode | undefined {
103
103
  const open = this.#open.get(prompt);
104
104
  if (open?.frame.kind !== "approval") return "unknown_prompt";
105
- if (open.principal !== from.id) return "forbidden";
105
+ if (open.principal !== from.principalId) return "forbidden";
106
106
  if (open.minTier && !atLeast(from.tier, open.minTier)) return "forbidden";
107
107
  open.settle(approved ? "approved" : "declined");
108
108
  return undefined;
@@ -116,7 +116,7 @@ export class PromptDesk {
116
116
  ): ErrorCode | undefined {
117
117
  const open = this.#open.get(prompt);
118
118
  if (open?.frame.kind !== "ask") return "unknown_prompt";
119
- if (open.principal !== from.id) return "forbidden";
119
+ if (open.principal !== from.principalId) return "forbidden";
120
120
  if (!fits(open.frame, answer)) return "bad_frame";
121
121
  open.settle("answered", answer);
122
122
  return undefined;
package/src/protocol.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AskOption, Tier, TurnProgress } from "pi-roundtable";
2
+ import type { Notice } from "./notices.ts";
2
3
 
3
4
  /**
4
5
  * The WebSocket subprotocol a client offers, and the server echoes, for this version of the
@@ -94,7 +95,7 @@ export type ServerFrame =
94
95
  | {
95
96
  type: "ready";
96
97
  protocol: typeof WEBCHAT_PROTOCOL_VERSION;
97
- speaker: { id: string; name: string; tier: Tier };
98
+ speaker: { id: string; name: string; tier: Tier; principalId: string };
98
99
  personas: readonly PersonaSummary[];
99
100
  /** When the token expires, as an ISO time. */
100
101
  expiresAt: string;
@@ -125,6 +126,8 @@ export type ServerFrame =
125
126
  prompt: string;
126
127
  outcome: PromptOutcome;
127
128
  }
129
+ /** A durable private inbox entry; fetch the REST inbox to recover missed notices. */
130
+ | { type: "notice"; notice: Notice }
128
131
  /** The token expires soon: send `auth` with a fresh one before `expiresAt`, or the server closes with 4401. */
129
132
  | { type: "reauth"; expiresAt: string }
130
133
  /** A frame was refused; `ref` is the `send` id or prompt id it was about. */
package/src/rest.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { ConversationRecord, Logger } from "pi-roundtable";
2
2
  import { type Admitted, Refusal, type WebChat } from "./chat.ts";
3
+ import { MAX_NOTICES, type PgNotices } from "./notices.ts";
3
4
  import { TokenRefused } from "./oidc.ts";
4
5
  import type { TicketBook } from "./tickets.ts";
5
6
 
6
7
  export interface RestOptions {
7
8
  chat: WebChat;
8
9
  tickets: TicketBook;
10
+ notices: PgNotices;
9
11
  /** The route's path, such as `/chat`, without a trailing slash. */
10
12
  path: string;
11
13
  /** The browser origins allowed to call the API; "any" answers every origin. */
@@ -17,6 +19,7 @@ export interface RestOptions {
17
19
  const MAX_LIMIT = 500;
18
20
  const DEFAULT_LIMIT = 50;
19
21
  const MAX_BODY_BYTES = 16 * 1024;
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
23
 
21
24
  type HeaderMap = Record<string, string>;
22
25
 
@@ -69,7 +72,7 @@ const STATUS: Record<string, number> = {
69
72
  * is allowed and 403 otherwise.
70
73
  */
71
74
  export function restHandler(options: RestOptions) {
72
- const { chat, tickets, path, origins, logger } = options;
75
+ const { chat, tickets, notices, path, origins, logger } = options;
73
76
  const surface = chat.surface.surface;
74
77
  const cors = (origin: string | null): HeaderMap | undefined => {
75
78
  if (origin === null) return {};
@@ -85,9 +88,34 @@ export function restHandler(options: RestOptions) {
85
88
  const rest = url.pathname.slice(path.length + 1);
86
89
  const { speaker, identity } = who;
87
90
  if (rest === "tickets" && request.method === "POST") {
88
- const { ticket, expiresAt } = tickets.issue(identity);
91
+ const { ticket, expiresAt } = tickets.issue(
92
+ identity,
93
+ speaker.principalId,
94
+ );
89
95
  return json({ ticket, expiresAt: expiresAt.toISOString() }, 201, headers);
90
96
  }
97
+ if (rest === "notices" && request.method === "GET") {
98
+ const before = url.searchParams.get("before") ?? undefined;
99
+ if (before !== undefined && !UUID.test(before))
100
+ throw new Refusal("bad_frame");
101
+ const asked = Number(url.searchParams.get("limit") ?? DEFAULT_LIMIT);
102
+ const limit = Number.isInteger(asked)
103
+ ? Math.min(Math.max(asked, 1), MAX_NOTICES)
104
+ : DEFAULT_LIMIT;
105
+ return json(
106
+ { notices: await notices.list(speaker.principalId, limit, before) },
107
+ 200,
108
+ headers,
109
+ );
110
+ }
111
+ const readNotice = /^notices\/([^/]+)\/read$/.exec(rest);
112
+ if (readNotice?.[1] && request.method === "POST") {
113
+ if (!UUID.test(readNotice[1])) throw new Refusal("bad_frame");
114
+ const notice = await notices.read(speaker.principalId, readNotice[1]);
115
+ return notice
116
+ ? json({ notice }, 200, headers)
117
+ : json({ error: "not_found" }, 404, headers);
118
+ }
91
119
  if (rest === "conversations" && request.method === "GET") {
92
120
  const records = await chat.list(speaker);
93
121
  return json(
package/src/surface.ts CHANGED
@@ -3,10 +3,10 @@ import {
3
3
  type ChatSurface,
4
4
  type InboundMessage,
5
5
  type OutboundReply,
6
- type OwnerPrompts,
7
6
  PluginError,
7
+ type PromptScope,
8
+ type Prompts,
8
9
  parseChannelKey,
9
- type Speaker,
10
10
  type TurnProgress,
11
11
  } from "pi-roundtable";
12
12
  import type { PromptDesk } from "./prompts.ts";
@@ -111,12 +111,16 @@ export class WebSurface implements ChatSurface {
111
111
  return this.#toggle(channel, "stoppable");
112
112
  }
113
113
 
114
- /** Prompts for the conversation's own person only; a turn for anyone else asks nothing here. */
115
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
116
- if (!speaker) return undefined;
114
+ /**
115
+ * Prompts for the conversation's own person only; a turn for anyone else asks nothing here.
116
+ * Its owners are not on the web chat, so a prompt above the person's tier goes to no one,
117
+ * whatever the scope escalates to.
118
+ */
119
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
120
+ if (!scope) return undefined;
117
121
  const { principal, conversation } = this.#to(channel);
118
- if (speaker.id !== principal) return undefined;
119
- return this.#options.prompts.prompts(conversation, speaker);
122
+ if (scope.principalId !== principal) return undefined;
123
+ return this.#options.prompts.prompts(conversation, scope);
120
124
  }
121
125
 
122
126
  progress(channel: ChannelKey, event: TurnProgress): void {