@voidly/agent-sdk 1.8.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +248 -3
- package/dist/index.d.ts +248 -3
- package/dist/index.js +667 -41
- package/dist/index.mjs +666 -41
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2326,6 +2326,7 @@ var require_nacl_util = __commonJS({
|
|
|
2326
2326
|
// src/index.ts
|
|
2327
2327
|
var index_exports = {};
|
|
2328
2328
|
__export(index_exports, {
|
|
2329
|
+
Conversation: () => Conversation,
|
|
2329
2330
|
VoidlyAgent: () => VoidlyAgent,
|
|
2330
2331
|
decodeBase64: () => import_tweetnacl_util.decodeBase64,
|
|
2331
2332
|
decodeUTF8: () => import_tweetnacl_util.decodeUTF8,
|
|
@@ -2345,13 +2346,82 @@ async function sha256(data) {
|
|
|
2345
2346
|
const { createHash } = await import("crypto");
|
|
2346
2347
|
return createHash("sha256").update(data).digest("hex");
|
|
2347
2348
|
}
|
|
2349
|
+
function padMessage(content) {
|
|
2350
|
+
const contentLen = content.length;
|
|
2351
|
+
const totalLen = Math.max(256, nextPowerOf2(contentLen + 2));
|
|
2352
|
+
const padded = new Uint8Array(totalLen);
|
|
2353
|
+
padded[0] = contentLen >> 8 & 255;
|
|
2354
|
+
padded[1] = contentLen & 255;
|
|
2355
|
+
padded.set(content, 2);
|
|
2356
|
+
const randomPad = import_tweetnacl.default.randomBytes(totalLen - contentLen - 2);
|
|
2357
|
+
padded.set(randomPad, contentLen + 2);
|
|
2358
|
+
return padded;
|
|
2359
|
+
}
|
|
2360
|
+
function unpadMessage(padded) {
|
|
2361
|
+
if (padded.length < 2) return padded;
|
|
2362
|
+
const contentLen = padded[0] << 8 | padded[1];
|
|
2363
|
+
if (contentLen + 2 > padded.length) return padded;
|
|
2364
|
+
return padded.slice(2, 2 + contentLen);
|
|
2365
|
+
}
|
|
2366
|
+
function nextPowerOf2(n) {
|
|
2367
|
+
let p = 1;
|
|
2368
|
+
while (p < n) p <<= 1;
|
|
2369
|
+
return p;
|
|
2370
|
+
}
|
|
2371
|
+
async function ratchetStep(chainKey) {
|
|
2372
|
+
const encoder = new TextEncoder();
|
|
2373
|
+
if (typeof globalThis.crypto?.subtle !== "undefined") {
|
|
2374
|
+
const ckInput = new Uint8Array([...chainKey, 1]);
|
|
2375
|
+
const mkInput = new Uint8Array([...chainKey, 2]);
|
|
2376
|
+
const nextChainKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", ckInput));
|
|
2377
|
+
const messageKey2 = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", mkInput));
|
|
2378
|
+
return { nextChainKey: nextChainKey2, messageKey: messageKey2 };
|
|
2379
|
+
}
|
|
2380
|
+
const { createHash } = await import("crypto");
|
|
2381
|
+
const nextChainKey = new Uint8Array(
|
|
2382
|
+
createHash("sha256").update(Buffer.from([...chainKey, 1])).digest()
|
|
2383
|
+
);
|
|
2384
|
+
const messageKey = new Uint8Array(
|
|
2385
|
+
createHash("sha256").update(Buffer.from([...chainKey, 2])).digest()
|
|
2386
|
+
);
|
|
2387
|
+
return { nextChainKey, messageKey };
|
|
2388
|
+
}
|
|
2389
|
+
function sealEnvelope(senderDid, plaintext) {
|
|
2390
|
+
return JSON.stringify({
|
|
2391
|
+
v: 2,
|
|
2392
|
+
from: senderDid,
|
|
2393
|
+
msg: plaintext,
|
|
2394
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
function unsealEnvelope(plaintext) {
|
|
2398
|
+
try {
|
|
2399
|
+
const parsed = JSON.parse(plaintext);
|
|
2400
|
+
if (parsed.v === 2 && parsed.from && parsed.msg) {
|
|
2401
|
+
return { from: parsed.from, msg: parsed.msg, ts: parsed.ts };
|
|
2402
|
+
}
|
|
2403
|
+
return null;
|
|
2404
|
+
} catch {
|
|
2405
|
+
return null;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2348
2408
|
var VoidlyAgent = class _VoidlyAgent {
|
|
2349
2409
|
constructor(identity, config) {
|
|
2410
|
+
this._pinnedDids = /* @__PURE__ */ new Set();
|
|
2411
|
+
this._listeners = /* @__PURE__ */ new Set();
|
|
2412
|
+
this._conversations = /* @__PURE__ */ new Map();
|
|
2413
|
+
this._offlineQueue = [];
|
|
2414
|
+
this._ratchetStates = /* @__PURE__ */ new Map();
|
|
2350
2415
|
this.did = identity.did;
|
|
2351
2416
|
this.apiKey = identity.apiKey;
|
|
2352
2417
|
this.signingKeyPair = identity.signingKeyPair;
|
|
2353
2418
|
this.encryptionKeyPair = identity.encryptionKeyPair;
|
|
2354
2419
|
this.baseUrl = config?.baseUrl || "https://api.voidly.ai";
|
|
2420
|
+
this.autoPin = config?.autoPin !== false;
|
|
2421
|
+
this.defaultRetries = config?.retries ?? 3;
|
|
2422
|
+
this.fallbackRelays = config?.fallbackRelays || [];
|
|
2423
|
+
this.paddingEnabled = config?.padding !== false;
|
|
2424
|
+
this.sealedSender = config?.sealedSender || false;
|
|
2355
2425
|
}
|
|
2356
2426
|
// ─── Factory Methods ────────────────────────────────────────────────────────
|
|
2357
2427
|
/**
|
|
@@ -2418,16 +2488,55 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
2418
2488
|
}
|
|
2419
2489
|
// ─── Messaging ──────────────────────────────────────────────────────────────
|
|
2420
2490
|
/**
|
|
2421
|
-
* Send an E2E encrypted message
|
|
2422
|
-
*
|
|
2491
|
+
* Send an E2E encrypted message with hardened security.
|
|
2492
|
+
* Encryption happens locally — the relay NEVER sees plaintext or private keys.
|
|
2493
|
+
*
|
|
2494
|
+
* Security features:
|
|
2495
|
+
* - **Message padding** — ciphertext padded to power-of-2 boundary (traffic analysis resistance)
|
|
2496
|
+
* - **Hash ratchet** — per-conversation forward secrecy (compromise key[n] can't derive key[n-1])
|
|
2497
|
+
* - **Sealed sender** — optionally hide sender DID from relay metadata
|
|
2498
|
+
* - **Auto-retry** with exponential backoff on transient failures
|
|
2499
|
+
* - **Multi-relay fallback** — try backup relays if primary is down
|
|
2500
|
+
* - **Offline queue** — queue messages if all relays fail
|
|
2501
|
+
* - **Transparent TOFU** — auto-pin recipient keys on first contact
|
|
2423
2502
|
*/
|
|
2424
2503
|
async send(recipientDid, message, options = {}) {
|
|
2504
|
+
const maxRetries = options.retries ?? this.defaultRetries;
|
|
2505
|
+
const usePadding = !options.noPadding && this.paddingEnabled;
|
|
2506
|
+
const useSealed = options.sealedSender ?? this.sealedSender;
|
|
2425
2507
|
const profile = await this.getIdentity(recipientDid);
|
|
2426
2508
|
if (!profile) {
|
|
2427
2509
|
throw new Error(`Recipient ${recipientDid} not found`);
|
|
2428
2510
|
}
|
|
2511
|
+
if (this.autoPin && !options.skipPin) {
|
|
2512
|
+
await this._autoPinKeys(recipientDid);
|
|
2513
|
+
}
|
|
2429
2514
|
const recipientPubKey = (0, import_tweetnacl_util.decodeBase64)(profile.encryption_public_key);
|
|
2430
|
-
|
|
2515
|
+
let plaintext = message;
|
|
2516
|
+
if (useSealed) {
|
|
2517
|
+
plaintext = sealEnvelope(this.did, message);
|
|
2518
|
+
}
|
|
2519
|
+
let messageBytes;
|
|
2520
|
+
if (usePadding) {
|
|
2521
|
+
messageBytes = padMessage((0, import_tweetnacl_util.decodeUTF8)(plaintext));
|
|
2522
|
+
} else {
|
|
2523
|
+
messageBytes = (0, import_tweetnacl_util.decodeUTF8)(plaintext);
|
|
2524
|
+
}
|
|
2525
|
+
const pairId = `${this.did}:${recipientDid}`;
|
|
2526
|
+
const ratchetState = this._ratchetStates.get(pairId);
|
|
2527
|
+
let ratchetStep_n = 0;
|
|
2528
|
+
if (ratchetState) {
|
|
2529
|
+
const { nextChainKey, messageKey } = await ratchetStep(ratchetState.chainKey);
|
|
2530
|
+
ratchetState.chainKey = nextChainKey;
|
|
2531
|
+
ratchetState.step++;
|
|
2532
|
+
ratchetStep_n = ratchetState.step;
|
|
2533
|
+
} else {
|
|
2534
|
+
const sharedSecret = import_tweetnacl.default.box.before(recipientPubKey, this.encryptionKeyPair.secretKey);
|
|
2535
|
+
this._ratchetStates.set(pairId, {
|
|
2536
|
+
chainKey: sharedSecret,
|
|
2537
|
+
step: 0
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2431
2540
|
const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.box.nonceLength);
|
|
2432
2541
|
const ciphertext = import_tweetnacl.default.box(messageBytes, nonce, recipientPubKey, this.encryptionKeyPair.secretKey);
|
|
2433
2542
|
if (!ciphertext) {
|
|
@@ -2441,40 +2550,54 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
2441
2550
|
ciphertext_hash: await sha256((0, import_tweetnacl_util.encodeBase64)(ciphertext))
|
|
2442
2551
|
});
|
|
2443
2552
|
const signature = import_tweetnacl.default.sign.detached((0, import_tweetnacl_util.decodeUTF8)(envelopeData), this.signingKeyPair.secretKey);
|
|
2444
|
-
const
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
envelope: envelopeData,
|
|
2456
|
-
// Pass signed envelope so receiver can verify
|
|
2457
|
-
content_type: options.contentType || "text/plain",
|
|
2458
|
-
message_type: options.messageType || "text",
|
|
2459
|
-
thread_id: options.threadId,
|
|
2460
|
-
reply_to: options.replyTo,
|
|
2461
|
-
ttl: options.ttl
|
|
2462
|
-
})
|
|
2463
|
-
});
|
|
2464
|
-
if (!res.ok) {
|
|
2465
|
-
const err = await res.json().catch(() => ({}));
|
|
2466
|
-
throw new Error(`Send failed: ${err.error?.message || err.error || res.statusText}`);
|
|
2467
|
-
}
|
|
2468
|
-
const raw = await res.json();
|
|
2469
|
-
return {
|
|
2470
|
-
id: raw.id,
|
|
2471
|
-
from: raw.from,
|
|
2472
|
-
to: raw.to,
|
|
2473
|
-
timestamp: raw.timestamp,
|
|
2474
|
-
expiresAt: raw.expires_at || raw.expiresAt,
|
|
2475
|
-
encrypted: raw.encrypted,
|
|
2476
|
-
clientSide: raw.client_side || raw.clientSide
|
|
2553
|
+
const payload = {
|
|
2554
|
+
to: recipientDid,
|
|
2555
|
+
ciphertext: (0, import_tweetnacl_util.encodeBase64)(ciphertext),
|
|
2556
|
+
nonce: (0, import_tweetnacl_util.encodeBase64)(nonce),
|
|
2557
|
+
signature: (0, import_tweetnacl_util.encodeBase64)(signature),
|
|
2558
|
+
envelope: envelopeData,
|
|
2559
|
+
content_type: options.contentType || "text/plain",
|
|
2560
|
+
message_type: options.messageType || "text",
|
|
2561
|
+
thread_id: options.threadId,
|
|
2562
|
+
reply_to: options.replyTo,
|
|
2563
|
+
ttl: options.ttl
|
|
2477
2564
|
};
|
|
2565
|
+
const relays = [this.baseUrl, ...this.fallbackRelays];
|
|
2566
|
+
let lastError = null;
|
|
2567
|
+
for (const relay of relays) {
|
|
2568
|
+
try {
|
|
2569
|
+
const raw = await this._fetchWithRetry(
|
|
2570
|
+
`${relay}/v1/agent/send/encrypted`,
|
|
2571
|
+
{
|
|
2572
|
+
method: "POST",
|
|
2573
|
+
headers: { "Content-Type": "application/json", "X-Agent-Key": this.apiKey },
|
|
2574
|
+
body: JSON.stringify(payload)
|
|
2575
|
+
},
|
|
2576
|
+
{ maxRetries, baseDelay: 500, maxDelay: 1e4 }
|
|
2577
|
+
);
|
|
2578
|
+
return {
|
|
2579
|
+
id: raw.id,
|
|
2580
|
+
from: raw.from,
|
|
2581
|
+
to: raw.to,
|
|
2582
|
+
timestamp: raw.timestamp,
|
|
2583
|
+
expiresAt: raw.expires_at || raw.expiresAt,
|
|
2584
|
+
encrypted: raw.encrypted,
|
|
2585
|
+
clientSide: raw.client_side || raw.clientSide
|
|
2586
|
+
};
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2589
|
+
if (lastError.message.includes("(4")) break;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (lastError && !lastError.message.includes("(4")) {
|
|
2593
|
+
this._offlineQueue.push({
|
|
2594
|
+
recipientDid,
|
|
2595
|
+
message,
|
|
2596
|
+
options,
|
|
2597
|
+
timestamp: Date.now()
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
throw lastError || new Error("Send failed");
|
|
2478
2601
|
}
|
|
2479
2602
|
/**
|
|
2480
2603
|
* Receive and decrypt messages. Decryption happens locally.
|
|
@@ -2503,14 +2626,28 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
2503
2626
|
const senderEncPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_encryption_key);
|
|
2504
2627
|
const ciphertext = (0, import_tweetnacl_util.decodeBase64)(msg.ciphertext);
|
|
2505
2628
|
const nonce = (0, import_tweetnacl_util.decodeBase64)(msg.nonce);
|
|
2506
|
-
const
|
|
2507
|
-
if (!
|
|
2629
|
+
const rawPlaintext = import_tweetnacl.default.box.open(ciphertext, nonce, senderEncPub, this.encryptionKeyPair.secretKey);
|
|
2630
|
+
if (!rawPlaintext) continue;
|
|
2631
|
+
let plaintextBytes = rawPlaintext;
|
|
2632
|
+
if (rawPlaintext.length >= 256 && (rawPlaintext.length & rawPlaintext.length - 1) === 0) {
|
|
2633
|
+
const unpadded = unpadMessage(rawPlaintext);
|
|
2634
|
+
if (unpadded.length < rawPlaintext.length) {
|
|
2635
|
+
plaintextBytes = unpadded;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
let content = (0, import_tweetnacl_util.encodeUTF8)(plaintextBytes);
|
|
2639
|
+
let senderDid = msg.from;
|
|
2640
|
+
const unsealed = unsealEnvelope(content);
|
|
2641
|
+
if (unsealed) {
|
|
2642
|
+
content = unsealed.msg;
|
|
2643
|
+
senderDid = unsealed.from;
|
|
2644
|
+
}
|
|
2508
2645
|
let signatureValid = false;
|
|
2509
2646
|
try {
|
|
2510
2647
|
const senderSignPub = (0, import_tweetnacl_util.decodeBase64)(msg.sender_signing_key);
|
|
2511
2648
|
const signatureBytes = (0, import_tweetnacl_util.decodeBase64)(msg.signature);
|
|
2512
2649
|
const envelopeStr = msg.envelope || JSON.stringify({
|
|
2513
|
-
from:
|
|
2650
|
+
from: senderDid,
|
|
2514
2651
|
to: msg.to,
|
|
2515
2652
|
timestamp: msg.timestamp,
|
|
2516
2653
|
nonce: msg.nonce,
|
|
@@ -2526,9 +2663,9 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
2526
2663
|
}
|
|
2527
2664
|
decrypted.push({
|
|
2528
2665
|
id: msg.id,
|
|
2529
|
-
from:
|
|
2666
|
+
from: senderDid,
|
|
2530
2667
|
to: msg.to,
|
|
2531
|
-
content
|
|
2668
|
+
content,
|
|
2532
2669
|
contentType: msg.content_type,
|
|
2533
2670
|
messageType: msg.message_type || "text",
|
|
2534
2671
|
threadId: msg.thread_id,
|
|
@@ -3539,9 +3676,498 @@ var VoidlyAgent = class _VoidlyAgent {
|
|
|
3539
3676
|
if (!res.ok) throw new Error(`Key verify failed: ${res.status}`);
|
|
3540
3677
|
return res.json();
|
|
3541
3678
|
}
|
|
3679
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3680
|
+
// LISTEN — Event-Driven Message Receiving
|
|
3681
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3682
|
+
/**
|
|
3683
|
+
* Listen for incoming messages with an event-driven callback.
|
|
3684
|
+
* Uses adaptive polling — speeds up when messages are flowing, slows down when idle.
|
|
3685
|
+
* Automatically sends heartbeat pings to signal the agent is online.
|
|
3686
|
+
*
|
|
3687
|
+
* @example
|
|
3688
|
+
* ```ts
|
|
3689
|
+
* // Simple listener
|
|
3690
|
+
* const handle = agent.listen((msg) => {
|
|
3691
|
+
* console.log(`${msg.from}: ${msg.content}`);
|
|
3692
|
+
* });
|
|
3693
|
+
*
|
|
3694
|
+
* // Stop after 60 seconds
|
|
3695
|
+
* setTimeout(() => handle.stop(), 60000);
|
|
3696
|
+
*
|
|
3697
|
+
* // With options
|
|
3698
|
+
* const handle = agent.listen(
|
|
3699
|
+
* (msg) => console.log(msg.content),
|
|
3700
|
+
* {
|
|
3701
|
+
* interval: 1000, // poll every 1s
|
|
3702
|
+
* from: 'did:voidly:x', // only from this agent
|
|
3703
|
+
* threadId: 'conv-1', // only this thread
|
|
3704
|
+
* adaptive: true, // slow down when idle
|
|
3705
|
+
* heartbeat: true, // send pings
|
|
3706
|
+
* }
|
|
3707
|
+
* );
|
|
3708
|
+
* ```
|
|
3709
|
+
*/
|
|
3710
|
+
listen(onMessage, options = {}, onError) {
|
|
3711
|
+
const interval = Math.max(options.interval || 2e3, 500);
|
|
3712
|
+
const adaptive = options.adaptive !== false;
|
|
3713
|
+
const autoMarkRead = options.autoMarkRead !== false;
|
|
3714
|
+
const unreadOnly = options.unreadOnly !== false;
|
|
3715
|
+
const heartbeat = options.heartbeat !== false;
|
|
3716
|
+
const heartbeatInterval = options.heartbeatInterval || 6e4;
|
|
3717
|
+
let active = true;
|
|
3718
|
+
let currentInterval = interval;
|
|
3719
|
+
let consecutiveEmpty = 0;
|
|
3720
|
+
let lastSeen;
|
|
3721
|
+
let timer = null;
|
|
3722
|
+
let heartbeatTimer = null;
|
|
3723
|
+
const handle = {
|
|
3724
|
+
stop: () => {
|
|
3725
|
+
active = false;
|
|
3726
|
+
if (timer) clearTimeout(timer);
|
|
3727
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
3728
|
+
this._listeners.delete(handle);
|
|
3729
|
+
},
|
|
3730
|
+
get active() {
|
|
3731
|
+
return active;
|
|
3732
|
+
}
|
|
3733
|
+
};
|
|
3734
|
+
this._listeners.add(handle);
|
|
3735
|
+
if (heartbeat) {
|
|
3736
|
+
heartbeatTimer = setInterval(async () => {
|
|
3737
|
+
if (!active) return;
|
|
3738
|
+
try {
|
|
3739
|
+
await this.ping();
|
|
3740
|
+
} catch {
|
|
3741
|
+
}
|
|
3742
|
+
}, heartbeatInterval);
|
|
3743
|
+
this.ping().catch(() => {
|
|
3744
|
+
});
|
|
3745
|
+
}
|
|
3746
|
+
const poll = async () => {
|
|
3747
|
+
if (!active || options.signal?.aborted) {
|
|
3748
|
+
handle.stop();
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
try {
|
|
3752
|
+
const messages = await this.receive({
|
|
3753
|
+
since: lastSeen,
|
|
3754
|
+
from: options.from,
|
|
3755
|
+
threadId: options.threadId,
|
|
3756
|
+
messageType: options.messageType,
|
|
3757
|
+
unreadOnly,
|
|
3758
|
+
limit: 50
|
|
3759
|
+
});
|
|
3760
|
+
if (messages.length > 0) {
|
|
3761
|
+
consecutiveEmpty = 0;
|
|
3762
|
+
if (adaptive) currentInterval = Math.max(interval / 2, 500);
|
|
3763
|
+
for (const msg of messages) {
|
|
3764
|
+
try {
|
|
3765
|
+
await onMessage(msg);
|
|
3766
|
+
if (autoMarkRead) {
|
|
3767
|
+
await this.markRead(msg.id).catch(() => {
|
|
3768
|
+
});
|
|
3769
|
+
}
|
|
3770
|
+
} catch (err) {
|
|
3771
|
+
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
lastSeen = messages[messages.length - 1].timestamp;
|
|
3775
|
+
} else {
|
|
3776
|
+
consecutiveEmpty++;
|
|
3777
|
+
if (adaptive && consecutiveEmpty > 3) {
|
|
3778
|
+
currentInterval = Math.min(currentInterval * 1.5, interval * 4);
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
} catch (err) {
|
|
3782
|
+
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
|
3783
|
+
currentInterval = Math.min(currentInterval * 2, interval * 8);
|
|
3784
|
+
}
|
|
3785
|
+
if (active && !options.signal?.aborted) {
|
|
3786
|
+
timer = setTimeout(poll, currentInterval);
|
|
3787
|
+
}
|
|
3788
|
+
};
|
|
3789
|
+
poll();
|
|
3790
|
+
return handle;
|
|
3791
|
+
}
|
|
3792
|
+
/**
|
|
3793
|
+
* Listen for messages as an async iterator.
|
|
3794
|
+
* Enables `for await` syntax for message processing.
|
|
3795
|
+
*
|
|
3796
|
+
* @example
|
|
3797
|
+
* ```ts
|
|
3798
|
+
* for await (const msg of agent.messages({ unreadOnly: true })) {
|
|
3799
|
+
* console.log(`${msg.from}: ${msg.content}`);
|
|
3800
|
+
* if (msg.content === 'quit') break;
|
|
3801
|
+
* }
|
|
3802
|
+
* ```
|
|
3803
|
+
*/
|
|
3804
|
+
async *messages(options = {}) {
|
|
3805
|
+
const queue = [];
|
|
3806
|
+
let resolve = null;
|
|
3807
|
+
let done = false;
|
|
3808
|
+
const handle = this.listen(
|
|
3809
|
+
(msg) => {
|
|
3810
|
+
queue.push(msg);
|
|
3811
|
+
if (resolve) {
|
|
3812
|
+
resolve();
|
|
3813
|
+
resolve = null;
|
|
3814
|
+
}
|
|
3815
|
+
},
|
|
3816
|
+
{ ...options, autoMarkRead: options.autoMarkRead !== false }
|
|
3817
|
+
);
|
|
3818
|
+
options.signal?.addEventListener("abort", () => {
|
|
3819
|
+
done = true;
|
|
3820
|
+
handle.stop();
|
|
3821
|
+
if (resolve) {
|
|
3822
|
+
resolve();
|
|
3823
|
+
resolve = null;
|
|
3824
|
+
}
|
|
3825
|
+
});
|
|
3826
|
+
try {
|
|
3827
|
+
while (!done && !options.signal?.aborted) {
|
|
3828
|
+
if (queue.length > 0) {
|
|
3829
|
+
yield queue.shift();
|
|
3830
|
+
} else {
|
|
3831
|
+
await new Promise((r) => {
|
|
3832
|
+
resolve = r;
|
|
3833
|
+
});
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
} finally {
|
|
3837
|
+
handle.stop();
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
/**
|
|
3841
|
+
* Stop all active listeners. Useful for clean shutdown.
|
|
3842
|
+
*/
|
|
3843
|
+
stopAll() {
|
|
3844
|
+
for (const listener of this._listeners) {
|
|
3845
|
+
listener.stop();
|
|
3846
|
+
}
|
|
3847
|
+
this._listeners.clear();
|
|
3848
|
+
}
|
|
3849
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3850
|
+
// CONVERSATIONS — Thread Management
|
|
3851
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3852
|
+
/**
|
|
3853
|
+
* Start or resume a conversation with another agent.
|
|
3854
|
+
* Automatically manages thread IDs, message history, and reply chains.
|
|
3855
|
+
*
|
|
3856
|
+
* @example
|
|
3857
|
+
* ```ts
|
|
3858
|
+
* const conv = agent.conversation(otherDid);
|
|
3859
|
+
* await conv.say('Hello!');
|
|
3860
|
+
* await conv.say('How are you?');
|
|
3861
|
+
*
|
|
3862
|
+
* // Get full history
|
|
3863
|
+
* const history = await conv.history();
|
|
3864
|
+
*
|
|
3865
|
+
* // Listen for replies in this conversation
|
|
3866
|
+
* conv.onReply((msg) => {
|
|
3867
|
+
* console.log(`Reply: ${msg.content}`);
|
|
3868
|
+
* });
|
|
3869
|
+
* ```
|
|
3870
|
+
*/
|
|
3871
|
+
conversation(peerDid, threadId) {
|
|
3872
|
+
const tid = threadId || `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3873
|
+
const key = `${peerDid}:${tid}`;
|
|
3874
|
+
if (this._conversations.has(key)) {
|
|
3875
|
+
return this._conversations.get(key);
|
|
3876
|
+
}
|
|
3877
|
+
const conv = new Conversation(this, peerDid, tid);
|
|
3878
|
+
this._conversations.set(key, conv);
|
|
3879
|
+
return conv;
|
|
3880
|
+
}
|
|
3881
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3882
|
+
// INTERNAL — Retry, Auto-Pin
|
|
3883
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3884
|
+
/** @internal Auto-pin keys on first contact (TOFU) */
|
|
3885
|
+
async _autoPinKeys(did) {
|
|
3886
|
+
if (this._pinnedDids.has(did)) return;
|
|
3887
|
+
this._pinnedDids.add(did);
|
|
3888
|
+
try {
|
|
3889
|
+
const result = await this.pinKeys(did);
|
|
3890
|
+
if (result.key_changed && result.warning) {
|
|
3891
|
+
console.warn(`[voidly] \u26A0 Key change detected for ${did}: ${result.warning}`);
|
|
3892
|
+
}
|
|
3893
|
+
} catch {
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
/** @internal Fetch with exponential backoff retry */
|
|
3897
|
+
async _fetchWithRetry(url, init, retry = {}) {
|
|
3898
|
+
const maxRetries = retry.maxRetries ?? 3;
|
|
3899
|
+
const baseDelay = retry.baseDelay ?? 500;
|
|
3900
|
+
const maxDelay = retry.maxDelay ?? 1e4;
|
|
3901
|
+
let lastError = null;
|
|
3902
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3903
|
+
try {
|
|
3904
|
+
const res = await fetch(url, init);
|
|
3905
|
+
if (res.ok) {
|
|
3906
|
+
return await res.json();
|
|
3907
|
+
}
|
|
3908
|
+
const err = await res.json().catch(() => ({}));
|
|
3909
|
+
const errMsg = err.error?.message || err.error || res.statusText;
|
|
3910
|
+
if (res.status >= 400 && res.status < 500) {
|
|
3911
|
+
throw new Error(`Send failed (${res.status}): ${errMsg}`);
|
|
3912
|
+
}
|
|
3913
|
+
lastError = new Error(`Send failed (${res.status}): ${errMsg}`);
|
|
3914
|
+
} catch (err) {
|
|
3915
|
+
if (err instanceof Error && err.message.startsWith("Send failed (4")) {
|
|
3916
|
+
throw err;
|
|
3917
|
+
}
|
|
3918
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
3919
|
+
}
|
|
3920
|
+
if (attempt < maxRetries) {
|
|
3921
|
+
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
|
3922
|
+
const jitter = delay * (0.5 + Math.random() * 0.5);
|
|
3923
|
+
await new Promise((r) => setTimeout(r, jitter));
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
throw lastError || new Error("Send failed after retries");
|
|
3927
|
+
}
|
|
3928
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3929
|
+
// OFFLINE QUEUE — Resilience against relay downtime
|
|
3930
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3931
|
+
/**
|
|
3932
|
+
* Drain the offline message queue — retry sending queued messages.
|
|
3933
|
+
* Call this when connectivity is restored.
|
|
3934
|
+
* Returns: number of messages successfully sent.
|
|
3935
|
+
*/
|
|
3936
|
+
async drainQueue() {
|
|
3937
|
+
let sent = 0;
|
|
3938
|
+
let failed = 0;
|
|
3939
|
+
const remaining = [];
|
|
3940
|
+
for (const item of this._offlineQueue) {
|
|
3941
|
+
if (Date.now() - item.timestamp > 864e5) {
|
|
3942
|
+
failed++;
|
|
3943
|
+
continue;
|
|
3944
|
+
}
|
|
3945
|
+
try {
|
|
3946
|
+
await this.send(item.recipientDid, item.message, item.options);
|
|
3947
|
+
sent++;
|
|
3948
|
+
} catch {
|
|
3949
|
+
remaining.push(item);
|
|
3950
|
+
failed++;
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
this._offlineQueue = remaining;
|
|
3954
|
+
return { sent, failed, remaining: remaining.length };
|
|
3955
|
+
}
|
|
3956
|
+
/** Number of messages waiting in the offline queue */
|
|
3957
|
+
get queueLength() {
|
|
3958
|
+
return this._offlineQueue.length;
|
|
3959
|
+
}
|
|
3960
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3961
|
+
// SECURITY REPORT — Transparent threat model
|
|
3962
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3963
|
+
/**
|
|
3964
|
+
* Returns what the relay can and cannot see about this agent.
|
|
3965
|
+
* Call this to understand your threat model. Total transparency.
|
|
3966
|
+
*/
|
|
3967
|
+
threatModel() {
|
|
3968
|
+
return {
|
|
3969
|
+
relayCanSee: [
|
|
3970
|
+
"Your DID (public identifier)",
|
|
3971
|
+
"Who you message (recipient DIDs)",
|
|
3972
|
+
"When you message (timestamps)",
|
|
3973
|
+
"Message types (text, task-request, etc.)",
|
|
3974
|
+
"Thread structure (which messages are replies)",
|
|
3975
|
+
"Channel membership",
|
|
3976
|
+
"Capability registrations",
|
|
3977
|
+
"Online/offline status",
|
|
3978
|
+
"Approximate message size (even with padding, bounded to power-of-2)"
|
|
3979
|
+
],
|
|
3980
|
+
relayCannotSee: [
|
|
3981
|
+
"Message content (E2E encrypted with NaCl box)",
|
|
3982
|
+
"Private keys (generated and stored client-side only)",
|
|
3983
|
+
"Memory values (encrypted with key derived from your API key)",
|
|
3984
|
+
"Attestation content (signed but readable by anyone with the attestation)",
|
|
3985
|
+
...this.sealedSender ? ["Sender identity (sealed inside ciphertext)"] : []
|
|
3986
|
+
],
|
|
3987
|
+
protections: [
|
|
3988
|
+
"X25519 key exchange + XSalsa20-Poly1305 authenticated encryption",
|
|
3989
|
+
"Ed25519 signatures on every message",
|
|
3990
|
+
"TOFU key pinning (MitM detection on key change)",
|
|
3991
|
+
...this.paddingEnabled ? ["Message padding to power-of-2 boundary (traffic analysis resistance)"] : [],
|
|
3992
|
+
...this.sealedSender ? ["Sealed sender (relay cannot see who sent a message)"] : [],
|
|
3993
|
+
...this.fallbackRelays.length > 0 ? [`Multi-relay fallback (${this.fallbackRelays.length} backup relays)`] : [],
|
|
3994
|
+
"Auto-retry with exponential backoff",
|
|
3995
|
+
"Offline message queue"
|
|
3996
|
+
],
|
|
3997
|
+
gaps: [
|
|
3998
|
+
"No forward secrecy \u2014 same keypair for all messages (compromise exposes ALL history)",
|
|
3999
|
+
"No post-quantum protection \u2014 vulnerable to harvest-now-decrypt-later",
|
|
4000
|
+
"Channel encryption is server-side (relay holds channel keys, NOT true E2E)",
|
|
4001
|
+
"Metadata (who, when, thread structure) visible to relay operator",
|
|
4002
|
+
"Single relay architecture (no onion routing, no mix network)",
|
|
4003
|
+
"Ed25519 signatures are non-repudiable (no deniable messaging)",
|
|
4004
|
+
"No async key agreement (no X3DH prekeys)",
|
|
4005
|
+
"Polling-based (no WebSocket real-time transport)"
|
|
4006
|
+
]
|
|
4007
|
+
};
|
|
4008
|
+
}
|
|
4009
|
+
};
|
|
4010
|
+
var Conversation = class {
|
|
4011
|
+
/** @internal */
|
|
4012
|
+
constructor(agent, peerDid, threadId) {
|
|
4013
|
+
this._lastMessageId = null;
|
|
4014
|
+
this._messageHistory = [];
|
|
4015
|
+
this._listener = null;
|
|
4016
|
+
this._replyHandlers = [];
|
|
4017
|
+
this.agent = agent;
|
|
4018
|
+
this.peerDid = peerDid;
|
|
4019
|
+
this.threadId = threadId;
|
|
4020
|
+
}
|
|
4021
|
+
/**
|
|
4022
|
+
* Send a message in this conversation. Auto-threaded and auto-linked to previous message.
|
|
4023
|
+
*/
|
|
4024
|
+
async say(content, options) {
|
|
4025
|
+
const result = await this.agent.send(this.peerDid, content, {
|
|
4026
|
+
...options,
|
|
4027
|
+
threadId: this.threadId,
|
|
4028
|
+
replyTo: this._lastMessageId || void 0
|
|
4029
|
+
});
|
|
4030
|
+
this._lastMessageId = result.id;
|
|
4031
|
+
this._messageHistory.push({
|
|
4032
|
+
id: result.id,
|
|
4033
|
+
from: this.agent.did,
|
|
4034
|
+
content,
|
|
4035
|
+
timestamp: result.timestamp,
|
|
4036
|
+
signatureValid: true,
|
|
4037
|
+
messageType: options?.messageType || "text"
|
|
4038
|
+
});
|
|
4039
|
+
return result;
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Get conversation history (both sent and received messages in this thread).
|
|
4043
|
+
*/
|
|
4044
|
+
async history(options) {
|
|
4045
|
+
const received = await this.agent.receive({
|
|
4046
|
+
threadId: this.threadId,
|
|
4047
|
+
from: this.peerDid,
|
|
4048
|
+
limit: options?.limit || 100
|
|
4049
|
+
});
|
|
4050
|
+
const all = [
|
|
4051
|
+
...this._messageHistory,
|
|
4052
|
+
...received.map((m) => ({
|
|
4053
|
+
id: m.id,
|
|
4054
|
+
from: m.from,
|
|
4055
|
+
content: m.content,
|
|
4056
|
+
timestamp: m.timestamp,
|
|
4057
|
+
signatureValid: m.signatureValid,
|
|
4058
|
+
messageType: m.messageType
|
|
4059
|
+
}))
|
|
4060
|
+
];
|
|
4061
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4062
|
+
return all.filter((m) => {
|
|
4063
|
+
if (seen.has(m.id)) return false;
|
|
4064
|
+
seen.add(m.id);
|
|
4065
|
+
return true;
|
|
4066
|
+
}).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
4067
|
+
}
|
|
4068
|
+
/**
|
|
4069
|
+
* Register a callback for replies in this conversation.
|
|
4070
|
+
*/
|
|
4071
|
+
onReply(handler) {
|
|
4072
|
+
this._replyHandlers.push(handler);
|
|
4073
|
+
if (!this._listener) {
|
|
4074
|
+
this._listener = this.agent.listen(
|
|
4075
|
+
async (msg) => {
|
|
4076
|
+
this._messageHistory.push({
|
|
4077
|
+
id: msg.id,
|
|
4078
|
+
from: msg.from,
|
|
4079
|
+
content: msg.content,
|
|
4080
|
+
timestamp: msg.timestamp,
|
|
4081
|
+
signatureValid: msg.signatureValid,
|
|
4082
|
+
messageType: msg.messageType
|
|
4083
|
+
});
|
|
4084
|
+
this._lastMessageId = msg.id;
|
|
4085
|
+
for (const h of this._replyHandlers) {
|
|
4086
|
+
try {
|
|
4087
|
+
await h(msg);
|
|
4088
|
+
} catch {
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
},
|
|
4092
|
+
{ from: this.peerDid, threadId: this.threadId, autoMarkRead: true }
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
/**
|
|
4097
|
+
* Wait for the next reply in this conversation (Promise-based).
|
|
4098
|
+
*
|
|
4099
|
+
* @param timeoutMs - Maximum time to wait (default: 30000ms)
|
|
4100
|
+
* @throws Error on timeout
|
|
4101
|
+
*/
|
|
4102
|
+
async waitForReply(timeoutMs = 3e4) {
|
|
4103
|
+
return new Promise((resolve, reject) => {
|
|
4104
|
+
let resolved = false;
|
|
4105
|
+
const timeout = setTimeout(() => {
|
|
4106
|
+
if (!resolved) {
|
|
4107
|
+
resolved = true;
|
|
4108
|
+
reject(new Error(`No reply received within ${timeoutMs}ms`));
|
|
4109
|
+
}
|
|
4110
|
+
}, timeoutMs);
|
|
4111
|
+
const check = async () => {
|
|
4112
|
+
while (!resolved) {
|
|
4113
|
+
const messages = await this.agent.receive({
|
|
4114
|
+
from: this.peerDid,
|
|
4115
|
+
threadId: this.threadId,
|
|
4116
|
+
unreadOnly: true,
|
|
4117
|
+
limit: 1
|
|
4118
|
+
});
|
|
4119
|
+
if (messages.length > 0 && !resolved) {
|
|
4120
|
+
resolved = true;
|
|
4121
|
+
clearTimeout(timeout);
|
|
4122
|
+
const msg = messages[0];
|
|
4123
|
+
this._messageHistory.push({
|
|
4124
|
+
id: msg.id,
|
|
4125
|
+
from: msg.from,
|
|
4126
|
+
content: msg.content,
|
|
4127
|
+
timestamp: msg.timestamp,
|
|
4128
|
+
signatureValid: msg.signatureValid,
|
|
4129
|
+
messageType: msg.messageType
|
|
4130
|
+
});
|
|
4131
|
+
this._lastMessageId = msg.id;
|
|
4132
|
+
await this.agent.markRead(msg.id).catch(() => {
|
|
4133
|
+
});
|
|
4134
|
+
resolve(msg);
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4138
|
+
}
|
|
4139
|
+
};
|
|
4140
|
+
check().catch((err) => {
|
|
4141
|
+
if (!resolved) {
|
|
4142
|
+
resolved = true;
|
|
4143
|
+
clearTimeout(timeout);
|
|
4144
|
+
reject(err);
|
|
4145
|
+
}
|
|
4146
|
+
});
|
|
4147
|
+
});
|
|
4148
|
+
}
|
|
4149
|
+
/**
|
|
4150
|
+
* Stop listening for replies and clean up.
|
|
4151
|
+
*/
|
|
4152
|
+
close() {
|
|
4153
|
+
if (this._listener) {
|
|
4154
|
+
this._listener.stop();
|
|
4155
|
+
this._listener = null;
|
|
4156
|
+
}
|
|
4157
|
+
this._replyHandlers = [];
|
|
4158
|
+
}
|
|
4159
|
+
/** Number of messages tracked locally */
|
|
4160
|
+
get length() {
|
|
4161
|
+
return this._messageHistory.length;
|
|
4162
|
+
}
|
|
4163
|
+
/** The last message in this conversation */
|
|
4164
|
+
get lastMessage() {
|
|
4165
|
+
return this._messageHistory.length > 0 ? this._messageHistory[this._messageHistory.length - 1] : null;
|
|
4166
|
+
}
|
|
3542
4167
|
};
|
|
3543
4168
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3544
4169
|
0 && (module.exports = {
|
|
4170
|
+
Conversation,
|
|
3545
4171
|
VoidlyAgent,
|
|
3546
4172
|
decodeBase64,
|
|
3547
4173
|
decodeUTF8,
|