@ninelives/agent-governance-sdk 5.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 (126) hide show
  1. package/README.md +363 -0
  2. package/dist/audit.d.ts +26 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +107 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/cascade-containment.d.ts +84 -0
  7. package/dist/cascade-containment.d.ts.map +1 -0
  8. package/dist/cascade-containment.js +387 -0
  9. package/dist/cascade-containment.js.map +1 -0
  10. package/dist/client.d.ts +34 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +112 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/context-poisoning.d.ts +58 -0
  15. package/dist/context-poisoning.d.ts.map +1 -0
  16. package/dist/context-poisoning.js +410 -0
  17. package/dist/context-poisoning.js.map +1 -0
  18. package/dist/credential-vault.d.ts +213 -0
  19. package/dist/credential-vault.d.ts.map +1 -0
  20. package/dist/credential-vault.js +567 -0
  21. package/dist/credential-vault.js.map +1 -0
  22. package/dist/discovery.d.ts +80 -0
  23. package/dist/discovery.d.ts.map +1 -0
  24. package/dist/discovery.js +404 -0
  25. package/dist/discovery.js.map +1 -0
  26. package/dist/encryption/channel.d.ts +29 -0
  27. package/dist/encryption/channel.d.ts.map +1 -0
  28. package/dist/encryption/channel.js +78 -0
  29. package/dist/encryption/channel.js.map +1 -0
  30. package/dist/encryption/index.d.ts +9 -0
  31. package/dist/encryption/index.d.ts.map +1 -0
  32. package/dist/encryption/index.js +23 -0
  33. package/dist/encryption/index.js.map +1 -0
  34. package/dist/encryption/mesh-client.d.ts +281 -0
  35. package/dist/encryption/mesh-client.d.ts.map +1 -0
  36. package/dist/encryption/mesh-client.js +1110 -0
  37. package/dist/encryption/mesh-client.js.map +1 -0
  38. package/dist/encryption/ratchet.d.ts +38 -0
  39. package/dist/encryption/ratchet.d.ts.map +1 -0
  40. package/dist/encryption/ratchet.js +223 -0
  41. package/dist/encryption/ratchet.js.map +1 -0
  42. package/dist/encryption/registry-client.d.ts +136 -0
  43. package/dist/encryption/registry-client.d.ts.map +1 -0
  44. package/dist/encryption/registry-client.js +369 -0
  45. package/dist/encryption/registry-client.js.map +1 -0
  46. package/dist/encryption/x3dh.d.ts +80 -0
  47. package/dist/encryption/x3dh.d.ts.map +1 -0
  48. package/dist/encryption/x3dh.js +238 -0
  49. package/dist/encryption/x3dh.js.map +1 -0
  50. package/dist/framework-adapter.d.ts +63 -0
  51. package/dist/framework-adapter.d.ts.map +1 -0
  52. package/dist/framework-adapter.js +253 -0
  53. package/dist/framework-adapter.js.map +1 -0
  54. package/dist/identity.d.ts +124 -0
  55. package/dist/identity.d.ts.map +1 -0
  56. package/dist/identity.js +446 -0
  57. package/dist/identity.js.map +1 -0
  58. package/dist/index.d.ts +39 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +95 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/kill-switch.d.ts +22 -0
  63. package/dist/kill-switch.d.ts.map +1 -0
  64. package/dist/kill-switch.js +60 -0
  65. package/dist/kill-switch.js.map +1 -0
  66. package/dist/lifecycle.d.ts +50 -0
  67. package/dist/lifecycle.d.ts.map +1 -0
  68. package/dist/lifecycle.js +113 -0
  69. package/dist/lifecycle.js.map +1 -0
  70. package/dist/mcp.d.ts +41 -0
  71. package/dist/mcp.d.ts.map +1 -0
  72. package/dist/mcp.js +233 -0
  73. package/dist/mcp.js.map +1 -0
  74. package/dist/metrics.d.ts +123 -0
  75. package/dist/metrics.d.ts.map +1 -0
  76. package/dist/metrics.js +289 -0
  77. package/dist/metrics.js.map +1 -0
  78. package/dist/oci-manifest.d.ts +49 -0
  79. package/dist/oci-manifest.d.ts.map +1 -0
  80. package/dist/oci-manifest.js +182 -0
  81. package/dist/oci-manifest.js.map +1 -0
  82. package/dist/policy-backends/cedar.d.ts +15 -0
  83. package/dist/policy-backends/cedar.d.ts.map +1 -0
  84. package/dist/policy-backends/cedar.js +88 -0
  85. package/dist/policy-backends/cedar.js.map +1 -0
  86. package/dist/policy-backends/opa.d.ts +17 -0
  87. package/dist/policy-backends/opa.d.ts.map +1 -0
  88. package/dist/policy-backends/opa.js +74 -0
  89. package/dist/policy-backends/opa.js.map +1 -0
  90. package/dist/policy.d.ts +77 -0
  91. package/dist/policy.d.ts.map +1 -0
  92. package/dist/policy.js +760 -0
  93. package/dist/policy.js.map +1 -0
  94. package/dist/prompt-defense.d.ts +37 -0
  95. package/dist/prompt-defense.d.ts.map +1 -0
  96. package/dist/prompt-defense.js +229 -0
  97. package/dist/prompt-defense.js.map +1 -0
  98. package/dist/protocol-facets.d.ts +87 -0
  99. package/dist/protocol-facets.d.ts.map +1 -0
  100. package/dist/protocol-facets.js +728 -0
  101. package/dist/protocol-facets.js.map +1 -0
  102. package/dist/rings.d.ts +17 -0
  103. package/dist/rings.d.ts.map +1 -0
  104. package/dist/rings.js +61 -0
  105. package/dist/rings.js.map +1 -0
  106. package/dist/sandbox.d.ts +61 -0
  107. package/dist/sandbox.d.ts.map +1 -0
  108. package/dist/sandbox.js +199 -0
  109. package/dist/sandbox.js.map +1 -0
  110. package/dist/surface-parity.d.ts +45 -0
  111. package/dist/surface-parity.d.ts.map +1 -0
  112. package/dist/surface-parity.js +216 -0
  113. package/dist/surface-parity.js.map +1 -0
  114. package/dist/trust.d.ts +28 -0
  115. package/dist/trust.d.ts.map +1 -0
  116. package/dist/trust.js +209 -0
  117. package/dist/trust.js.map +1 -0
  118. package/dist/types.d.ts +422 -0
  119. package/dist/types.d.ts.map +1 -0
  120. package/dist/types.js +29 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/verify.d.ts +135 -0
  123. package/dist/verify.d.ts.map +1 -0
  124. package/dist/verify.js +356 -0
  125. package/dist/verify.js.map +1 -0
  126. package/package.json +66 -0
@@ -0,0 +1,1110 @@
1
+ "use strict";
2
+ // Copyright (c) Microsoft Corporation.
3
+ // Licensed under the MIT License.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.MeshClient = exports.WS_CLOSE_SESSION_REPLACED = void 0;
6
+ /**
7
+ * AgentMesh transport client — WebSocket connection to relay with
8
+ * plaintext-peer support, KNOCK pending queue, and wsFactory hook.
9
+ *
10
+ * Spec: docs/specs/AGENTMESH-WIRE-1.0.md Sections 9, 10, 12
11
+ *
12
+ * Features added for AzureClaw compatibility:
13
+ * - plaintextPeers: bypass E2E encryption for legacy peers (e.g., Rust controller)
14
+ * - wsFactory: custom WebSocket constructor for HTTPS_PROXY CONNECT tunneling
15
+ * - KNOCK pending queue: handle race between KNOCK and first message
16
+ */
17
+ const channel_1 = require("./channel");
18
+ const registry_client_1 = require("./registry-client");
19
+ /**
20
+ * Derive the canonical AGT-main agent DID from an Ed25519 public key.
21
+ *
22
+ * Format: `did:mesh:<sha256(public_key)[:32]>` (32 hex chars = 16 bytes
23
+ * of digest). Matches the server's derivation in
24
+ * `agent-governance-python/agent-mesh/src/agentmesh/registry/app.py`
25
+ * (`key_hash = hashlib.sha256(public_key).hexdigest()[:32]`).
26
+ *
27
+ * If `fallback` looks like a `did:mesh:` already (callers that compute
28
+ * it themselves), prefer that; otherwise compute fresh from the public
29
+ * key. Sync function — relies on Node's `node:crypto` or the browser's
30
+ * SubtleCrypto can't be used synchronously so we hand-roll SHA-256 if
31
+ * neither is available. In practice the SDK only runs in Node so this
32
+ * just falls through to `require("node:crypto")`.
33
+ */
34
+ function computeCanonicalDid(ed25519Public, fallback) {
35
+ if (fallback && fallback.startsWith("did:mesh:"))
36
+ return fallback;
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const nodeCrypto = require("node:crypto");
39
+ const hex = nodeCrypto.createHash("sha256").update(ed25519Public).digest("hex");
40
+ return `did:mesh:${hex.slice(0, 32)}`;
41
+ }
42
+ /**
43
+ * WebSocket close code sent by the relay when another connection
44
+ * authenticates for the same DID and displaces this socket.
45
+ *
46
+ * Deliberately not 1000 (Normal Closure): 1000 is indistinguishable from the
47
+ * client closing its own socket, which made a mailbox takeover silent and
48
+ * unattributable. Clients receive this as a server-initiated close but must
49
+ * NOT auto-reconnect on it — reconnecting would displace the replacing socket
50
+ * and the two would evict each other in a loop.
51
+ *
52
+ * Must stay in sync with `WS_CLOSE_SESSION_REPLACED` in the relay
53
+ * (`agentmesh/relay/app.py`).
54
+ */
55
+ exports.WS_CLOSE_SESSION_REPLACED = 4006;
56
+ /**
57
+ * High-level mesh client for agent-to-agent communication.
58
+ *
59
+ * Manages WebSocket connection to the relay, session establishment
60
+ * (KNOCK + X3DH), and message encryption/decryption. Supports
61
+ * plaintext peers for legacy interop.
62
+ */
63
+ class MeshClient {
64
+ options;
65
+ sessions = new Map();
66
+ plaintextPeers;
67
+ knockPending = new Map();
68
+ knockAccepted = new Set();
69
+ messageHandlers = [];
70
+ knockHandlers = [];
71
+ errorHandlers = [];
72
+ disconnectHandlers = [];
73
+ e2eVerifiedHandlers = [];
74
+ /** Tracks peers whose first encrypted message we've seen — feeds onE2EVerified. */
75
+ e2eVerifiedSet = new Set();
76
+ ws = null;
77
+ connected = false;
78
+ knockTimeout;
79
+ autoReconnect;
80
+ maxReconnectAttempts;
81
+ reconnectBaseDelayMs;
82
+ reconnectMaxDelayMs;
83
+ reconnectAttempts = 0;
84
+ reconnectTimer = null;
85
+ clientInitiatedClose = false;
86
+ preKnockBufferSize;
87
+ preKnockBufferTtlMs;
88
+ maxBufferedPeers;
89
+ /**
90
+ * Per-peer buffer for encrypted message frames that arrived before the
91
+ * peer's KNOCK was processed. Drained when the KNOCK is later accepted.
92
+ * Mirrors vendored agentmesh-sdk patch #16.
93
+ */
94
+ preKnockBuffer = new Map();
95
+ registry;
96
+ autoRegister;
97
+ oneTimePrekeyCount;
98
+ registered = false;
99
+ /**
100
+ * The agent DID used on every wire-level send (`connect.from`,
101
+ * `mesh_send.from`, `knock.from`, …). Starts at `options.agentDid` so
102
+ * back-compat is preserved when no registry is configured or when
103
+ * registering against a pre-2533 registry. When `registerSelf()` runs
104
+ * against a POP-aware registry (AGT main since 2026-05-23), the server
105
+ * returns the canonical `did:mesh:<sha256(public_key)[:32]>` and we
106
+ * adopt it here — every subsequent frame uses the canonical form so
107
+ * relay and registry lookups agree.
108
+ */
109
+ activeDid;
110
+ /**
111
+ * Public read-only view of the active DID. Caller code that previously
112
+ * read `options.agentDid` and stored it should read this instead so
113
+ * post-registration DID swaps are visible.
114
+ */
115
+ get currentDid() {
116
+ return this.activeDid;
117
+ }
118
+ constructor(options) {
119
+ this.options = options;
120
+ // Compute the canonical AGT main DID locally so the connect frame
121
+ // can include the matching `from` field even on the very first
122
+ // connect (registerSelf runs AFTER connect resolves; the relay's
123
+ // POP gate validates `from == "did:mesh:" + sha256(public_key)[:32]`
124
+ // on the connect frame, NOT on the eventual server-derived DID).
125
+ // We replace whatever caller-supplied DID was passed because the
126
+ // POP-aware relay/registry will reject any other format. Pre-POP
127
+ // deployments don't care, so this is back-compat-safe.
128
+ this.activeDid = computeCanonicalDid(options.keyManager.identityKeyEd, options.agentDid);
129
+ this.plaintextPeers = new Set(options.plaintextPeers ?? []);
130
+ this.knockTimeout = options.knockTimeout ?? 10_000;
131
+ this.autoReconnect = options.autoReconnect ?? true;
132
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
133
+ this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 1000;
134
+ this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 60_000;
135
+ this.preKnockBufferSize = options.preKnockBufferSize ?? 5;
136
+ this.preKnockBufferTtlMs = options.preKnockBufferTtlMs ?? 3_000;
137
+ this.maxBufferedPeers = options.maxBufferedPeers ?? 100;
138
+ this.autoRegister = options.autoRegister ?? true;
139
+ this.oneTimePrekeyCount = options.oneTimePrekeyCount ?? 20;
140
+ if (options.registryClient) {
141
+ this.registry = options.registryClient;
142
+ }
143
+ else if (options.registryUrl) {
144
+ // Wire an Ed25519-Timestamp signer to every registry call so
145
+ // `PUT /v1/agents/{did}/prekeys` and the other authed endpoints
146
+ // (heartbeat, reputation) pass `verify_ed25519_timestamp_auth`.
147
+ // The signer uses the same identity key the relay POP / connect-frame
148
+ // signature uses, so the registry can map `authed_did == activeDid`.
149
+ // The caller can override by passing their own `authSigner` via
150
+ // `registryClientOptions`.
151
+ const callerOpts = options.registryClientOptions ?? {};
152
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
153
+ const self = this;
154
+ const authSigner = callerOpts.authSigner ?? {
155
+ get did() { return self.activeDid; }, // late-bind: DID can change after register
156
+ sign: (m) => options.keyManager.signMessage(m),
157
+ };
158
+ this.registry = new registry_client_1.RegistryClient({
159
+ baseUrl: options.registryUrl,
160
+ ...callerOpts,
161
+ authSigner,
162
+ });
163
+ }
164
+ else {
165
+ this.registry = null;
166
+ }
167
+ }
168
+ /**
169
+ * Access the RegistryClient (built from registryUrl or injected via
170
+ * registryClient). Returns null only if MeshClient was constructed
171
+ * without a registry URL or client — which means discover/peer lookup
172
+ * features are disabled.
173
+ */
174
+ getRegistry() {
175
+ return this.registry;
176
+ }
177
+ // ── Plaintext peers ─────────────────────────────────────────────
178
+ addPlaintextPeer(peerId) {
179
+ this.plaintextPeers.add(peerId);
180
+ }
181
+ removePlaintextPeer(peerId) {
182
+ this.plaintextPeers.delete(peerId);
183
+ }
184
+ isPlaintextPeer(peerId) {
185
+ return this.plaintextPeers.has(peerId);
186
+ }
187
+ // ── Connection ──────────────────────────────────────────────────
188
+ async connect() {
189
+ if (this.connected)
190
+ return;
191
+ const wsUrl = this.options.relayUrl.replace(/^http/, "ws") + "/ws";
192
+ const wsFactory = this.options.wsFactory ?? ((url) => new WebSocket(url));
193
+ this.ws = wsFactory(wsUrl);
194
+ await new Promise((resolve, reject) => {
195
+ this.ws.onopen = () => {
196
+ this.connected = true;
197
+ // POP-enabled connect frame (AGT main since 2026-05-23 PR #2632).
198
+ // Pre-POP relays ignore the extra fields — back-compat preserved.
199
+ // The relay verifies:
200
+ // - DID equals `did:mesh:` + sha256(public_key)[:32]
201
+ // - Ed25519 signature over the timestamp string (NOT pub||ts)
202
+ // -- distinct from registry POP which signs pub||ts
203
+ //
204
+ // Encoding gotcha: the relay decodes with stdlib `base64.b64decode`
205
+ // (standard base64 with +/=), while the registry decodes with
206
+ // `urlsafe_b64decode` (base64url with -_). Mismatch in upstream
207
+ // server code; we have to match each endpoint's expectation
208
+ // separately. Emitting standard base64 here so the relay's
209
+ // `base64.b64decode(pub_b64)` and signature decode succeed.
210
+ const pubB64 = Buffer.from(this.options.keyManager.identityKeyEd).toString("base64");
211
+ const tsStr = new Date().toISOString();
212
+ const sig = this.options.keyManager.signMessage(new TextEncoder().encode(tsStr));
213
+ const sigB64 = Buffer.from(sig).toString("base64");
214
+ this.sendFrame({
215
+ v: 1,
216
+ type: "connect",
217
+ from: this.activeDid,
218
+ public_key: pubB64,
219
+ timestamp: tsStr,
220
+ signature: sigB64,
221
+ });
222
+ // Request any messages queued while offline (inbox replay)
223
+ this.sendFrame({
224
+ v: 1,
225
+ type: "fetch_pending",
226
+ from: this.activeDid,
227
+ });
228
+ resolve();
229
+ };
230
+ this.ws.onerror = (e) => {
231
+ // Surface to AzureClaw-style observers BEFORE rejecting connect.
232
+ // Once connect resolves, subsequent ws errors flow through this same path.
233
+ const errEvent = e;
234
+ const detail = errEvent?.message ?? errEvent?.type ?? "ws-error";
235
+ for (const h of this.errorHandlers) {
236
+ try {
237
+ h("ws", this.activeDid, detail);
238
+ }
239
+ catch { /* swallow handler errors */ }
240
+ }
241
+ if (!this.connected)
242
+ reject(new Error(`WebSocket error: ${e}`));
243
+ };
244
+ this.ws.onmessage = (event) => {
245
+ // Guard against malformed frames (upstream #1998) — combines with
246
+ // our additive event-hook handler.
247
+ let frame;
248
+ try {
249
+ frame = JSON.parse(String(event.data));
250
+ }
251
+ catch (err) {
252
+ console.warn(`MeshClient: dropping malformed frame (JSON parse): ${err}`);
253
+ return;
254
+ }
255
+ this.handleFrame(frame).catch((err) => {
256
+ console.warn(`MeshClient: handler error for frame type=${String(frame.type)}: ${err}`);
257
+ });
258
+ };
259
+ this.ws.onclose = (event) => {
260
+ const wasConnected = this.connected;
261
+ this.connected = false;
262
+ if (wasConnected) {
263
+ // Distinguish client-initiated disconnect (1000 Normal Closure) from server / network drops.
264
+ const code = event?.code;
265
+ const isClientInitiated = code === 1000 || this.clientInitiatedClose;
266
+ // The relay sends WS_CLOSE_SESSION_REPLACED when another connection
267
+ // authenticated for this same DID and displaced this socket. It is
268
+ // genuinely server-initiated (so it is NOT reported as "client"),
269
+ // but it must not trigger auto-reconnect: reconnecting would displace
270
+ // the socket that just replaced us and the two would evict each other
271
+ // in a loop. Surface it through the error channel instead so the host
272
+ // can act — if this agent did not initiate a reconnect, then another
273
+ // party successfully authenticated as this DID.
274
+ const isSessionReplaced = code === exports.WS_CLOSE_SESSION_REPLACED;
275
+ const reason = isClientInitiated ? "client" : "server";
276
+ for (const h of this.disconnectHandlers) {
277
+ try {
278
+ h(reason, code);
279
+ }
280
+ catch { /* swallow handler errors */ }
281
+ }
282
+ if (isSessionReplaced) {
283
+ for (const h of this.errorHandlers) {
284
+ try {
285
+ h("ws", this.activeDid, "session replaced: another connection authenticated for this DID; " +
286
+ "not reconnecting to avoid an eviction loop");
287
+ }
288
+ catch { /* swallow handler errors */ }
289
+ }
290
+ }
291
+ // Auto-reconnect on non-client closures (network drops, relay restart).
292
+ // Mirrors vendored agentmesh-sdk patch #9: never give up by default,
293
+ // exponential backoff capped at 60s. Caller can opt out via
294
+ // autoReconnect: false in MeshClientOptions.
295
+ if (!isClientInitiated && !isSessionReplaced && this.autoReconnect) {
296
+ this.scheduleReconnect();
297
+ }
298
+ }
299
+ this.clientInitiatedClose = false;
300
+ };
301
+ });
302
+ // Successful connect — reset reconnect counter.
303
+ this.reconnectAttempts = 0;
304
+ // Auto-register the agent in the registry on first successful connect.
305
+ // Idempotent: a re-connect after a relay restart skips this. The
306
+ // registry POST is idempotent on its end too (409 = already present).
307
+ if (this.autoRegister && this.registry && !this.registered) {
308
+ await this.registerSelf();
309
+ }
310
+ }
311
+ /**
312
+ * Publish this agent in the registry and upload an X3DH pre-key bundle.
313
+ *
314
+ * - Generates a signed pre-key and `oneTimePrekeyCount` one-time
315
+ * pre-keys via `keyManager.generateSignedPreKey()` /
316
+ * `generateOneTimePreKeys()`.
317
+ * - Capabilities are `[displayName?, ...options.capabilities]` so peers
318
+ * can find this agent via `registry.discover(displayName)`.
319
+ * - Throws RegistryError on transport / 4xx (other than 409) / 5xx
320
+ * failure. Callers that want best-effort registration should catch.
321
+ *
322
+ * Safe to call directly when `autoRegister: false` was used.
323
+ */
324
+ async registerSelf() {
325
+ if (!this.registry) {
326
+ throw new Error("MeshClient.registerSelf: no registry configured");
327
+ }
328
+ const km = this.options.keyManager;
329
+ // identityKey is the long-term X25519 public key derived from the
330
+ // Ed25519 signing key in the X3DHKeyManager constructor.
331
+ // identityKeyEd is the Ed25519 public key — peers MUST receive it
332
+ // to verify the signed pre-key signature (see x3dh.ts verifyBundle).
333
+ const identityKey = km.identityKey.publicKey;
334
+ const identityKeyEd = km.identityKeyEd;
335
+ const signedPreKey = km.generateSignedPreKey();
336
+ const oneTimePreKeys = km.generateOneTimePreKeys(this.oneTimePrekeyCount);
337
+ // Capabilities: include displayName so name-based discover() works.
338
+ const caps = [];
339
+ const dn = this.options.displayName;
340
+ if (dn)
341
+ caps.push(dn);
342
+ for (const c of this.options.capabilities ?? []) {
343
+ if (c && !caps.includes(c))
344
+ caps.push(c);
345
+ }
346
+ const metadata = { ...(this.options.registrationMetadata ?? {}) };
347
+ if (dn && metadata.display_name === undefined)
348
+ metadata.display_name = dn;
349
+ // POP-aware registration (AGT registry built from main since 2026-05-23,
350
+ // PR #2533). The registry verifies Ed25519(public_key_b64 || timestamp)
351
+ // against the supplied `public_key` (which MUST be the Ed25519 key,
352
+ // not the X25519 key — the server uses pynacl `VerifyKey` on it).
353
+ // Server then derives the canonical DID as
354
+ // `did:mesh:<sha256(public_key)[:32]>` and returns it. We adopt that
355
+ // DID as the active DID so subsequent registry lookups + relay
356
+ // `connect.from` agree with the server's view.
357
+ const popSigner = { sign: (m) => km.signMessage(m) };
358
+ const registerResult = await this.registry.register(this.activeDid, // ignored by POP-aware registries; kept for legacy back-compat
359
+ identityKeyEd, // POP path uses the Ed25519 public; legacy path tolerates
360
+ // either key since it only forwards to peers verbatim.
361
+ caps, metadata, popSigner);
362
+ // Adopt the canonical server DID. Legacy registries return the
363
+ // caller-supplied DID, so this is a no-op when registering against an
364
+ // older deployment.
365
+ if (registerResult.did && registerResult.did !== this.activeDid) {
366
+ this.activeDid = registerResult.did;
367
+ }
368
+ // Prekey upload must use the canonical DID (the registry indexes
369
+ // prekeys by the DID it derived, not the one the SDK started with).
370
+ await this.registry.uploadPrekeys(this.activeDid, identityKey, identityKeyEd, signedPreKey, oneTimePreKeys);
371
+ this.registered = true;
372
+ }
373
+ /**
374
+ * Discover peers advertising a given capability. Returns [] if no
375
+ * registry is configured.
376
+ */
377
+ async discover(capability, limit = 50) {
378
+ if (!this.registry)
379
+ return [];
380
+ return this.registry.discover(capability, limit);
381
+ }
382
+ /**
383
+ * Convenience: fetch a peer's pre-key bundle from the registry, then
384
+ * call `establishSession`. Throws if no registry is configured or no
385
+ * bundle is published for `peerId`.
386
+ */
387
+ async establishSessionWithPeer(peerId) {
388
+ const existing = this.sessions.get(peerId);
389
+ if (existing)
390
+ return existing;
391
+ if (this.isPlaintextPeer(peerId)) {
392
+ return this.establishSession(peerId, {});
393
+ }
394
+ if (!this.registry) {
395
+ throw new Error("MeshClient.establishSessionWithPeer: no registry configured");
396
+ }
397
+ const bundle = await this.registry.fetchPrekeys(peerId);
398
+ if (!bundle) {
399
+ throw new Error(`MeshClient.establishSessionWithPeer: no prekey bundle for ${peerId}`);
400
+ }
401
+ return this.establishSession(peerId, bundle);
402
+ }
403
+ scheduleReconnect() {
404
+ if (this.reconnectTimer)
405
+ return; // already scheduled
406
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
407
+ for (const h of this.errorHandlers) {
408
+ try {
409
+ h("ws", this.activeDid, `auto-reconnect gave up after ${this.reconnectAttempts} attempts`);
410
+ }
411
+ catch { /* swallow */ }
412
+ }
413
+ return;
414
+ }
415
+ const exp = Math.min(this.reconnectBaseDelayMs * 2 ** this.reconnectAttempts, this.reconnectMaxDelayMs);
416
+ // Light jitter (±20%) to avoid thundering-herd reconnects across many sandboxes.
417
+ const jitter = exp * (0.8 + Math.random() * 0.4);
418
+ this.reconnectAttempts++;
419
+ this.reconnectTimer = setTimeout(() => {
420
+ this.reconnectTimer = null;
421
+ void this.reconnect().catch((err) => {
422
+ for (const h of this.errorHandlers) {
423
+ try {
424
+ h("ws", this.activeDid, `reconnect failed: ${err instanceof Error ? err.message : String(err)}`);
425
+ }
426
+ catch { /* swallow */ }
427
+ }
428
+ // Schedule next attempt — onclose may not fire if connect() rejected before ws.onopen.
429
+ this.scheduleReconnect();
430
+ });
431
+ }, Math.round(jitter));
432
+ }
433
+ async disconnect() {
434
+ // Cancel any pending reconnect — caller asked to stay disconnected.
435
+ if (this.reconnectTimer) {
436
+ clearTimeout(this.reconnectTimer);
437
+ this.reconnectTimer = null;
438
+ }
439
+ this.reconnectAttempts = 0;
440
+ // Drop any buffered pre-KNOCK frames + their eviction timers.
441
+ for (const peer of [...this.preKnockBuffer.keys()]) {
442
+ this.dropPreKnockBuffer(peer);
443
+ }
444
+ if (!this.connected || !this.ws)
445
+ return;
446
+ this.clientInitiatedClose = true;
447
+ this.sendFrame({ v: 1, type: "disconnect", from: this.activeDid });
448
+ this.ws.close(1000, "client disconnect");
449
+ this.connected = false;
450
+ this.ws = null;
451
+ }
452
+ /**
453
+ * Disconnect and reconnect to the relay.
454
+ *
455
+ * Resets the WebSocket connection and sends a fresh connect +
456
+ * fetch_pending sequence, triggering inbox replay for any messages
457
+ * queued while offline.
458
+ */
459
+ async reconnect() {
460
+ this.connected = false;
461
+ if (this.ws) {
462
+ try {
463
+ this.ws.close();
464
+ }
465
+ catch {
466
+ /* ignore close errors */
467
+ }
468
+ this.ws = null;
469
+ }
470
+ await this.connect();
471
+ }
472
+ get isConnected() {
473
+ return this.connected && this.ws !== null;
474
+ }
475
+ // ── Sending ─────────────────────────────────────────────────────
476
+ async send(peerId, payload) {
477
+ if (!this.isConnected)
478
+ throw new Error("Not connected to relay");
479
+ const messageId = crypto.randomUUID();
480
+ if (this.isPlaintextPeer(peerId)) {
481
+ // Legacy plaintext path — no encryption
482
+ this.sendFrame({
483
+ v: 1,
484
+ type: "message",
485
+ from: this.activeDid,
486
+ to: peerId,
487
+ id: messageId,
488
+ ts: new Date().toISOString(),
489
+ ciphertext: btoa(JSON.stringify(payload)),
490
+ plaintext: true,
491
+ });
492
+ this.incrementSessionCount(peerId, true);
493
+ return;
494
+ }
495
+ // Encrypted path
496
+ let session = this.sessions.get(peerId);
497
+ if (!session || !session.channel) {
498
+ throw new Error(`No encrypted session with ${peerId}. Call establishSession() first.`);
499
+ }
500
+ const encrypted = session.channel.send(new TextEncoder().encode(JSON.stringify(payload)));
501
+ this.sendFrame({
502
+ v: 1,
503
+ type: "message",
504
+ from: this.activeDid,
505
+ to: peerId,
506
+ id: messageId,
507
+ ts: new Date().toISOString(),
508
+ header: {
509
+ dh: this.uint8ToBase64(encrypted.header.dhPublicKey),
510
+ pn: encrypted.header.previousChainLength,
511
+ n: encrypted.header.messageNumber,
512
+ },
513
+ ciphertext: this.uint8ToBase64(encrypted.ciphertext),
514
+ });
515
+ session.messageCount++;
516
+ }
517
+ // ── Session establishment ───────────────────────────────────────
518
+ async establishSession(peerId, peerBundle) {
519
+ // Check for existing session
520
+ const existing = this.sessions.get(peerId);
521
+ if (existing)
522
+ return existing;
523
+ if (this.isPlaintextPeer(peerId)) {
524
+ const session = {
525
+ peerId,
526
+ channel: null,
527
+ isPlaintext: true,
528
+ createdAt: new Date(),
529
+ messageCount: 0,
530
+ };
531
+ this.sessions.set(peerId, session);
532
+ return session;
533
+ }
534
+ // Send KNOCK
535
+ const knockId = crypto.randomUUID();
536
+ // X3DH + SecureChannel — compute establishment FIRST so we can embed it
537
+ // in the KNOCK frame. This lets the receiver auto-bootstrap the responder
538
+ // session before any ciphertext arrives, eliminating the
539
+ // "No encrypted session" race that vendored agentmesh-sdk patch #4b
540
+ // worked around. Backwards-compatible: receivers that don't understand
541
+ // the `establishment` field fall back to manual acceptSession() calls.
542
+ const [channel, establishment] = channel_1.SecureChannel.createSender(this.options.keyManager, peerBundle, new TextEncoder().encode(`${this.activeDid}|${peerId}`));
543
+ this.sendFrame({
544
+ v: 1,
545
+ type: "knock",
546
+ from: this.activeDid,
547
+ to: peerId,
548
+ id: knockId,
549
+ ts: new Date().toISOString(),
550
+ intent: { action: "establish_session" },
551
+ establishment: this.serializeEstablishment(establishment),
552
+ });
553
+ const session = {
554
+ peerId,
555
+ channel,
556
+ isPlaintext: false,
557
+ createdAt: new Date(),
558
+ messageCount: 0,
559
+ };
560
+ this.sessions.set(peerId, session);
561
+ return session;
562
+ }
563
+ acceptSession(peerId, establishment) {
564
+ const channel = channel_1.SecureChannel.createReceiver(this.options.keyManager, establishment, new TextEncoder().encode(`${peerId}|${this.activeDid}`));
565
+ const session = {
566
+ peerId,
567
+ channel,
568
+ isPlaintext: false,
569
+ createdAt: new Date(),
570
+ messageCount: 0,
571
+ };
572
+ this.sessions.set(peerId, session);
573
+ this.knockAccepted.add(peerId);
574
+ return session;
575
+ }
576
+ getSession(peerId) {
577
+ return this.sessions.get(peerId);
578
+ }
579
+ closeSession(peerId) {
580
+ const session = this.sessions.get(peerId);
581
+ if (!session)
582
+ return false;
583
+ if (session.channel)
584
+ session.channel.close();
585
+ this.sessions.delete(peerId);
586
+ this.knockAccepted.delete(peerId);
587
+ return true;
588
+ }
589
+ // ── Handlers ────────────────────────────────────────────────────
590
+ onMessage(handler) {
591
+ this.messageHandlers.push(handler);
592
+ }
593
+ onKnock(handler) {
594
+ this.knockHandlers.push(handler);
595
+ }
596
+ /**
597
+ * Register a callback for transport-level errors.
598
+ *
599
+ * Fires for: WebSocket errors (handshake, mid-stream), decrypt failures,
600
+ * KNOCK protocol errors, frame validation, and `session_desync` events
601
+ * (recoverable ratchet drift — caller should re-establishSession to that
602
+ * peer; differs from "decrypt" which means no session existed at all).
603
+ * Multiple handlers may be registered; each is invoked in registration
604
+ * order. Handler exceptions are swallowed so one buggy observer cannot
605
+ * break the others.
606
+ */
607
+ onError(handler) {
608
+ this.errorHandlers.push(handler);
609
+ }
610
+ /**
611
+ * Register a callback for transport disconnect events.
612
+ *
613
+ * `reason` is `"client"` for caller-initiated `disconnect()`, `"server"`
614
+ * for relay-side closes (network drop, relay restart), and `"ws-error"`
615
+ * when an error event fires on an already-connected socket.
616
+ */
617
+ onDisconnect(handler) {
618
+ this.disconnectHandlers.push(handler);
619
+ }
620
+ /**
621
+ * Register a callback that fires the first time we successfully decrypt
622
+ * a message from a given peer (i.e. the X3DH+Double-Ratchet session
623
+ * with that peer is fully end-to-end verified).
624
+ *
625
+ * `isFirstPeer` is `true` only for the very first verified peer in the
626
+ * client's lifetime; subsequent peers fire with `false`. This lets
627
+ * orchestrators print "mesh online" once and "+ peer X verified" for
628
+ * the rest.
629
+ */
630
+ onE2EVerified(handler) {
631
+ this.e2eVerifiedHandlers.push(handler);
632
+ }
633
+ // ── Heartbeat ───────────────────────────────────────────────────
634
+ sendHeartbeat() {
635
+ if (!this.isConnected)
636
+ return;
637
+ this.sendFrame({
638
+ v: 1,
639
+ type: "heartbeat",
640
+ from: this.activeDid,
641
+ ts: new Date().toISOString(),
642
+ });
643
+ }
644
+ // ── Frame handling ──────────────────────────────────────────────
645
+ async handleFrame(frame) {
646
+ const type = frame.type;
647
+ const from = frame.from;
648
+ if (type === "message") {
649
+ await this.handleMessage(frame);
650
+ }
651
+ else if (type === "knock") {
652
+ await this.handleKnock(frame);
653
+ }
654
+ else if (type === "knock_accept") {
655
+ this.handleKnockAccept(frame);
656
+ }
657
+ else if (type === "knock_reject") {
658
+ this.handleKnockReject(frame);
659
+ }
660
+ else if (type === "ack") {
661
+ // ACK processed — nothing to do
662
+ }
663
+ else if (type === "pending_messages") {
664
+ await this.handlePendingMessages(frame);
665
+ }
666
+ }
667
+ async handleMessage(frame) {
668
+ const from = frame.from;
669
+ // Check KNOCK pending queue — wait for resolution if KNOCK is in-flight
670
+ if (!this.knockAccepted.has(from) && !this.isPlaintextPeer(from)) {
671
+ const pending = this.knockPending.get(from);
672
+ if (pending) {
673
+ // Wait for KNOCK resolution
674
+ const accepted = await new Promise((resolve) => {
675
+ const originalResolve = pending.resolve;
676
+ pending.resolve = (val) => {
677
+ originalResolve(val);
678
+ resolve(val);
679
+ };
680
+ });
681
+ if (!accepted)
682
+ return; // KNOCK rejected — drop message
683
+ }
684
+ }
685
+ let payload;
686
+ let isPlaintext = false;
687
+ // Security hardening: whether an inbound message is treated as legacy
688
+ // plaintext is decided solely from the receiver's own operator
689
+ // configuration (`plaintextPeers`, via isPlaintextPeer(from)). The wire
690
+ // frame also carries a `plaintext` boolean, but it is sender-controlled and
691
+ // must not select this branch — trusting it would let the sender skip
692
+ // X3DH / Double Ratchet / AEAD verification and be attributed an arbitrary
693
+ // `from` DID. Gate on isPlaintextPeer only; a peer that is not explicitly
694
+ // allow-listed for plaintext always takes the encrypted path below and is
695
+ // dropped if it cannot be cryptographically authenticated.
696
+ if (this.isPlaintextPeer(from) &&
697
+ !this.sessions.get(from)?.channel &&
698
+ !this.e2eVerifiedSet.has(from)) {
699
+ // Legacy plaintext — taken ONLY for an operator-allow-listed peer that has
700
+ // no negotiated encrypted session AND has never been E2E-verified.
701
+ //
702
+ // The `e2eVerifiedSet` term is a one-way latch and is load-bearing. A live
703
+ // `session.channel` is NOT sufficient on its own, because several paths
704
+ // delete the session and would otherwise re-open this gate for a peer that
705
+ // had already been cryptographically verified:
706
+ // * the Gap-G3 ratchet-desync handler below calls closeSession() after a
707
+ // single decrypt failure — a malicious relay can force that with one
708
+ // garbage-ciphertext frame and then downgrade with the next frame;
709
+ // * handleKnockReject() calls closeSession() on an unauthenticated
710
+ // `knock_reject` frame — a one-frame variant of the same attack;
711
+ // * any future teardown path would silently inherit the same weakness.
712
+ // Latching on e2eVerifiedSet (which is only ever added to, never cleared)
713
+ // makes the guarantee teardown-path-agnostic: once a peer has produced a
714
+ // successfully decrypted frame, it can never again be handled as plaintext
715
+ // for the lifetime of this client.
716
+ //
717
+ // Once a live channel exists for the peer we deliberately fall through to
718
+ // the encrypted path instead of handling the frame as plaintext: a genuine
719
+ // encrypted frame is decrypted via the ratchet, while a plaintext /
720
+ // headerless downgrade frame fails closed on the missing-ratchet-header
721
+ // check below. This keeps downgrade protection while no longer
722
+ // black-holing legitimate encrypted traffic from a peer that is also
723
+ // allow-listed for plaintext.
724
+ payload = JSON.parse(atob(frame.ciphertext));
725
+ isPlaintext = true;
726
+ }
727
+ else {
728
+ // Encrypted
729
+ const header = frame.header;
730
+ if (!header || typeof header.dh !== "string") {
731
+ // Security hardening: an encrypted `message` frame must carry a ratchet
732
+ // header. Validate this BEFORE the pre-KNOCK buffering path below so a
733
+ // malformed / headerless frame — which can never be decrypted — is
734
+ // dropped immediately, instead of occupying pre-KNOCK buffer capacity
735
+ // until TTL eviction and only failing closed when the buffer is drained.
736
+ for (const h of this.errorHandlers) {
737
+ try {
738
+ h("decrypt", from, "encrypted message missing ratchet header — dropping");
739
+ }
740
+ catch { /* swallow */ }
741
+ }
742
+ return;
743
+ }
744
+ const session = this.sessions.get(from);
745
+ if (!session?.channel) {
746
+ // Gap-G4 (vendored agentmesh-sdk patch #16): pre-KNOCK message buffer.
747
+ // The relay does not guarantee inter-frame ordering — an encrypted
748
+ // message can land before its KNOCK is processed. Drop-on-floor was
749
+ // the upstream behaviour; instead, buffer the raw frame here and
750
+ // drain it from handleKnock() once the KNOCK is accepted. Capped at
751
+ // preKnockBufferSize entries per peer with preKnockBufferTtlMs TTL.
752
+ if (this.preKnockBufferSize > 0 && !this.isPlaintextPeer(from)) {
753
+ this.bufferPreKnockFrame(from, frame);
754
+ }
755
+ else {
756
+ for (const h of this.errorHandlers) {
757
+ try {
758
+ h("decrypt", from, "no session for encrypted message — dropping");
759
+ }
760
+ catch { /* swallow */ }
761
+ }
762
+ }
763
+ return;
764
+ }
765
+ const encrypted = {
766
+ header: {
767
+ dhPublicKey: this.base64ToUint8(header.dh),
768
+ previousChainLength: header.pn,
769
+ messageNumber: header.n,
770
+ },
771
+ ciphertext: this.base64ToUint8(frame.ciphertext),
772
+ };
773
+ let plaintext;
774
+ try {
775
+ plaintext = session.channel.receive(encrypted);
776
+ }
777
+ catch (err) {
778
+ // Gap-G3 (vendored agentmesh-sdk patch #13): on decrypt failure inside
779
+ // an existing session, the ratchet is desynchronised — every
780
+ // subsequent inbound frame will fail the same way. Tear down the
781
+ // broken session so the next establishSession() to this peer runs a
782
+ // fresh X3DH + KNOCK round, and surface a dedicated "session_desync"
783
+ // error kind so callers can distinguish recoverable ratchet drift
784
+ // (re-establish & retry) from genuine tampering (drop & alert).
785
+ const detail = err instanceof Error ? err.message : String(err);
786
+ this.closeSession(from);
787
+ this.knockAccepted.delete(from);
788
+ for (const h of this.errorHandlers) {
789
+ try {
790
+ h("session_desync", from, detail);
791
+ }
792
+ catch { /* swallow */ }
793
+ }
794
+ return;
795
+ }
796
+ payload = JSON.parse(new TextDecoder().decode(plaintext));
797
+ session.messageCount++;
798
+ // First successfully-decrypted message from this peer means E2E
799
+ // is end-to-end verified (KNOCK + X3DH + Double Ratchet all worked).
800
+ // Surface to observers exactly once per peer per process lifetime.
801
+ if (!this.e2eVerifiedSet.has(from)) {
802
+ this.e2eVerifiedSet.add(from);
803
+ const isFirstPeer = this.e2eVerifiedSet.size === 1;
804
+ for (const h of this.e2eVerifiedHandlers) {
805
+ try {
806
+ h(from, isFirstPeer);
807
+ }
808
+ catch { /* swallow handler errors */ }
809
+ }
810
+ }
811
+ }
812
+ // Send ACK
813
+ this.sendFrame({ v: 1, type: "ack", id: frame.id });
814
+ // Notify handlers
815
+ for (const handler of this.messageHandlers) {
816
+ try {
817
+ handler(from, payload, isPlaintext);
818
+ }
819
+ catch { /* swallow handler errors */ }
820
+ }
821
+ }
822
+ /**
823
+ * Handle a batch of pending messages replayed by the relay on reconnect.
824
+ *
825
+ * The relay may respond to a fetch_pending frame with a
826
+ * pending_messages frame containing an array of queued messages.
827
+ * Each message is dispatched through the standard handleMessage path.
828
+ */
829
+ async handlePendingMessages(frame) {
830
+ // Typed as unknown[] rather than Record<string, unknown>[] because this is
831
+ // relay-supplied JSON: entries can legally be null or primitives, and the
832
+ // stricter type would be a lie that hides the validation below.
833
+ const messages = frame.messages;
834
+ if (!Array.isArray(messages))
835
+ return;
836
+ for (const msg of messages) {
837
+ const isFrameShaped = typeof msg === "object" && msg !== null && !Array.isArray(msg);
838
+ // Extract the peer *before* the try and without dereferencing a
839
+ // possibly-null entry. Doing it inside the catch would throw on a null
840
+ // entry, and that throw would be absorbed by the handler guard below,
841
+ // losing the drop report entirely.
842
+ const rawFrom = isFrameShaped ? msg.from : undefined;
843
+ const peer = typeof rawFrom === "string" ? rawFrom : "";
844
+ // Isolate each queued message: the batch is relay-supplied, so a single
845
+ // malformed entry (e.g. ciphertext that fails JSON.parse) must not abort
846
+ // the loop and silently discard the remaining pending mail. Surface the
847
+ // failure and continue draining.
848
+ try {
849
+ if (!isFrameShaped) {
850
+ throw new Error(`expected a frame object, got ${msg === null ? "null" : typeof msg}`);
851
+ }
852
+ await this.handleMessage(msg);
853
+ }
854
+ catch (err) {
855
+ const detail = err instanceof Error ? err.message : String(err);
856
+ for (const h of this.errorHandlers) {
857
+ try {
858
+ h("frame", peer, `pending message dropped: ${detail}`);
859
+ }
860
+ catch { /* swallow */ }
861
+ }
862
+ }
863
+ }
864
+ }
865
+ async handleKnock(frame) {
866
+ const from = frame.from;
867
+ const intent = frame.intent;
868
+ // Register pending entry so concurrent handleMessage calls wait for
869
+ // the verdict. handleMessage may wrap `resolve` to add its own waiter;
870
+ // we look up the (possibly-wrapped) function when we resolve below.
871
+ //
872
+ // The timer is the abort path: if no verdict arrives within
873
+ // knockTimeout, resolve waiters to false and clear the entry. A
874
+ // `timedOut` flag prevents a slow handler from sending knock_accept
875
+ // after the timer has already told waiters the KNOCK was rejected.
876
+ let timedOut = false;
877
+ const timer = setTimeout(() => {
878
+ timedOut = true;
879
+ const entry = this.knockPending.get(from);
880
+ if (entry) {
881
+ this.knockPending.delete(from);
882
+ entry.resolve(false);
883
+ }
884
+ }, this.knockTimeout);
885
+ this.knockPending.set(from, { resolve: () => { }, timer });
886
+ // Evaluate via registered handlers. Use try/finally so the timer is
887
+ // cleared and the pending entry resolved even if a handler throws —
888
+ // otherwise the entry would leak and a re-KNOCK from the same peer
889
+ // would race against stale state.
890
+ let accepted = true;
891
+ try {
892
+ for (const handler of this.knockHandlers) {
893
+ if (!(await handler(from, intent))) {
894
+ accepted = false;
895
+ break;
896
+ }
897
+ }
898
+ }
899
+ finally {
900
+ clearTimeout(timer);
901
+ if (!timedOut) {
902
+ const entry = this.knockPending.get(from);
903
+ if (entry) {
904
+ this.knockPending.delete(from);
905
+ entry.resolve(accepted);
906
+ }
907
+ }
908
+ }
909
+ // If the timer beat the handler eval, waiters were already told
910
+ // "rejected"; honor that decision when responding to the relay.
911
+ if (timedOut)
912
+ accepted = false;
913
+ if (accepted) {
914
+ // Auto-bootstrap responder session if establishment data was embedded
915
+ // in the knock. Mirrors vendored agentmesh-sdk patch #4b — receiver no
916
+ // longer needs to call acceptSession() manually before the first
917
+ // encrypted message arrives.
918
+ const est = frame.establishment;
919
+ if (est && !this.sessions.has(from)) {
920
+ try {
921
+ const establishment = this.deserializeEstablishment(est);
922
+ this.acceptSession(from, establishment);
923
+ }
924
+ catch (err) {
925
+ for (const h of this.errorHandlers) {
926
+ try {
927
+ h("knock", from, `failed to bootstrap responder from KNOCK: ${err instanceof Error ? err.message : String(err)}`);
928
+ }
929
+ catch { /* swallow */ }
930
+ }
931
+ accepted = false;
932
+ }
933
+ }
934
+ // Always record acceptance so the encrypted-message gate stops
935
+ // waiting/buffering. (`acceptSession` also sets this, but keep the
936
+ // legacy-peer path covered too.)
937
+ if (accepted)
938
+ this.knockAccepted.add(from);
939
+ }
940
+ if (accepted) {
941
+ this.sendFrame({
942
+ v: 1,
943
+ type: "knock_accept",
944
+ from: this.activeDid,
945
+ to: from,
946
+ id: crypto.randomUUID(),
947
+ knock_id: frame.id,
948
+ ts: new Date().toISOString(),
949
+ });
950
+ // Gap-G4: drain any encrypted frames that arrived for this peer
951
+ // before its KNOCK was processed. The session is now established
952
+ // (via auto-bootstrap above or a prior acceptSession call), so the
953
+ // buffered frames can be replayed through the normal handleMessage
954
+ // path and decrypted against the fresh session.
955
+ await this.drainPreKnockBuffer(from);
956
+ }
957
+ else {
958
+ this.sendFrame({
959
+ v: 1,
960
+ type: "knock_reject",
961
+ from: this.activeDid,
962
+ to: from,
963
+ id: crypto.randomUUID(),
964
+ knock_id: frame.id,
965
+ reason: "policy_denied",
966
+ ts: new Date().toISOString(),
967
+ });
968
+ // Drop any buffered frames — KNOCK was rejected so we cannot decrypt
969
+ // them anyway. Avoids unbounded buffer growth for hostile peers.
970
+ this.dropPreKnockBuffer(from);
971
+ }
972
+ }
973
+ handleKnockAccept(frame) {
974
+ const from = frame.from;
975
+ this.knockAccepted.add(from);
976
+ }
977
+ handleKnockReject(frame) {
978
+ const from = frame.from;
979
+ this.closeSession(from);
980
+ }
981
+ // ── Pre-KNOCK buffer (Gap-G4 / vendored patch #16) ──────────────
982
+ bufferPreKnockFrame(from, frame) {
983
+ let entries = this.preKnockBuffer.get(from);
984
+ if (!entries) {
985
+ // Enforce global peer cap before adding a new peer's buffer.
986
+ if (this.preKnockBuffer.size >= this.maxBufferedPeers) {
987
+ const oldestPeer = this.preKnockBuffer.keys().next().value;
988
+ this.dropPreKnockBuffer(oldestPeer);
989
+ for (const h of this.errorHandlers) {
990
+ try {
991
+ h("frame", oldestPeer, `pre-knock buffer evicted: global peer cap (${this.maxBufferedPeers}) reached`);
992
+ }
993
+ catch { /* swallow */ }
994
+ }
995
+ }
996
+ entries = [];
997
+ this.preKnockBuffer.set(from, entries);
998
+ }
999
+ // Cap: drop oldest when full to keep newest message (sender most likely
1000
+ // to retransmit nothing — newer frames carry the most recent ratchet
1001
+ // state and have the best chance of being decryptable).
1002
+ if (entries.length >= this.preKnockBufferSize) {
1003
+ const evicted = entries.shift();
1004
+ if (evicted)
1005
+ clearTimeout(evicted.timer);
1006
+ }
1007
+ const timer = setTimeout(() => {
1008
+ const list = this.preKnockBuffer.get(from);
1009
+ if (!list)
1010
+ return;
1011
+ const idx = list.findIndex((e) => e.frame === frame);
1012
+ if (idx >= 0)
1013
+ list.splice(idx, 1);
1014
+ if (list.length === 0)
1015
+ this.preKnockBuffer.delete(from);
1016
+ }, this.preKnockBufferTtlMs);
1017
+ entries.push({ frame, timer });
1018
+ }
1019
+ async drainPreKnockBuffer(from) {
1020
+ const entries = this.preKnockBuffer.get(from);
1021
+ if (!entries || entries.length === 0)
1022
+ return;
1023
+ this.preKnockBuffer.delete(from);
1024
+ for (const entry of entries) {
1025
+ clearTimeout(entry.timer);
1026
+ try {
1027
+ await this.handleMessage(entry.frame);
1028
+ }
1029
+ catch (err) {
1030
+ for (const h of this.errorHandlers) {
1031
+ try {
1032
+ h("decrypt", from, `pre-knock drain failed: ${err instanceof Error ? err.message : String(err)}`);
1033
+ }
1034
+ catch { /* swallow */ }
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+ dropPreKnockBuffer(from) {
1040
+ const entries = this.preKnockBuffer.get(from);
1041
+ if (!entries)
1042
+ return;
1043
+ for (const entry of entries)
1044
+ clearTimeout(entry.timer);
1045
+ this.preKnockBuffer.delete(from);
1046
+ }
1047
+ // ── Establishment (de)serialization ─────────────────────────────
1048
+ serializeEstablishment(est) {
1049
+ const out = {
1050
+ ik: this.uint8ToBase64(est.initiatorIdentityKey),
1051
+ ek: this.uint8ToBase64(est.ephemeralPublicKey),
1052
+ };
1053
+ if (typeof est.usedOneTimeKeyId === "number")
1054
+ out.otk = est.usedOneTimeKeyId;
1055
+ return out;
1056
+ }
1057
+ deserializeEstablishment(obj) {
1058
+ const ik = obj.ik;
1059
+ const ek = obj.ek;
1060
+ if (typeof ik !== "string" || typeof ek !== "string") {
1061
+ throw new Error("malformed establishment: missing ik/ek");
1062
+ }
1063
+ const result = {
1064
+ initiatorIdentityKey: this.base64ToUint8(ik),
1065
+ ephemeralPublicKey: this.base64ToUint8(ek),
1066
+ };
1067
+ if (typeof obj.otk === "number")
1068
+ result.usedOneTimeKeyId = obj.otk;
1069
+ return result;
1070
+ }
1071
+ // ── Utilities ───────────────────────────────────────────────────
1072
+ sendFrame(frame) {
1073
+ if (this.ws && this.connected) {
1074
+ this.ws.send(JSON.stringify(frame));
1075
+ }
1076
+ }
1077
+ incrementSessionCount(peerId, isPlaintext) {
1078
+ let session = this.sessions.get(peerId);
1079
+ if (!session) {
1080
+ session = { peerId, channel: null, isPlaintext, createdAt: new Date(), messageCount: 0 };
1081
+ this.sessions.set(peerId, session);
1082
+ }
1083
+ session.messageCount++;
1084
+ }
1085
+ uint8ToBase64(data) {
1086
+ // Use Buffer in Node.js to avoid stack overflow on large payloads
1087
+ if (typeof Buffer !== "undefined") {
1088
+ return Buffer.from(data).toString("base64");
1089
+ }
1090
+ // Browser fallback — loop-based
1091
+ let binary = "";
1092
+ for (let i = 0; i < data.length; i++) {
1093
+ binary += String.fromCharCode(data[i]);
1094
+ }
1095
+ return btoa(binary);
1096
+ }
1097
+ base64ToUint8(b64) {
1098
+ if (typeof Buffer !== "undefined") {
1099
+ return new Uint8Array(Buffer.from(b64, "base64"));
1100
+ }
1101
+ const binary = atob(b64);
1102
+ const bytes = new Uint8Array(binary.length);
1103
+ for (let i = 0; i < binary.length; i++) {
1104
+ bytes[i] = binary.charCodeAt(i);
1105
+ }
1106
+ return bytes;
1107
+ }
1108
+ }
1109
+ exports.MeshClient = MeshClient;
1110
+ //# sourceMappingURL=mesh-client.js.map