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/CHANGELOG.md +12 -0
- package/README.md +72 -34
- package/dist/chunk-0vqnvf92.js +36 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/api-types.ts +33 -3
- package/src/cloudflare-access.ts +35 -9
- package/src/console-api.ts +155 -41
- package/src/console-server.ts +35 -13
- package/src/conversations.ts +49 -5
- package/src/index.ts +7 -2
- package/src/options.ts +11 -5
- package/src/verifier.ts +23 -5
- package/src/visitors.ts +89 -0
- package/src/web-plugin.ts +22 -5
- package/dist/chunk-mr32cg74.js +0 -36
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-
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
84
|
-
|
|
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;
|
package/src/cloudflare-access.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { createRemoteJWKSet, type JWTVerifyGetKey, jwtVerify } from "jose";
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
83
|
+
return admitAs({ provider, subject, name: email });
|
|
58
84
|
};
|
|
59
85
|
}
|
package/src/console-api.ts
CHANGED
|
@@ -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
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
276
|
-
const
|
|
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(
|
|
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
|
|
292
|
-
const parsed =
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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 === "
|
|
328
|
-
? "
|
|
329
|
-
:
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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") {
|
package/src/console-server.ts
CHANGED
|
@@ -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
|
|
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
|
|
117
|
-
if (refusal
|
|
118
|
-
this.#options.logger.warn(
|
|
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(
|
|
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
|
-
/**
|
|
171
|
-
async #
|
|
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
|
|
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. */
|