@a-t-h-i/bot-lobby 0.6.8 → 0.6.9

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/README.md CHANGED
@@ -260,6 +260,7 @@ footer.
260
260
  | **5 Metrics** | Run time, success rate, tokens and cost per model and agent |
261
261
  | **6 Git** | The repository's open pull requests; review one with an agent, or have Jev read it |
262
262
  | **7 Knowledge** | Everything each agent knows about the project; edit an entry, or leave a note every agent reads |
263
+ | **8 Excalidraw** | Up to five shared Excalidraw sessions, each assigned to one agent or several, who read the board and draw on it with you |
263
264
 
264
265
  ### Lobby
265
266
 
@@ -343,6 +344,51 @@ prompt says how many sections it left out and where the whole file is. Nothing
343
344
  is looked up on demand: what a step needs has to be in the file and near the
344
345
  top of its relevance, so keep entries short, one topic under one heading.
345
346
 
347
+ ### Excalidraw
348
+
349
+ A live [Excalidraw](https://excalidraw.com) room that you and your agents draw
350
+ in together. Add up to **five sessions**, assign each to **one agent or several**
351
+ (the oracle, Designer, Backend, QA, scouts, the researcher, quick fix, the
352
+ planner), and those agents can look at what you drew and add to it.
353
+
354
+ - `a` **adds a session**: in Excalidraw, *Share → Live collaboration → Start
355
+ session*, copy the link, paste it into the prompt (a name may follow it).
356
+ `n` **makes a new room** instead and shows its link, for you to open in
357
+ Excalidraw. Neither works past five sessions: `d d` removes one.
358
+ - `enter` moves into the checklist of agents; `enter` or `space` assigns the
359
+ picked agent (or takes the session back), `*` assigns every agent.
360
+ - `w` lets agents **draw** in the session or **only look** at it. An agent whose
361
+ sessions are all look-only gets no drawing tool.
362
+ - `t` **checks** the session: joins the room for a moment and says whether the
363
+ server can be reached, who is in it, and how much is on the board. `r` renames it.
364
+
365
+ **What an assigned agent can do.** It gets two tools and the room link:
366
+ `excalidraw_read` describes the board in words (shapes with their labels, arrows
367
+ as *from → to*, free text, each with its id and place), and `excalidraw_draw`
368
+ adds labelled rectangles, ellipses and diamonds, arrows between them (with
369
+ labels), free text and lines, or changes and deletes what is there by id. It
370
+ joins the room as its own collaborator (`Backend · bot-lobby`) with a cursor
371
+ where it drew, so you watch it work. The board stays yours: agents may move,
372
+ recolour and relabel what you drew, but delete only what agents drew (elements
373
+ they draw are marked, and Excalidraw cannot undo another collaborator's
374
+ deletion), and one call draws at most 100 shapes and removes at most 50.
375
+
376
+ - **You must have the session open in Excalidraw.** A room's board lives in the
377
+ browsers that are in it; an agent that joins an empty room can read nothing,
378
+ and is not allowed to draw, since nothing would keep it. It says so and asks
379
+ you to open the link.
380
+ - **Boards are other people's writing.** What an agent reads from a board comes
381
+ fenced as untrusted data, like a web page, and it is told never to follow
382
+ instructions written on it.
383
+ - **The link is a key.** Anyone with a room link can read and draw in the room, so
384
+ bot-lobby keeps your sessions with your own settings
385
+ (`~/.pi/bot-lobby/excalidraw/`, one file for each project) and never in the
386
+ project, where a commit could publish them. An agent's process is handed only the
387
+ links of the sessions assigned to it.
388
+ - Rooms are Excalidraw's own collaboration protocol, end-to-end encrypted with the
389
+ key in the link, over `oss-collab.excalidraw.com`. A self-hosted collaboration
390
+ server is used when `BOT_LOBBY_EXCALIDRAW_SERVER` names it.
391
+
346
392
  Common keys: `tab` switches tabs, `esc` browses (arrows, single-key
347
393
  commands), `ctrl+f` searches, `ctrl+s` saves the plan, `alt+o` browses
348
394
  sessions, `alt+n` starts a task in a new session, `alt+s` opens settings.
@@ -629,12 +675,15 @@ Live checks (spend tokens or need a key and network):
629
675
  BOT_LOBBY_E2E=1 node --test test/e2e.test.ts
630
676
  BOT_LOBBY_LIVE_WEB=1 node --test test/web.test.ts
631
677
  BOT_LOBBY_JEV_E2E=1 OPENCODE_API_KEY=… node --test test/jev-e2e.test.ts
678
+ BOT_LOBBY_LIVE_EXCALIDRAW=1 node --test test/excalidraw-live.test.ts
632
679
  ```
633
680
 
634
681
  Source layout: `src/workflow` (engine), `src/master` (delegation),
635
682
  `src/execution` (subagent processes), `src/lobby` (the UI),
636
683
  `src/classifier` (Jev), `src/state` (persistence), `src/ask` (the
637
- questionnaire), `src/web` (the web tools), `prompts/` (agent prompts).
684
+ questionnaire), `src/web` (the web tools), `src/excalidraw` (shared
685
+ Excalidraw sessions: the room protocol, the sessions, the agents' tools),
686
+ `prompts/` (agent prompts).
638
687
 
639
688
  ## Publishing
640
689
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -51,7 +51,11 @@
51
51
  "@earendil-works/pi-coding-agent": "0.87.0",
52
52
  "@earendil-works/pi-tui": "0.87.0",
53
53
  "@types/node": "^22.10.0",
54
+ "socket.io": "4.8.4",
54
55
  "typebox": "1.3.27",
55
56
  "typescript": "^5.7.0"
57
+ },
58
+ "dependencies": {
59
+ "socket.io-client": "4.8.4"
56
60
  }
57
61
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * "Does this session work?": join the room for a moment, as a seat named
3
+ * after the lobby, and say what is there — the server reached, who is in the
4
+ * room, how much is on the board, whether the link's key fits. The seat leaves
5
+ * again at once, so a check never leaves an extra collaborator in the room.
6
+ */
7
+ import { ExcalidrawRoom, type RoomOptions } from "./client.ts";
8
+ import { parseRoomLink } from "./room.ts";
9
+
10
+ export interface CheckResult {
11
+ ok: boolean;
12
+ text: string;
13
+ }
14
+
15
+ export async function checkSession(link: string, name: string, options: Pick<RoomOptions, "server" | "sceneWaitMs" | "connectTimeoutMs" | "connect"> = {}): Promise<CheckResult> {
16
+ const parsed = parseRoomLink(link);
17
+ if (!parsed) return { ok: false, text: "not a valid room link" };
18
+ const room = new ExcalidrawRoom({ link: parsed, username: "bot-lobby · check", name, ...options });
19
+ try {
20
+ const status = await room.ready();
21
+ // Give an answer to the newcomer's arrival a moment more when someone is there but the board has not come yet.
22
+ if (status.undecryptable > 0 && status.elements === 0) return { ok: false, text: "reached the room, but its messages would not open: the link's key does not match, so it may have been copied incompletely" };
23
+ if (status.peers === 0) return { ok: true, text: "reached the server; nobody has this session open yet — open the link in Excalidraw, and agents can read and draw" };
24
+ const who = status.people.length > 0 ? status.people.join(", ") : `${status.peers} other${status.peers === 1 ? "" : "s"}`;
25
+ return { ok: true, text: `reached the room with ${who}; ${status.synced ? `${status.elements} element${status.elements === 1 ? "" : "s"} on the board` : "the board has not been sent yet (it comes when someone in the room answers)"}` };
26
+ } catch (error) {
27
+ return { ok: false, text: (error as Error).message };
28
+ } finally {
29
+ room.close();
30
+ }
31
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * One agent's seat in an Excalidraw shared session. It joins the room the way
3
+ * a second browser tab would: over Excalidraw's collaboration socket, sending
4
+ * and receiving the encrypted scene messages every client in the room sends.
5
+ * Others in the room answer a newcomer with the whole scene, so a seat learns
6
+ * the board from whoever is there; what the agent draws goes out as an update
7
+ * that every browser merges in place, under the agent's name.
8
+ *
9
+ * A seat never answers a newcomer with a scene it does not have: a browser
10
+ * that took an empty scene from an agent would skip loading the board it saved
11
+ * itself. And it does not draw while nobody else is in the room, because
12
+ * nothing keeps a scene for a room but the browsers in it.
13
+ */
14
+ import { io, type Socket } from "socket.io-client";
15
+ import { DEFAULT_SERVER, seal, unseal, type RoomLink } from "./room.ts";
16
+ import { describeScene, mergeElements, plainText, planDraw, sceneBounds, visible, type DrawOutcome, type DrawRequest, type Scene, type SceneElement } from "./scene.ts";
17
+
18
+ /** The part of a socket.io client a seat uses; tests and other transports can stand in for it. */
19
+ export type SocketLike = Pick<Socket, "on" | "emit" | "close" | "connected"> & { id?: string | undefined };
20
+
21
+ export interface RoomOptions {
22
+ link: Pick<RoomLink, "roomId" | "roomKey">;
23
+ /** The name other people see next to this seat's cursor. */
24
+ username: string;
25
+ /** The session's name in the lobby, for readings and messages. */
26
+ name: string;
27
+ server?: string;
28
+ connect?: (server: string) => SocketLike;
29
+ /** Give up on reaching the server after this long. */
30
+ connectTimeoutMs?: number;
31
+ /** How long to wait for a collaborator to send the scene (Excalidraw's own client waits five seconds). */
32
+ sceneWaitMs?: number;
33
+ }
34
+
35
+ export interface RoomStatus {
36
+ connected: boolean;
37
+ /** Others in the room, people and agents, not counting this seat. */
38
+ peers: number;
39
+ /** A collaborator has sent the whole scene. */
40
+ synced: boolean;
41
+ elements: number;
42
+ /** Messages that would not open: the link's key does not match the room's. */
43
+ undecryptable: number;
44
+ /** Names the collaborators have given themselves. */
45
+ people: string[];
46
+ }
47
+
48
+ const CONNECT_TIMEOUT_MS = 10_000;
49
+ const SCENE_WAIT_MS = 5000;
50
+ /** The collaboration server relays at most a megabyte a message; a scene near that is not sent whole. */
51
+ const MAX_MESSAGE_CHARS = 900_000;
52
+
53
+ function defaultConnect(server: string): SocketLike {
54
+ return io(server, { transports: ["websocket", "polling"], autoUnref: true, timeout: 8000 });
55
+ }
56
+
57
+ export function collaborationServer(env: NodeJS.ProcessEnv = process.env): string {
58
+ return env.BOT_LOBBY_EXCALIDRAW_SERVER?.trim() || DEFAULT_SERVER;
59
+ }
60
+
61
+ export class ExcalidrawRoom {
62
+ readonly scene: Scene = new Map();
63
+ private readonly options: RoomOptions;
64
+ private socket: SocketLike | undefined;
65
+ private opening: Promise<RoomStatus> | undefined;
66
+ private peerIds: string[] = [];
67
+ private readonly names = new Map<string, string>();
68
+ private synced = false;
69
+ private undecryptable = 0;
70
+ private closed = false;
71
+ private timers: Array<ReturnType<typeof setTimeout>> = [];
72
+
73
+ constructor(options: RoomOptions) {
74
+ this.options = options;
75
+ }
76
+
77
+ get name(): string {
78
+ return this.options.name;
79
+ }
80
+
81
+ /** Which room this seat is in; a seat is replaced when its session's link changes. */
82
+ get linkKey(): string {
83
+ return `${this.options.link.roomId},${this.options.link.roomKey}`;
84
+ }
85
+
86
+ status(): RoomStatus {
87
+ return {
88
+ connected: this.socket?.connected ?? false,
89
+ peers: this.peerIds.length,
90
+ synced: this.synced,
91
+ elements: visible(this.scene).length,
92
+ undecryptable: this.undecryptable,
93
+ people: [...new Set(this.peerIds.map((id) => this.names.get(id)).filter((name): name is string => Boolean(name)))],
94
+ };
95
+ }
96
+
97
+ /** Join the room (once) and wait until a collaborator has sent the scene, or the wait for it is over. */
98
+ ready(): Promise<RoomStatus> {
99
+ this.opening ??= this.open();
100
+ return this.opening;
101
+ }
102
+
103
+ private open(): Promise<RoomStatus> {
104
+ return new Promise((resolve, reject) => {
105
+ // A seat that failed to connect once may try again.
106
+ this.closed = false;
107
+ const { roomId } = this.options.link;
108
+ const server = this.options.server ?? collaborationServer();
109
+ const socket = (this.options.connect ?? defaultConnect)(server);
110
+ this.socket = socket;
111
+ let done = false;
112
+ let lastError = "";
113
+ const settle = () => {
114
+ if (done) return;
115
+ done = true;
116
+ resolve(this.status());
117
+ this.announce();
118
+ };
119
+ this.timers.push(
120
+ setTimeout(() => {
121
+ if (done) return;
122
+ done = true;
123
+ this.opening = undefined;
124
+ this.close();
125
+ reject(new Error(`could not reach the Excalidraw collaboration server ${server}${lastError ? ` (${lastError})` : ""}`));
126
+ }, this.options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS),
127
+ );
128
+ socket.on("connect_error", (error: Error) => {
129
+ lastError = error.message;
130
+ });
131
+ // Every (re)connection is greeted with init-room; the seat answers by joining.
132
+ socket.on("init-room", () => {
133
+ socket.emit("join-room", roomId);
134
+ this.timers.push(setTimeout(settle, this.options.sceneWaitMs ?? SCENE_WAIT_MS));
135
+ });
136
+ socket.on("first-in-room", () => settle());
137
+ socket.on("room-user-change", (clients: string[]) => {
138
+ this.peerIds = clients.filter((id) => id !== socket.id);
139
+ for (const id of [...this.names.keys()]) if (!clients.includes(id)) this.names.delete(id);
140
+ });
141
+ socket.on("new-user", () => {
142
+ // What this seat does not know, it does not claim to.
143
+ if (this.synced || visible(this.scene).length > 0) void this.send("SCENE_INIT", [...this.scene.values()]);
144
+ });
145
+ socket.on("client-broadcast", (data: unknown, iv: unknown) => {
146
+ void this.receive(data, iv).then((initial) => {
147
+ if (initial) settle();
148
+ });
149
+ });
150
+ });
151
+ }
152
+
153
+ /** A received message; true when it was the scene a newcomer is waiting for. */
154
+ private async receive(data: unknown, iv: unknown): Promise<boolean> {
155
+ const message = (await unseal(this.options.link.roomKey, data, iv)) as { type?: string; payload?: Record<string, unknown> } | undefined;
156
+ if (!message || typeof message !== "object") {
157
+ this.undecryptable += 1;
158
+ return false;
159
+ }
160
+ const payload = message.payload ?? {};
161
+ switch (message.type) {
162
+ case "SCENE_INIT":
163
+ case "SCENE_UPDATE":
164
+ if (Array.isArray(payload.elements)) mergeElements(this.scene, payload.elements);
165
+ if (message.type === "SCENE_INIT") this.synced = true;
166
+ return message.type === "SCENE_INIT";
167
+ case "MOUSE_LOCATION":
168
+ case "IDLE_STATUS":
169
+ if (typeof payload.socketId === "string" && typeof payload.username === "string" && plainText(payload.username)) this.names.set(payload.socketId, plainText(payload.username).slice(0, 40));
170
+ return false;
171
+ default:
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /** Send a scene message to the room; false when the seat is not connected. */
177
+ private async send(type: "SCENE_INIT" | "SCENE_UPDATE" | "IDLE_STATUS" | "MOUSE_LOCATION", body: unknown): Promise<boolean> {
178
+ const socket = this.socket;
179
+ if (!socket?.connected || this.closed) return false;
180
+ const payload = type === "SCENE_INIT" || type === "SCENE_UPDATE" ? { elements: body } : body;
181
+ if (JSON.stringify(payload).length > MAX_MESSAGE_CHARS) return false;
182
+ const sealed = await seal(this.options.link.roomKey, { type, payload });
183
+ socket.emit("server-broadcast", this.options.link.roomId, sealed.data, sealed.iv);
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Tell the room who this seat is (its collaborator name), and where it just
189
+ * drew. Excalidraw sends these as volatile messages because it sends them
190
+ * constantly; a seat says it once, so it does not let the server drop it.
191
+ */
192
+ private announce(near?: { x: number; y: number }): void {
193
+ const socketId = this.socket?.id;
194
+ if (!socketId) return;
195
+ void this.send("IDLE_STATUS", { socketId, userState: "active", username: this.options.username });
196
+ if (near) void this.send("MOUSE_LOCATION", { socketId, pointer: { x: near.x, y: near.y, tool: "pointer" }, button: "up", selectedElementIds: {}, username: this.options.username });
197
+ }
198
+
199
+ /** Why nothing may be drawn right now, or undefined when it may. */
200
+ cannotDraw(): string | undefined {
201
+ if (!this.socket?.connected) return "this seat is not connected to the room";
202
+ if (this.peerIds.length === 0) return "nobody else is in the room, and only a browser keeps a room's board; ask the user to open the session's link in Excalidraw, then try again";
203
+ return undefined;
204
+ }
205
+
206
+ /** Draw: the elements go out to the room and into this seat's own scene. */
207
+ async draw(request: DrawRequest): Promise<DrawOutcome> {
208
+ const outcome = planDraw(this.scene, request);
209
+ if (outcome.changed.length === 0) return outcome;
210
+ const reason = this.cannotDraw();
211
+ if (reason) return { ...outcome, changed: [], drawn: [], removed: [], problems: [...outcome.problems, reason] };
212
+ for (const element of outcome.changed) this.scene.set(element.id, element);
213
+ await this.send("SCENE_UPDATE", outcome.changed);
214
+ const around = sceneBounds(outcome.changed.filter((element) => !element.isDeleted));
215
+ this.announce(around ? { x: around.x1, y: around.y1 } : undefined);
216
+ return outcome;
217
+ }
218
+
219
+ /** The board in words, for a model, with what is known about the room around it. */
220
+ read(): string {
221
+ const status = this.status();
222
+ const notes: string[] = [];
223
+ if (!status.connected) notes.push("Not connected to the room right now; this is the board as last seen.");
224
+ else if (status.undecryptable > 0 && status.elements === 0) notes.push("Messages in this room would not open: the session link's key does not match the room's, so it may have been copied incompletely.");
225
+ else if (status.peers === 0) notes.push("Nobody else is in the room, so the board above is not known: ask the user to open the session's link in Excalidraw, then read again.");
226
+ else if (!status.synced) notes.push("No collaborator has sent the whole board yet, so this may be incomplete; read again in a few seconds.");
227
+ if (status.peers > 0) notes.push(`In the room with you: ${status.people.length > 0 ? status.people.join(", ") : `${status.peers} other${status.peers === 1 ? "" : "s"}`}.`);
228
+ return [describeScene(this.scene, this.options.name), ...notes].join("\n");
229
+ }
230
+
231
+ elements(): SceneElement[] {
232
+ return visible(this.scene);
233
+ }
234
+
235
+ close(): void {
236
+ this.closed = true;
237
+ for (const timer of this.timers) clearTimeout(timer);
238
+ this.timers = [];
239
+ this.socket?.close();
240
+ }
241
+ }
242
+
243
+ /** The seats an agent has open, one per session, kept for the length of its run. */
244
+ export class RoomPool {
245
+ private readonly rooms = new Map<string, ExcalidrawRoom>();
246
+ private readonly make: (options: RoomOptions) => ExcalidrawRoom;
247
+
248
+ constructor(make: (options: RoomOptions) => ExcalidrawRoom = (options) => new ExcalidrawRoom(options)) {
249
+ this.make = make;
250
+ }
251
+
252
+ /** The seat for a session, opened on first use; a session whose link changed gets a new seat. */
253
+ room(key: string, options: RoomOptions): ExcalidrawRoom {
254
+ const existing = this.rooms.get(key);
255
+ if (existing && existing.linkKey === `${options.link.roomId},${options.link.roomKey}`) return existing;
256
+ existing?.close();
257
+ const room = this.make(options);
258
+ this.rooms.set(key, room);
259
+ return room;
260
+ }
261
+
262
+ closeAll(): void {
263
+ for (const room of this.rooms.values()) room.close();
264
+ this.rooms.clear();
265
+ }
266
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Excalidraw's shared-session ("live collaboration") rooms, the parts that do
3
+ * not touch the network: reading and making room links, and the AES-GCM
4
+ * envelope every message in a room travels in. Excalidraw's collaboration
5
+ * server only relays opaque bytes; the key sits in the link's `#room=id,key`
6
+ * fragment, which a browser never sends to any server, so whoever holds the
7
+ * link can read and draw in the room and nobody else can.
8
+ */
9
+ import { randomBytes, webcrypto } from "node:crypto";
10
+
11
+ /** Excalidraw's own collaboration server; `BOT_LOBBY_EXCALIDRAW_SERVER` points at a self-hosted one. */
12
+ export const DEFAULT_SERVER = "https://oss-collab.excalidraw.com";
13
+
14
+ /** The site a made-up room link opens on. */
15
+ export const DEFAULT_ORIGIN = "https://excalidraw.com";
16
+
17
+ const ROOM_ID = /^[a-zA-Z0-9_-]+$/;
18
+ /** A 128-bit key in base64url is exactly 22 characters. */
19
+ const ROOM_KEY = /^[a-zA-Z0-9_-]{22}$/;
20
+ const FRAGMENT = /#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
21
+ const IV_BYTES = 12;
22
+
23
+ export interface RoomLink {
24
+ roomId: string;
25
+ roomKey: string;
26
+ /** The link as Excalidraw writes it: `https://excalidraw.com/#room=<id>,<key>`. */
27
+ url: string;
28
+ }
29
+
30
+ /**
31
+ * A room link as people paste it: the full URL (any Excalidraw host, so a
32
+ * self-hosted one works), `#room=id,key`, or the bare `id,key`. Undefined when
33
+ * it is none of those or the key is not the 22 characters Excalidraw makes.
34
+ */
35
+ export function parseRoomLink(input: string): RoomLink | undefined {
36
+ const text = input.trim();
37
+ if (!text) return undefined;
38
+ let roomId: string | undefined;
39
+ let roomKey: string | undefined;
40
+ let origin = DEFAULT_ORIGIN;
41
+ const fragment = text.match(FRAGMENT);
42
+ if (fragment) {
43
+ [, roomId, roomKey] = fragment;
44
+ const before = text.slice(0, fragment.index);
45
+ if (before) {
46
+ try {
47
+ const parsed = new URL(before);
48
+ if (parsed.protocol === "https:" || parsed.protocol === "http:") origin = `${parsed.origin}${parsed.pathname === "/" ? "" : parsed.pathname}`;
49
+ else return undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ } else {
55
+ const bare = text.match(/^([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
56
+ if (!bare) return undefined;
57
+ [, roomId, roomKey] = bare;
58
+ }
59
+ if (!roomId || !roomKey || !ROOM_ID.test(roomId) || !ROOM_KEY.test(roomKey)) return undefined;
60
+ return { roomId, roomKey, url: `${origin.replace(/\/$/, "")}/#room=${roomId},${roomKey}` };
61
+ }
62
+
63
+ /** A fresh room: opening its link in Excalidraw starts the session, and the room is the first thing in it. */
64
+ export function newRoomLink(origin = DEFAULT_ORIGIN): RoomLink {
65
+ const roomId = randomBytes(10).toString("hex");
66
+ const roomKey = randomBytes(16).toString("base64url");
67
+ return { roomId, roomKey, url: `${origin.replace(/\/$/, "")}/#room=${roomId},${roomKey}` };
68
+ }
69
+
70
+ /** The link with its key hidden, for logs and lists. */
71
+ export function maskedLink(link: RoomLink): string {
72
+ return `room ${link.roomId}`;
73
+ }
74
+
75
+ const keys = new Map<string, Promise<webcrypto.CryptoKey>>();
76
+
77
+ function cryptoKey(roomKey: string, usage: "encrypt" | "decrypt"): Promise<webcrypto.CryptoKey> {
78
+ const id = `${usage}:${roomKey}`;
79
+ let key = keys.get(id);
80
+ if (!key) {
81
+ key = webcrypto.subtle.importKey("jwk", { alg: "A128GCM", ext: true, k: roomKey, key_ops: ["encrypt", "decrypt"], kty: "oct" }, { name: "AES-GCM", length: 128 }, false, [usage]);
82
+ keys.set(id, key);
83
+ if (keys.size > 32) keys.delete(keys.keys().next().value!);
84
+ }
85
+ return key;
86
+ }
87
+
88
+ export interface Sealed {
89
+ data: Buffer;
90
+ iv: Buffer;
91
+ }
92
+
93
+ /** A message as Excalidraw sends it: the JSON of `payload`, encrypted with a fresh IV. */
94
+ export async function seal(roomKey: string, payload: unknown): Promise<Sealed> {
95
+ const iv = randomBytes(IV_BYTES);
96
+ const encrypted = await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, await cryptoKey(roomKey, "encrypt"), new TextEncoder().encode(JSON.stringify(payload)));
97
+ return { data: Buffer.from(encrypted), iv };
98
+ }
99
+
100
+ function bytes(value: unknown): Uint8Array | undefined {
101
+ if (value instanceof Uint8Array) return value;
102
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
103
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
104
+ return undefined;
105
+ }
106
+
107
+ /** What `seal` made, opened; undefined when it is not ours to read (a wrong key, a damaged message). */
108
+ export async function unseal(roomKey: string, data: unknown, iv: unknown): Promise<unknown> {
109
+ const body = bytes(data);
110
+ const nonce = bytes(iv);
111
+ if (!body || !nonce) return undefined;
112
+ try {
113
+ const opened = await webcrypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, await cryptoKey(roomKey, "decrypt"), body);
114
+ return JSON.parse(new TextDecoder().decode(opened));
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }