@voidly/agent-sdk 3.1.0 → 3.2.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.
package/dist/index.d.mts CHANGED
@@ -85,6 +85,17 @@ interface VoidlyAgentConfig {
85
85
  jitterMs?: number;
86
86
  /** Use long-poll for listen() instead of short-interval polling (default: true) */
87
87
  longPoll?: boolean;
88
+ /** Auto-persist ratchet state after every send/receive (default: 'memory' = no persistence) */
89
+ persist?: 'memory' | 'localStorage' | 'indexedDB' | 'file' | 'relay' | 'custom';
90
+ /** Custom persistence: called after each ratchet step with encrypted blob */
91
+ onPersist?: (encryptedState: string) => void | Promise<void>;
92
+ /** Custom persistence: called on startup to load encrypted blob */
93
+ onLoad?: () => string | null | Promise<string | null>;
94
+ /** File path for persist: 'file' (Node.js environments only) */
95
+ persistPath?: string;
96
+ /** Transport preference for listen() — tries in order, falls back automatically
97
+ * Default: ['sse', 'long-poll']. Options: 'websocket' | 'sse' | 'long-poll' */
98
+ transport?: ('websocket' | 'sse' | 'long-poll')[];
88
99
  }
89
100
  interface ListenOptions {
90
101
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -185,6 +196,12 @@ declare class VoidlyAgent {
185
196
  private _rpcPending;
186
197
  private _coverTrafficTimer;
187
198
  private _rpcListener;
199
+ private _persistMode;
200
+ private _onPersist?;
201
+ private _onLoad?;
202
+ private _persistPath?;
203
+ private _persistKey;
204
+ private _transportPrefs;
188
205
  private constructor();
189
206
  /**
190
207
  * Register a new agent on the Voidly relay.
@@ -248,6 +265,26 @@ declare class VoidlyAgent {
248
265
  signedPrekeyPublic?: string;
249
266
  signedPrekeyId?: number;
250
267
  };
268
+ /** Derive persistence encryption key from signing secret */
269
+ private _derivePersistKey;
270
+ /** Auto-persist ratchet state (called after every ratchet mutation) */
271
+ private _persistRatchetState;
272
+ /** Load persisted ratchet state and restore into memory */
273
+ private _loadPersistedRatchetState;
274
+ /** IndexedDB put helper (browser only) */
275
+ private _idbPut;
276
+ /** IndexedDB get helper (browser only) */
277
+ private _idbGet;
278
+ /**
279
+ * Force-persist current ratchet state.
280
+ * Useful to call before process exit to ensure state is saved.
281
+ */
282
+ flushRatchetState(): Promise<void>;
283
+ /**
284
+ * Restore an agent from credentials with async persistence loading.
285
+ * Use this instead of `fromCredentials()` when using file/relay/custom persistence.
286
+ */
287
+ static fromCredentialsAsync(creds: Parameters<typeof VoidlyAgent.fromCredentials>[0], config?: VoidlyAgentConfig): Promise<VoidlyAgent>;
251
288
  /**
252
289
  * Get the number of messages that failed to decrypt.
253
290
  * Useful for detecting key mismatches, attacks, or corruption.
@@ -300,6 +337,8 @@ declare class VoidlyAgent {
300
337
  messageType?: string;
301
338
  unreadOnly?: boolean;
302
339
  }): Promise<DecryptedMessage[]>;
340
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
341
+ private _decryptMessages;
303
342
  /**
304
343
  * Delete a message by ID (must be sender or recipient).
305
344
  */
package/dist/index.d.ts CHANGED
@@ -85,6 +85,17 @@ interface VoidlyAgentConfig {
85
85
  jitterMs?: number;
86
86
  /** Use long-poll for listen() instead of short-interval polling (default: true) */
87
87
  longPoll?: boolean;
88
+ /** Auto-persist ratchet state after every send/receive (default: 'memory' = no persistence) */
89
+ persist?: 'memory' | 'localStorage' | 'indexedDB' | 'file' | 'relay' | 'custom';
90
+ /** Custom persistence: called after each ratchet step with encrypted blob */
91
+ onPersist?: (encryptedState: string) => void | Promise<void>;
92
+ /** Custom persistence: called on startup to load encrypted blob */
93
+ onLoad?: () => string | null | Promise<string | null>;
94
+ /** File path for persist: 'file' (Node.js environments only) */
95
+ persistPath?: string;
96
+ /** Transport preference for listen() — tries in order, falls back automatically
97
+ * Default: ['sse', 'long-poll']. Options: 'websocket' | 'sse' | 'long-poll' */
98
+ transport?: ('websocket' | 'sse' | 'long-poll')[];
88
99
  }
89
100
  interface ListenOptions {
90
101
  /** Milliseconds between polls (default: 2000, min: 500) */
@@ -185,6 +196,12 @@ declare class VoidlyAgent {
185
196
  private _rpcPending;
186
197
  private _coverTrafficTimer;
187
198
  private _rpcListener;
199
+ private _persistMode;
200
+ private _onPersist?;
201
+ private _onLoad?;
202
+ private _persistPath?;
203
+ private _persistKey;
204
+ private _transportPrefs;
188
205
  private constructor();
189
206
  /**
190
207
  * Register a new agent on the Voidly relay.
@@ -248,6 +265,26 @@ declare class VoidlyAgent {
248
265
  signedPrekeyPublic?: string;
249
266
  signedPrekeyId?: number;
250
267
  };
268
+ /** Derive persistence encryption key from signing secret */
269
+ private _derivePersistKey;
270
+ /** Auto-persist ratchet state (called after every ratchet mutation) */
271
+ private _persistRatchetState;
272
+ /** Load persisted ratchet state and restore into memory */
273
+ private _loadPersistedRatchetState;
274
+ /** IndexedDB put helper (browser only) */
275
+ private _idbPut;
276
+ /** IndexedDB get helper (browser only) */
277
+ private _idbGet;
278
+ /**
279
+ * Force-persist current ratchet state.
280
+ * Useful to call before process exit to ensure state is saved.
281
+ */
282
+ flushRatchetState(): Promise<void>;
283
+ /**
284
+ * Restore an agent from credentials with async persistence loading.
285
+ * Use this instead of `fromCredentials()` when using file/relay/custom persistence.
286
+ */
287
+ static fromCredentialsAsync(creds: Parameters<typeof VoidlyAgent.fromCredentials>[0], config?: VoidlyAgentConfig): Promise<VoidlyAgent>;
251
288
  /**
252
289
  * Get the number of messages that failed to decrypt.
253
290
  * Useful for detecting key mismatches, attacks, or corruption.
@@ -300,6 +337,8 @@ declare class VoidlyAgent {
300
337
  messageType?: string;
301
338
  unreadOnly?: boolean;
302
339
  }): Promise<DecryptedMessage[]>;
340
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
341
+ private _decryptMessages;
303
342
  /**
304
343
  * Delete a message by ID (must be sender or recipient).
305
344
  */
package/dist/index.js CHANGED
@@ -2230,22 +2230,22 @@ var require_nacl_fast = __commonJS({
2230
2230
  randombytes = fn;
2231
2231
  };
2232
2232
  (function() {
2233
- var crypto2 = typeof self !== "undefined" ? self.crypto || self.msCrypto : null;
2234
- if (crypto2 && crypto2.getRandomValues) {
2233
+ var crypto = typeof self !== "undefined" ? self.crypto || self.msCrypto : null;
2234
+ if (crypto && crypto.getRandomValues) {
2235
2235
  var QUOTA = 65536;
2236
2236
  nacl2.setPRNG(function(x, n) {
2237
2237
  var i, v = new Uint8Array(n);
2238
2238
  for (i = 0; i < n; i += QUOTA) {
2239
- crypto2.getRandomValues(v.subarray(i, i + Math.min(n - i, QUOTA)));
2239
+ crypto.getRandomValues(v.subarray(i, i + Math.min(n - i, QUOTA)));
2240
2240
  }
2241
2241
  for (i = 0; i < n; i++) x[i] = v[i];
2242
2242
  cleanup(v);
2243
2243
  });
2244
2244
  } else if (typeof require !== "undefined") {
2245
- crypto2 = require("crypto");
2246
- if (crypto2 && crypto2.randomBytes) {
2245
+ crypto = require("crypto");
2246
+ if (crypto && crypto.randomBytes) {
2247
2247
  nacl2.setPRNG(function(x, n) {
2248
- var i, v = crypto2.randomBytes(n);
2248
+ var i, v = crypto.randomBytes(n);
2249
2249
  for (i = 0; i < n; i++) x[i] = v[i];
2250
2250
  cleanup(v);
2251
2251
  });
@@ -4091,6 +4091,9 @@ var VoidlyAgent = class _VoidlyAgent {
4091
4091
  this._coverTrafficTimer = null;
4092
4092
  // RPC listener handle (started on first onInvoke)
4093
4093
  this._rpcListener = null;
4094
+ // Persistence (v3.2)
4095
+ this._persistMode = "memory";
4096
+ this._persistKey = null;
4094
4097
  this.did = identity.did;
4095
4098
  this.apiKey = identity.apiKey;
4096
4099
  this.signingKeyPair = identity.signingKeyPair;
@@ -4110,6 +4113,11 @@ var VoidlyAgent = class _VoidlyAgent {
4110
4113
  this.longPoll = config?.longPoll !== false;
4111
4114
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4112
4115
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4116
+ this._persistMode = config?.persist || "memory";
4117
+ this._onPersist = config?.onPersist;
4118
+ this._onLoad = config?.onLoad;
4119
+ this._persistPath = config?.persistPath;
4120
+ this._transportPrefs = config?.transport || ["sse", "long-poll"];
4113
4121
  }
4114
4122
  // ─── Factory Methods ────────────────────────────────────────────────────────
4115
4123
  /**
@@ -4314,6 +4322,186 @@ var VoidlyAgent = class _VoidlyAgent {
4314
4322
  } : {}
4315
4323
  };
4316
4324
  }
4325
+ // ─── Ratchet Persistence (v3.2) ────────────────────────────────────────────
4326
+ /** Derive persistence encryption key from signing secret */
4327
+ _derivePersistKey() {
4328
+ if (this._persistKey) return this._persistKey;
4329
+ const salt = (0, import_tweetnacl_util.decodeUTF8)("voidly-persist-v1");
4330
+ const input = new Uint8Array(this.signingKeyPair.secretKey.length + salt.length);
4331
+ input.set(this.signingKeyPair.secretKey, 0);
4332
+ input.set(salt, this.signingKeyPair.secretKey.length);
4333
+ this._persistKey = import_tweetnacl.default.hash(input).slice(0, 32);
4334
+ return this._persistKey;
4335
+ }
4336
+ /** Auto-persist ratchet state (called after every ratchet mutation) */
4337
+ async _persistRatchetState() {
4338
+ if (this._persistMode === "memory") return;
4339
+ try {
4340
+ const creds = this.exportCredentials();
4341
+ const data = JSON.stringify(creds.ratchetStates || {});
4342
+ const key = this._derivePersistKey();
4343
+ const nonce = import_tweetnacl.default.randomBytes(24);
4344
+ const encrypted = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(data), nonce, key);
4345
+ const blob = JSON.stringify({ n: (0, import_tweetnacl_util.encodeBase64)(nonce), c: (0, import_tweetnacl_util.encodeBase64)(encrypted), v: 1 });
4346
+ switch (this._persistMode) {
4347
+ case "localStorage":
4348
+ if (typeof localStorage !== "undefined") {
4349
+ localStorage.setItem(`voidly-ratchet-${this.did}`, blob);
4350
+ }
4351
+ break;
4352
+ case "indexedDB":
4353
+ await this._idbPut(blob);
4354
+ break;
4355
+ case "file":
4356
+ if (this._persistPath) {
4357
+ const fs = await import("fs/promises");
4358
+ await fs.writeFile(this._persistPath, blob, "utf-8");
4359
+ }
4360
+ break;
4361
+ case "relay":
4362
+ await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4363
+ method: "PUT",
4364
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
4365
+ body: JSON.stringify({ value: blob })
4366
+ }).catch(() => {
4367
+ });
4368
+ break;
4369
+ case "custom":
4370
+ if (this._onPersist) await this._onPersist(blob);
4371
+ break;
4372
+ }
4373
+ } catch {
4374
+ }
4375
+ }
4376
+ /** Load persisted ratchet state and restore into memory */
4377
+ async _loadPersistedRatchetState() {
4378
+ if (this._persistMode === "memory") return;
4379
+ let blob = null;
4380
+ try {
4381
+ switch (this._persistMode) {
4382
+ case "localStorage":
4383
+ if (typeof localStorage !== "undefined") {
4384
+ blob = localStorage.getItem(`voidly-ratchet-${this.did}`);
4385
+ }
4386
+ break;
4387
+ case "indexedDB":
4388
+ blob = await this._idbGet();
4389
+ break;
4390
+ case "file":
4391
+ if (this._persistPath) {
4392
+ try {
4393
+ const fs = await import("fs/promises");
4394
+ blob = await fs.readFile(this._persistPath, "utf-8");
4395
+ } catch {
4396
+ }
4397
+ }
4398
+ break;
4399
+ case "relay":
4400
+ try {
4401
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4402
+ headers: { "X-Agent-Key": this.apiKey }
4403
+ });
4404
+ if (res.ok) {
4405
+ const data = await res.json();
4406
+ blob = data.value;
4407
+ }
4408
+ } catch {
4409
+ }
4410
+ break;
4411
+ case "custom":
4412
+ if (this._onLoad) blob = await this._onLoad();
4413
+ break;
4414
+ }
4415
+ } catch {
4416
+ return;
4417
+ }
4418
+ if (!blob) return;
4419
+ try {
4420
+ const { n, c } = JSON.parse(blob);
4421
+ const key = this._derivePersistKey();
4422
+ const decrypted = import_tweetnacl.default.secretbox.open((0, import_tweetnacl_util.decodeBase64)(c), (0, import_tweetnacl_util.decodeBase64)(n), key);
4423
+ if (!decrypted) return;
4424
+ const states = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(decrypted));
4425
+ for (const [pairId, rs] of Object.entries(states)) {
4426
+ if (this._ratchetStates.has(pairId)) continue;
4427
+ const state = {
4428
+ sendChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey),
4429
+ sendStep: rs.sendStep,
4430
+ recvChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey),
4431
+ recvStep: rs.recvStep,
4432
+ skippedKeys: /* @__PURE__ */ new Map()
4433
+ };
4434
+ if (rs.rootKey) state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4435
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4436
+ state.dhSendKeyPair = {
4437
+ secretKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey),
4438
+ publicKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey)
4439
+ };
4440
+ }
4441
+ if (rs.dhRecvPubKey) state.dhRecvPubKey = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4442
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4443
+ if (state.sendChainKey.length === 32 && state.recvChainKey.length === 32) {
4444
+ this._ratchetStates.set(pairId, state);
4445
+ }
4446
+ }
4447
+ } catch {
4448
+ }
4449
+ }
4450
+ /** IndexedDB put helper (browser only) */
4451
+ async _idbPut(blob) {
4452
+ if (typeof indexedDB === "undefined") return;
4453
+ return new Promise((resolve, reject) => {
4454
+ const req = indexedDB.open("voidly-agent", 1);
4455
+ req.onupgradeneeded = () => {
4456
+ const db = req.result;
4457
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4458
+ };
4459
+ req.onsuccess = () => {
4460
+ const tx = req.result.transaction("ratchet", "readwrite");
4461
+ tx.objectStore("ratchet").put(blob, this.did);
4462
+ tx.oncomplete = () => resolve();
4463
+ tx.onerror = () => reject(tx.error);
4464
+ };
4465
+ req.onerror = () => reject(req.error);
4466
+ });
4467
+ }
4468
+ /** IndexedDB get helper (browser only) */
4469
+ async _idbGet() {
4470
+ if (typeof indexedDB === "undefined") return null;
4471
+ return new Promise((resolve, reject) => {
4472
+ const req = indexedDB.open("voidly-agent", 1);
4473
+ req.onupgradeneeded = () => {
4474
+ const db = req.result;
4475
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4476
+ };
4477
+ req.onsuccess = () => {
4478
+ const tx = req.result.transaction("ratchet", "readonly");
4479
+ const getReq = tx.objectStore("ratchet").get(this.did);
4480
+ getReq.onsuccess = () => resolve(getReq.result || null);
4481
+ getReq.onerror = () => reject(getReq.error);
4482
+ };
4483
+ req.onerror = () => reject(req.error);
4484
+ });
4485
+ }
4486
+ /**
4487
+ * Force-persist current ratchet state.
4488
+ * Useful to call before process exit to ensure state is saved.
4489
+ */
4490
+ async flushRatchetState() {
4491
+ const origMode = this._persistMode;
4492
+ if (origMode === "memory") this._persistMode = "file";
4493
+ await this._persistRatchetState();
4494
+ this._persistMode = origMode;
4495
+ }
4496
+ /**
4497
+ * Restore an agent from credentials with async persistence loading.
4498
+ * Use this instead of `fromCredentials()` when using file/relay/custom persistence.
4499
+ */
4500
+ static async fromCredentialsAsync(creds, config) {
4501
+ const agent = _VoidlyAgent.fromCredentials(creds, config);
4502
+ await agent._loadPersistedRatchetState();
4503
+ return agent;
4504
+ }
4317
4505
  /**
4318
4506
  * Get the number of messages that failed to decrypt.
4319
4507
  * Useful for detecting key mismatches, attacks, or corruption.
@@ -4390,7 +4578,7 @@ var VoidlyAgent = class _VoidlyAgent {
4390
4578
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4391
4579
  combined.set(x25519Shared, 0);
4392
4580
  combined.set(pqShared, x25519Shared.length);
4393
- initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4581
+ initialKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined));
4394
4582
  } catch {
4395
4583
  initialKey = x25519Shared;
4396
4584
  }
@@ -4489,6 +4677,8 @@ var VoidlyAgent = class _VoidlyAgent {
4489
4677
  payload.thread_id = options.threadId;
4490
4678
  payload.reply_to = options.replyTo;
4491
4679
  }
4680
+ this._persistRatchetState().catch(() => {
4681
+ });
4492
4682
  const relays = [this.baseUrl, ...this.fallbackRelays];
4493
4683
  let lastError = null;
4494
4684
  for (const relay of relays) {
@@ -4547,8 +4737,12 @@ var VoidlyAgent = class _VoidlyAgent {
4547
4737
  throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
4548
4738
  }
4549
4739
  const data = await res.json();
4740
+ return this._decryptMessages(data.messages);
4741
+ }
4742
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4743
+ async _decryptMessages(rawMessages) {
4550
4744
  const decrypted = [];
4551
- for (const msg of data.messages) {
4745
+ for (const msg of rawMessages) {
4552
4746
  try {
4553
4747
  if (this._seenMessageIds.has(msg.id)) continue;
4554
4748
  let senderEncPub;
@@ -4572,7 +4766,6 @@ var VoidlyAgent = class _VoidlyAgent {
4572
4766
  let envelopePqCiphertext = null;
4573
4767
  let envelopeDhRatchetKey = null;
4574
4768
  let envelopePn = 0;
4575
- let envelopeDeniable = false;
4576
4769
  if (msg.envelope) {
4577
4770
  try {
4578
4771
  const env = JSON.parse(msg.envelope);
@@ -4605,7 +4798,7 @@ var VoidlyAgent = class _VoidlyAgent {
4605
4798
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4606
4799
  combined.set(x25519Shared, 0);
4607
4800
  combined.set(pqShared, x25519Shared.length);
4608
- initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4801
+ initialKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined));
4609
4802
  } catch {
4610
4803
  initialKey = x25519Shared;
4611
4804
  }
@@ -4802,6 +4995,10 @@ var VoidlyAgent = class _VoidlyAgent {
4802
4995
  this._decryptFailCount++;
4803
4996
  }
4804
4997
  }
4998
+ if (decrypted.length > 0) {
4999
+ this._persistRatchetState().catch(() => {
5000
+ });
5001
+ }
4805
5002
  return decrypted;
4806
5003
  }
4807
5004
  // ─── Message Management ─────────────────────────────────────────────────────
@@ -6090,6 +6287,114 @@ var VoidlyAgent = class _VoidlyAgent {
6090
6287
  this.ping().catch(() => {
6091
6288
  });
6092
6289
  }
6290
+ const deliverMessages = async (messages) => {
6291
+ for (const msg of messages) {
6292
+ try {
6293
+ await onMessage(msg);
6294
+ if (autoMarkRead) {
6295
+ await this.markRead(msg.id).catch(() => {
6296
+ });
6297
+ }
6298
+ } catch (err) {
6299
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6300
+ }
6301
+ }
6302
+ if (messages.length > 0) {
6303
+ lastSeen = messages[messages.length - 1].timestamp;
6304
+ }
6305
+ };
6306
+ let lastEventId = "";
6307
+ let sseFailures = 0;
6308
+ const startSSE = async () => {
6309
+ try {
6310
+ const params = new URLSearchParams();
6311
+ if (lastSeen) params.set("since", lastSeen);
6312
+ if (options.from) params.set("from", options.from);
6313
+ const sseUrl = `${this.baseUrl}/v1/agent/receive/sse?${params}`;
6314
+ const headers = { "X-Agent-Key": this.apiKey };
6315
+ if (lastEventId) headers["Last-Event-ID"] = lastEventId;
6316
+ const controller = new AbortController();
6317
+ const sseTimeout = setTimeout(() => controller.abort(), 6e4);
6318
+ let res;
6319
+ try {
6320
+ res = await fetch(sseUrl, { headers, signal: controller.signal });
6321
+ } catch (e) {
6322
+ clearTimeout(sseTimeout);
6323
+ throw e;
6324
+ }
6325
+ if (!res.ok || !res.body) {
6326
+ clearTimeout(sseTimeout);
6327
+ return false;
6328
+ }
6329
+ const reader = res.body.getReader();
6330
+ const decoder = new TextDecoder();
6331
+ let buffer = "";
6332
+ try {
6333
+ while (active && !options.signal?.aborted) {
6334
+ const { done: streamDone, value } = await reader.read();
6335
+ if (streamDone) break;
6336
+ buffer += decoder.decode(value, { stream: true });
6337
+ const lines = buffer.split("\n");
6338
+ buffer = lines.pop() || "";
6339
+ let eventType = "";
6340
+ let dataStr = "";
6341
+ let eventId = "";
6342
+ for (const line of lines) {
6343
+ if (line.startsWith("event: ")) {
6344
+ eventType = line.slice(7).trim();
6345
+ } else if (line.startsWith("data: ")) {
6346
+ dataStr += (dataStr ? "\n" : "") + line.slice(6);
6347
+ } else if (line.startsWith("id: ")) {
6348
+ eventId = line.slice(4).trim();
6349
+ } else if (line === "" && (dataStr || eventType)) {
6350
+ if (eventId) lastEventId = eventId;
6351
+ if (eventType === "message" && dataStr) {
6352
+ try {
6353
+ const rawMsg = JSON.parse(dataStr);
6354
+ const decrypted = await this._decryptMessages([rawMsg]);
6355
+ if (decrypted.length > 0) {
6356
+ consecutiveEmpty = 0;
6357
+ sseFailures = 0;
6358
+ await deliverMessages(decrypted);
6359
+ }
6360
+ } catch {
6361
+ }
6362
+ } else if (eventType === "reconnect") {
6363
+ break;
6364
+ }
6365
+ eventType = "";
6366
+ dataStr = "";
6367
+ eventId = "";
6368
+ }
6369
+ }
6370
+ }
6371
+ } finally {
6372
+ reader.releaseLock();
6373
+ clearTimeout(sseTimeout);
6374
+ }
6375
+ sseFailures = 0;
6376
+ return true;
6377
+ } catch {
6378
+ sseFailures++;
6379
+ return false;
6380
+ }
6381
+ };
6382
+ const sseLoop = async () => {
6383
+ while (active && !options.signal?.aborted) {
6384
+ const ok = await startSSE();
6385
+ if (!active || options.signal?.aborted) break;
6386
+ if (!ok) {
6387
+ if (sseFailures >= 3) {
6388
+ poll();
6389
+ return;
6390
+ }
6391
+ const backoff = Math.min(1e3 * Math.pow(2, sseFailures - 1), 4e3);
6392
+ await new Promise((r) => setTimeout(r, backoff));
6393
+ continue;
6394
+ }
6395
+ await new Promise((r) => setTimeout(r, 500));
6396
+ }
6397
+ };
6093
6398
  const useLongPoll = this.longPoll;
6094
6399
  const poll = async () => {
6095
6400
  if (!active || options.signal?.aborted) {
@@ -6097,62 +6402,18 @@ var VoidlyAgent = class _VoidlyAgent {
6097
6402
  return;
6098
6403
  }
6099
6404
  try {
6100
- let messages;
6101
- if (useLongPoll) {
6102
- const params = new URLSearchParams({ timeout: "25" });
6103
- if (lastSeen) params.set("since", lastSeen);
6104
- if (options.from) params.set("from", options.from);
6105
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6106
- headers: { "X-Agent-Key": this.apiKey }
6107
- });
6108
- if (res.ok) {
6109
- const data = await res.json();
6110
- messages = [];
6111
- for (const raw of data.messages) {
6112
- try {
6113
- if (this._seenMessageIds.has(raw.id)) continue;
6114
- this._seenMessageIds.add(raw.id);
6115
- } catch {
6116
- }
6117
- }
6118
- if (data.messages.length > 0) {
6119
- messages = await this.receive({
6120
- since: lastSeen,
6121
- from: options.from,
6122
- threadId: options.threadId,
6123
- messageType: options.messageType,
6124
- unreadOnly,
6125
- limit: 50
6126
- });
6127
- }
6128
- } else {
6129
- messages = [];
6130
- }
6131
- } else {
6132
- messages = await this.receive({
6133
- since: lastSeen,
6134
- from: options.from,
6135
- threadId: options.threadId,
6136
- messageType: options.messageType,
6137
- unreadOnly,
6138
- limit: 50
6139
- });
6140
- }
6405
+ const messages = await this.receive({
6406
+ since: lastSeen,
6407
+ from: options.from,
6408
+ threadId: options.threadId,
6409
+ messageType: options.messageType,
6410
+ unreadOnly,
6411
+ limit: 50
6412
+ });
6141
6413
  if (messages.length > 0) {
6142
6414
  consecutiveEmpty = 0;
6143
6415
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
6144
- for (const msg of messages) {
6145
- try {
6146
- await onMessage(msg);
6147
- if (autoMarkRead) {
6148
- await this.markRead(msg.id).catch(() => {
6149
- });
6150
- }
6151
- } catch (err) {
6152
- if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6153
- }
6154
- }
6155
- lastSeen = messages[messages.length - 1].timestamp;
6416
+ await deliverMessages(messages);
6156
6417
  } else {
6157
6418
  consecutiveEmpty++;
6158
6419
  if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
@@ -6167,7 +6428,12 @@ var VoidlyAgent = class _VoidlyAgent {
6167
6428
  timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
6168
6429
  }
6169
6430
  };
6170
- poll();
6431
+ const prefs = this._transportPrefs;
6432
+ if (prefs.includes("sse")) {
6433
+ sseLoop();
6434
+ } else {
6435
+ poll();
6436
+ }
6171
6437
  return handle;
6172
6438
  }
6173
6439
  /**
@@ -6499,7 +6765,12 @@ var VoidlyAgent = class _VoidlyAgent {
6499
6765
  for (const relay of relays) {
6500
6766
  try {
6501
6767
  const res = await this._timedFetch(`${relay}${path}`, init);
6502
- if (res.ok || res.status >= 400 && res.status < 500) return res;
6768
+ if (res.ok || res.status >= 400 && res.status < 500 && res.status !== 429) return res;
6769
+ if (res.status === 429) {
6770
+ const retryAfter = res.headers.get("Retry-After");
6771
+ const waitMs = retryAfter ? Math.min(parseInt(retryAfter, 10) * 1e3, 5e3) : 1e3;
6772
+ await new Promise((r) => setTimeout(r, waitMs));
6773
+ }
6503
6774
  } catch (err) {
6504
6775
  lastError = err instanceof Error ? err : new Error(String(err));
6505
6776
  }
@@ -6686,7 +6957,9 @@ var VoidlyAgent = class _VoidlyAgent {
6686
6957
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
6687
6958
  ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6688
6959
  ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6960
+ ...this._transportPrefs.includes("sse") ? ["SSE streaming transport (real-time push delivery from relay)"] : [],
6689
6961
  ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6962
+ ...this._persistMode !== "memory" ? [`Ratchet state auto-persistence (${this._persistMode} backend \u2014 survives process restart)`] : [],
6690
6963
  ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6691
6964
  "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6692
6965
  "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
@@ -6700,7 +6973,7 @@ var VoidlyAgent = class _VoidlyAgent {
6700
6973
  ...this.sealedSender ? ["Relay sees to_did (needed for routing) but NOT from_did, thread_id, or message_type"] : ["Relay sees from_did, to_did, thread_id, message_type in cleartext \u2014 enable sealedSender to strip metadata"],
6701
6974
  "Relay sees channel membership, task delegation, trust scores (social graph)",
6702
6975
  ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6703
- "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6976
+ ...this._persistMode === "memory" ? ["Ratchet state is in-memory (lost on process restart \u2014 use persist option or exportCredentials)"] : [],
6704
6977
  ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6705
6978
  ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6706
6979
  ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
package/dist/index.mjs CHANGED
@@ -2230,22 +2230,22 @@ var require_nacl_fast = __commonJS({
2230
2230
  randombytes = fn;
2231
2231
  };
2232
2232
  (function() {
2233
- var crypto2 = typeof self !== "undefined" ? self.crypto || self.msCrypto : null;
2234
- if (crypto2 && crypto2.getRandomValues) {
2233
+ var crypto = typeof self !== "undefined" ? self.crypto || self.msCrypto : null;
2234
+ if (crypto && crypto.getRandomValues) {
2235
2235
  var QUOTA = 65536;
2236
2236
  nacl2.setPRNG(function(x, n) {
2237
2237
  var i, v = new Uint8Array(n);
2238
2238
  for (i = 0; i < n; i += QUOTA) {
2239
- crypto2.getRandomValues(v.subarray(i, i + Math.min(n - i, QUOTA)));
2239
+ crypto.getRandomValues(v.subarray(i, i + Math.min(n - i, QUOTA)));
2240
2240
  }
2241
2241
  for (i = 0; i < n; i++) x[i] = v[i];
2242
2242
  cleanup(v);
2243
2243
  });
2244
2244
  } else if (typeof __require !== "undefined") {
2245
- crypto2 = __require("crypto");
2246
- if (crypto2 && crypto2.randomBytes) {
2245
+ crypto = __require("crypto");
2246
+ if (crypto && crypto.randomBytes) {
2247
2247
  nacl2.setPRNG(function(x, n) {
2248
- var i, v = crypto2.randomBytes(n);
2248
+ var i, v = crypto.randomBytes(n);
2249
2249
  for (i = 0; i < n; i++) x[i] = v[i];
2250
2250
  cleanup(v);
2251
2251
  });
@@ -4080,6 +4080,9 @@ var VoidlyAgent = class _VoidlyAgent {
4080
4080
  this._coverTrafficTimer = null;
4081
4081
  // RPC listener handle (started on first onInvoke)
4082
4082
  this._rpcListener = null;
4083
+ // Persistence (v3.2)
4084
+ this._persistMode = "memory";
4085
+ this._persistKey = null;
4083
4086
  this.did = identity.did;
4084
4087
  this.apiKey = identity.apiKey;
4085
4088
  this.signingKeyPair = identity.signingKeyPair;
@@ -4099,6 +4102,11 @@ var VoidlyAgent = class _VoidlyAgent {
4099
4102
  this.longPoll = config?.longPoll !== false;
4100
4103
  this.mlkemPublicKey = identity.mlkemPublicKey || null;
4101
4104
  this.mlkemSecretKey = identity.mlkemSecretKey || null;
4105
+ this._persistMode = config?.persist || "memory";
4106
+ this._onPersist = config?.onPersist;
4107
+ this._onLoad = config?.onLoad;
4108
+ this._persistPath = config?.persistPath;
4109
+ this._transportPrefs = config?.transport || ["sse", "long-poll"];
4102
4110
  }
4103
4111
  // ─── Factory Methods ────────────────────────────────────────────────────────
4104
4112
  /**
@@ -4303,6 +4311,186 @@ var VoidlyAgent = class _VoidlyAgent {
4303
4311
  } : {}
4304
4312
  };
4305
4313
  }
4314
+ // ─── Ratchet Persistence (v3.2) ────────────────────────────────────────────
4315
+ /** Derive persistence encryption key from signing secret */
4316
+ _derivePersistKey() {
4317
+ if (this._persistKey) return this._persistKey;
4318
+ const salt = (0, import_tweetnacl_util.decodeUTF8)("voidly-persist-v1");
4319
+ const input = new Uint8Array(this.signingKeyPair.secretKey.length + salt.length);
4320
+ input.set(this.signingKeyPair.secretKey, 0);
4321
+ input.set(salt, this.signingKeyPair.secretKey.length);
4322
+ this._persistKey = import_tweetnacl.default.hash(input).slice(0, 32);
4323
+ return this._persistKey;
4324
+ }
4325
+ /** Auto-persist ratchet state (called after every ratchet mutation) */
4326
+ async _persistRatchetState() {
4327
+ if (this._persistMode === "memory") return;
4328
+ try {
4329
+ const creds = this.exportCredentials();
4330
+ const data = JSON.stringify(creds.ratchetStates || {});
4331
+ const key = this._derivePersistKey();
4332
+ const nonce = import_tweetnacl.default.randomBytes(24);
4333
+ const encrypted = import_tweetnacl.default.secretbox((0, import_tweetnacl_util.decodeUTF8)(data), nonce, key);
4334
+ const blob = JSON.stringify({ n: (0, import_tweetnacl_util.encodeBase64)(nonce), c: (0, import_tweetnacl_util.encodeBase64)(encrypted), v: 1 });
4335
+ switch (this._persistMode) {
4336
+ case "localStorage":
4337
+ if (typeof localStorage !== "undefined") {
4338
+ localStorage.setItem(`voidly-ratchet-${this.did}`, blob);
4339
+ }
4340
+ break;
4341
+ case "indexedDB":
4342
+ await this._idbPut(blob);
4343
+ break;
4344
+ case "file":
4345
+ if (this._persistPath) {
4346
+ const fs = await import("fs/promises");
4347
+ await fs.writeFile(this._persistPath, blob, "utf-8");
4348
+ }
4349
+ break;
4350
+ case "relay":
4351
+ await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4352
+ method: "PUT",
4353
+ headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
4354
+ body: JSON.stringify({ value: blob })
4355
+ }).catch(() => {
4356
+ });
4357
+ break;
4358
+ case "custom":
4359
+ if (this._onPersist) await this._onPersist(blob);
4360
+ break;
4361
+ }
4362
+ } catch {
4363
+ }
4364
+ }
4365
+ /** Load persisted ratchet state and restore into memory */
4366
+ async _loadPersistedRatchetState() {
4367
+ if (this._persistMode === "memory") return;
4368
+ let blob = null;
4369
+ try {
4370
+ switch (this._persistMode) {
4371
+ case "localStorage":
4372
+ if (typeof localStorage !== "undefined") {
4373
+ blob = localStorage.getItem(`voidly-ratchet-${this.did}`);
4374
+ }
4375
+ break;
4376
+ case "indexedDB":
4377
+ blob = await this._idbGet();
4378
+ break;
4379
+ case "file":
4380
+ if (this._persistPath) {
4381
+ try {
4382
+ const fs = await import("fs/promises");
4383
+ blob = await fs.readFile(this._persistPath, "utf-8");
4384
+ } catch {
4385
+ }
4386
+ }
4387
+ break;
4388
+ case "relay":
4389
+ try {
4390
+ const res = await this._timedFetch(`${this.baseUrl}/v1/agent/memory/ratchet/state`, {
4391
+ headers: { "X-Agent-Key": this.apiKey }
4392
+ });
4393
+ if (res.ok) {
4394
+ const data = await res.json();
4395
+ blob = data.value;
4396
+ }
4397
+ } catch {
4398
+ }
4399
+ break;
4400
+ case "custom":
4401
+ if (this._onLoad) blob = await this._onLoad();
4402
+ break;
4403
+ }
4404
+ } catch {
4405
+ return;
4406
+ }
4407
+ if (!blob) return;
4408
+ try {
4409
+ const { n, c } = JSON.parse(blob);
4410
+ const key = this._derivePersistKey();
4411
+ const decrypted = import_tweetnacl.default.secretbox.open((0, import_tweetnacl_util.decodeBase64)(c), (0, import_tweetnacl_util.decodeBase64)(n), key);
4412
+ if (!decrypted) return;
4413
+ const states = JSON.parse((0, import_tweetnacl_util.encodeUTF8)(decrypted));
4414
+ for (const [pairId, rs] of Object.entries(states)) {
4415
+ if (this._ratchetStates.has(pairId)) continue;
4416
+ const state = {
4417
+ sendChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.sendChainKey),
4418
+ sendStep: rs.sendStep,
4419
+ recvChainKey: (0, import_tweetnacl_util.decodeBase64)(rs.recvChainKey),
4420
+ recvStep: rs.recvStep,
4421
+ skippedKeys: /* @__PURE__ */ new Map()
4422
+ };
4423
+ if (rs.rootKey) state.rootKey = (0, import_tweetnacl_util.decodeBase64)(rs.rootKey);
4424
+ if (rs.dhSendSecretKey && rs.dhSendPublicKey) {
4425
+ state.dhSendKeyPair = {
4426
+ secretKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendSecretKey),
4427
+ publicKey: (0, import_tweetnacl_util.decodeBase64)(rs.dhSendPublicKey)
4428
+ };
4429
+ }
4430
+ if (rs.dhRecvPubKey) state.dhRecvPubKey = (0, import_tweetnacl_util.decodeBase64)(rs.dhRecvPubKey);
4431
+ if (rs.prevSendStep !== void 0) state.prevSendStep = rs.prevSendStep;
4432
+ if (state.sendChainKey.length === 32 && state.recvChainKey.length === 32) {
4433
+ this._ratchetStates.set(pairId, state);
4434
+ }
4435
+ }
4436
+ } catch {
4437
+ }
4438
+ }
4439
+ /** IndexedDB put helper (browser only) */
4440
+ async _idbPut(blob) {
4441
+ if (typeof indexedDB === "undefined") return;
4442
+ return new Promise((resolve, reject) => {
4443
+ const req = indexedDB.open("voidly-agent", 1);
4444
+ req.onupgradeneeded = () => {
4445
+ const db = req.result;
4446
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4447
+ };
4448
+ req.onsuccess = () => {
4449
+ const tx = req.result.transaction("ratchet", "readwrite");
4450
+ tx.objectStore("ratchet").put(blob, this.did);
4451
+ tx.oncomplete = () => resolve();
4452
+ tx.onerror = () => reject(tx.error);
4453
+ };
4454
+ req.onerror = () => reject(req.error);
4455
+ });
4456
+ }
4457
+ /** IndexedDB get helper (browser only) */
4458
+ async _idbGet() {
4459
+ if (typeof indexedDB === "undefined") return null;
4460
+ return new Promise((resolve, reject) => {
4461
+ const req = indexedDB.open("voidly-agent", 1);
4462
+ req.onupgradeneeded = () => {
4463
+ const db = req.result;
4464
+ if (!db.objectStoreNames.contains("ratchet")) db.createObjectStore("ratchet");
4465
+ };
4466
+ req.onsuccess = () => {
4467
+ const tx = req.result.transaction("ratchet", "readonly");
4468
+ const getReq = tx.objectStore("ratchet").get(this.did);
4469
+ getReq.onsuccess = () => resolve(getReq.result || null);
4470
+ getReq.onerror = () => reject(getReq.error);
4471
+ };
4472
+ req.onerror = () => reject(req.error);
4473
+ });
4474
+ }
4475
+ /**
4476
+ * Force-persist current ratchet state.
4477
+ * Useful to call before process exit to ensure state is saved.
4478
+ */
4479
+ async flushRatchetState() {
4480
+ const origMode = this._persistMode;
4481
+ if (origMode === "memory") this._persistMode = "file";
4482
+ await this._persistRatchetState();
4483
+ this._persistMode = origMode;
4484
+ }
4485
+ /**
4486
+ * Restore an agent from credentials with async persistence loading.
4487
+ * Use this instead of `fromCredentials()` when using file/relay/custom persistence.
4488
+ */
4489
+ static async fromCredentialsAsync(creds, config) {
4490
+ const agent = _VoidlyAgent.fromCredentials(creds, config);
4491
+ await agent._loadPersistedRatchetState();
4492
+ return agent;
4493
+ }
4306
4494
  /**
4307
4495
  * Get the number of messages that failed to decrypt.
4308
4496
  * Useful for detecting key mismatches, attacks, or corruption.
@@ -4379,7 +4567,7 @@ var VoidlyAgent = class _VoidlyAgent {
4379
4567
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4380
4568
  combined.set(x25519Shared, 0);
4381
4569
  combined.set(pqShared, x25519Shared.length);
4382
- initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4570
+ initialKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined));
4383
4571
  } catch {
4384
4572
  initialKey = x25519Shared;
4385
4573
  }
@@ -4478,6 +4666,8 @@ var VoidlyAgent = class _VoidlyAgent {
4478
4666
  payload.thread_id = options.threadId;
4479
4667
  payload.reply_to = options.replyTo;
4480
4668
  }
4669
+ this._persistRatchetState().catch(() => {
4670
+ });
4481
4671
  const relays = [this.baseUrl, ...this.fallbackRelays];
4482
4672
  let lastError = null;
4483
4673
  for (const relay of relays) {
@@ -4536,8 +4726,12 @@ var VoidlyAgent = class _VoidlyAgent {
4536
4726
  throw new Error(`Receive failed: ${err.error?.message || err.error || res.statusText}`);
4537
4727
  }
4538
4728
  const data = await res.json();
4729
+ return this._decryptMessages(data.messages);
4730
+ }
4731
+ /** Decrypt raw message objects (shared by receive(), SSE, WebSocket transports) */
4732
+ async _decryptMessages(rawMessages) {
4539
4733
  const decrypted = [];
4540
- for (const msg of data.messages) {
4734
+ for (const msg of rawMessages) {
4541
4735
  try {
4542
4736
  if (this._seenMessageIds.has(msg.id)) continue;
4543
4737
  let senderEncPub;
@@ -4561,7 +4755,6 @@ var VoidlyAgent = class _VoidlyAgent {
4561
4755
  let envelopePqCiphertext = null;
4562
4756
  let envelopeDhRatchetKey = null;
4563
4757
  let envelopePn = 0;
4564
- let envelopeDeniable = false;
4565
4758
  if (msg.envelope) {
4566
4759
  try {
4567
4760
  const env = JSON.parse(msg.envelope);
@@ -4594,7 +4787,7 @@ var VoidlyAgent = class _VoidlyAgent {
4594
4787
  const combined = new Uint8Array(x25519Shared.length + pqShared.length);
4595
4788
  combined.set(x25519Shared, 0);
4596
4789
  combined.set(pqShared, x25519Shared.length);
4597
- initialKey = new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
4790
+ initialKey = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", combined));
4598
4791
  } catch {
4599
4792
  initialKey = x25519Shared;
4600
4793
  }
@@ -4791,6 +4984,10 @@ var VoidlyAgent = class _VoidlyAgent {
4791
4984
  this._decryptFailCount++;
4792
4985
  }
4793
4986
  }
4987
+ if (decrypted.length > 0) {
4988
+ this._persistRatchetState().catch(() => {
4989
+ });
4990
+ }
4794
4991
  return decrypted;
4795
4992
  }
4796
4993
  // ─── Message Management ─────────────────────────────────────────────────────
@@ -6079,6 +6276,114 @@ var VoidlyAgent = class _VoidlyAgent {
6079
6276
  this.ping().catch(() => {
6080
6277
  });
6081
6278
  }
6279
+ const deliverMessages = async (messages) => {
6280
+ for (const msg of messages) {
6281
+ try {
6282
+ await onMessage(msg);
6283
+ if (autoMarkRead) {
6284
+ await this.markRead(msg.id).catch(() => {
6285
+ });
6286
+ }
6287
+ } catch (err) {
6288
+ if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6289
+ }
6290
+ }
6291
+ if (messages.length > 0) {
6292
+ lastSeen = messages[messages.length - 1].timestamp;
6293
+ }
6294
+ };
6295
+ let lastEventId = "";
6296
+ let sseFailures = 0;
6297
+ const startSSE = async () => {
6298
+ try {
6299
+ const params = new URLSearchParams();
6300
+ if (lastSeen) params.set("since", lastSeen);
6301
+ if (options.from) params.set("from", options.from);
6302
+ const sseUrl = `${this.baseUrl}/v1/agent/receive/sse?${params}`;
6303
+ const headers = { "X-Agent-Key": this.apiKey };
6304
+ if (lastEventId) headers["Last-Event-ID"] = lastEventId;
6305
+ const controller = new AbortController();
6306
+ const sseTimeout = setTimeout(() => controller.abort(), 6e4);
6307
+ let res;
6308
+ try {
6309
+ res = await fetch(sseUrl, { headers, signal: controller.signal });
6310
+ } catch (e) {
6311
+ clearTimeout(sseTimeout);
6312
+ throw e;
6313
+ }
6314
+ if (!res.ok || !res.body) {
6315
+ clearTimeout(sseTimeout);
6316
+ return false;
6317
+ }
6318
+ const reader = res.body.getReader();
6319
+ const decoder = new TextDecoder();
6320
+ let buffer = "";
6321
+ try {
6322
+ while (active && !options.signal?.aborted) {
6323
+ const { done: streamDone, value } = await reader.read();
6324
+ if (streamDone) break;
6325
+ buffer += decoder.decode(value, { stream: true });
6326
+ const lines = buffer.split("\n");
6327
+ buffer = lines.pop() || "";
6328
+ let eventType = "";
6329
+ let dataStr = "";
6330
+ let eventId = "";
6331
+ for (const line of lines) {
6332
+ if (line.startsWith("event: ")) {
6333
+ eventType = line.slice(7).trim();
6334
+ } else if (line.startsWith("data: ")) {
6335
+ dataStr += (dataStr ? "\n" : "") + line.slice(6);
6336
+ } else if (line.startsWith("id: ")) {
6337
+ eventId = line.slice(4).trim();
6338
+ } else if (line === "" && (dataStr || eventType)) {
6339
+ if (eventId) lastEventId = eventId;
6340
+ if (eventType === "message" && dataStr) {
6341
+ try {
6342
+ const rawMsg = JSON.parse(dataStr);
6343
+ const decrypted = await this._decryptMessages([rawMsg]);
6344
+ if (decrypted.length > 0) {
6345
+ consecutiveEmpty = 0;
6346
+ sseFailures = 0;
6347
+ await deliverMessages(decrypted);
6348
+ }
6349
+ } catch {
6350
+ }
6351
+ } else if (eventType === "reconnect") {
6352
+ break;
6353
+ }
6354
+ eventType = "";
6355
+ dataStr = "";
6356
+ eventId = "";
6357
+ }
6358
+ }
6359
+ }
6360
+ } finally {
6361
+ reader.releaseLock();
6362
+ clearTimeout(sseTimeout);
6363
+ }
6364
+ sseFailures = 0;
6365
+ return true;
6366
+ } catch {
6367
+ sseFailures++;
6368
+ return false;
6369
+ }
6370
+ };
6371
+ const sseLoop = async () => {
6372
+ while (active && !options.signal?.aborted) {
6373
+ const ok = await startSSE();
6374
+ if (!active || options.signal?.aborted) break;
6375
+ if (!ok) {
6376
+ if (sseFailures >= 3) {
6377
+ poll();
6378
+ return;
6379
+ }
6380
+ const backoff = Math.min(1e3 * Math.pow(2, sseFailures - 1), 4e3);
6381
+ await new Promise((r) => setTimeout(r, backoff));
6382
+ continue;
6383
+ }
6384
+ await new Promise((r) => setTimeout(r, 500));
6385
+ }
6386
+ };
6082
6387
  const useLongPoll = this.longPoll;
6083
6388
  const poll = async () => {
6084
6389
  if (!active || options.signal?.aborted) {
@@ -6086,62 +6391,18 @@ var VoidlyAgent = class _VoidlyAgent {
6086
6391
  return;
6087
6392
  }
6088
6393
  try {
6089
- let messages;
6090
- if (useLongPoll) {
6091
- const params = new URLSearchParams({ timeout: "25" });
6092
- if (lastSeen) params.set("since", lastSeen);
6093
- if (options.from) params.set("from", options.from);
6094
- const res = await this._timedFetch(`${this.baseUrl}/v1/agent/receive/poll?${params}`, {
6095
- headers: { "X-Agent-Key": this.apiKey }
6096
- });
6097
- if (res.ok) {
6098
- const data = await res.json();
6099
- messages = [];
6100
- for (const raw of data.messages) {
6101
- try {
6102
- if (this._seenMessageIds.has(raw.id)) continue;
6103
- this._seenMessageIds.add(raw.id);
6104
- } catch {
6105
- }
6106
- }
6107
- if (data.messages.length > 0) {
6108
- messages = await this.receive({
6109
- since: lastSeen,
6110
- from: options.from,
6111
- threadId: options.threadId,
6112
- messageType: options.messageType,
6113
- unreadOnly,
6114
- limit: 50
6115
- });
6116
- }
6117
- } else {
6118
- messages = [];
6119
- }
6120
- } else {
6121
- messages = await this.receive({
6122
- since: lastSeen,
6123
- from: options.from,
6124
- threadId: options.threadId,
6125
- messageType: options.messageType,
6126
- unreadOnly,
6127
- limit: 50
6128
- });
6129
- }
6394
+ const messages = await this.receive({
6395
+ since: lastSeen,
6396
+ from: options.from,
6397
+ threadId: options.threadId,
6398
+ messageType: options.messageType,
6399
+ unreadOnly,
6400
+ limit: 50
6401
+ });
6130
6402
  if (messages.length > 0) {
6131
6403
  consecutiveEmpty = 0;
6132
6404
  if (adaptive) currentInterval = Math.max(interval / 2, 500);
6133
- for (const msg of messages) {
6134
- try {
6135
- await onMessage(msg);
6136
- if (autoMarkRead) {
6137
- await this.markRead(msg.id).catch(() => {
6138
- });
6139
- }
6140
- } catch (err) {
6141
- if (onError) onError(err instanceof Error ? err : new Error(String(err)));
6142
- }
6143
- }
6144
- lastSeen = messages[messages.length - 1].timestamp;
6405
+ await deliverMessages(messages);
6145
6406
  } else {
6146
6407
  consecutiveEmpty++;
6147
6408
  if (adaptive && consecutiveEmpty > 3 && !useLongPoll) {
@@ -6156,7 +6417,12 @@ var VoidlyAgent = class _VoidlyAgent {
6156
6417
  timer = setTimeout(poll, useLongPoll ? 100 : currentInterval);
6157
6418
  }
6158
6419
  };
6159
- poll();
6420
+ const prefs = this._transportPrefs;
6421
+ if (prefs.includes("sse")) {
6422
+ sseLoop();
6423
+ } else {
6424
+ poll();
6425
+ }
6160
6426
  return handle;
6161
6427
  }
6162
6428
  /**
@@ -6488,7 +6754,12 @@ var VoidlyAgent = class _VoidlyAgent {
6488
6754
  for (const relay of relays) {
6489
6755
  try {
6490
6756
  const res = await this._timedFetch(`${relay}${path}`, init);
6491
- if (res.ok || res.status >= 400 && res.status < 500) return res;
6757
+ if (res.ok || res.status >= 400 && res.status < 500 && res.status !== 429) return res;
6758
+ if (res.status === 429) {
6759
+ const retryAfter = res.headers.get("Retry-After");
6760
+ const waitMs = retryAfter ? Math.min(parseInt(retryAfter, 10) * 1e3, 5e3) : 1e3;
6761
+ await new Promise((r) => setTimeout(r, waitMs));
6762
+ }
6492
6763
  } catch (err) {
6493
6764
  lastError = err instanceof Error ? err : new Error(String(err));
6494
6765
  }
@@ -6675,7 +6946,9 @@ var VoidlyAgent = class _VoidlyAgent {
6675
6946
  ...this.requireSignatures ? ["Strict signature enforcement (reject unsigned/invalid messages)"] : [],
6676
6947
  ...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays \u2014 receive, discover, identity all use fallbacks)`] : [],
6677
6948
  ...this.jitterMs > 0 ? [`Timing jitter (random ${this.jitterMs}ms delay \u2014 metadata timing protection)`] : [],
6949
+ ...this._transportPrefs.includes("sse") ? ["SSE streaming transport (real-time push delivery from relay)"] : [],
6678
6950
  ...this.longPoll ? ["Long-poll transport (25s server-held connection \u2014 near-real-time delivery)"] : [],
6951
+ ...this._persistMode !== "memory" ? [`Ratchet state auto-persistence (${this._persistMode} backend \u2014 survives process restart)`] : [],
6679
6952
  ...this._coverTrafficTimer !== null ? ["Cover traffic (encrypted noise at random intervals \u2014 traffic analysis resistance)"] : [],
6680
6953
  "Agent RPC (invoke/onInvoke \u2014 synchronous function calls between agents)",
6681
6954
  "P2P direct send (bypass relay via webhook \u2014 true peer-to-peer when possible)",
@@ -6689,7 +6962,7 @@ var VoidlyAgent = class _VoidlyAgent {
6689
6962
  ...this.sealedSender ? ["Relay sees to_did (needed for routing) but NOT from_did, thread_id, or message_type"] : ["Relay sees from_did, to_did, thread_id, message_type in cleartext \u2014 enable sealedSender to strip metadata"],
6690
6963
  "Relay sees channel membership, task delegation, trust scores (social graph)",
6691
6964
  ...this.fallbackRelays.length === 0 ? ["Single relay with no fallbacks \u2014 configure fallbackRelays for resilience"] : [],
6692
- "Ratchet state is in-memory (lost on process restart \u2014 export credentials to persist)",
6965
+ ...this._persistMode === "memory" ? ["Ratchet state is in-memory (lost on process restart \u2014 use persist option or exportCredentials)"] : [],
6693
6966
  ...!this.deniable ? ["Ed25519 signatures are non-repudiable \u2014 enable deniable option for HMAC auth"] : [],
6694
6967
  ...!this.doubleRatchet ? ["Hash ratchet only \u2014 enable doubleRatchet option for post-compromise recovery"] : [],
6695
6968
  ...this.jitterMs === 0 ? ["No timing jitter \u2014 enable jitterMs option for metadata protection"] : [],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@voidly/agent-sdk",
3
- "version": "3.1.0",
4
- "description": "E2E encrypted agent-to-agent communication SDK — Double Ratchet, X3DH, deniable auth, ML-KEM-768 post-quantum, metadata privacy, RPC, P2P direct, cover traffic",
3
+ "version": "3.2.1",
4
+ "description": "E2E encrypted agent-to-agent communication SDK — Double Ratchet, X3DH, deniable auth, ML-KEM-768 post-quantum, SSE streaming, ratchet persistence, multi-relay federation",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",