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