@cello-protocol/client 0.0.21 → 0.0.23

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 (57) hide show
  1. package/dist/client-send-helpers.d.ts +25 -0
  2. package/dist/client-send-helpers.d.ts.map +1 -0
  3. package/dist/client-send-helpers.js +118 -0
  4. package/dist/client-send-helpers.js.map +1 -0
  5. package/dist/client-startup.d.ts +74 -0
  6. package/dist/client-startup.d.ts.map +1 -0
  7. package/dist/client-startup.js +338 -0
  8. package/dist/client-startup.js.map +1 -0
  9. package/dist/client-wiring.d.ts +120 -0
  10. package/dist/client-wiring.d.ts.map +1 -0
  11. package/dist/client-wiring.js +289 -0
  12. package/dist/client-wiring.js.map +1 -0
  13. package/dist/client.d.ts +29 -169
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +222 -5396
  16. package/dist/client.js.map +1 -1
  17. package/dist/connection-inbound-handler.d.ts +47 -0
  18. package/dist/connection-inbound-handler.d.ts.map +1 -0
  19. package/dist/connection-inbound-handler.js +325 -0
  20. package/dist/connection-inbound-handler.js.map +1 -0
  21. package/dist/connection-manager.d.ts +191 -0
  22. package/dist/connection-manager.d.ts.map +1 -0
  23. package/dist/connection-manager.js +692 -0
  24. package/dist/connection-manager.js.map +1 -0
  25. package/dist/frame-dispatch.d.ts +28 -0
  26. package/dist/frame-dispatch.d.ts.map +1 -0
  27. package/dist/frame-dispatch.js +118 -0
  28. package/dist/frame-dispatch.js.map +1 -0
  29. package/dist/network-directory-node.d.ts +2 -0
  30. package/dist/network-directory-node.d.ts.map +1 -1
  31. package/dist/network-directory-node.js +24 -16
  32. package/dist/network-directory-node.js.map +1 -1
  33. package/dist/registration-manager.d.ts +54 -0
  34. package/dist/registration-manager.d.ts.map +1 -0
  35. package/dist/registration-manager.js +249 -0
  36. package/dist/registration-manager.js.map +1 -0
  37. package/dist/relay-stream-manager.d.ts +136 -0
  38. package/dist/relay-stream-manager.d.ts.map +1 -0
  39. package/dist/relay-stream-manager.js +834 -0
  40. package/dist/relay-stream-manager.js.map +1 -0
  41. package/dist/seal-manager.d.ts +133 -0
  42. package/dist/seal-manager.d.ts.map +1 -0
  43. package/dist/seal-manager.js +803 -0
  44. package/dist/seal-manager.js.map +1 -0
  45. package/dist/session-assignment-parser.d.ts +33 -0
  46. package/dist/session-assignment-parser.d.ts.map +1 -0
  47. package/dist/session-assignment-parser.js +149 -0
  48. package/dist/session-assignment-parser.js.map +1 -0
  49. package/dist/session-manager.d.ts +132 -0
  50. package/dist/session-manager.d.ts.map +1 -0
  51. package/dist/session-manager.js +611 -0
  52. package/dist/session-manager.js.map +1 -0
  53. package/dist/signaling-manager.d.ts +85 -0
  54. package/dist/signaling-manager.d.ts.map +1 -0
  55. package/dist/signaling-manager.js +605 -0
  56. package/dist/signaling-manager.js.map +1 -0
  57. package/package.json +2 -2
@@ -0,0 +1,834 @@
1
+ /**
2
+ * RelayStreamManager — relay reader loop, reconnect logic, cross-check, content frame handling.
3
+ *
4
+ * Extracted from CelloClientImpl. Handles MSG-004 relay stream lifecycle:
5
+ * - #runRelayStreamReader: reads leaf_deliver, hash_submit_ack/error, gap_fill frames
6
+ * - #reconnectRelayStream: SESSION-006 exponential backoff reconnect
7
+ * - #handleInboundLeafDeliver: S2 decode, sequence/signature checks, cross-check pairing
8
+ * - #handleContentStream: content path receiver, cross-check trigger
9
+ * - #crossCheckDelivery / #drainReadyQueue: in-order delivery with prevRoot check
10
+ * - #desync: session desynchronization
11
+ * - #performRelayAuth: challenge-response auth
12
+ * - #performGapFillReconciliation: PERSIST-014 gap fill
13
+ * - #handleGapFillResponse
14
+ *
15
+ * State owned here (not in facade):
16
+ * - #relayStreams, #relayRecvSeq, #reconnectInProgress
17
+ * - #readyQueue, #pendingS2, #pendingContent
18
+ * - #tamperedContentClaims, #pendingGapFillResolvers
19
+ * - #directoryStreams
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { Encoder, decode } from "cbor-x";
23
+ import * as lp from "it-length-prefixed";
24
+ import { verify, buildMerkleTree, merkleRoot } from "@cello-protocol/crypto";
25
+ const CBOR_ENC = new Encoder({ tagUint8Array: false });
26
+ const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
27
+ const AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
28
+ const RELAY_AUTH_TIMEOUT_MS = 5_000;
29
+ const DEFAULT_RECONNECT_TIMEOUT_MS = 60_000;
30
+ const RECONNECT_INITIAL_BACKOFF_MS = 200;
31
+ const RECONNECT_MAX_BACKOFF_MS = 5_000;
32
+ const PENDING_CONTENT_BOUND = 256;
33
+ function toU8(v) {
34
+ if (v instanceof Uint8Array)
35
+ return v;
36
+ if (Buffer.isBuffer(v))
37
+ return new Uint8Array(v);
38
+ if (typeof v.slice === "function") {
39
+ return v.slice();
40
+ }
41
+ throw new Error(`expected bytes, got ${typeof v}`);
42
+ }
43
+ function toU8Safe(v) {
44
+ if (v instanceof Uint8Array)
45
+ return v;
46
+ if (Buffer.isBuffer(v))
47
+ return new Uint8Array(v);
48
+ return null;
49
+ }
50
+ async function nextWithTimeout(iter, timeoutMs) {
51
+ return Promise.race([
52
+ iter.next(),
53
+ new Promise((_, reject) => setTimeout(() => reject(new Error("auth_timeout")), timeoutMs)),
54
+ ]);
55
+ }
56
+ export class RelayStreamManager {
57
+ #ctx;
58
+ // ─── Owned state ──────────────────────────────────────────────────────────────
59
+ // session_id_hex → persistent relay stream
60
+ #relayStreams = new Map();
61
+ // session_id_hex → directory signaling stream (SESSION-003)
62
+ #directoryStreams = new Map();
63
+ // session_id_hex → highest seq# received from relay
64
+ #relayRecvSeq = new Map();
65
+ // SESSION-006: track whether a reconnect loop is already running per session
66
+ #reconnectInProgress = new Set();
67
+ // session_id_hex → fully-ready cross-checks keyed by seqNum, awaiting in-order processing
68
+ #readyQueue = new Map();
69
+ // session_id_hex → pending S2 entries keyed by content_hash_hex
70
+ #pendingS2 = new Map();
71
+ // session_id_hex → pending content entries keyed by content_hash_hex
72
+ #pendingContent = new Map();
73
+ // session_id_hex → set of content_hash_hex values from tampered frames
74
+ #tamperedContentClaims = new Map();
75
+ // session_id_hex → pending gap fill resolver
76
+ #pendingGapFillResolvers = new Map();
77
+ constructor(ctx) {
78
+ this.#ctx = ctx;
79
+ }
80
+ // ─── State accessors (called by SessionManager, SealManager, facade) ──────────
81
+ getRelayStream(sessionIdHex) {
82
+ return this.#relayStreams.get(sessionIdHex);
83
+ }
84
+ setRelayStream(sessionIdHex, stream) {
85
+ this.#relayStreams.set(sessionIdHex, stream);
86
+ }
87
+ deleteRelayStream(sessionIdHex) {
88
+ this.#relayStreams.delete(sessionIdHex);
89
+ }
90
+ getDirectoryStream(sessionIdHex) {
91
+ return this.#directoryStreams.get(sessionIdHex);
92
+ }
93
+ setDirectoryStream(sessionIdHex, stream) {
94
+ this.#directoryStreams.set(sessionIdHex, stream);
95
+ }
96
+ deleteDirectoryStream(sessionIdHex) {
97
+ this.#directoryStreams.delete(sessionIdHex);
98
+ }
99
+ /** Called by SessionManager.receiveSessionAssignment to initialize per-session relay state. */
100
+ initSession(sessionIdHex) {
101
+ this.#relayRecvSeq.set(sessionIdHex, 0);
102
+ this.#readyQueue.set(sessionIdHex, new Map());
103
+ this.#pendingS2.set(sessionIdHex, new Map());
104
+ this.#pendingContent.set(sessionIdHex, new Map());
105
+ this.#tamperedContentClaims.set(sessionIdHex, new Set());
106
+ }
107
+ /** Called by facade.closeSession to tear down per-session relay state. */
108
+ closeSession(sessionIdHex) {
109
+ // Clear pending S2 timers to avoid memory leaks
110
+ const ps2 = this.#pendingS2.get(sessionIdHex);
111
+ if (ps2) {
112
+ for (const entry of ps2.values())
113
+ clearTimeout(entry.timer_handle);
114
+ }
115
+ this.#relayRecvSeq.delete(sessionIdHex);
116
+ this.#readyQueue.delete(sessionIdHex);
117
+ this.#pendingS2.delete(sessionIdHex);
118
+ this.#pendingContent.delete(sessionIdHex);
119
+ this.#tamperedContentClaims.delete(sessionIdHex);
120
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
121
+ const stream = this.#relayStreams.get(sessionIdHex);
122
+ if (stream) {
123
+ this.#relayStreams.delete(sessionIdHex);
124
+ stream.abort(new Error("session_closed"));
125
+ }
126
+ const dirStream = this.#directoryStreams.get(sessionIdHex);
127
+ if (dirStream) {
128
+ this.#directoryStreams.delete(sessionIdHex);
129
+ dirStream.abort(new Error("session_closed"));
130
+ }
131
+ this.#reconnectInProgress.delete(sessionIdHex);
132
+ }
133
+ // ─── Public API: TEST-ONLY escape hatches ────────────────────────────────────
134
+ injectLeafDeliver(sessionIdHex, frame) {
135
+ const myPubkeyHex = this.#ctx.getMyPubkeyHex();
136
+ if (!myPubkeyHex)
137
+ throw new Error("injectLeafDeliver: client not yet authenticated (no session registered)");
138
+ this.#handleInboundLeafDeliver(sessionIdHex, frame, myPubkeyHex);
139
+ }
140
+ injectRelayDisconnect(sessionIdHex) {
141
+ const stream = this.#relayStreams.get(sessionIdHex);
142
+ const myPubkeyHex = this.#ctx.getMyPubkeyHex();
143
+ if (!myPubkeyHex)
144
+ return;
145
+ if (stream) {
146
+ this.#relayStreams.delete(sessionIdHex);
147
+ try {
148
+ stream.abort(new Error("test_inject_disconnect"));
149
+ }
150
+ catch { /* ignore */ }
151
+ }
152
+ this.#ctx.onRelayDisconnected(sessionIdHex, myPubkeyHex);
153
+ }
154
+ // ─── Relay stream reader (MSG-004) ─────────────────────────────────────────
155
+ runRelayStreamReader(sessionIdHex, stream, myPubkeyHex, iter) {
156
+ void this.#doRunRelayStreamReader(sessionIdHex, stream, myPubkeyHex, iter);
157
+ }
158
+ async #doRunRelayStreamReader(sessionIdHex, stream, myPubkeyHex, iter) {
159
+ const source = iter ?? lp.decode(stream)[Symbol.asyncIterator]();
160
+ try {
161
+ while (true) {
162
+ let result;
163
+ try {
164
+ result = await source.next();
165
+ }
166
+ catch {
167
+ break;
168
+ }
169
+ if (result.done || result.value === undefined)
170
+ break;
171
+ const bytes = toU8(result.value);
172
+ let frame;
173
+ try {
174
+ frame = decode(bytes);
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ if (frame["type"] === "hash_submit_ack") {
180
+ const resolve = this.#ctx.getPendingAckResolver(sessionIdHex);
181
+ if (resolve) {
182
+ this.#ctx.deletePendingAckResolver(sessionIdHex);
183
+ const seqNum = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : 0;
184
+ resolve({ ok: true, sequence_number: seqNum });
185
+ }
186
+ }
187
+ else if (frame["type"] === "hash_submit_error") {
188
+ const resolve = this.#ctx.getPendingAckResolver(sessionIdHex);
189
+ if (resolve) {
190
+ this.#ctx.deletePendingAckResolver(sessionIdHex);
191
+ resolve({ ok: false, reason: String(frame["reason"] ?? "unknown") });
192
+ }
193
+ }
194
+ else if (frame["type"] === "leaf_deliver") {
195
+ const deliverSessionIdRaw = frame["session_id"];
196
+ const deliverSessionId = deliverSessionIdRaw instanceof Uint8Array ? deliverSessionIdRaw
197
+ : Buffer.isBuffer(deliverSessionIdRaw) ? new Uint8Array(deliverSessionIdRaw) : null;
198
+ const targetSessionHex = deliverSessionId
199
+ ? Buffer.from(deliverSessionId).toString("hex")
200
+ : sessionIdHex;
201
+ this.#handleInboundLeafDeliver(targetSessionHex, frame, myPubkeyHex);
202
+ }
203
+ else if (frame["type"] === "gap_fill_response") {
204
+ this.#handleGapFillResponse(sessionIdHex, frame);
205
+ }
206
+ else if (frame["type"] === "gap_fill_error") {
207
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
208
+ const pendingReconciliation = this.#pendingGapFillResolvers.get(sessionIdHex);
209
+ if (pendingReconciliation) {
210
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
211
+ pendingReconciliation({ ok: false, reason });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ catch { /* stream closed */ }
217
+ if (this.#relayStreams.get(sessionIdHex) === stream) {
218
+ this.#relayStreams.delete(sessionIdHex);
219
+ }
220
+ this.#ctx.onRelayDisconnected(sessionIdHex, myPubkeyHex);
221
+ }
222
+ // ─── SESSION-006 reconnect ──────────────────────────────────────────────────
223
+ async reconnectRelayStream(sessionIdHex, myPubkeyHex) {
224
+ if (this.#reconnectInProgress.has(sessionIdHex))
225
+ return;
226
+ this.#reconnectInProgress.add(sessionIdHex);
227
+ const reconnectTimeoutMs = this.#ctx.reconnectTimeoutMs;
228
+ const deadline = Date.now() + reconnectTimeoutMs;
229
+ let backoff = RECONNECT_INITIAL_BACKOFF_MS;
230
+ try {
231
+ while (Date.now() < deadline) {
232
+ const session = this.#ctx.getSession(sessionIdHex);
233
+ if (!session || session.desynchronized)
234
+ return;
235
+ if (session.status !== "transport_lost")
236
+ return;
237
+ try {
238
+ const relayPeerId = session.relay_endpoint.peer_id;
239
+ const relayMultiaddr = session.relay_endpoint.multiaddrs[0];
240
+ if (relayMultiaddr) {
241
+ try {
242
+ await this.#ctx.node.dial(relayMultiaddr);
243
+ }
244
+ catch { /* non-fatal */ }
245
+ }
246
+ let newStream;
247
+ try {
248
+ newStream = await this.#ctx.node.newStream(relayPeerId, RELAY_PROTOCOL_ID);
249
+ }
250
+ catch {
251
+ throw new Error("relay_unreachable");
252
+ }
253
+ const myPubkey = Buffer.from(myPubkeyHex, "hex");
254
+ const authResult = await this.#performRelayAuth(newStream, myPubkey);
255
+ if (!authResult.ok) {
256
+ newStream.abort(new Error("auth_failed"));
257
+ throw new Error("relay_auth_failed");
258
+ }
259
+ const sessionAfterAuth = this.#ctx.getSession(sessionIdHex);
260
+ if (!sessionAfterAuth || sessionAfterAuth.desynchronized) {
261
+ newStream.abort(new Error("session_gone"));
262
+ return;
263
+ }
264
+ this.#relayStreams.set(sessionIdHex, newStream);
265
+ sessionAfterAuth.status = "active";
266
+ void this.#ctx.persistence?.persistSession(sessionIdHex, sessionAfterAuth);
267
+ if (this.#ctx.hashQueue) {
268
+ const pendingEntries = await this.#ctx.hashQueue.getPending(sessionIdHex);
269
+ if (pendingEntries.length > 0) {
270
+ this.#ctx.logger.info("client.hashqueue.resubmit.pending", {
271
+ agentId: myPubkeyHex,
272
+ sessionId: sessionIdHex,
273
+ pendingCount: pendingEntries.length,
274
+ });
275
+ }
276
+ }
277
+ this.runRelayStreamReader(sessionIdHex, newStream, myPubkeyHex, authResult.iter);
278
+ return;
279
+ }
280
+ catch {
281
+ const remaining = deadline - Date.now();
282
+ if (remaining <= 0)
283
+ break;
284
+ const waitMs = Math.min(backoff, remaining);
285
+ await new Promise((r) => setTimeout(r, waitMs));
286
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_BACKOFF_MS);
287
+ }
288
+ }
289
+ }
290
+ finally {
291
+ this.#reconnectInProgress.delete(sessionIdHex);
292
+ }
293
+ }
294
+ // ─── Content frame handler (MSG-004) ─────────────────────────────────────────
295
+ async handleContentStream(stream) {
296
+ let payload;
297
+ try {
298
+ for await (const chunk of lp.decode(stream)) {
299
+ payload = toU8(chunk);
300
+ break;
301
+ }
302
+ }
303
+ catch {
304
+ stream.abort(new Error("content_stream_error"));
305
+ return;
306
+ }
307
+ if (!payload) {
308
+ stream.close().catch(() => { });
309
+ return;
310
+ }
311
+ let frame;
312
+ try {
313
+ frame = decode(payload);
314
+ }
315
+ catch {
316
+ stream.close().catch(() => { });
317
+ return;
318
+ }
319
+ if (frame["type"] !== "content_frame") {
320
+ stream.close().catch(() => { });
321
+ return;
322
+ }
323
+ const sessionIdRaw = frame["session_id"];
324
+ const sessionIdBytes = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
325
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
326
+ if (!sessionIdBytes) {
327
+ stream.close().catch(() => { });
328
+ return;
329
+ }
330
+ const sessionIdHex = Buffer.from(sessionIdBytes).toString("hex");
331
+ const session = this.#ctx.getSession(sessionIdHex);
332
+ if (!session || session.desynchronized) {
333
+ stream.close().catch(() => { });
334
+ return;
335
+ }
336
+ const contentBytesRaw = frame["content_bytes"];
337
+ const contentBytes = contentBytesRaw instanceof Uint8Array ? contentBytesRaw
338
+ : Buffer.isBuffer(contentBytesRaw) ? new Uint8Array(contentBytesRaw) : null;
339
+ if (!contentBytes) {
340
+ stream.close().catch(() => { });
341
+ return;
342
+ }
343
+ const declaredHashRaw = frame["content_hash"];
344
+ const declaredHash = declaredHashRaw instanceof Uint8Array ? declaredHashRaw
345
+ : Buffer.isBuffer(declaredHashRaw) ? new Uint8Array(declaredHashRaw) : null;
346
+ if (!declaredHash || declaredHash.length !== 32) {
347
+ stream.close().catch(() => { });
348
+ return;
349
+ }
350
+ const declaredHashHex = Buffer.from(declaredHash).toString("hex");
351
+ const ps2MapEarly = this.#pendingS2.get(sessionIdHex);
352
+ const s2EntryEarly = ps2MapEarly?.get(declaredHashHex);
353
+ if (s2EntryEarly) {
354
+ const recomputed = new Uint8Array(createHash("sha256").update(new Uint8Array([s2EntryEarly.leaf_kind])).update(contentBytes).digest());
355
+ if (Buffer.compare(Buffer.from(recomputed), Buffer.from(declaredHash)) !== 0) {
356
+ clearTimeout(s2EntryEarly.timer_handle);
357
+ ps2MapEarly.delete(declaredHashHex);
358
+ this.#desync(sessionIdHex, "content_hash_mismatch");
359
+ stream.close().catch(() => { });
360
+ return;
361
+ }
362
+ }
363
+ else {
364
+ const msgHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest());
365
+ const ctrlHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x02])).update(contentBytes).digest());
366
+ const matchesMsg = Buffer.compare(Buffer.from(msgHash), Buffer.from(declaredHash)) === 0;
367
+ const matchesCtrl = Buffer.compare(Buffer.from(ctrlHash), Buffer.from(declaredHash)) === 0;
368
+ if (!matchesMsg && !matchesCtrl) {
369
+ this.#tamperedContentClaims.get(sessionIdHex)?.add(declaredHashHex);
370
+ stream.close().catch(() => { });
371
+ return;
372
+ }
373
+ }
374
+ const contentHashHex = declaredHashHex;
375
+ const ps2Map = this.#pendingS2.get(sessionIdHex);
376
+ const s2Entry = ps2Map?.get(contentHashHex);
377
+ if (s2Entry) {
378
+ ps2Map.delete(contentHashHex);
379
+ clearTimeout(s2Entry.timer_handle);
380
+ this.#crossCheckDelivery(sessionIdHex, s2Entry.s2, s2Entry.s2_cbor, s2Entry.s1_fields, s2Entry.leaf_kind, contentBytes, s2Entry.is_own_send, s2Entry.echo_resolve);
381
+ }
382
+ else {
383
+ const pending = this.#pendingContent.get(sessionIdHex);
384
+ if (pending) {
385
+ if (pending.size >= PENDING_CONTENT_BOUND) {
386
+ const firstKey = pending.keys().next().value;
387
+ if (firstKey !== undefined)
388
+ pending.delete(firstKey);
389
+ }
390
+ pending.set(contentHashHex, { content_bytes: contentBytes, arrived_at: Date.now() });
391
+ console.debug(`[cello-client] content_without_hash: session=${sessionIdHex} hash=${contentHashHex}`);
392
+ }
393
+ }
394
+ stream.close().catch(() => { });
395
+ }
396
+ // ─── Relay auth ──────────────────────────────────────────────────────────────
397
+ async performRelayAuth(stream, myPubkey) {
398
+ return this.#performRelayAuth(stream, myPubkey);
399
+ }
400
+ async #performRelayAuth(stream, myPubkey) {
401
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
402
+ const { value: challengeRaw, done } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
403
+ if (done || challengeRaw === undefined)
404
+ return { ok: false, reason: "relay_auth_error" };
405
+ const challengeBytes = toU8(challengeRaw);
406
+ let challenge;
407
+ try {
408
+ challenge = decode(challengeBytes);
409
+ }
410
+ catch {
411
+ return { ok: false, reason: "relay_auth_error" };
412
+ }
413
+ if (challenge["type"] !== "relay_auth_challenge")
414
+ return { ok: false, reason: "relay_auth_error" };
415
+ const nonce = toU8(challenge["nonce"]);
416
+ if (nonce.length !== 32)
417
+ return { ok: false, reason: "relay_auth_error" };
418
+ const domain = Buffer.from(AUTH_DOMAIN, "utf8");
419
+ const authMsg = new Uint8Array(Buffer.concat([domain, nonce, myPubkey]));
420
+ const msgHash = new Uint8Array(createHash("sha256").update(authMsg).digest());
421
+ const signature = await this.#ctx.keyProvider.sign(msgHash);
422
+ const responseFrame = CBOR_ENC.encode({
423
+ type: "relay_auth_response",
424
+ pubkey: myPubkey,
425
+ signature,
426
+ });
427
+ stream.send(lp.encode.single(responseFrame));
428
+ const { value: ackRaw, done: ackDone } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
429
+ if (ackDone || ackRaw === undefined)
430
+ return { ok: false, reason: "relay_auth_error" };
431
+ let ackFrame;
432
+ try {
433
+ ackFrame = decode(toU8(ackRaw));
434
+ }
435
+ catch {
436
+ return { ok: false, reason: "relay_auth_error" };
437
+ }
438
+ if (ackFrame["type"] === "relay_auth_failed")
439
+ return { ok: false, reason: "relay_auth_failed" };
440
+ if (ackFrame["type"] !== "relay_auth_ok")
441
+ return { ok: false, reason: "relay_auth_error" };
442
+ return { ok: true, iter };
443
+ }
444
+ // ─── Inbound leaf deliver ────────────────────────────────────────────────────
445
+ #handleInboundLeafDeliver(sessionIdHex, frame, myPubkeyHex) {
446
+ const session = this.#ctx.getSession(sessionIdHex);
447
+ if (!session || session.desynchronized)
448
+ return;
449
+ const s2CborRaw = frame["structure2_cbor"];
450
+ const s2Cbor = s2CborRaw instanceof Uint8Array ? s2CborRaw
451
+ : Buffer.isBuffer(s2CborRaw) ? new Uint8Array(s2CborRaw) : null;
452
+ if (!s2Cbor) {
453
+ this.#desync(sessionIdHex, "structure2_malformed");
454
+ return;
455
+ }
456
+ let s2Arr;
457
+ try {
458
+ const decoded = decode(s2Cbor);
459
+ if (!Array.isArray(decoded) || decoded.length !== 6) {
460
+ this.#desync(sessionIdHex, "structure2_malformed");
461
+ return;
462
+ }
463
+ s2Arr = decoded;
464
+ }
465
+ catch {
466
+ this.#desync(sessionIdHex, "structure2_malformed");
467
+ return;
468
+ }
469
+ const seqNum = typeof s2Arr[0] === "number" ? s2Arr[0] : null;
470
+ if (seqNum === null) {
471
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
472
+ return;
473
+ }
474
+ const senderPubkey = toU8Safe(s2Arr[1]);
475
+ const contentHash = toU8Safe(s2Arr[2]);
476
+ const senderSig = toU8Safe(s2Arr[3]);
477
+ const prevRoot = toU8Safe(s2Arr[5]);
478
+ if (!senderPubkey || senderPubkey.length !== 32) {
479
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
480
+ return;
481
+ }
482
+ if (!contentHash || contentHash.length !== 32) {
483
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
484
+ return;
485
+ }
486
+ if (!senderSig || senderSig.length !== 64) {
487
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
488
+ return;
489
+ }
490
+ if (!prevRoot || prevRoot.length !== 32) {
491
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
492
+ return;
493
+ }
494
+ const s2 = {
495
+ sequence_number: seqNum,
496
+ sender_pubkey: senderPubkey,
497
+ content_hash: contentHash,
498
+ sender_signature: senderSig,
499
+ scan_result: { score: null, verdict: "unscanned", model_hash: new Uint8Array(32) },
500
+ prev_root: prevRoot,
501
+ };
502
+ const relayRecvSeq = this.#relayRecvSeq.get(sessionIdHex) ?? 0;
503
+ if (seqNum <= relayRecvSeq) {
504
+ this.#desync(sessionIdHex, "sequence_replay");
505
+ return;
506
+ }
507
+ if (seqNum > relayRecvSeq + 1) {
508
+ this.#desync(sessionIdHex, "sequence_gap");
509
+ return;
510
+ }
511
+ this.#relayRecvSeq.set(sessionIdHex, seqNum);
512
+ const s1CborRaw = frame["structure1_cbor"];
513
+ const s1Cbor = s1CborRaw instanceof Uint8Array ? s1CborRaw
514
+ : Buffer.isBuffer(s1CborRaw) ? new Uint8Array(s1CborRaw) : null;
515
+ if (!s1Cbor) {
516
+ this.#desync(sessionIdHex, "structure1_malformed");
517
+ return;
518
+ }
519
+ let s1Fields = null;
520
+ try {
521
+ const s1Decoded = decode(s1Cbor);
522
+ if (Array.isArray(s1Decoded) && s1Decoded.length === 6) {
523
+ const lss = s1Decoded[4];
524
+ const ts = s1Decoded[5];
525
+ if (typeof lss === "number" && (typeof ts === "number" || typeof ts === "bigint")) {
526
+ s1Fields = { last_seen_seq: lss, timestamp: ts };
527
+ }
528
+ }
529
+ }
530
+ catch { /* fall through */ }
531
+ if (!s1Fields) {
532
+ this.#desync(sessionIdHex, "structure1_malformed");
533
+ return;
534
+ }
535
+ if (!verify(senderPubkey, s1Cbor, s2.sender_signature)) {
536
+ this.#desync(sessionIdHex, "signature_verification_failed");
537
+ return;
538
+ }
539
+ const senderHex = Buffer.from(senderPubkey).toString("hex");
540
+ const isOwnSend = senderHex === myPubkeyHex;
541
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
542
+ const leafKind = typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : 0x00;
543
+ const tamperedClaims = this.#tamperedContentClaims.get(sessionIdHex);
544
+ if (tamperedClaims?.has(contentHashHex)) {
545
+ tamperedClaims.delete(contentHashHex);
546
+ this.#desync(sessionIdHex, "content_hash_mismatch");
547
+ return;
548
+ }
549
+ const ownPendingContent = isOwnSend ? this.#ctx.getOwnPendingContent(sessionIdHex) : undefined;
550
+ const pendingContent = this.#pendingContent.get(sessionIdHex);
551
+ const contentEntry = isOwnSend
552
+ ? ownPendingContent?.get(contentHashHex)
553
+ : pendingContent?.get(contentHashHex);
554
+ let echoResolve;
555
+ if (isOwnSend) {
556
+ const resolvers = this.#ctx.getOwnEchoResolvers(sessionIdHex);
557
+ echoResolve = resolvers?.get(seqNum);
558
+ resolvers?.delete(seqNum);
559
+ }
560
+ if (contentEntry) {
561
+ if (isOwnSend) {
562
+ ownPendingContent.delete(contentHashHex);
563
+ }
564
+ else {
565
+ pendingContent.delete(contentHashHex);
566
+ }
567
+ this.#crossCheckDelivery(sessionIdHex, s2, s2Cbor, s1Fields, leafKind, contentEntry.content_bytes, isOwnSend, echoResolve);
568
+ }
569
+ else {
570
+ const timerHandle = setTimeout(() => {
571
+ const ps2Map = this.#pendingS2.get(sessionIdHex);
572
+ if (ps2Map?.has(contentHashHex)) {
573
+ ps2Map.delete(contentHashHex);
574
+ this.#desync(sessionIdHex, "content_missing");
575
+ }
576
+ }, this.#ctx.contentGraceMs);
577
+ const entry = {
578
+ s2,
579
+ s2_cbor: s2Cbor,
580
+ s1_fields: s1Fields,
581
+ leaf_kind: leafKind,
582
+ sequence_number: seqNum,
583
+ content_hash: contentHash,
584
+ is_own_send: isOwnSend,
585
+ arrived_at: Date.now(),
586
+ timer_handle: timerHandle,
587
+ echo_resolve: echoResolve,
588
+ };
589
+ this.#pendingS2.get(sessionIdHex)?.set(contentHashHex, entry);
590
+ }
591
+ }
592
+ #crossCheckDelivery(sessionIdHex, s2, s2Cbor, s1Fields, leafKind, contentBytes, isOwnSend, echoResolve) {
593
+ const session = this.#ctx.getSession(sessionIdHex);
594
+ if (!session || session.desynchronized)
595
+ return;
596
+ const readyQ = this.#readyQueue.get(sessionIdHex);
597
+ if (!readyQ)
598
+ return;
599
+ readyQ.set(s2.sequence_number, { s2, s2_cbor: s2Cbor, s1_fields: s1Fields, leaf_kind: leafKind, content_bytes: contentBytes, is_own_send: isOwnSend, echo_resolve: echoResolve });
600
+ this.#drainReadyQueue(sessionIdHex);
601
+ }
602
+ #drainReadyQueue(sessionIdHex) {
603
+ const session = this.#ctx.getSession(sessionIdHex);
604
+ const readyQ = this.#readyQueue.get(sessionIdHex);
605
+ if (!session || !readyQ || session.desynchronized)
606
+ return;
607
+ while (true) {
608
+ const nextSeq = session.next_expected_seq;
609
+ const entry = readyQ.get(nextSeq);
610
+ if (!entry)
611
+ break;
612
+ readyQ.delete(nextSeq);
613
+ const { s2, s2_cbor, s1_fields, leaf_kind, content_bytes, is_own_send, echo_resolve } = entry;
614
+ const expectedPrevRoot = session.local_tree_leaves.length === 0
615
+ ? session.genesis_prev_root
616
+ : (() => {
617
+ const inputs = session.local_tree_leaves.map(l => ({
618
+ kind: l.kind,
619
+ data: l.s2_cbor,
620
+ }));
621
+ return merkleRoot(buildMerkleTree(inputs));
622
+ })();
623
+ if (Buffer.compare(Buffer.from(s2.prev_root), Buffer.from(expectedPrevRoot)) !== 0) {
624
+ this.#desync(sessionIdHex, "prev_root_mismatch");
625
+ return;
626
+ }
627
+ if (!is_own_send && s1_fields.last_seen_seq > session.last_sent_seq) {
628
+ this.#desync(sessionIdHex, "sequence_causal_inconsistency");
629
+ return;
630
+ }
631
+ const kind = leaf_kind === 0x02 ? "ctrl" : "msg";
632
+ const leafIndex = session.local_tree_leaves.length;
633
+ session.local_tree_leaves.push({ kind, s2_cbor });
634
+ session.next_expected_seq += 1;
635
+ if (this.#ctx.persistence) {
636
+ void this.#ctx.persistence.persistSessionTreeLeaf({
637
+ sessionIdHex,
638
+ leafIndex,
639
+ leafKind: kind,
640
+ s2Cbor: s2_cbor,
641
+ sequenceNumber: s2.sequence_number,
642
+ });
643
+ }
644
+ const leafHash = new Uint8Array(createHash("sha256").update(new Uint8Array([leaf_kind])).update(s2_cbor).digest());
645
+ if (is_own_send) {
646
+ session.last_sent_seq = s2.sequence_number;
647
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
648
+ echo_resolve?.();
649
+ }
650
+ else {
651
+ session.last_seen_seq = s2.sequence_number;
652
+ if (kind === "ctrl" && session.status === "active") {
653
+ session.status = "sealing";
654
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
655
+ void (async () => {
656
+ // Notify the seal manager that responder received SEAL leaf
657
+ this.#ctx.handleSealVerified(sessionIdHex, {
658
+ type: "_responder_seal_trigger",
659
+ sessionIdHex,
660
+ });
661
+ })();
662
+ }
663
+ else {
664
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
665
+ const msg = {
666
+ type: "message",
667
+ content: content_bytes,
668
+ senderPubkey: s2.sender_pubkey,
669
+ sequenceNumber: s2.sequence_number,
670
+ leafHash,
671
+ };
672
+ this.#ctx.enqueueReceivedMessage(sessionIdHex, msg);
673
+ }
674
+ }
675
+ }
676
+ }
677
+ #desync(sessionIdHex, reason) {
678
+ const session = this.#ctx.getSession(sessionIdHex);
679
+ if (!session)
680
+ return;
681
+ session.desynchronized = true;
682
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
683
+ const ps2 = this.#pendingS2.get(sessionIdHex);
684
+ if (ps2) {
685
+ for (const entry of ps2.values()) {
686
+ clearTimeout(entry.timer_handle);
687
+ entry.echo_resolve?.();
688
+ }
689
+ ps2.clear();
690
+ }
691
+ const resolvers = this.#ctx.getOwnEchoResolvers(sessionIdHex);
692
+ if (resolvers) {
693
+ for (const resolve of resolvers.values())
694
+ resolve();
695
+ resolvers.clear();
696
+ }
697
+ this.#pendingContent.get(sessionIdHex)?.clear();
698
+ this.#ctx.getOwnPendingContent(sessionIdHex)?.clear();
699
+ this.#relayRecvSeq.delete(sessionIdHex);
700
+ this.#readyQueue.get(sessionIdHex)?.clear();
701
+ const ackResolve = this.#ctx.getPendingAckResolver(sessionIdHex);
702
+ if (ackResolve) {
703
+ this.#ctx.deletePendingAckResolver(sessionIdHex);
704
+ ackResolve({ ok: false, reason });
705
+ }
706
+ this.#ctx.logger.warn("session.relay.desynchronized", { sessionId: sessionIdHex, reason });
707
+ }
708
+ // ─── Gap fill (PERSIST-014) ──────────────────────────────────────────────────
709
+ async performGapFillReconciliation(sessionIdHex, fromSeq, toSeq, correlationId) {
710
+ const session = this.#ctx.getSession(sessionIdHex);
711
+ if (!session)
712
+ return;
713
+ const startMs = Date.now();
714
+ const gapSize = toSeq - fromSeq;
715
+ const relayStream = this.#relayStreams.get(sessionIdHex);
716
+ if (!relayStream || relayStream.status !== "open") {
717
+ this.#ctx.logger.error("session.gap.fill.failed", {
718
+ sessionId: sessionIdHex,
719
+ reason: "relay_stream_unavailable",
720
+ correlationId,
721
+ });
722
+ return;
723
+ }
724
+ const gapFillRequestFrame = CBOR_ENC.encode({
725
+ type: "gap_fill_request",
726
+ session_id: session.session_id,
727
+ from_seq: fromSeq,
728
+ to_seq: toSeq,
729
+ });
730
+ const responsePromise = new Promise((resolve) => {
731
+ this.#pendingGapFillResolvers.set(sessionIdHex, resolve);
732
+ });
733
+ try {
734
+ relayStream.send(lp.encode.single(gapFillRequestFrame));
735
+ }
736
+ catch {
737
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
738
+ this.#ctx.logger.error("session.gap.fill.failed", {
739
+ sessionId: sessionIdHex,
740
+ reason: "relay_send_failed",
741
+ correlationId,
742
+ });
743
+ return;
744
+ }
745
+ const responseResult = await responsePromise;
746
+ if (!responseResult.ok) {
747
+ this.#ctx.logger.error("session.gap.fill.failed", {
748
+ sessionId: sessionIdHex,
749
+ reason: responseResult.reason,
750
+ correlationId,
751
+ });
752
+ return;
753
+ }
754
+ const gapLeaves = responseResult.leaves;
755
+ for (let i = 0; i < gapLeaves.length; i++) {
756
+ const leaf = gapLeaves[i];
757
+ const senderPubkey = leaf["sender_pubkey"] instanceof Uint8Array ? leaf["sender_pubkey"]
758
+ : Buffer.isBuffer(leaf["sender_pubkey"]) ? new Uint8Array(leaf["sender_pubkey"]) : null;
759
+ const structure1Cbor = leaf["structure1_cbor"] instanceof Uint8Array ? leaf["structure1_cbor"]
760
+ : Buffer.isBuffer(leaf["structure1_cbor"]) ? new Uint8Array(leaf["structure1_cbor"]) : null;
761
+ const senderSignature = leaf["sender_signature"] instanceof Uint8Array ? leaf["sender_signature"]
762
+ : Buffer.isBuffer(leaf["sender_signature"]) ? new Uint8Array(leaf["sender_signature"]) : null;
763
+ if (!senderPubkey || !structure1Cbor || !senderSignature) {
764
+ this.#ctx.logger.warn("session.gap.fill.leaf.invalid", {
765
+ sessionId: sessionIdHex,
766
+ leafIndex: i,
767
+ reason: "missing_fields",
768
+ correlationId,
769
+ });
770
+ return;
771
+ }
772
+ if (!verify(senderPubkey, structure1Cbor, senderSignature)) {
773
+ this.#ctx.logger.warn("session.gap.fill.leaf.invalid", {
774
+ sessionId: sessionIdHex,
775
+ leafIndex: i,
776
+ reason: "signature_invalid",
777
+ correlationId,
778
+ });
779
+ return;
780
+ }
781
+ }
782
+ const latestSeq = gapLeaves.reduce((max, l) => {
783
+ const seq = typeof l["sequence_number"] === "number" ? l["sequence_number"] : max;
784
+ return Math.max(max, seq);
785
+ }, session.next_expected_seq - 1);
786
+ session.next_expected_seq = latestSeq + 1;
787
+ this.#ctx.logger.info("session.reconciliation.completed", {
788
+ sessionId: sessionIdHex,
789
+ gapSize,
790
+ durationMs: Date.now() - startMs,
791
+ correlationId,
792
+ });
793
+ const localRoot = this.#computeLocalRoot(session);
794
+ const dirStream = this.#directoryStreams.get(sessionIdHex);
795
+ if (localRoot && dirStream && dirStream.status === "open") {
796
+ const sealAttemptFrame = CBOR_ENC.encode({
797
+ type: "seal_attempt",
798
+ session_id: session.session_id,
799
+ reported_root: localRoot,
800
+ reported_seq: session.next_expected_seq - 1,
801
+ });
802
+ try {
803
+ dirStream.send(lp.encode.single(sealAttemptFrame));
804
+ }
805
+ catch {
806
+ this.#ctx.logger.error("session.gap.fill.failed", {
807
+ sessionId: sessionIdHex,
808
+ reason: "seal_retry_send_failed",
809
+ correlationId,
810
+ });
811
+ }
812
+ }
813
+ }
814
+ #handleGapFillResponse(sessionIdHex, frame) {
815
+ const resolver = this.#pendingGapFillResolvers.get(sessionIdHex);
816
+ if (!resolver)
817
+ return;
818
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
819
+ const leavesRaw = Array.isArray(frame["leaves"]) ? frame["leaves"] : [];
820
+ resolver({ ok: true, leaves: leavesRaw });
821
+ }
822
+ #computeLocalRoot(session) {
823
+ if (!session.local_tree_leaves || session.local_tree_leaves.length === 0)
824
+ return null;
825
+ const leafInputs = session.local_tree_leaves.map((l) => ({
826
+ kind: l.kind,
827
+ data: l.s2_cbor,
828
+ }));
829
+ const tree = buildMerkleTree(leafInputs);
830
+ return merkleRoot(tree);
831
+ }
832
+ }
833
+ export { DEFAULT_RECONNECT_TIMEOUT_MS };
834
+ //# sourceMappingURL=relay-stream-manager.js.map