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.
- package/CHANGELOG.md +18 -0
- package/README.md +55 -26
- package/package.json +3 -3
- package/src/chat.ts +169 -31
- package/src/connections.ts +4 -4
- package/src/direct-channel.ts +66 -0
- package/src/index.ts +2 -2
- package/src/notices.ts +131 -0
- package/src/oidc.ts +47 -4
- package/src/plugin.ts +25 -14
- package/src/prompts.ts +12 -12
- package/src/protocol.ts +4 -1
- package/src/rest.ts +30 -2
- package/src/surface.ts +11 -7
- package/src/tickets.ts +16 -8
- package/src/access.ts +0 -99
package/src/connections.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
176
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
|
58
|
-
* the speaker lacks expires at once without being shown: no one else can answer in
|
|
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,
|
|
61
|
+
prompts(conversation: string, scope: PromptScope): Prompts {
|
|
62
62
|
return {
|
|
63
63
|
confirm: async (title, message, signal, minTier = "owner") => {
|
|
64
|
-
if (!atLeast(
|
|
64
|
+
if (!atLeast(scope.tier, minTier)) return "expired";
|
|
65
65
|
const outcome = await this.#ask(
|
|
66
66
|
conversation,
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
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 (
|
|
119
|
-
return this.#options.prompts.prompts(conversation,
|
|
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 {
|