@particle-academy/agent-integrations 0.42.0 → 0.44.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/README.md CHANGED
@@ -18,6 +18,35 @@ npm install @particle-academy/agent-integrations
18
18
  import "@particle-academy/agent-integrations/styles.css";
19
19
  ```
20
20
 
21
+ ## Which version range to depend on
22
+
23
+ **Use a caret. `^0.42.0` is correct here, and it is deliberate.**
24
+
25
+ ```jsonc
26
+ "@particle-academy/agent-integrations": "^0.42.0"
27
+ ```
28
+
29
+ This package is **pre-1.0, and breaking changes land in MINOR releases** — see
30
+ the note at the top of [`CHANGELOG.md`](./CHANGELOG.md). A caret on a `0.x` locks
31
+ the minor (npm reads `^0.42.0` as `>=0.42.0 <0.43.0`), so it gives you patches and
32
+ holds you at a surface you have already integrated against. That is the right
33
+ default when the next minor may change something under you.
34
+
35
+ **Moving up a minor is a deliberate act.** Read that version's `CHANGELOG.md`
36
+ entry before you do — breaking entries say what a consumer must actually DO, and
37
+ about half of what reads as breaking needs no action at all.
38
+
39
+ > **A note on the rest of the suite.** Some Fancy packages recommend an open
40
+ > `>=X <2.0` range instead. That is not an inconsistency: those packages carry a
41
+ > **runtime compatibility check** that fails loudly when a consumer is out of
42
+ > step — `fancy-connector-core`'s `CONNECTOR_API_VERSION` is the example — which
43
+ > is a stronger guarantee than a caret and makes the caret unnecessary. This
44
+ > package has no such check, so the caret is doing real work.
45
+ >
46
+ > The rule inside the suite differs again: first-party packages depend on each
47
+ > other with an open range because they are **released and tested together** at a
48
+ > kit version. A consumer is not, which is why the advice here is not the same.
49
+
21
50
  ## Tree-shake-safe imports
22
51
 
23
52
  The root barrel is **core-only** — MCP server, presence, undo, sharing,
@@ -26,6 +26,8 @@ var RelayBroker = class {
26
26
  constructor(opts = {}) {
27
27
  /** Per-session, per-direction subscriber list. */
28
28
  this.subs = /* @__PURE__ */ new Map();
29
+ /** Per-session map of JSON-RPC request id -> the client that asked. */
30
+ this.callers = /* @__PURE__ */ new Map();
29
31
  this.ttlMs = opts.ttlMs ?? 4 * 60 * 60 * 1e3;
30
32
  this.store = opts.store ?? new MemoryStore();
31
33
  const tick = opts.reapIntervalMs ?? 6e4;
@@ -39,6 +41,7 @@ var RelayBroker = class {
39
41
  dispose() {
40
42
  if (this.reaper) clearInterval(this.reaper);
41
43
  this.subs.clear();
44
+ this.callers.clear();
42
45
  }
43
46
  /** Register a session id + token. Idempotent — same id+token re-registers,
44
47
  * different token fails. */
@@ -62,22 +65,64 @@ var RelayBroker = class {
62
65
  if (!this.validate(id, token)) return false;
63
66
  this.store.deleteSession(id);
64
67
  this.subs.delete(id);
68
+ this.callers.delete(id);
65
69
  return true;
66
70
  }
67
- /** Validate an authenticated touch and slide the TTL forward. */
68
- validate(id, token) {
69
- if (!id || !token) return false;
71
+ /**
72
+ * Validate an authenticated touch, saying WHY when it fails.
73
+ *
74
+ * `session_gone` and `invalid_token` are different facts and a client acts on
75
+ * them differently. A gone session means the page has closed or the TTL has
76
+ * elapsed — the NORMAL end of a relay lifecycle, since the browser is the
77
+ * server and no state persists across restarts. A bad token means the caller
78
+ * is wrong.
79
+ *
80
+ * They were collapsed into one boolean and reported as `invalid_token`, so an
81
+ * agent holding a perfectly good token against a closed page was told its
82
+ * credentials were wrong. Not a missing signal — an actively misleading one,
83
+ * which sends the reader to debug an auth path that was never the problem.
84
+ *
85
+ * Missing credentials stay `invalid_token`: a request that never named a
86
+ * session has not discovered a dead one.
87
+ *
88
+ * The reason is deliberately NOT widened to cover a wrong token on a live
89
+ * session — answering `session_gone` there would tell an unauthenticated
90
+ * caller which sessions exist.
91
+ *
92
+ * Requested by the Prism harness while designing a non-Node relay client.
93
+ */
94
+ check(id, token) {
95
+ if (!id || !token) return { ok: false, reason: "invalid_token" };
70
96
  const s = this.store.getSession(id);
71
- if (!s) return false;
72
- if (!timingSafeEqualHex(s.tokenHash, sha256Hex(token))) return false;
97
+ if (!s) return { ok: false, reason: "session_gone" };
98
+ if (!timingSafeEqualHex(s.tokenHash, sha256Hex(token))) {
99
+ return { ok: false, reason: "invalid_token" };
100
+ }
73
101
  s.lastSeen = Date.now();
74
102
  this.store.putSession(s);
75
- return true;
103
+ return { ok: true };
104
+ }
105
+ /**
106
+ * Validate an authenticated touch and slide the TTL forward.
107
+ *
108
+ * Kept as-is for existing callers; `check()` is the one that says why.
109
+ */
110
+ validate(id, token) {
111
+ return this.check(id, token).ok;
112
+ }
113
+ /** Drop a session — the page closed, or a test needs it gone. */
114
+ dropSession(id) {
115
+ this.store.deleteSession(id);
116
+ this.subs.delete(id);
117
+ this.callers.delete(id);
76
118
  }
77
119
  /** Push a frame onto the inbound queue (external agent → browser). */
78
- inbox(id, token, payload) {
120
+ inbox(id, token, payload, opts = {}) {
79
121
  if (!this.validate(id, token)) return false;
80
122
  if (!this.isFrame(payload)) return false;
123
+ if (opts.client !== void 0) {
124
+ this.rememberCaller(id, payload, opts.client);
125
+ }
81
126
  this.fanOut(id, "inbound", payload);
82
127
  return true;
83
128
  }
@@ -85,17 +130,28 @@ var RelayBroker = class {
85
130
  outbox(id, token, payload) {
86
131
  if (!this.validate(id, token)) return false;
87
132
  if (!this.isFrame(payload)) return false;
88
- this.fanOut(id, "outbound", payload);
133
+ const target = this.callerFor(id, payload);
134
+ if (target === null) {
135
+ this.fanOut(id, "outbound", payload);
136
+ return true;
137
+ }
138
+ this.fanOutTo(id, "outbound", payload, target);
89
139
  return true;
90
140
  }
91
141
  /**
92
142
  * Subscribe to a session's queue for one direction. Returns an iterable
93
143
  * the caller (an HTTP handler) pumps as SSE.
94
144
  */
95
- subscribe(id, token, direction) {
145
+ subscribe(id, token, direction, opts = {}) {
96
146
  if (!this.validate(id, token)) return { ok: false, reason: "invalid_token" };
97
147
  const subscriberId = randomBytes(8).toString("hex");
98
- const subscriber = { id: subscriberId, direction, queue: [], resolveNext: null };
148
+ const subscriber = {
149
+ id: subscriberId,
150
+ direction,
151
+ queue: [],
152
+ resolveNext: null,
153
+ client: opts.client
154
+ };
99
155
  this.getDirSubs(id, direction).set(subscriberId, subscriber);
100
156
  if (direction === "outbound") {
101
157
  this.fanOut(
@@ -161,6 +217,85 @@ var RelayBroker = class {
161
217
  }
162
218
  return byDir;
163
219
  }
220
+ /**
221
+ * Record which client asked, keyed by JSON-RPC id.
222
+ *
223
+ * Only for REQUESTS — a frame with both an `id` and a `method`. A
224
+ * notification answers nobody and a response is not a question.
225
+ */
226
+ rememberCaller(sessionId, payload, client) {
227
+ const frame = this.parseFrame(payload);
228
+ if (!frame || frame.id === void 0 || frame.id === null || typeof frame.method !== "string") {
229
+ return;
230
+ }
231
+ let byId = this.callers.get(sessionId);
232
+ if (!byId) {
233
+ byId = /* @__PURE__ */ new Map();
234
+ this.callers.set(sessionId, byId);
235
+ }
236
+ if (byId.size >= 1e3) {
237
+ const oldest = byId.keys().next();
238
+ if (!oldest.done) byId.delete(oldest.value);
239
+ }
240
+ byId.set(String(frame.id), client);
241
+ }
242
+ /**
243
+ * The client a RESPONSE belongs to, or `null` when the frame is a broadcast.
244
+ *
245
+ * Returns the empty string for a response whose id nobody is recorded as
246
+ * having asked — routed to NOBODY rather than to everyone. Broadcasting an
247
+ * uncorrelated reply "just in case" is precisely the leak being closed.
248
+ */
249
+ callerFor(sessionId, payload) {
250
+ const frame = this.parseFrame(payload);
251
+ if (!frame || frame.id === void 0 || frame.id === null || typeof frame.method === "string") {
252
+ return null;
253
+ }
254
+ if (!this.anyIdentifiedSubscriber(sessionId)) {
255
+ return null;
256
+ }
257
+ const byId = this.callers.get(sessionId);
258
+ const key = String(frame.id);
259
+ const client = byId?.get(key);
260
+ byId?.delete(key);
261
+ return client ?? "";
262
+ }
263
+ /**
264
+ * Has any subscriber on this session told the broker who it is?
265
+ *
266
+ * The discriminator between a scoped session and a legacy one. Checked across
267
+ * BOTH directions: the page subscribes inbound and rarely labels itself, so
268
+ * asking only about the outbound side would misread a scoped session whose
269
+ * agents have not yet attached.
270
+ */
271
+ anyIdentifiedSubscriber(sessionId) {
272
+ const dirs = this.subs.get(sessionId);
273
+ if (!dirs) return false;
274
+ for (const dir of dirs.values()) {
275
+ for (const sub of dir.values()) {
276
+ if (sub.client !== void 0) return true;
277
+ }
278
+ }
279
+ return false;
280
+ }
281
+ parseFrame(payload) {
282
+ try {
283
+ const parsed = JSON.parse(payload);
284
+ return parsed && typeof parsed === "object" ? parsed : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+ /** Deliver to the subscribers of one client only. */
290
+ fanOutTo(sessionId, direction, payload, client) {
291
+ const dir = this.subs.get(sessionId)?.get(direction);
292
+ if (!dir) return;
293
+ for (const sub of dir.values()) {
294
+ if (sub.client !== client) continue;
295
+ sub.queue.push(payload);
296
+ sub.resolveNext?.(sub.queue.shift() ?? null);
297
+ }
298
+ }
164
299
  fanOut(sessionId, direction, payload) {
165
300
  const dir = this.subs.get(sessionId)?.get(direction);
166
301
  if (!dir) return;
@@ -211,6 +346,7 @@ var RelayBroker = class {
211
346
  }
212
347
  }
213
348
  this.subs.delete(id);
349
+ this.callers.delete(id);
214
350
  this.store.deleteSession(id);
215
351
  }
216
352
  }
@@ -310,6 +446,10 @@ function createNodeRelay(opts = {}) {
310
446
  if (!session) return json(res, 400, { error: "missing_session" });
311
447
  const token = getQuery(req).get("token") ?? "";
312
448
  if (direction === "unregister") {
449
+ const state = broker.check(session, token);
450
+ if (!state.ok && state.reason === "session_gone") {
451
+ return json(res, 200, { ok: true, alreadyGone: true });
452
+ }
313
453
  const ok2 = broker.unregister(session, token);
314
454
  return json(res, ok2 ? 200 : 401, ok2 ? { ok: true } : { error: "invalid_token" });
315
455
  }
@@ -319,7 +459,12 @@ function createNodeRelay(opts = {}) {
319
459
  } catch (e) {
320
460
  return json(res, 413, { error: e instanceof Error ? e.message : "payload_error" });
321
461
  }
322
- const ok = direction === "inbound" ? broker.inbox(session, token, body) : broker.outbox(session, token, body);
462
+ const auth = broker.check(session, token);
463
+ if (!auth.ok && auth.reason === "session_gone") {
464
+ return json(res, 410, { error: "session_gone" });
465
+ }
466
+ const client = getQuery(req).get("client") ?? void 0;
467
+ const ok = direction === "inbound" ? broker.inbox(session, token, body, { client }) : broker.outbox(session, token, body);
323
468
  return json(res, ok ? 200 : 401, ok ? { ok: true } : { error: "invalid_token_or_frame" });
324
469
  };
325
470
  }
@@ -338,7 +483,9 @@ function createNodeRelay(opts = {}) {
338
483
  const q = getQuery(req);
339
484
  const token = q.get("token") ?? "";
340
485
  const direction = q.get("direction") === "outbound" ? "outbound" : "inbound";
341
- const sub = broker.subscribe(session, token, direction);
486
+ const sub = broker.subscribe(session, token, direction, {
487
+ client: getQuery(req).get("client") ?? void 0
488
+ });
342
489
  if (!sub.ok) {
343
490
  res.statusCode = 401;
344
491
  res.setHeader("content-type", "text/event-stream");
@@ -418,5 +565,5 @@ function flush(res) {
418
565
  }
419
566
 
420
567
  export { RelayBroker, createNodeRelay };
421
- //# sourceMappingURL=chunk-GRHPEK6M.js.map
422
- //# sourceMappingURL=chunk-GRHPEK6M.js.map
568
+ //# sourceMappingURL=chunk-OZBUPO4K.js.map
569
+ //# sourceMappingURL=chunk-OZBUPO4K.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/relay-server/core.ts","../src/relay-server/node.ts"],"names":["next","ok"],"mappings":"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","file":"chunk-OZBUPO4K.js","sourcesContent":["import { createHash, randomBytes, timingSafeEqual } from \"node:crypto\";\n\n/**\n * RelayBroker — pure logic for the SSE+POST tunnel described in\n * docs/relay-protocol.md, hostable in any Node-compatible runtime\n * (Node, Bun, Deno-with-Node-compat, Cloudflare Workers via the Web\n * standards subset). No HTTP framework opinions; this class just\n * stores sessions, validates tokens, enqueues frames, and produces\n * SSE event payloads ready to flush.\n *\n * const broker = new RelayBroker();\n * const reg = broker.register(\"session-id\", \"token\"); // ok / error\n * broker.inbox(\"session-id\", \"token\", '{\"jsonrpc\":\"2.0\",…}'); // enqueue inbound\n * const sub = broker.subscribe(\"session-id\", \"token\", \"inbound\");\n * for await (const payload of sub.frames()) yield encodeSse(payload);\n *\n * Storage is an in-memory Map by default — fine for a single relay\n * process. To run multiple instances behind a load balancer, swap\n * `MemoryStore` for a Redis-backed equivalent (same Store interface).\n */\n\nexport type Direction = \"inbound\" | \"outbound\";\n\nexport type Session = {\n id: string;\n /** SHA-256 hex of the original token. Compared with timing-safe equals. */\n tokenHash: string;\n /** Last touched (ms since epoch). Used for TTL cleanup. */\n lastSeen: number;\n};\n\nexport type Subscriber = {\n id: string;\n direction: Direction;\n queue: string[];\n resolveNext: ((frame: string | null) => void) | null;\n /**\n * The caller-supplied client label, when one was given.\n *\n * Used to route a RESPONSE back to whoever asked. Without it the broker\n * cannot tell two holders of one bearer token apart, and every reply goes to\n * every subscriber -- so a second token holder passively receives the results\n * of everyone else's tool calls without making one.\n */\n client?: string;\n};\n\nexport type RelayBrokerOptions = {\n /** Sessions auto-expire after this many ms of inactivity. Default 4h. */\n ttlMs?: number;\n /** Cleanup tick interval in ms. Default 60 000. */\n reapIntervalMs?: number;\n /** Bring-your-own storage layer (redis, etc.). Defaults to in-memory. */\n store?: Store;\n};\n\nexport interface Store {\n putSession(s: Session): void;\n getSession(id: string): Session | undefined;\n deleteSession(id: string): void;\n /** Used by the reap tick — return ids whose lastSeen < cutoff. */\n expiredSessionIds(cutoff: number): string[];\n}\n\nclass MemoryStore implements Store {\n private sessions = new Map<string, Session>();\n putSession(s: Session) { this.sessions.set(s.id, s); }\n getSession(id: string) { return this.sessions.get(id); }\n deleteSession(id: string) { this.sessions.delete(id); }\n expiredSessionIds(cutoff: number) {\n const out: string[] = [];\n for (const [id, s] of this.sessions) if (s.lastSeen < cutoff) out.push(id);\n return out;\n }\n}\n\nconst SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;\n\nexport class RelayBroker {\n private readonly ttlMs: number;\n private readonly store: Store;\n /** Per-session, per-direction subscriber list. */\n private subs: Map<string, Map<string, Map<string, Subscriber>>> = new Map();\n\n /** Per-session map of JSON-RPC request id -> the client that asked. */\n private callers: Map<string, Map<string, string>> = new Map();\n private reaper?: ReturnType<typeof setInterval>;\n\n constructor(opts: RelayBrokerOptions = {}) {\n this.ttlMs = opts.ttlMs ?? 4 * 60 * 60 * 1000; // 4h\n this.store = opts.store ?? new MemoryStore();\n const tick = opts.reapIntervalMs ?? 60_000;\n if (tick > 0) {\n this.reaper = setInterval(() => this.reap(), tick);\n // Don't keep the process alive just for the reaper.\n if (typeof (this.reaper as { unref?: () => void }).unref === \"function\") {\n (this.reaper as { unref: () => void }).unref();\n }\n }\n }\n\n dispose() {\n if (this.reaper) clearInterval(this.reaper);\n this.subs.clear();\n this.callers.clear();\n }\n\n /** Register a session id + token. Idempotent — same id+token re-registers,\n * different token fails. */\n register(id: string, token: string): { ok: true } | { ok: false; reason: string } {\n if (!SESSION_ID_PATTERN.test(id)) return { ok: false, reason: \"invalid_session_id\" };\n if (typeof token !== \"string\" || token.length < 16 || token.length > 128) {\n return { ok: false, reason: \"invalid_token\" };\n }\n const existing = this.store.getSession(id);\n const hash = sha256Hex(token);\n if (existing) {\n if (!timingSafeEqualHex(existing.tokenHash, hash)) return { ok: false, reason: \"session_taken\" };\n existing.lastSeen = Date.now();\n this.store.putSession(existing);\n return { ok: true };\n }\n this.store.putSession({ id, tokenHash: hash, lastSeen: Date.now() });\n return { ok: true };\n }\n\n unregister(id: string, token: string): boolean {\n if (!this.validate(id, token)) return false;\n this.store.deleteSession(id);\n this.subs.delete(id);\n this.callers.delete(id);\n return true;\n }\n\n /**\n * Validate an authenticated touch, saying WHY when it fails.\n *\n * `session_gone` and `invalid_token` are different facts and a client acts on\n * them differently. A gone session means the page has closed or the TTL has\n * elapsed — the NORMAL end of a relay lifecycle, since the browser is the\n * server and no state persists across restarts. A bad token means the caller\n * is wrong.\n *\n * They were collapsed into one boolean and reported as `invalid_token`, so an\n * agent holding a perfectly good token against a closed page was told its\n * credentials were wrong. Not a missing signal — an actively misleading one,\n * which sends the reader to debug an auth path that was never the problem.\n *\n * Missing credentials stay `invalid_token`: a request that never named a\n * session has not discovered a dead one.\n *\n * The reason is deliberately NOT widened to cover a wrong token on a live\n * session — answering `session_gone` there would tell an unauthenticated\n * caller which sessions exist.\n *\n * Requested by the Prism harness while designing a non-Node relay client.\n */\n check(id: string, token: string): { ok: true } | { ok: false; reason: \"invalid_token\" | \"session_gone\" } {\n if (!id || !token) return { ok: false, reason: \"invalid_token\" };\n\n const s = this.store.getSession(id);\n if (!s) return { ok: false, reason: \"session_gone\" };\n\n if (!timingSafeEqualHex(s.tokenHash, sha256Hex(token))) {\n return { ok: false, reason: \"invalid_token\" };\n }\n\n s.lastSeen = Date.now();\n this.store.putSession(s);\n return { ok: true };\n }\n\n /**\n * Validate an authenticated touch and slide the TTL forward.\n *\n * Kept as-is for existing callers; `check()` is the one that says why.\n */\n validate(id: string, token: string): boolean {\n return this.check(id, token).ok;\n }\n\n /** Drop a session — the page closed, or a test needs it gone. */\n dropSession(id: string): void {\n this.store.deleteSession(id);\n this.subs.delete(id);\n this.callers.delete(id);\n }\n\n /** Push a frame onto the inbound queue (external agent → browser). */\n inbox(id: string, token: string, payload: string, opts: { client?: string } = {}): boolean {\n if (!this.validate(id, token)) return false;\n if (!this.isFrame(payload)) return false;\n\n // Remember WHO asked, so the reply can go back to them alone.\n if (opts.client !== undefined) {\n this.rememberCaller(id, payload, opts.client);\n }\n\n this.fanOut(id, \"inbound\", payload);\n return true;\n }\n\n /** Push a frame onto the outbound queue (browser server → external agents). */\n outbox(id: string, token: string, payload: string): boolean {\n if (!this.validate(id, token)) return false;\n if (!this.isFrame(payload)) return false;\n\n // A RESPONSE goes to the caller. A NOTIFICATION goes to everyone.\n //\n // `relay-protocol.md` has always said \"tool-call replies go back on the\n // transport that originated the call\"; the relay broadcast them instead.\n // Combined with a bearer token that carries no per-agent identity, that\n // meant a second token holder passively received the results of everyone\n // else's calls -- turning a share link from \"you may act here\" into \"you\n // may watch everyone acting here\", which nobody chose.\n //\n // Notifications stay broadcast: presence, activity and server-pushed state\n // answer nobody and are meant for every attached client. Narrowing those\n // would break the collaboration this exists to serve.\n const target = this.callerFor(id, payload);\n\n if (target === null) {\n this.fanOut(id, \"outbound\", payload);\n return true;\n }\n\n this.fanOutTo(id, \"outbound\", payload, target);\n return true;\n }\n\n /**\n * Subscribe to a session's queue for one direction. Returns an iterable\n * the caller (an HTTP handler) pumps as SSE.\n */\n subscribe(id: string, token: string, direction: Direction, opts: { client?: string } = {}): SubscribeResult {\n if (!this.validate(id, token)) return { ok: false, reason: \"invalid_token\" };\n const subscriberId = randomBytes(8).toString(\"hex\");\n const subscriber: Subscriber = {\n id: subscriberId,\n direction,\n queue: [],\n resolveNext: null,\n client: opts.client,\n };\n this.getDirSubs(id, direction).set(subscriberId, subscriber);\n\n // Notify the inbound side (= browser) that an outbound subscriber\n // (= external agent) just connected.\n if (direction === \"outbound\") {\n this.fanOut(\n id,\n \"inbound\",\n JSON.stringify({\n jsonrpc: \"2.0\",\n method: \"notifications/peer_joined\",\n params: { subscriberId, ts: Date.now() },\n }),\n );\n }\n\n const unsubscribe = () => {\n this.getDirSubs(id, direction).delete(subscriberId);\n if (direction === \"outbound\") {\n this.fanOut(\n id,\n \"inbound\",\n JSON.stringify({\n jsonrpc: \"2.0\",\n method: \"notifications/peer_left\",\n params: { subscriberId, ts: Date.now() },\n }),\n );\n }\n };\n\n /** Async generator the HTTP handler drains. Yields raw frame payloads;\n * the handler is responsible for SSE framing (`event: mcp\\ndata: …`). */\n const frames = async function* (this: Subscriber): AsyncGenerator<string, void, void> {\n while (true) {\n if (this.queue.length > 0) {\n const next = this.queue.shift();\n if (next !== undefined) yield next;\n continue;\n }\n const next = await new Promise<string | null>((resolve) => {\n this.resolveNext = resolve;\n });\n this.resolveNext = null;\n if (next === null) return;\n yield next;\n }\n }.bind(subscriber);\n\n return {\n ok: true,\n subscriberId,\n frames: frames(),\n unsubscribe: () => {\n // Wake the generator and let it return cleanly.\n subscriber.resolveNext?.(null);\n unsubscribe();\n },\n };\n }\n\n // ────────────────────────────────────────────────────────────── internals\n\n private getDirSubs(sessionId: string, direction: Direction): Map<string, Subscriber> {\n let bySession = this.subs.get(sessionId);\n if (!bySession) {\n bySession = new Map();\n this.subs.set(sessionId, bySession);\n }\n let byDir = bySession.get(direction);\n if (!byDir) {\n byDir = new Map();\n bySession.set(direction, byDir);\n }\n return byDir;\n }\n\n /**\n * Record which client asked, keyed by JSON-RPC id.\n *\n * Only for REQUESTS — a frame with both an `id` and a `method`. A\n * notification answers nobody and a response is not a question.\n */\n private rememberCaller(sessionId: string, payload: string, client: string): void {\n const frame = this.parseFrame(payload);\n if (!frame || frame.id === undefined || frame.id === null || typeof frame.method !== \"string\") {\n return;\n }\n\n let byId = this.callers.get(sessionId);\n if (!byId) {\n byId = new Map();\n this.callers.set(sessionId, byId);\n }\n\n // BOUNDED. A session that asks for hours must not grow this without limit,\n // and an unanswered request would otherwise sit here forever. Oldest out\n // first; losing the oldest correlation degrades to \"delivered to nobody\",\n // which fails closed rather than open.\n if (byId.size >= 1000) {\n const oldest = byId.keys().next();\n if (!oldest.done) byId.delete(oldest.value);\n }\n\n byId.set(String(frame.id), client);\n }\n\n /**\n * The client a RESPONSE belongs to, or `null` when the frame is a broadcast.\n *\n * Returns the empty string for a response whose id nobody is recorded as\n * having asked — routed to NOBODY rather than to everyone. Broadcasting an\n * uncorrelated reply \"just in case\" is precisely the leak being closed.\n */\n private callerFor(sessionId: string, payload: string): string | null {\n const frame = this.parseFrame(payload);\n\n // Not a response: no id, or it carries a method (a request/notification).\n if (!frame || frame.id === undefined || frame.id === null || typeof frame.method === \"string\") {\n return null;\n }\n\n // LEGACY MODE is decided by whether any subscriber IDENTIFIED ITSELF, not\n // by whether a correlation happens to have been recorded yet.\n //\n // Keying on the correlation map was wrong in a way a test caught: a\n // labelled client that has subscribed but not yet asked leaves the map\n // empty, and an unsolicited response would then broadcast to everyone --\n // the leak reopening in the window before the first call.\n //\n // If nobody labelled themselves, every client is legacy and broadcast is\n // the only thing that can work; narrowing would break them in silence.\n if (!this.anyIdentifiedSubscriber(sessionId)) {\n return null;\n }\n\n const byId = this.callers.get(sessionId);\n const key = String(frame.id);\n const client = byId?.get(key);\n\n // CONSUMED. Correlation is not a standing subscription: leaving it would\n // let a replayed frame with the same id be delivered again, and would make\n // the map a slow leak on a long session.\n byId?.delete(key);\n\n return client ?? \"\";\n }\n\n /**\n * Has any subscriber on this session told the broker who it is?\n *\n * The discriminator between a scoped session and a legacy one. Checked across\n * BOTH directions: the page subscribes inbound and rarely labels itself, so\n * asking only about the outbound side would misread a scoped session whose\n * agents have not yet attached.\n */\n private anyIdentifiedSubscriber(sessionId: string): boolean {\n const dirs = this.subs.get(sessionId);\n if (!dirs) return false;\n\n for (const dir of dirs.values()) {\n for (const sub of dir.values()) {\n if (sub.client !== undefined) return true;\n }\n }\n\n return false;\n }\n\n private parseFrame(payload: string): { id?: unknown; method?: unknown } | null {\n try {\n const parsed: unknown = JSON.parse(payload);\n return parsed && typeof parsed === \"object\" ? (parsed as { id?: unknown; method?: unknown }) : null;\n } catch {\n return null;\n }\n }\n\n /** Deliver to the subscribers of one client only. */\n private fanOutTo(sessionId: string, direction: Direction, payload: string, client: string) {\n const dir = this.subs.get(sessionId)?.get(direction);\n if (!dir) return;\n\n for (const sub of dir.values()) {\n if (sub.client !== client) continue;\n sub.queue.push(payload);\n sub.resolveNext?.(sub.queue.shift() ?? null);\n }\n }\n\n private fanOut(sessionId: string, direction: Direction, payload: string) {\n const dir = this.subs.get(sessionId)?.get(direction);\n if (!dir) return;\n for (const sub of dir.values()) {\n sub.queue.push(payload);\n sub.resolveNext?.(sub.queue.shift() ?? null);\n }\n }\n\n /**\n * Validate that a peer-supplied payload is a well-formed JSON-RPC 2.0 frame\n * (or batch) before it is fanned out. Previously this was a substring match on\n * `\"jsonrpc\"`, which let any token holder inject spoofed frames — a forged\n * `notifications/peer_joined` / `peer_left` (broker-reserved presence control),\n * a fake `notifications/agent_activity {source:\"user\"}` (\"human took control\"),\n * or a forged response with a matching id. We now parse + shape-check, and\n * reject the broker-reserved control methods (the broker emits those itself,\n * never a peer).\n */\n private isFrame(payload: string): boolean {\n if (payload.length === 0) return false;\n let parsed: unknown;\n try {\n parsed = JSON.parse(payload);\n } catch {\n return false;\n }\n const frames = Array.isArray(parsed) ? parsed : [parsed];\n return frames.length > 0 && frames.every((f) => this.isValidFrame(f));\n }\n\n private isValidFrame(f: unknown): boolean {\n if (!f || typeof f !== \"object\" || Array.isArray(f)) return false;\n const o = f as Record<string, unknown>;\n if (o.jsonrpc !== \"2.0\") return false;\n const method = typeof o.method === \"string\" ? o.method : undefined;\n // Broker-reserved control frames may never arrive from a peer.\n if (method === \"notifications/peer_joined\" || method === \"notifications/peer_left\") return false;\n if (method !== undefined) return true; // request or notification\n // Response: needs an id and exactly one of result / error.\n const hasResult = \"result\" in o;\n const hasError = \"error\" in o;\n return \"id\" in o && hasResult !== hasError;\n }\n\n private reap() {\n const cutoff = Date.now() - this.ttlMs;\n for (const id of this.store.expiredSessionIds(cutoff)) {\n const dirs = this.subs.get(id);\n if (dirs) {\n for (const dir of dirs.values()) {\n for (const sub of dir.values()) sub.resolveNext?.(null);\n }\n }\n this.subs.delete(id);\n this.callers.delete(id);\n this.store.deleteSession(id);\n }\n }\n}\n\nexport type SubscribeResult =\n | { ok: false; reason: string }\n | {\n ok: true;\n subscriberId: string;\n frames: AsyncGenerator<string, void, void>;\n unsubscribe: () => void;\n };\n\nfunction sha256Hex(input: string): string {\n return createHash(\"sha256\").update(input).digest(\"hex\");\n}\n\nfunction timingSafeEqualHex(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n return timingSafeEqual(Buffer.from(a, \"hex\"), Buffer.from(b, \"hex\"));\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { URL } from \"node:url\";\nimport { RelayBroker, type RelayBrokerOptions } from \"./core\";\n\n/**\n * Node HTTP adapter for {@link RelayBroker}. Returns a single request\n * handler plus per-route handlers, so you can either drop it into\n * `http.createServer(...)` directly or mount the individual handlers\n * onto your existing Node HTTP framework (Express, Hono w/ node-adapter,\n * native http).\n *\n * const relay = createNodeRelay({ pathPrefix: \"/mcp-relay\" });\n * http.createServer(relay.handler).listen(8787);\n *\n * // Or piecemeal:\n * app.post(\"/mcp-relay/register\", relay.register);\n * app.post(\"/mcp-relay/:s/inbox\", relay.inbox);\n * app.post(\"/mcp-relay/:s/outbox\", relay.outbox);\n * app.get (\"/mcp-relay/:s/events\", relay.events);\n * app.post(\"/mcp-relay/:s/unregister\", relay.unregister);\n */\n\nexport type NodeRelayOptions = RelayBrokerOptions & {\n /** URL path prefix (without trailing slash). Default `\"\"` — handlers\n * expect paths like `/register`, `/{id}/inbox`, etc. directly. */\n pathPrefix?: string;\n /** Comma-separated origins (or `*`) for CORS. Default `*` — relays\n * are typically called cross-origin from the demo host. */\n corsAllowOrigin?: string;\n /**\n * Strict browser-origin allow-list. When set, only these Origins get a\n * matching `Access-Control-Allow-Origin` (the request's own Origin is\n * reflected, never `*`), which blunts DNS-rebinding + hostile cross-origin\n * pages from reading relay responses. Recommended for any browser-facing\n * deployment. When unset, `corsAllowOrigin` (default `*`) is used and the\n * session token is the only auth — pair that with a loopback bind.\n */\n allowedOrigins?: string[];\n};\n\nexport type NodeHandler = (req: IncomingMessage, res: ServerResponse) => unknown | Promise<unknown>;\n\nexport type NodeRelay = {\n broker: RelayBroker;\n /** Single-handler shape — routes internally based on method + URL. */\n handler: NodeHandler;\n /** Per-route handlers. Each handler ignores the URL prefix and\n * acts on the path remainder, so you can mount them under any\n * prefix in your existing app. */\n register: NodeHandler;\n inbox: NodeHandler;\n outbox: NodeHandler;\n events: NodeHandler;\n unregister: NodeHandler;\n};\n\nexport function createNodeRelay(opts: NodeRelayOptions = {}): NodeRelay {\n const broker = new RelayBroker(opts);\n const prefix = (opts.pathPrefix ?? \"\").replace(/\\/$/, \"\");\n const cors = opts.corsAllowOrigin ?? \"*\";\n\n const allowedOrigins = opts.allowedOrigins;\n\n function setCorsHeaders(res: ServerResponse, req?: IncomingMessage) {\n if (allowedOrigins && allowedOrigins.length) {\n // Strict mode: reflect the request Origin only when it's allow-listed;\n // otherwise emit an origin that no browser will match.\n const origin = req?.headers.origin;\n res.setHeader(\"access-control-allow-origin\", origin && allowedOrigins.includes(origin) ? origin : \"null\");\n res.setHeader(\"vary\", \"origin\");\n } else {\n res.setHeader(\"access-control-allow-origin\", cors);\n }\n res.setHeader(\"access-control-allow-methods\", \"GET, POST, OPTIONS\");\n // `authorization` is forward-compat for a header-borne session token.\n res.setHeader(\"access-control-allow-headers\", \"content-type, x-csrf-token, accept, authorization\");\n res.setHeader(\"access-control-max-age\", \"86400\");\n }\n\n function readBody(req: IncomingMessage): Promise<string> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let bytes = 0;\n req.on(\"data\", (chunk: Buffer) => {\n bytes += chunk.length;\n // Cap individual frames at 256 KB — protect against runaway payloads.\n if (bytes > 256 * 1024) {\n reject(new Error(\"payload_too_large\"));\n req.destroy();\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve(Buffer.concat(chunks).toString(\"utf8\")));\n req.on(\"error\", reject);\n });\n }\n\n function json(res: ServerResponse, status: number, body: unknown): void {\n res.statusCode = status;\n res.setHeader(\"content-type\", \"application/json\");\n res.end(JSON.stringify(body));\n }\n\n function getQuery(req: IncomingMessage): URLSearchParams {\n const host = req.headers.host || \"x\";\n const u = new URL(req.url || \"/\", `http://${host}`);\n return u.searchParams;\n }\n\n function getPathname(req: IncomingMessage): string {\n const host = req.headers.host || \"x\";\n const u = new URL(req.url || \"/\", `http://${host}`);\n return u.pathname;\n }\n\n const register: NodeHandler = async (req, res) => {\n setCorsHeaders(res, req);\n if (req.method === \"OPTIONS\") { res.statusCode = 204; return res.end(); }\n if (req.method !== \"POST\") return json(res, 405, { error: \"method_not_allowed\" });\n let body: string;\n try { body = await readBody(req); } catch (e) {\n return json(res, 413, { error: e instanceof Error ? e.message : \"payload_error\" });\n }\n let parsed: unknown;\n try { parsed = JSON.parse(body); } catch { return json(res, 400, { error: \"invalid_json\" }); }\n const { session, token } = (parsed ?? {}) as { session?: string; token?: string };\n if (typeof session !== \"string\" || typeof token !== \"string\") {\n return json(res, 400, { error: \"missing_fields\" });\n }\n const result = broker.register(session, token);\n if (!result.ok) return json(res, 401, { error: result.reason });\n return json(res, 200, { ok: true });\n };\n\n /** Handler for endpoints with a `{session}` segment. The path matcher\n * caller passes the session id explicitly so this works mounted under\n * any route shape. */\n function makeSessionHandler(\n direction: Direction | \"unregister\",\n ): NodeHandler {\n return async (req, res) => {\n setCorsHeaders(res, req);\n if (req.method === \"OPTIONS\") { res.statusCode = 204; return res.end(); }\n if (req.method !== \"POST\") return json(res, 405, { error: \"method_not_allowed\" });\n const session = extractSession(req, prefix);\n if (!session) return json(res, 400, { error: \"missing_session\" });\n const token = getQuery(req).get(\"token\") ?? \"\";\n\n if (direction === \"unregister\") {\n // Unregistering a session that is already gone is a SUCCESS, not an\n // auth failure. The caller wanted it gone and it is gone; reporting\n // `invalid_token` for a correct token would make a clean shutdown look\n // like a broken one, and a client retrying it would loop.\n const state = broker.check(session, token);\n if (!state.ok && state.reason === \"session_gone\") {\n return json(res, 200, { ok: true, alreadyGone: true });\n }\n\n const ok = broker.unregister(session, token);\n return json(res, ok ? 200 : 401, ok ? { ok: true } : { error: \"invalid_token\" });\n }\n\n let body: string;\n try { body = await readBody(req); } catch (e) {\n return json(res, 413, { error: e instanceof Error ? e.message : \"payload_error\" });\n }\n // A GONE SESSION IS REPORTED AS ITSELF, with 410, before the frame is\n // even considered.\n //\n // Every failure here used to come back as `401 invalid_token_or_frame`,\n // so an agent whose page had simply closed was told its credentials or\n // its JSON were wrong. A relay session ending is the NORMAL end of a\n // lifecycle -- the browser is the server and nothing persists -- so it is\n // the one outcome a server-side client must be able to recognise.\n //\n // 410 Gone rather than 404: the session existed and is now over, which is\n // exactly what the status means, and it distinguishes \"this is finished\"\n // from \"you have the wrong URL\".\n const auth = broker.check(session, token);\n if (!auth.ok && auth.reason === \"session_gone\") {\n return json(res, 410, { error: \"session_gone\" });\n }\n\n // `client` identifies WHICH agent is calling, so its reply can be routed\n // back to it alone. Optional: a client that sends none gets the legacy\n // broadcast, which is the only thing that can work for it.\n const client = getQuery(req).get(\"client\") ?? undefined;\n\n const ok = direction === \"inbound\"\n ? broker.inbox(session, token, body, { client })\n : broker.outbox(session, token, body);\n return json(res, ok ? 200 : 401, ok ? { ok: true } : { error: \"invalid_token_or_frame\" });\n };\n }\n\n const inbox = makeSessionHandler(\"inbound\");\n const outbox = makeSessionHandler(\"outbound\");\n const unregister = makeSessionHandler(\"unregister\");\n\n const events: NodeHandler = async (req, res) => {\n setCorsHeaders(res, req);\n if (req.method === \"OPTIONS\") { res.statusCode = 204; return res.end(); }\n if (req.method !== \"GET\") return json(res, 405, { error: \"method_not_allowed\" });\n const session = extractSession(req, prefix);\n if (!session) return json(res, 400, { error: \"missing_session\" });\n const q = getQuery(req);\n const token = q.get(\"token\") ?? \"\";\n const direction = q.get(\"direction\") === \"outbound\" ? \"outbound\" : \"inbound\";\n\n // The SAME `client` the agent posts under, so a reply can find its way\n // back. An agent that subscribes with a label and posts without one (or\n // vice versa) simply does not correlate and its replies go nowhere -- which\n // fails closed, and is the right way round for a leak fix.\n const sub = broker.subscribe(session, token, direction, {\n client: getQuery(req).get(\"client\") ?? undefined,\n });\n if (!sub.ok) {\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"text/event-stream\");\n res.write(`event: error\\ndata: ${sub.reason}\\n\\n`);\n return res.end();\n }\n\n res.statusCode = 200;\n res.setHeader(\"content-type\", \"text/event-stream\");\n res.setHeader(\"cache-control\", \"no-cache\");\n res.setHeader(\"connection\", \"keep-alive\");\n res.setHeader(\"x-accel-buffering\", \"no\");\n res.write(\"retry: 2000\\n\\n\");\n flush(res);\n\n let heartbeat: ReturnType<typeof setInterval> | null = setInterval(() => {\n res.write(\": keepalive\\n\\n\");\n flush(res);\n }, 15_000);\n if (heartbeat && typeof (heartbeat as { unref?: () => void }).unref === \"function\") {\n (heartbeat as { unref: () => void }).unref();\n }\n\n const cleanup = () => {\n if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }\n sub.unsubscribe();\n };\n req.on(\"close\", cleanup);\n req.on(\"error\", cleanup);\n\n try {\n for await (const frame of sub.frames) {\n res.write(`event: mcp\\ndata: ${frame}\\n\\n`);\n flush(res);\n }\n } catch {\n /* stream ended */\n } finally {\n cleanup();\n res.end();\n }\n };\n\n /**\n * Single handler — routes based on method + path. Useful for mounting\n * via `http.createServer(relay.handler)` without an Express layer.\n */\n const handler: NodeHandler = async (req, res) => {\n const pathname = getPathname(req);\n if (!pathname.startsWith(prefix + \"/\")) {\n return json(res, 404, { error: \"not_found\" });\n }\n const rest = pathname.slice(prefix.length); // \"/register\", \"/<id>/inbox\", etc.\n if (rest === \"/register\") return register(req, res);\n const m = /^\\/([A-Za-z0-9_-]{4,64})\\/(inbox|outbox|events|unregister)$/.exec(rest);\n if (!m) return json(res, 404, { error: \"not_found\" });\n const route = m[2];\n if (route === \"inbox\") return inbox(req, res);\n if (route === \"outbox\") return outbox(req, res);\n if (route === \"events\") return events(req, res);\n if (route === \"unregister\") return unregister(req, res);\n return json(res, 404, { error: \"not_found\" });\n };\n\n return { broker, handler, register, inbox, outbox, events, unregister };\n}\n\nfunction extractSession(req: IncomingMessage, prefix: string): string | null {\n const host = req.headers.host || \"x\";\n const u = new URL(req.url || \"/\", `http://${host}`);\n const path = u.pathname;\n if (prefix && !path.startsWith(prefix + \"/\")) return null;\n const rest = prefix ? path.slice(prefix.length) : path;\n const m = /^\\/([A-Za-z0-9_-]{4,64})\\//.exec(rest);\n return m ? m[1] : null;\n}\n\nfunction flush(res: ServerResponse) {\n // Node doesn't expose explicit flush, but write returns false when buffered;\n // explicit flushHeaders + write is enough for SSE in practice.\n const r = res as { flush?: () => void };\n r.flush?.();\n}\n\ntype Direction = \"inbound\" | \"outbound\";\n"]}
@@ -31,6 +31,15 @@ type Subscriber = {
31
31
  direction: Direction;
32
32
  queue: string[];
33
33
  resolveNext: ((frame: string | null) => void) | null;
34
+ /**
35
+ * The caller-supplied client label, when one was given.
36
+ *
37
+ * Used to route a RESPONSE back to whoever asked. Without it the broker
38
+ * cannot tell two holders of one bearer token apart, and every reply goes to
39
+ * every subscriber -- so a second token holder passively receives the results
40
+ * of everyone else's tool calls without making one.
41
+ */
42
+ client?: string;
34
43
  };
35
44
  type RelayBrokerOptions = {
36
45
  /** Sessions auto-expire after this many ms of inactivity. Default 4h. */
@@ -52,6 +61,8 @@ declare class RelayBroker {
52
61
  private readonly store;
53
62
  /** Per-session, per-direction subscriber list. */
54
63
  private subs;
64
+ /** Per-session map of JSON-RPC request id -> the client that asked. */
65
+ private callers;
55
66
  private reaper?;
56
67
  constructor(opts?: RelayBrokerOptions);
57
68
  dispose(): void;
@@ -64,18 +75,84 @@ declare class RelayBroker {
64
75
  reason: string;
65
76
  };
66
77
  unregister(id: string, token: string): boolean;
67
- /** Validate an authenticated touch and slide the TTL forward. */
78
+ /**
79
+ * Validate an authenticated touch, saying WHY when it fails.
80
+ *
81
+ * `session_gone` and `invalid_token` are different facts and a client acts on
82
+ * them differently. A gone session means the page has closed or the TTL has
83
+ * elapsed — the NORMAL end of a relay lifecycle, since the browser is the
84
+ * server and no state persists across restarts. A bad token means the caller
85
+ * is wrong.
86
+ *
87
+ * They were collapsed into one boolean and reported as `invalid_token`, so an
88
+ * agent holding a perfectly good token against a closed page was told its
89
+ * credentials were wrong. Not a missing signal — an actively misleading one,
90
+ * which sends the reader to debug an auth path that was never the problem.
91
+ *
92
+ * Missing credentials stay `invalid_token`: a request that never named a
93
+ * session has not discovered a dead one.
94
+ *
95
+ * The reason is deliberately NOT widened to cover a wrong token on a live
96
+ * session — answering `session_gone` there would tell an unauthenticated
97
+ * caller which sessions exist.
98
+ *
99
+ * Requested by the Prism harness while designing a non-Node relay client.
100
+ */
101
+ check(id: string, token: string): {
102
+ ok: true;
103
+ } | {
104
+ ok: false;
105
+ reason: "invalid_token" | "session_gone";
106
+ };
107
+ /**
108
+ * Validate an authenticated touch and slide the TTL forward.
109
+ *
110
+ * Kept as-is for existing callers; `check()` is the one that says why.
111
+ */
68
112
  validate(id: string, token: string): boolean;
113
+ /** Drop a session — the page closed, or a test needs it gone. */
114
+ dropSession(id: string): void;
69
115
  /** Push a frame onto the inbound queue (external agent → browser). */
70
- inbox(id: string, token: string, payload: string): boolean;
116
+ inbox(id: string, token: string, payload: string, opts?: {
117
+ client?: string;
118
+ }): boolean;
71
119
  /** Push a frame onto the outbound queue (browser server → external agents). */
72
120
  outbox(id: string, token: string, payload: string): boolean;
73
121
  /**
74
122
  * Subscribe to a session's queue for one direction. Returns an iterable
75
123
  * the caller (an HTTP handler) pumps as SSE.
76
124
  */
77
- subscribe(id: string, token: string, direction: Direction): SubscribeResult;
125
+ subscribe(id: string, token: string, direction: Direction, opts?: {
126
+ client?: string;
127
+ }): SubscribeResult;
78
128
  private getDirSubs;
129
+ /**
130
+ * Record which client asked, keyed by JSON-RPC id.
131
+ *
132
+ * Only for REQUESTS — a frame with both an `id` and a `method`. A
133
+ * notification answers nobody and a response is not a question.
134
+ */
135
+ private rememberCaller;
136
+ /**
137
+ * The client a RESPONSE belongs to, or `null` when the frame is a broadcast.
138
+ *
139
+ * Returns the empty string for a response whose id nobody is recorded as
140
+ * having asked — routed to NOBODY rather than to everyone. Broadcasting an
141
+ * uncorrelated reply "just in case" is precisely the leak being closed.
142
+ */
143
+ private callerFor;
144
+ /**
145
+ * Has any subscriber on this session told the broker who it is?
146
+ *
147
+ * The discriminator between a scoped session and a legacy one. Checked across
148
+ * BOTH directions: the page subscribes inbound and rarely labels itself, so
149
+ * asking only about the outbound side would misread a scoped session whose
150
+ * agents have not yet attached.
151
+ */
152
+ private anyIdentifiedSubscriber;
153
+ private parseFrame;
154
+ /** Deliver to the subscribers of one client only. */
155
+ private fanOutTo;
79
156
  private fanOut;
80
157
  /**
81
158
  * Validate that a peer-supplied payload is a well-formed JSON-RPC 2.0 frame
@@ -31,6 +31,15 @@ type Subscriber = {
31
31
  direction: Direction;
32
32
  queue: string[];
33
33
  resolveNext: ((frame: string | null) => void) | null;
34
+ /**
35
+ * The caller-supplied client label, when one was given.
36
+ *
37
+ * Used to route a RESPONSE back to whoever asked. Without it the broker
38
+ * cannot tell two holders of one bearer token apart, and every reply goes to
39
+ * every subscriber -- so a second token holder passively receives the results
40
+ * of everyone else's tool calls without making one.
41
+ */
42
+ client?: string;
34
43
  };
35
44
  type RelayBrokerOptions = {
36
45
  /** Sessions auto-expire after this many ms of inactivity. Default 4h. */
@@ -52,6 +61,8 @@ declare class RelayBroker {
52
61
  private readonly store;
53
62
  /** Per-session, per-direction subscriber list. */
54
63
  private subs;
64
+ /** Per-session map of JSON-RPC request id -> the client that asked. */
65
+ private callers;
55
66
  private reaper?;
56
67
  constructor(opts?: RelayBrokerOptions);
57
68
  dispose(): void;
@@ -64,18 +75,84 @@ declare class RelayBroker {
64
75
  reason: string;
65
76
  };
66
77
  unregister(id: string, token: string): boolean;
67
- /** Validate an authenticated touch and slide the TTL forward. */
78
+ /**
79
+ * Validate an authenticated touch, saying WHY when it fails.
80
+ *
81
+ * `session_gone` and `invalid_token` are different facts and a client acts on
82
+ * them differently. A gone session means the page has closed or the TTL has
83
+ * elapsed — the NORMAL end of a relay lifecycle, since the browser is the
84
+ * server and no state persists across restarts. A bad token means the caller
85
+ * is wrong.
86
+ *
87
+ * They were collapsed into one boolean and reported as `invalid_token`, so an
88
+ * agent holding a perfectly good token against a closed page was told its
89
+ * credentials were wrong. Not a missing signal — an actively misleading one,
90
+ * which sends the reader to debug an auth path that was never the problem.
91
+ *
92
+ * Missing credentials stay `invalid_token`: a request that never named a
93
+ * session has not discovered a dead one.
94
+ *
95
+ * The reason is deliberately NOT widened to cover a wrong token on a live
96
+ * session — answering `session_gone` there would tell an unauthenticated
97
+ * caller which sessions exist.
98
+ *
99
+ * Requested by the Prism harness while designing a non-Node relay client.
100
+ */
101
+ check(id: string, token: string): {
102
+ ok: true;
103
+ } | {
104
+ ok: false;
105
+ reason: "invalid_token" | "session_gone";
106
+ };
107
+ /**
108
+ * Validate an authenticated touch and slide the TTL forward.
109
+ *
110
+ * Kept as-is for existing callers; `check()` is the one that says why.
111
+ */
68
112
  validate(id: string, token: string): boolean;
113
+ /** Drop a session — the page closed, or a test needs it gone. */
114
+ dropSession(id: string): void;
69
115
  /** Push a frame onto the inbound queue (external agent → browser). */
70
- inbox(id: string, token: string, payload: string): boolean;
116
+ inbox(id: string, token: string, payload: string, opts?: {
117
+ client?: string;
118
+ }): boolean;
71
119
  /** Push a frame onto the outbound queue (browser server → external agents). */
72
120
  outbox(id: string, token: string, payload: string): boolean;
73
121
  /**
74
122
  * Subscribe to a session's queue for one direction. Returns an iterable
75
123
  * the caller (an HTTP handler) pumps as SSE.
76
124
  */
77
- subscribe(id: string, token: string, direction: Direction): SubscribeResult;
125
+ subscribe(id: string, token: string, direction: Direction, opts?: {
126
+ client?: string;
127
+ }): SubscribeResult;
78
128
  private getDirSubs;
129
+ /**
130
+ * Record which client asked, keyed by JSON-RPC id.
131
+ *
132
+ * Only for REQUESTS — a frame with both an `id` and a `method`. A
133
+ * notification answers nobody and a response is not a question.
134
+ */
135
+ private rememberCaller;
136
+ /**
137
+ * The client a RESPONSE belongs to, or `null` when the frame is a broadcast.
138
+ *
139
+ * Returns the empty string for a response whose id nobody is recorded as
140
+ * having asked — routed to NOBODY rather than to everyone. Broadcasting an
141
+ * uncorrelated reply "just in case" is precisely the leak being closed.
142
+ */
143
+ private callerFor;
144
+ /**
145
+ * Has any subscriber on this session told the broker who it is?
146
+ *
147
+ * The discriminator between a scoped session and a legacy one. Checked across
148
+ * BOTH directions: the page subscribes inbound and rarely labels itself, so
149
+ * asking only about the outbound side would misread a scoped session whose
150
+ * agents have not yet attached.
151
+ */
152
+ private anyIdentifiedSubscriber;
153
+ private parseFrame;
154
+ /** Deliver to the subscribers of one client only. */
155
+ private fanOutTo;
79
156
  private fanOut;
80
157
  /**
81
158
  * Validate that a peer-supplied payload is a well-formed JSON-RPC 2.0 frame