agent-comms 9.1.3 → 10.0.1

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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +12 -0
  4. package/dist/bridges/user/web/bridge-port-range.d.ts +9 -3
  5. package/dist/bridges/user/web/bridge-port-range.d.ts.map +1 -1
  6. package/dist/bridges/user/web/bridge-port-range.js +17 -4
  7. package/dist/bridges/user/web/bridge-port-range.js.map +1 -1
  8. package/dist/bridges/user/web/dist/bundle.js +1 -1
  9. package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
  10. package/dist/bridges/user/web/dist/sw.js +1 -1
  11. package/dist/bridges/user/web/pairing-handshake.d.ts +17 -0
  12. package/dist/bridges/user/web/pairing-handshake.d.ts.map +1 -0
  13. package/dist/bridges/user/web/pairing-handshake.js +121 -0
  14. package/dist/bridges/user/web/pairing-handshake.js.map +1 -0
  15. package/dist/bridges/user/web/pairing-protocol.d.ts +64 -0
  16. package/dist/bridges/user/web/pairing-protocol.d.ts.map +1 -0
  17. package/dist/bridges/user/web/pairing-protocol.js +65 -0
  18. package/dist/bridges/user/web/pairing-protocol.js.map +1 -0
  19. package/dist/bridges/user/web/server.d.ts +6 -0
  20. package/dist/bridges/user/web/server.d.ts.map +1 -1
  21. package/dist/bridges/user/web/server.js +91 -60
  22. package/dist/bridges/user/web/server.js.map +1 -1
  23. package/dist/bridges/user/web/web-access.d.ts +9 -1
  24. package/dist/bridges/user/web/web-access.d.ts.map +1 -1
  25. package/dist/bridges/user/web/web-access.js +32 -7
  26. package/dist/bridges/user/web/web-access.js.map +1 -1
  27. package/dist/bridges/user/web/web-listen.d.ts +11 -0
  28. package/dist/bridges/user/web/web-listen.d.ts.map +1 -0
  29. package/dist/bridges/user/web/web-listen.js +53 -0
  30. package/dist/bridges/user/web/web-listen.js.map +1 -0
  31. package/dist/bridges/user/web/web-pairing-exchange.d.ts +68 -0
  32. package/dist/bridges/user/web/web-pairing-exchange.d.ts.map +1 -0
  33. package/dist/bridges/user/web/web-pairing-exchange.js +201 -0
  34. package/dist/bridges/user/web/web-pairing-exchange.js.map +1 -0
  35. package/dist/bridges/user/web/web-pairing.d.ts +58 -0
  36. package/dist/bridges/user/web/web-pairing.d.ts.map +1 -0
  37. package/dist/bridges/user/web/web-pairing.js +215 -0
  38. package/dist/bridges/user/web/web-pairing.js.map +1 -0
  39. package/dist/cli-main.d.ts.map +1 -1
  40. package/dist/cli-main.js +12 -1
  41. package/dist/cli-main.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/bridges/user/web/bridge-port-range.ts +24 -4
  44. package/src/bridges/user/web/dist/bundle.js +1 -1
  45. package/src/bridges/user/web/dist/mesh-worker.js +4 -4
  46. package/src/bridges/user/web/dist/sw.js +1 -1
  47. package/src/bridges/user/web/pairing-handshake.ts +170 -0
  48. package/src/bridges/user/web/pairing-protocol.ts +122 -0
  49. package/src/bridges/user/web/server.ts +137 -69
  50. package/src/bridges/user/web/web-access.ts +40 -6
  51. package/src/bridges/user/web/web-listen.ts +68 -0
  52. package/src/bridges/user/web/web-pairing-exchange.ts +292 -0
  53. package/src/bridges/user/web/web-pairing.ts +273 -0
  54. package/src/cli-main.ts +14 -1
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Network exposure policy for the bridge web server (agent-comms#346).
3
3
  *
4
- * The default is loopback only, with no token. Setting AGENT_COMMS_WEB_HOST to a non-loopback address is the explicit opt-in to LAN reachability. Because the server exposes every mutating action (POST /api/action and the /ws/mesh oRPC socket), a non-loopback bind always comes with a per-process secret token. Every request, from any client, must carry a Host header naming this server (the DNS rebinding defence: a rebound name resolves here but arrives with the attacker's name in Host), and any Origin it carries must be this server over http (the cross-site defence: a page open in the operator's own browser reaches loopback as readily as a LAN page does, so the check cannot be limited to remote clients). The one other origin admitted, and only from a client on this machine, is the hosted dashboard (see HOSTED_DASHBOARD_ORIGIN). A client on this machine (loopback, or one of this machine's own interface addresses) needs nothing more. Any other client must also present the token, as a bearer token or as the cookie a one-time `?token=` URL exchange sets.
4
+ * The default is loopback only, with no token. Setting AGENT_COMMS_WEB_HOST to a non-loopback address is the explicit opt-in to LAN reachability. Because the server exposes every mutating action (POST /api/action and the /ws/mesh oRPC socket), a non-loopback bind always comes with a per-process secret token. Every request, from any client, must carry a Host header naming this server (the DNS rebinding defence: a rebound name resolves here but arrives with the attacker's name in Host), and any Origin it carries must be this server over http (the cross-site defence: a page open in the operator's own browser reaches loopback as readily as a LAN page does, so the check cannot be limited to remote clients). The one other origin admitted, and only from a client on this machine and only on the two sockets of the pairing exchange (web-pairing.ts), is the hosted dashboard (see HOSTED_DASHBOARD_ORIGIN). A client on this machine (loopback, or one of this machine's own interface addresses) needs nothing more. Any other client must also present the token, as a bearer token or as the cookie a one-time `?token=` URL exchange sets.
5
5
  */
6
6
 
7
7
  import * as crypto from "node:crypto";
8
8
  import * as net from "node:net";
9
9
  import * as os from "node:os";
10
10
  import { HOSTED_DASHBOARD_ORIGIN } from "./hosted-dashboard.js";
11
+ import { HELLO_PATH, MESH_PATH } from "./pairing-protocol.js";
12
+ import { presentedTicket } from "./web-pairing.js";
11
13
 
12
14
  /** The environment variable that opts the web server into binding beyond loopback. Its value is an IP address literal to bind: `0.0.0.0` or `::` for every interface, or one interface's own address. */
13
15
  export const WEB_HOST_ENV = "AGENT_COMMS_WEB_HOST";
@@ -40,6 +42,16 @@ const HTTP_FOUND = 302;
40
42
  /** The authentication scheme of an `Authorization` header carrying the token. Scheme names are case-insensitive (RFC 9110 section 11.1). */
41
43
  const BEARER_SCHEME = "bearer";
42
44
 
45
+ /** Whether an Origin header names the hosted dashboard's origin. */
46
+ export function isHostedDashboardOrigin(origin: string | undefined): boolean {
47
+ if (origin === undefined) return false;
48
+ try {
49
+ return new URL(origin).origin === HOSTED_DASHBOARD_ORIGIN;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
43
55
  /** How long the address sets derived from the machine's interfaces and hostname are reused. Reading them costs a system call per interface, which every request would otherwise pay; this bounds how long a newly attached interface waits to be recognised. */
44
56
  const INTERFACE_CACHE_MS = 5000;
45
57
 
@@ -173,6 +185,10 @@ export interface AccessPolicy {
173
185
  port: number;
174
186
  /** The addresses whose clients count as on this machine besides loopback, read per request. A client connecting to this machine's own LAN address arrives from that address and not from loopback, so without this the `web_url` of a server bound to one interface address would answer 401 to the operator. Tests replace it to stand in for a remote client. */
175
187
  readonly localClientAddresses: () => ReadonlySet<string>;
188
+ /** The tickets that admit the hosted dashboard's mesh socket (web-pairing-exchange.ts). Undefined admits no request from that origin to any path but the exchange's own. */
189
+ readonly tickets?:
190
+ | Readonly<{ consume: (presented: string | undefined) => boolean }>
191
+ | undefined;
176
192
  }
177
193
 
178
194
  /** The parts of an incoming request the gate looks at. Shared by HTTP requests and WebSocket upgrades (an upgrade is an HTTP request). */
@@ -263,12 +279,28 @@ function hostHeaderAllowed(
263
279
  return allowedHostnames(policy.bindHost).has(authority.hostname);
264
280
  }
265
281
 
266
- /** An Origin, when the browser sends one, must be this very server over http: otherwise a page from anywhere (on the LAN, or open in the operator's own browser) could drive it. The hosted dashboard is the one other origin, and only for a client on this machine. */
282
+ /** What a page of the hosted dashboard's origin may reach: the exchange's own socket with nothing, and the mesh socket with a ticket the exchange issued, which is spent by being presented. Both only as a WebSocket upgrade, so none of the HTTP routes and no ticket is spent by a plain request. */
283
+ function hostedPageAllowed(
284
+ policy: Readonly<AccessPolicy>,
285
+ request: AccessRequest,
286
+ ): boolean {
287
+ if (headerValue(request.headers, "upgrade")?.toLowerCase() !== "websocket") {
288
+ return false;
289
+ }
290
+ if (request.url === HELLO_PATH) return true;
291
+ return (
292
+ request.url === MESH_PATH &&
293
+ policy.tickets?.consume(presentedTicket(request.headers)) === true
294
+ );
295
+ }
296
+
297
+ /** An Origin, when the browser sends one, must be this very server over http: otherwise a page from anywhere (on the LAN, or open in the operator's own browser) could drive it. The hosted dashboard is the one other origin, and only for a client on this machine that opens the exchange's socket, or the mesh socket with a ticket from it. */
267
298
  function originAllowed(
268
299
  policy: Readonly<AccessPolicy>,
269
- headers: AccessRequest["headers"],
300
+ request: AccessRequest,
270
301
  ownClient: boolean,
271
302
  ): boolean {
303
+ const { headers } = request;
272
304
  const origin = headerValue(headers, "origin");
273
305
  if (origin === undefined) return true;
274
306
  let parsed: URL;
@@ -277,7 +309,9 @@ function originAllowed(
277
309
  } catch {
278
310
  return false;
279
311
  }
280
- if (ownClient && parsed.origin === HOSTED_DASHBOARD_ORIGIN) return true;
312
+ if (parsed.origin === HOSTED_DASHBOARD_ORIGIN) {
313
+ return ownClient && hostedPageAllowed(policy, request);
314
+ }
281
315
  if (parsed.protocol !== "http:") return false;
282
316
  const authority = parseAuthority(parsed.host);
283
317
  if (authority?.port !== policy.port) return false;
@@ -323,7 +357,7 @@ function presentedCredential(
323
357
  }
324
358
 
325
359
  /** True for a client on this machine: loopback, or one of this machine's own interface addresses. */
326
- function isOwnClient(
360
+ export function isOwnClient(
327
361
  policy: Readonly<AccessPolicy>,
328
362
  remoteAddress: string | undefined,
329
363
  ): boolean {
@@ -345,7 +379,7 @@ export function authorise(
345
379
  return { kind: "deny", status: HTTP_FORBIDDEN, message: "Forbidden host" };
346
380
  }
347
381
  const ownClient = isOwnClient(policy, request.remoteAddress);
348
- if (!originAllowed(policy, request.headers, ownClient)) {
382
+ if (!originAllowed(policy, request, ownClient)) {
349
383
  return {
350
384
  kind: "deny",
351
385
  status: HTTP_FORBIDDEN,
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Binding the web server's listening socket: the first free port of a range, each port bound for real, with a fallback to an operating-system port. Split from server.ts to keep that file under the line cap.
3
+ */
4
+
5
+ import { once } from "node:events";
6
+ import type * as http from "node:http";
7
+
8
+ /** Whether a listen failure means the port is already taken. */
9
+ function isAddressInUse(error: unknown): boolean {
10
+ return (
11
+ typeof error === "object" &&
12
+ error !== null &&
13
+ "code" in error &&
14
+ error.code === "EADDRINUSE"
15
+ );
16
+ }
17
+
18
+ /** Binds `server` to `port` on `host`; true once listening, false when the port is taken, and rejects on any other failure. */
19
+ async function tryListen(
20
+ server: http.Server,
21
+ port: number,
22
+ host: string,
23
+ ): Promise<boolean> {
24
+ // once() rejects when the server emits "error" before "listening", and removes both of its listeners either way.
25
+ const listening = once(server, "listening");
26
+ server.listen(port, host);
27
+ try {
28
+ await listening;
29
+ return true;
30
+ } catch (error) {
31
+ if (isAddressInUse(error)) return false;
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Binds `server` to the first of `ports` that is free, in order, and returns the port it holds.
38
+ *
39
+ * Each port is bound for real, so a port another process takes between two attempts is simply skipped, where a test-then-bind would hand two processes the same port. Only "already in use" moves on to the next port: any other bind error rejects with that error. When `fallBackToAnyPort` is set and every port is taken the operating system picks one; when nothing could be bound the error names every port tried.
40
+ */
41
+ export async function listen(
42
+ server: http.Server,
43
+ host: string,
44
+ ports: readonly number[],
45
+ fallBackToAnyPort: boolean,
46
+ ): Promise<number> {
47
+ for (const port of ports) {
48
+ if (await tryListen(server, port, host)) return boundPortOf(server);
49
+ }
50
+ if (fallBackToAnyPort && (await tryListen(server, 0, host))) {
51
+ return boundPortOf(server);
52
+ }
53
+ const tried = fallBackToAnyPort
54
+ ? [...ports.map(String), "an operating-system port"]
55
+ : ports.map(String);
56
+ throw new Error(
57
+ `Agent Comms web UI: every port tried is already in use on ${host}: ${tried.join(", ")}`,
58
+ );
59
+ }
60
+
61
+ /** The port a listening server holds. */
62
+ function boundPortOf(server: http.Server): number {
63
+ const addr = server.address();
64
+ if (typeof addr !== "object" || addr === null) {
65
+ throw new Error("Agent Comms web UI: the listening server has no address");
66
+ }
67
+ return addr.port;
68
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The bridge's half of the proof exchange that admits the hosted dashboard (web-pairing.ts describes the design): the keyed proof, the single-use tickets a completed exchange issues, and the handler of one `/ws/hello` socket.
3
+ *
4
+ * A listener that does not hold the secret is told nothing and given nothing by this module beyond a nonce: the bridge's proof is keyed with the secret, and the dashboard's proof is checked before any ticket is issued.
5
+ */
6
+
7
+ import * as crypto from "node:crypto";
8
+ import {
9
+ NONCE_BYTES,
10
+ isHelloAuth,
11
+ isHelloRequest,
12
+ parseHelloMessage,
13
+ proofInput,
14
+ type HelloReply,
15
+ type HelloTicket,
16
+ type ProofTranscript,
17
+ } from "./pairing-protocol.js";
18
+ import type { PairingSecretStore } from "./web-pairing.js";
19
+
20
+ /** How long a ticket admits a mesh socket after it is issued. The dashboard opens the socket at once, so a few seconds is far above the real delay and keeps an unused ticket from being worth holding. */
21
+ export const TICKET_LIFETIME_MS = 10_000;
22
+
23
+ /** The most tickets that wait to be used at once: a bridge has one dashboard, so a few is generous, and a client that asks for more is refused rather than served. */
24
+ export const MAX_PENDING_TICKETS = 16;
25
+
26
+ /** How long one exchange may take from its first byte to the ticket, which is a few small frames over loopback. */
27
+ export const HELLO_TIMEOUT_MS = 5000;
28
+
29
+ /** The most exchanges in progress at once. */
30
+ export const MAX_HELLO_SOCKETS = 16;
31
+
32
+ /** The largest message of the exchange: a JSON object of at most two 64-character hex values. */
33
+ export const MAX_HELLO_MESSAGE_BYTES = 1024;
34
+
35
+ /** WebSocket close code for a message that breaks the endpoint's policy, used for every refusal so none says which check failed. */
36
+ const CLOSE_POLICY_VIOLATION = 1008;
37
+ const CLOSE_NORMAL = 1000;
38
+
39
+ /** The proof a party holding `secret` gives over this exchange, as hex. */
40
+ export function computeProof(
41
+ secret: string,
42
+ transcript: Readonly<ProofTranscript>,
43
+ ): string {
44
+ return crypto
45
+ .createHmac("sha256", Buffer.from(secret, "hex"))
46
+ .update(proofInput(transcript))
47
+ .digest("hex");
48
+ }
49
+
50
+ /** Whether `presented` is the proof for this exchange under `secret`, compared in constant time. */
51
+ export function proofMatches(
52
+ presented: string,
53
+ secret: string,
54
+ transcript: Readonly<ProofTranscript>,
55
+ ): boolean {
56
+ const expected = Buffer.from(computeProof(secret, transcript), "hex");
57
+ const given = Buffer.from(presented, "hex");
58
+ return (
59
+ given.length === expected.length && crypto.timingSafeEqual(given, expected)
60
+ );
61
+ }
62
+
63
+ /** The single-use tickets a completed exchange issues. */
64
+ export interface TicketBook {
65
+ /** A fresh ticket, or undefined when too many wait to be used. */
66
+ issue: () => string | undefined;
67
+ /** Whether `presented` is a ticket that was issued, has not been used or expired, and was issued under the secret still in force. Spends it either way: a ticket is offered once. */
68
+ consume: (presented: string | undefined) => boolean;
69
+ }
70
+
71
+ /** A ticket book over a secret store: a ticket issued under a secret that has since been replaced is never accepted. */
72
+ export function createTicketBook(
73
+ secret: Readonly<PairingSecretStore>,
74
+ now: () => number = Date.now,
75
+ ): TicketBook {
76
+ const pending = new Map<string, { expires: number; generation: number }>();
77
+ const prune = (): void => {
78
+ const current = now();
79
+ for (const [ticket, entry] of pending) {
80
+ if (entry.expires <= current) pending.delete(ticket);
81
+ }
82
+ };
83
+ return {
84
+ issue: () => {
85
+ prune();
86
+ if (pending.size >= MAX_PENDING_TICKETS) return undefined;
87
+ secret.current();
88
+ const ticket = crypto.randomBytes(NONCE_BYTES).toString("hex");
89
+ pending.set(ticket, {
90
+ expires: now() + TICKET_LIFETIME_MS,
91
+ generation: secret.generation(),
92
+ });
93
+ return ticket;
94
+ },
95
+ consume: (presented) => {
96
+ if (presented === undefined) return false;
97
+ prune();
98
+ const entry = pending.get(presented);
99
+ if (entry === undefined) return false;
100
+ pending.delete(presented);
101
+ secret.current();
102
+ return entry.generation === secret.generation();
103
+ },
104
+ };
105
+ }
106
+
107
+ /** What serving a hello socket needs. */
108
+ export interface HelloOptions {
109
+ secret: Readonly<PairingSecretStore>;
110
+ tickets: Readonly<TicketBook>;
111
+ /** The port the server listens on, which both proofs are keyed over. */
112
+ port: () => number;
113
+ /** The timer in force for the exchange; tests replace it. */
114
+ timeoutMs?: number;
115
+ }
116
+
117
+ /** The text of one WebSocket message as `ws` hands it over. */
118
+ function textOf(data: Buffer | ArrayBuffer | readonly Buffer[]): string {
119
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString();
120
+ if (Buffer.isBuffer(data)) return data.toString();
121
+ return Buffer.concat([...data]).toString();
122
+ }
123
+
124
+ function newNonce(): string {
125
+ return crypto.randomBytes(NONCE_BYTES).toString("hex");
126
+ }
127
+
128
+ /** The parts of a `ws` socket the exchange uses, so a test can stand in for one. */
129
+ export interface HelloSocket {
130
+ on: ((
131
+ event: "message",
132
+ listener: (
133
+ data: Buffer | ArrayBuffer | readonly Buffer[],
134
+ isBinary: boolean,
135
+ ) => void,
136
+ ) => unknown) &
137
+ ((event: "close" | "error", listener: () => void) => unknown);
138
+ send: (data: string, callback?: () => void) => void;
139
+ close: (code?: number) => void;
140
+ terminate: () => void;
141
+ }
142
+
143
+ /**
144
+ * Runs the bridge's half of the exchange on one `/ws/hello` socket: answers the dashboard's nonce with the bridge's own and a proof, checks the dashboard's proof, and on success sends one ticket and closes. Any other message, a message of the wrong form, a wrong proof, a secret that was replaced meanwhile or silence past the timeout closes the socket with the same status and no explanation.
145
+ */
146
+ export function serveHello(
147
+ socket: Readonly<HelloSocket>,
148
+ options: Readonly<HelloOptions>,
149
+ ): void {
150
+ let stage: "hello" | "auth" | "done" = "hello";
151
+ let exchange:
152
+ | {
153
+ secret: string;
154
+ clientNonce: string;
155
+ bridgeNonce: string;
156
+ generation: number;
157
+ }
158
+ | undefined;
159
+
160
+ const timer: { handle?: NodeJS.Timeout } = {};
161
+ const refuse = (): void => {
162
+ stage = "done";
163
+ clearTimeout(timer.handle);
164
+ socket.close(CLOSE_POLICY_VIOLATION);
165
+ };
166
+ timer.handle = setTimeout(refuse, options.timeoutMs ?? HELLO_TIMEOUT_MS);
167
+ timer.handle.unref();
168
+ socket.on("close", () => {
169
+ stage = "done";
170
+ clearTimeout(timer.handle);
171
+ });
172
+ // A socket error is that connection's alone; the close that follows ends the exchange.
173
+ socket.on("error", () => {
174
+ socket.terminate();
175
+ });
176
+
177
+ socket.on("message", (data, isBinary) => {
178
+ if (isBinary || stage === "done") {
179
+ refuse();
180
+ return;
181
+ }
182
+ const message = parseHelloMessage(textOf(data));
183
+ if (stage === "hello") {
184
+ if (!isHelloRequest(message)) {
185
+ refuse();
186
+ return;
187
+ }
188
+ const secret = options.secret.current();
189
+ const bridgeNonce = newNonce();
190
+ const port = options.port();
191
+ exchange = {
192
+ secret,
193
+ clientNonce: message.nonce,
194
+ bridgeNonce,
195
+ generation: options.secret.generation(),
196
+ };
197
+ const reply: HelloReply = {
198
+ type: "hello-ok",
199
+ nonce: bridgeNonce,
200
+ proof: computeProof(secret, {
201
+ role: "bridge",
202
+ clientNonce: message.nonce,
203
+ bridgeNonce,
204
+ port,
205
+ }),
206
+ };
207
+ stage = "auth";
208
+ socket.send(JSON.stringify(reply));
209
+ return;
210
+ }
211
+ if (!isHelloAuth(message) || exchange === undefined) {
212
+ refuse();
213
+ return;
214
+ }
215
+ const { secret, clientNonce, bridgeNonce, generation } = exchange;
216
+ const proven = proofMatches(message.proof, secret, {
217
+ role: "client",
218
+ clientNonce,
219
+ bridgeNonce,
220
+ port: options.port(),
221
+ });
222
+ options.secret.current();
223
+ if (!proven || options.secret.generation() !== generation) {
224
+ refuse();
225
+ return;
226
+ }
227
+ const ticket = options.tickets.issue();
228
+ if (ticket === undefined) {
229
+ refuse();
230
+ return;
231
+ }
232
+ stage = "done";
233
+ clearTimeout(timer.handle);
234
+ const reply: HelloTicket = { type: "ticket", ticket };
235
+ socket.send(JSON.stringify(reply), () => {
236
+ socket.close(CLOSE_NORMAL);
237
+ });
238
+ });
239
+ }
240
+
241
+ /** How often the server looks for sockets admitted under a secret that has been replaced, as a backstop to the watch on the secret file: a file system need not report every change, and the watch can fail to start. A rotation is otherwise acted on at once. */
242
+ export const ROTATION_SWEEP_MS = 1000;
243
+
244
+ /** WebSocket close code a socket gets when the secret it was admitted under is gone. */
245
+ const CLOSE_REVOKED = 1008;
246
+
247
+ /** The parts of a `ws` socket the guard uses. */
248
+ export interface AdmittedSocket {
249
+ close: (code?: number) => void;
250
+ on: (event: "close", listener: () => void) => unknown;
251
+ }
252
+
253
+ /** Tracks the mesh sockets admitted to the hosted dashboard, so that replacing the secret ends them. */
254
+ export interface HostedSocketGuard {
255
+ /** Starts tracking a socket admitted under the secret now in force, and ends it when that secret is replaced. */
256
+ track: (socket: Readonly<AdmittedSocket>) => void;
257
+ /** Whether the socket is still admitted; closes it and answers false when its secret has been replaced. */
258
+ check: (socket: Readonly<AdmittedSocket>) => boolean;
259
+ /** Closes every tracked socket whose secret has been replaced. Run on a timer, so an idle socket ends too. */
260
+ sweep: () => void;
261
+ }
262
+
263
+ export function createHostedSocketGuard(
264
+ secret: Readonly<PairingSecretStore>,
265
+ ): HostedSocketGuard {
266
+ const admitted = new Map<AdmittedSocket, number>();
267
+ const revoked = (generation: number): boolean => {
268
+ secret.current();
269
+ return secret.generation() !== generation;
270
+ };
271
+ const check = (socket: Readonly<AdmittedSocket>): boolean => {
272
+ const generation = admitted.get(socket);
273
+ if (generation === undefined) return true;
274
+ if (!revoked(generation)) return true;
275
+ admitted.delete(socket);
276
+ socket.close(CLOSE_REVOKED);
277
+ return false;
278
+ };
279
+ return {
280
+ track: (socket) => {
281
+ secret.current();
282
+ admitted.set(socket, secret.generation());
283
+ socket.on("close", () => {
284
+ admitted.delete(socket);
285
+ });
286
+ },
287
+ check,
288
+ sweep: () => {
289
+ for (const socket of [...admitted.keys()]) check(socket);
290
+ },
291
+ };
292
+ }