@claw-network/node 0.2.2 → 0.4.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.
@@ -0,0 +1,959 @@
1
+ /**
2
+ * MessagingService — orchestrates P2P direct messaging.
3
+ *
4
+ * Responsibilities:
5
+ * - Send messages to target DIDs via libp2p stream protocol
6
+ * - Receive inbound messages and store in inbox
7
+ * - Queue messages for offline peers in outbox, deliver on reconnect
8
+ * - Maintain DID → PeerId mapping via announce protocol
9
+ * - Periodic TTL cleanup of expired messages
10
+ */
11
+ import { generateX25519Keypair, x25519SharedSecret, hkdfSha256, encryptAes256Gcm, decryptAes256Gcm, bytesToHex, hexToBytes, } from '@claw-network/core';
12
+ import { encodeDirectMessageBytes, decodeDirectMessageBytes, encodeDeliveryReceiptBytes, decodeDeliveryReceiptBytes, encodeDidAnnounceBytes, decodeDidAnnounceBytes, encodeDidResolveRequestBytes, decodeDidResolveRequestBytes, encodeDidResolveResponseBytes, decodeDidResolveResponseBytes, encodeE2EEnvelope, decodeE2EEnvelope, } from '@claw-network/protocol/messaging';
13
+ import { createLogger } from '../logger.js';
14
+ import { gzipSync, gunzipSync } from 'node:zlib';
15
+ // ── Constants ────────────────────────────────────────────────────
16
+ const PROTO_DM = '/clawnet/1.0.0/dm';
17
+ const PROTO_DID_ANNOUNCE = '/clawnet/1.0.0/did-announce';
18
+ const PROTO_RECEIPT = '/clawnet/1.0.0/receipt';
19
+ const PROTO_DID_RESOLVE = '/clawnet/1.0.0/did-resolve';
20
+ /** Timeout for DID resolve queries (ms). */
21
+ const DID_RESOLVE_TIMEOUT_MS = 5_000;
22
+ /** Maximum number of peers to query in parallel for DID resolve. */
23
+ const DID_RESOLVE_MAX_PEERS = 3;
24
+ /** DID→PeerId mapping TTL: re-resolve after 30 minutes to handle stale mappings. */
25
+ const DID_PEER_TTL_MS = 30 * 60_000;
26
+ /** Maximum payload size in bytes (64 KB). */
27
+ const MAX_PAYLOAD_BYTES = 65_536;
28
+ /** Cleanup interval for expired messages (5 minutes). */
29
+ const CLEANUP_INTERVAL_MS = 5 * 60_000;
30
+ /** Max attempts before giving up on an outbox message. */
31
+ const MAX_DELIVERY_ATTEMPTS = 50;
32
+ /** Rate limit: max messages per DID per minute. */
33
+ const RATE_LIMIT_PER_MIN = 600;
34
+ /** Rate limit window in milliseconds (1 minute). */
35
+ const RATE_LIMIT_WINDOW_MS = 60_000;
36
+ /** Inbound P2P rate limit: max inbound messages per peer per minute. */
37
+ const INBOUND_RATE_LIMIT = 300;
38
+ /** Global inbound rate limit: max total inbound messages per minute (all peers combined). */
39
+ const GLOBAL_INBOUND_RATE_LIMIT = 3000;
40
+ /** Stream read timeout in milliseconds — abort slow/stalled streams. */
41
+ const STREAM_READ_TIMEOUT_MS = 10_000;
42
+ /** Maximum concurrency for multicast delivery. */
43
+ const MULTICAST_CONCURRENCY = 20;
44
+ /** Base delay for exponential backoff in outbox retry (ms). */
45
+ const OUTBOX_RETRY_BASE_MS = 1_000;
46
+ /** Maximum backoff delay for outbox retry (ms). */
47
+ const OUTBOX_RETRY_MAX_MS = 60_000;
48
+ /** Valid DID format: did:claw:<multibase-base58btc-encoded-key>. */
49
+ const DID_PATTERN = /^did:claw:z[1-9A-HJ-NP-Za-km-z]{32,64}$/;
50
+ /** Payload size threshold for automatic gzip compression (1 KB). */
51
+ const COMPRESSION_THRESHOLD_BYTES = 1024;
52
+ /** HKDF info tag for E2E messaging encryption. */
53
+ const E2E_MSG_INFO = Buffer.from('clawnet:e2e-msg:v1', 'utf-8');
54
+ /** Topic used for delivery receipt notifications via WebSocket. */
55
+ export const RECEIPT_TOPIC = '_receipt';
56
+ /** Priority levels — higher number = higher priority. */
57
+ export var MessagePriority;
58
+ (function (MessagePriority) {
59
+ MessagePriority[MessagePriority["LOW"] = 0] = "LOW";
60
+ MessagePriority[MessagePriority["NORMAL"] = 1] = "NORMAL";
61
+ MessagePriority[MessagePriority["HIGH"] = 2] = "HIGH";
62
+ MessagePriority[MessagePriority["URGENT"] = 3] = "URGENT";
63
+ })(MessagePriority || (MessagePriority = {}));
64
+ // ── Helpers ──────────────────────────────────────────────────────
65
+ /** Read all data from a stream source into a single Buffer, enforcing a size limit and timeout. */
66
+ async function readStream(source, maxBytes = MAX_PAYLOAD_BYTES * 2, timeoutMs = STREAM_READ_TIMEOUT_MS) {
67
+ const chunks = [];
68
+ let total = 0;
69
+ const ac = new AbortController();
70
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
71
+ try {
72
+ for await (const chunk of source) {
73
+ if (ac.signal.aborted)
74
+ throw new Error(`Stream read timed out after ${timeoutMs}ms`);
75
+ const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
76
+ total += bytes.length;
77
+ if (total > maxBytes) {
78
+ throw new Error(`Stream exceeded size limit: ${total} > ${maxBytes}`);
79
+ }
80
+ chunks.push(Buffer.from(bytes));
81
+ }
82
+ }
83
+ finally {
84
+ clearTimeout(timer);
85
+ }
86
+ return Buffer.concat(chunks);
87
+ }
88
+ /** Write raw binary data to a stream sink. */
89
+ async function writeBinaryStream(sink, data) {
90
+ await sink((async function* () {
91
+ yield data;
92
+ })());
93
+ }
94
+ // ── Service ──────────────────────────────────────────────────────
95
+ export class MessagingService {
96
+ log;
97
+ store;
98
+ p2p;
99
+ localDid;
100
+ cleanupTimer;
101
+ /**
102
+ * DID → PeerId mapping. Populated via the did-announce protocol when
103
+ * peers connect. Persisted to SQLite and restored on startup.
104
+ */
105
+ didToPeerId = new Map();
106
+ peerIdToDid = new Map();
107
+ /** Tracks when each DID→PeerId mapping was last confirmed (for TTL-based re-resolve). */
108
+ didPeerUpdatedAt = new Map();
109
+ /** WebSocket subscribers that receive real-time inbox pushes. */
110
+ subscribers = new Set();
111
+ constructor(p2p, store, localDid) {
112
+ this.log = createLogger({ level: 'info' });
113
+ this.p2p = p2p;
114
+ this.store = store;
115
+ this.localDid = localDid;
116
+ }
117
+ // ── Lifecycle ──────────────────────────────────────────────────
118
+ async start() {
119
+ // Restore persisted DID→PeerId mappings from SQLite
120
+ for (const { did, peerId, updatedAtMs } of this.store.getAllDidPeers()) {
121
+ this.didToPeerId.set(did, peerId);
122
+ this.peerIdToDid.set(peerId, did);
123
+ this.didPeerUpdatedAt.set(did, updatedAtMs);
124
+ }
125
+ this.log.info('[messaging] restored DID mappings', { count: this.didToPeerId.size });
126
+ // Register stream protocol handlers with per-protocol inbound stream limits
127
+ await this.p2p.handleProtocol(PROTO_DM, (incoming) => {
128
+ void this.handleInboundMessage(incoming);
129
+ }, { maxInboundStreams: 256 });
130
+ await this.p2p.handleProtocol(PROTO_DID_ANNOUNCE, (incoming) => {
131
+ void this.handleDidAnnounce(incoming);
132
+ }, { maxInboundStreams: 64 });
133
+ await this.p2p.handleProtocol(PROTO_RECEIPT, (incoming) => {
134
+ void this.handleDeliveryReceipt(incoming);
135
+ }, { maxInboundStreams: 64 });
136
+ await this.p2p.handleProtocol(PROTO_DID_RESOLVE, (incoming) => {
137
+ void this.handleDidResolve(incoming);
138
+ }, { maxInboundStreams: 128 });
139
+ // When a new peer connects, exchange DID announcements
140
+ this.p2p.onPeerDisconnect(() => {
141
+ // No-op for now; outbox delivery is handled via flush on connect.
142
+ });
143
+ // Announce our DID to all currently connected peers
144
+ void this.announceToAll();
145
+ // Periodic cleanup of expired messages and stale rate-limit entries
146
+ this.cleanupTimer = setInterval(() => {
147
+ try {
148
+ this.store.cleanupInbox();
149
+ this.store.cleanupOutbox();
150
+ this.store.pruneRateEvents(Date.now() - RATE_LIMIT_WINDOW_MS);
151
+ }
152
+ catch {
153
+ /* best-effort */
154
+ }
155
+ }, CLEANUP_INTERVAL_MS);
156
+ this.log.info('[messaging] service started', { localDid: this.localDid });
157
+ }
158
+ async stop() {
159
+ if (this.cleanupTimer) {
160
+ clearInterval(this.cleanupTimer);
161
+ this.cleanupTimer = undefined;
162
+ }
163
+ try {
164
+ await this.p2p.unhandleProtocol(PROTO_DM);
165
+ }
166
+ catch { /* ignore */ }
167
+ try {
168
+ await this.p2p.unhandleProtocol(PROTO_DID_ANNOUNCE);
169
+ }
170
+ catch { /* ignore */ }
171
+ try {
172
+ await this.p2p.unhandleProtocol(PROTO_RECEIPT);
173
+ }
174
+ catch { /* ignore */ }
175
+ try {
176
+ await this.p2p.unhandleProtocol(PROTO_DID_RESOLVE);
177
+ }
178
+ catch { /* ignore */ }
179
+ this.subscribers.clear();
180
+ }
181
+ // ── Public API ─────────────────────────────────────────────────
182
+ /**
183
+ * Send a message to a target DID.
184
+ * If the target peer is online and reachable, delivers directly.
185
+ * Otherwise queues in outbox for later delivery.
186
+ */
187
+ async send(targetDid, topic, payload, opts = {}) {
188
+ const ttlSec = opts.ttlSec ?? 86400;
189
+ const priority = opts.priority ?? MessagePriority.NORMAL;
190
+ // Rate limit check
191
+ this.enforceRateLimit(this.localDid);
192
+ // Apply compression + encryption to payload
193
+ const { payloadBytes, storagePayload, compressed, encrypted } = this.encodePayload(payload, opts);
194
+ // Validate payload size after encoding
195
+ if (payloadBytes.length > MAX_PAYLOAD_BYTES) {
196
+ throw new Error(`Payload too large: ${payloadBytes.length} bytes (max ${MAX_PAYLOAD_BYTES})`);
197
+ }
198
+ const peerId = this.didToPeerId.get(targetDid);
199
+ if (peerId) {
200
+ // Try direct delivery
201
+ const delivered = await this.deliverDirect(peerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
202
+ if (delivered) {
203
+ return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
204
+ }
205
+ // Delivery failed — if mapping is stale, try re-resolving
206
+ if (this.isStalePeerMapping(targetDid)) {
207
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
208
+ if (resolvedPeerId && resolvedPeerId !== peerId) {
209
+ this.registerDidPeer(targetDid, resolvedPeerId);
210
+ try {
211
+ await this.p2p.dialPeer(resolvedPeerId);
212
+ }
213
+ catch { /* ignore */ }
214
+ const reDelivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
215
+ if (reDelivered) {
216
+ return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
217
+ }
218
+ }
219
+ }
220
+ }
221
+ // DID unknown locally — ask connected peers (bootstrap/others) to resolve
222
+ if (!peerId) {
223
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
224
+ if (resolvedPeerId) {
225
+ this.registerDidPeer(targetDid, resolvedPeerId);
226
+ try {
227
+ await this.p2p.dialPeer(resolvedPeerId);
228
+ }
229
+ catch { /* peer may already be connected or unreachable */ }
230
+ const delivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
231
+ if (delivered) {
232
+ return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
233
+ }
234
+ }
235
+ }
236
+ // Queue in outbox for later delivery (uses string storage format)
237
+ const messageId = this.store.addToOutbox({ targetDid, topic, payload: storagePayload, ttlSec, priority });
238
+ this.log.info('message queued in outbox', { messageId, targetDid, topic });
239
+ return { messageId, delivered: false, compressed, encrypted };
240
+ }
241
+ /**
242
+ * Send a message to multiple target DIDs (multicast).
243
+ * Each target is attempted independently — partial success is possible.
244
+ */
245
+ async sendMulticast(targetDids, topic, payload, opts = {}) {
246
+ const ttlSec = opts.ttlSec ?? 86400;
247
+ const priority = opts.priority ?? MessagePriority.NORMAL;
248
+ // Rate limit check (counts as 1 call for rate-limit purposes)
249
+ this.enforceRateLimit(this.localDid);
250
+ // Pre-encode a shared payload (without per-recipient encryption)
251
+ const { payloadBytes: sharedPayloadBytes, storagePayload: sharedStoragePayload, compressed } = this.encodePayload(payload, { ...opts, encryptForKeyHex: undefined });
252
+ if (sharedPayloadBytes.length > MAX_PAYLOAD_BYTES) {
253
+ throw new Error(`Payload too large: ${sharedPayloadBytes.length} bytes (max ${MAX_PAYLOAD_BYTES})`);
254
+ }
255
+ // Deliver to all targets concurrently with bounded concurrency
256
+ // Per-recipient E2E encryption is applied inside deliverMulticast when recipientKeys are provided
257
+ const results = await this.deliverMulticast(targetDids, topic, sharedPayloadBytes, sharedStoragePayload, ttlSec, priority, compressed, opts.recipientKeys, opts.idempotencyKey);
258
+ return { results };
259
+ }
260
+ /** Query the local inbox. */
261
+ getInbox(opts) {
262
+ return this.store.getInbox(opts);
263
+ }
264
+ /** Acknowledge (consume) a message from inbox. */
265
+ ackMessage(messageId) {
266
+ return this.store.consumeMessage(messageId);
267
+ }
268
+ /** Flush outbox: attempt to deliver all pending messages for a specific DID with bounded concurrency. */
269
+ async flushOutboxForDid(targetDid) {
270
+ const peerId = this.didToPeerId.get(targetDid);
271
+ if (!peerId)
272
+ return 0;
273
+ const entries = this.store.getOutboxForTarget(targetDid);
274
+ const now = Date.now();
275
+ // Pre-filter: separate eligible entries from those still in backoff or over limit
276
+ const eligible = [];
277
+ for (const entry of entries) {
278
+ if (entry.attempts > MAX_DELIVERY_ATTEMPTS) {
279
+ this.store.removeFromOutbox(entry.id);
280
+ continue;
281
+ }
282
+ const backoff = Math.min(OUTBOX_RETRY_BASE_MS * (2 ** entry.attempts), OUTBOX_RETRY_MAX_MS);
283
+ const lastAttempt = entry.lastAttempt ?? 0;
284
+ if (lastAttempt > 0 && now - lastAttempt < backoff) {
285
+ continue;
286
+ }
287
+ eligible.push(entry);
288
+ }
289
+ // Deliver in batches of MULTICAST_CONCURRENCY using Promise.allSettled
290
+ let delivered = 0;
291
+ for (let i = 0; i < eligible.length; i += MULTICAST_CONCURRENCY) {
292
+ const batch = eligible.slice(i, i + MULTICAST_CONCURRENCY);
293
+ const settled = await Promise.allSettled(batch.map(async (entry) => {
294
+ this.store.recordAttempt(entry.id);
295
+ // Outbox stores storagePayload (string); convert to bytes for wire delivery
296
+ const ok = await this.deliverDirect(peerId, targetDid, entry.topic, Buffer.from(entry.payload, 'utf-8'), entry.ttlSec);
297
+ if (ok) {
298
+ this.store.removeFromOutbox(entry.id);
299
+ return true;
300
+ }
301
+ return false;
302
+ }));
303
+ for (const result of settled) {
304
+ if (result.status === 'fulfilled' && result.value)
305
+ delivered++;
306
+ }
307
+ }
308
+ return delivered;
309
+ }
310
+ /**
311
+ * Called when a peer connects. Announces our DID and flushes any
312
+ * pending outbox messages for that peer's DID.
313
+ */
314
+ async onPeerConnected(peerId) {
315
+ // Announce our DID to the new peer
316
+ await this.announceDidToPeer(peerId);
317
+ // Check if we know this peer's DID and flush outbox
318
+ const did = this.peerIdToDid.get(peerId);
319
+ if (did) {
320
+ const flushed = await this.flushOutboxForDid(did);
321
+ if (flushed > 0) {
322
+ this.log.info('flushed outbox messages on reconnect', { peerId, did, flushed });
323
+ }
324
+ }
325
+ }
326
+ /** Return the current DID→PeerId mapping (for debugging/status). */
327
+ getDidPeerMap() {
328
+ return Object.fromEntries(this.didToPeerId);
329
+ }
330
+ // ── Subscriber Management (WebSocket push) ─────────────────────
331
+ /** Register a subscriber for real-time inbox pushes. */
332
+ addSubscriber(cb) {
333
+ this.subscribers.add(cb);
334
+ }
335
+ /** Remove a subscriber. */
336
+ removeSubscriber(cb) {
337
+ this.subscribers.delete(cb);
338
+ }
339
+ /** Number of active WS subscribers. */
340
+ get subscriberCount() {
341
+ return this.subscribers.size;
342
+ }
343
+ /** Notify all subscribers of a new inbox message (non-blocking). */
344
+ notifySubscribers(msg) {
345
+ // Use queueMicrotask to avoid blocking the current handler when there are many subscribers
346
+ for (const cb of this.subscribers) {
347
+ queueMicrotask(() => {
348
+ try {
349
+ cb(msg);
350
+ }
351
+ catch { /* best-effort */ }
352
+ });
353
+ }
354
+ }
355
+ // ── Rate Limiting (SQLite-backed for multi-instance support) ───
356
+ /**
357
+ * Check rate limit for a DID. Throws if limit exceeded.
358
+ * Uses SQLite-backed sliding window for cross-process correctness.
359
+ */
360
+ enforceRateLimit(did) {
361
+ this.checkRateBucket(`out:${did}`, RATE_LIMIT_PER_MIN);
362
+ }
363
+ /** Check if a DID is currently rate-limited (without consuming a slot). */
364
+ isRateLimited(did) {
365
+ const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
366
+ return this.store.countRateEvents(`out:${did}`, windowStart) >= RATE_LIMIT_PER_MIN;
367
+ }
368
+ /**
369
+ * Enforce inbound rate limit for a peerId. Throws if limit exceeded.
370
+ * Prevents P2P peers from spamming without limit.
371
+ */
372
+ enforceInboundRateLimit(peerId) {
373
+ this.checkRateBucket(`in:${peerId}`, INBOUND_RATE_LIMIT);
374
+ }
375
+ /**
376
+ * Enforce global aggregate inbound rate limit (all peers combined).
377
+ * Prevents total flooding even when spread across many peers.
378
+ */
379
+ enforceGlobalInboundRateLimit() {
380
+ this.checkRateBucket('in:_global', GLOBAL_INBOUND_RATE_LIMIT);
381
+ }
382
+ /**
383
+ * Core rate-limit check: count events in the sliding window via SQLite,
384
+ * record a new event, throw if over limit.
385
+ */
386
+ checkRateBucket(bucket, limit) {
387
+ const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
388
+ const count = this.store.countRateEvents(bucket, windowStart);
389
+ if (count >= limit) {
390
+ throw new RateLimitError(bucket, limit);
391
+ }
392
+ this.store.recordRateEvent(bucket);
393
+ }
394
+ // ── Private: Payload Encoding (compression + encryption) ────────
395
+ /**
396
+ * Encode a payload: optionally compress (gzip) then optionally encrypt (X25519+AES-256-GCM).
397
+ *
398
+ * Returns:
399
+ * - `payloadBytes`: raw binary payload for FlatBuffers wire format (Uint8Array)
400
+ * - `storagePayload`: string representation for SQLite TEXT storage (backward compat)
401
+ * - `compressed` / `encrypted` flags
402
+ */
403
+ encodePayload(payload, opts) {
404
+ let data = Buffer.from(payload, 'utf-8');
405
+ let storagePayload = payload;
406
+ let compressed = false;
407
+ let encrypted = false;
408
+ // Compression: gzip if enabled and payload > threshold
409
+ if (opts.compress !== false && data.length > COMPRESSION_THRESHOLD_BYTES) {
410
+ data = gzipSync(data);
411
+ // Storage format: base64-encoded gzip wrapped in JSON object (backward compat with REST API)
412
+ storagePayload = JSON.stringify({ _compressed: 1, data: data.toString('base64') });
413
+ compressed = true;
414
+ }
415
+ // E2E Encryption: X25519 ECDH + HKDF + AES-256-GCM
416
+ if (opts.encryptForKeyHex) {
417
+ const recipientPubKey = hexToBytes(opts.encryptForKeyHex);
418
+ const ephemeral = generateX25519Keypair();
419
+ const shared = x25519SharedSecret(ephemeral.privateKey, recipientPubKey);
420
+ const derived = hkdfSha256(shared, undefined, new Uint8Array(E2E_MSG_INFO), 32);
421
+ const enc = encryptAes256Gcm(derived, new Uint8Array(data));
422
+ // Wire format: binary E2E envelope (60 bytes header + ciphertext)
423
+ data = Buffer.from(encodeE2EEnvelope({
424
+ ephemeralPk: ephemeral.publicKey,
425
+ nonce: hexToBytes(enc.nonceHex),
426
+ tag: hexToBytes(enc.tagHex),
427
+ ciphertext: hexToBytes(enc.ciphertextHex),
428
+ }));
429
+ // Storage format: JSON E2E envelope (backward compat with static decryptPayload)
430
+ storagePayload = JSON.stringify({
431
+ _e2e: 1,
432
+ pk: bytesToHex(ephemeral.publicKey),
433
+ n: enc.nonceHex,
434
+ c: enc.ciphertextHex,
435
+ t: enc.tagHex,
436
+ });
437
+ encrypted = true;
438
+ }
439
+ return { payloadBytes: new Uint8Array(data), storagePayload, compressed, encrypted };
440
+ }
441
+ /**
442
+ * Decrypt an E2E-encrypted payload using the local node's X25519 private key.
443
+ * Returns the decrypted payload string or null if not encrypted / decryption fails.
444
+ */
445
+ static decryptPayload(payload, recipientPrivateKey) {
446
+ try {
447
+ const envelope = JSON.parse(payload);
448
+ if (envelope._e2e !== 1 || !envelope.pk || !envelope.n || !envelope.c || !envelope.t)
449
+ return null;
450
+ const senderPub = hexToBytes(envelope.pk);
451
+ const shared = x25519SharedSecret(recipientPrivateKey, senderPub);
452
+ const derived = hkdfSha256(shared, undefined, new Uint8Array(E2E_MSG_INFO), 32);
453
+ const decrypted = decryptAes256Gcm(derived, {
454
+ nonceHex: envelope.n,
455
+ ciphertextHex: envelope.c,
456
+ tagHex: envelope.t,
457
+ });
458
+ return Buffer.from(decrypted).toString('utf-8');
459
+ }
460
+ catch {
461
+ return null;
462
+ }
463
+ }
464
+ /**
465
+ * Decompress a gzip-compressed payload (base64-encoded gzip → utf-8 string).
466
+ * Returns the decompressed string, or null if decompression fails.
467
+ */
468
+ static decompressPayload(payload) {
469
+ try {
470
+ const buf = Buffer.from(payload, 'base64');
471
+ const decompressed = gunzipSync(buf);
472
+ return decompressed.toString('utf-8');
473
+ }
474
+ catch {
475
+ return null;
476
+ }
477
+ }
478
+ /** Get the current inbox sequence number (for WS replay). */
479
+ getCurrentSeq() {
480
+ return this.store.currentSeq();
481
+ }
482
+ // ── Private: Direct Delivery ───────────────────────────────────
483
+ async deliverDirect(peerId, targetDid, topic, payload, ttlSec, priority = MessagePriority.NORMAL, compressed = false, encrypted = false, idempotencyKey) {
484
+ let stream = null;
485
+ try {
486
+ stream = await this.p2p.newStream(peerId, PROTO_DM);
487
+ const bytes = encodeDirectMessageBytes({
488
+ sourceDid: this.localDid,
489
+ targetDid,
490
+ topic,
491
+ payload,
492
+ ttlSec,
493
+ sentAtMs: BigInt(Date.now()),
494
+ priority,
495
+ compressed,
496
+ encrypted,
497
+ idempotencyKey: idempotencyKey ?? '',
498
+ });
499
+ await writeBinaryStream(stream.sink, bytes);
500
+ await stream.close();
501
+ this.log.info('message delivered', { peerId, targetDid, topic });
502
+ return true;
503
+ }
504
+ catch (err) {
505
+ this.log.warn('direct delivery failed', {
506
+ peerId,
507
+ targetDid,
508
+ error: err.message,
509
+ });
510
+ if (stream) {
511
+ try {
512
+ await stream.close();
513
+ }
514
+ catch { /* ignore */ }
515
+ }
516
+ return false;
517
+ }
518
+ }
519
+ // ── Private: Inbound Message Handler ───────────────────────────
520
+ async handleInboundMessage(incoming) {
521
+ const { stream, connection } = incoming;
522
+ try {
523
+ // Inbound rate limit check — prevent P2P spam
524
+ const remotePeer = connection.remotePeer?.toString();
525
+ if (remotePeer) {
526
+ try {
527
+ this.enforceInboundRateLimit(remotePeer);
528
+ this.enforceGlobalInboundRateLimit();
529
+ }
530
+ catch {
531
+ this.log.warn('inbound rate limit exceeded, dropping stream', { peerId: remotePeer });
532
+ try {
533
+ await stream.close();
534
+ }
535
+ catch { /* ignore */ }
536
+ return;
537
+ }
538
+ }
539
+ // readStream enforces size limit before reading all into memory
540
+ const raw = await readStream(stream.source);
541
+ await stream.close();
542
+ const msg = decodeDirectMessageBytes(new Uint8Array(raw));
543
+ if (!msg.sourceDid || !msg.topic || msg.payload.length === 0) {
544
+ this.log.warn('inbound message missing required fields');
545
+ return;
546
+ }
547
+ // Reconstruct string payload for SQLite TEXT storage (backward compat with REST API / SDK)
548
+ let storagePayload;
549
+ if (msg.encrypted) {
550
+ // Binary E2E → JSON E2E envelope (for static decryptPayload backward compat)
551
+ const e2e = decodeE2EEnvelope(msg.payload);
552
+ storagePayload = JSON.stringify({
553
+ _e2e: 1,
554
+ pk: bytesToHex(e2e.ephemeralPk),
555
+ n: bytesToHex(e2e.nonce),
556
+ c: bytesToHex(e2e.ciphertext),
557
+ t: bytesToHex(e2e.tag),
558
+ });
559
+ }
560
+ else if (msg.compressed) {
561
+ // Raw gzip bytes → base64-encoded gzip wrapped in JSON (for static decompressPayload)
562
+ storagePayload = JSON.stringify({ _compressed: 1, data: Buffer.from(msg.payload).toString('base64') });
563
+ }
564
+ else {
565
+ // Plain UTF-8 text
566
+ storagePayload = Buffer.from(msg.payload).toString('utf-8');
567
+ }
568
+ // Store in inbox (deduplication handled by store if idempotencyKey is present)
569
+ const messageId = this.store.addToInbox({
570
+ sourceDid: msg.sourceDid,
571
+ targetDid: msg.targetDid || this.localDid,
572
+ topic: msg.topic,
573
+ payload: storagePayload,
574
+ ttlSec: msg.ttlSec || undefined,
575
+ sentAtMs: msg.sentAtMs ? Number(msg.sentAtMs) : undefined,
576
+ priority: msg.priority ?? MessagePriority.NORMAL,
577
+ idempotencyKey: msg.idempotencyKey || undefined,
578
+ });
579
+ // Record DID → PeerId mapping from the sender (persisted to SQLite)
580
+ const remotePeerId = connection.remotePeer?.toString();
581
+ if (remotePeerId && msg.sourceDid) {
582
+ this.registerDidPeer(msg.sourceDid, remotePeerId);
583
+ }
584
+ this.log.info('message received', { messageId, sourceDid: msg.sourceDid, topic: msg.topic });
585
+ // Push to WebSocket subscribers
586
+ const currentSeq = this.store.currentSeq();
587
+ this.notifySubscribers({
588
+ messageId,
589
+ sourceDid: msg.sourceDid,
590
+ topic: msg.topic,
591
+ payload: storagePayload,
592
+ receivedAtMs: Date.now(),
593
+ priority: msg.priority ?? MessagePriority.NORMAL,
594
+ seq: currentSeq,
595
+ });
596
+ // Send delivery receipt back to sender
597
+ if (remotePeerId) {
598
+ void this.sendDeliveryReceipt(remotePeerId, messageId, msg.sourceDid);
599
+ }
600
+ }
601
+ catch (err) {
602
+ this.log.warn('failed to handle inbound message', { error: err.message });
603
+ try {
604
+ await stream.close();
605
+ }
606
+ catch { /* ignore */ }
607
+ }
608
+ }
609
+ // ── Private: DID Announce Protocol ────────────────────────────
610
+ async handleDidAnnounce(incoming) {
611
+ const { stream, connection } = incoming;
612
+ try {
613
+ // Rate limit DID announcements to prevent mapping table poisoning
614
+ const remotePeerId = connection.remotePeer?.toString();
615
+ if (remotePeerId) {
616
+ try {
617
+ this.enforceInboundRateLimit(remotePeerId);
618
+ this.enforceGlobalInboundRateLimit();
619
+ }
620
+ catch {
621
+ this.log.warn('announce rate limit exceeded, dropping', { peerId: remotePeerId });
622
+ try {
623
+ await stream.close();
624
+ }
625
+ catch { /* ignore */ }
626
+ return;
627
+ }
628
+ }
629
+ const raw = await readStream(stream.source, 1024); // DID announces are tiny
630
+ await stream.close();
631
+ const msg = decodeDidAnnounceBytes(new Uint8Array(raw));
632
+ // Validate DID format to prevent spoofing / garbage entries
633
+ if (msg.did && !DID_PATTERN.test(msg.did)) {
634
+ this.log.warn('invalid DID in announce, ignoring', { did: msg.did, peerId: remotePeerId });
635
+ return;
636
+ }
637
+ if (msg.did && remotePeerId) {
638
+ this.registerDidPeer(msg.did, remotePeerId);
639
+ this.log.info('peer DID registered', { did: msg.did, peerId: remotePeerId });
640
+ // Flush any pending outbox messages for this DID
641
+ const flushed = await this.flushOutboxForDid(msg.did);
642
+ if (flushed > 0) {
643
+ this.log.info('flushed outbox on DID announce', { did: msg.did, flushed });
644
+ }
645
+ }
646
+ }
647
+ catch (err) {
648
+ this.log.warn('failed to handle DID announce', { error: err.message });
649
+ try {
650
+ await stream.close();
651
+ }
652
+ catch { /* ignore */ }
653
+ }
654
+ }
655
+ // ── Private: DID Resolve Protocol ────────────────────────────
656
+ async handleDidResolve(incoming) {
657
+ const { stream, connection } = incoming;
658
+ try {
659
+ const remotePeerId = connection.remotePeer?.toString();
660
+ if (remotePeerId) {
661
+ try {
662
+ this.enforceInboundRateLimit(remotePeerId);
663
+ this.enforceGlobalInboundRateLimit();
664
+ }
665
+ catch {
666
+ this.log.warn('resolve rate limit exceeded, dropping', { peerId: remotePeerId });
667
+ try {
668
+ await stream.close();
669
+ }
670
+ catch { /* ignore */ }
671
+ return;
672
+ }
673
+ }
674
+ const raw = await readStream(stream.source, 1024);
675
+ const msg = decodeDidResolveRequestBytes(new Uint8Array(raw));
676
+ if (!msg.did || !DID_PATTERN.test(msg.did)) {
677
+ const respBytes = encodeDidResolveResponseBytes({ did: msg.did ?? '', peerId: '', found: false });
678
+ await writeBinaryStream(stream.sink, respBytes);
679
+ await stream.close();
680
+ return;
681
+ }
682
+ const peerId = this.didToPeerId.get(msg.did);
683
+ const respBytes = encodeDidResolveResponseBytes(peerId
684
+ ? { did: msg.did, peerId, found: true }
685
+ : { did: msg.did, peerId: '', found: false });
686
+ await writeBinaryStream(stream.sink, respBytes);
687
+ await stream.close();
688
+ this.log.info('DID resolve handled', { did: msg.did, found: !!peerId });
689
+ }
690
+ catch (err) {
691
+ this.log.warn('failed to handle DID resolve', { error: err.message });
692
+ try {
693
+ await stream.close();
694
+ }
695
+ catch { /* ignore */ }
696
+ }
697
+ }
698
+ /**
699
+ * Query connected peers to resolve an unknown DID → PeerId.
700
+ * Sends DID resolve requests to up to DID_RESOLVE_MAX_PEERS peers concurrently.
701
+ * Returns the first PeerId found, or null if none of the queried peers know the DID.
702
+ */
703
+ async resolveDidViaPeers(targetDid) {
704
+ const connectedPeers = this.p2p.getConnections().slice(0, DID_RESOLVE_MAX_PEERS);
705
+ if (connectedPeers.length === 0)
706
+ return null;
707
+ let timer;
708
+ const timeout = new Promise((_, reject) => {
709
+ timer = setTimeout(() => reject(new Error('resolve timeout')), DID_RESOLVE_TIMEOUT_MS);
710
+ });
711
+ try {
712
+ const result = await Promise.race([
713
+ Promise.any(connectedPeers.map(async (peerId) => {
714
+ let stream = null;
715
+ try {
716
+ stream = await this.p2p.newStream(peerId, PROTO_DID_RESOLVE);
717
+ const reqBytes = encodeDidResolveRequestBytes({ did: targetDid });
718
+ await writeBinaryStream(stream.sink, reqBytes);
719
+ const raw = await readStream(stream.source, 1024, DID_RESOLVE_TIMEOUT_MS);
720
+ await stream.close();
721
+ const resp = decodeDidResolveResponseBytes(new Uint8Array(raw));
722
+ if (resp.found && resp.peerId)
723
+ return resp.peerId;
724
+ throw new Error('not found');
725
+ }
726
+ catch (err) {
727
+ if (stream) {
728
+ try {
729
+ await stream.close();
730
+ }
731
+ catch { /* ignore */ }
732
+ }
733
+ throw err;
734
+ }
735
+ })),
736
+ timeout,
737
+ ]);
738
+ return result;
739
+ }
740
+ catch {
741
+ return null;
742
+ }
743
+ finally {
744
+ clearTimeout(timer);
745
+ }
746
+ }
747
+ /** Announce our DID to a specific peer. */
748
+ async announceDidToPeer(peerId) {
749
+ let stream = null;
750
+ try {
751
+ stream = await this.p2p.newStream(peerId, PROTO_DID_ANNOUNCE);
752
+ const bytes = encodeDidAnnounceBytes({ did: this.localDid });
753
+ await writeBinaryStream(stream.sink, bytes);
754
+ await stream.close();
755
+ }
756
+ catch {
757
+ // Best-effort; the peer may not support this protocol yet
758
+ if (stream) {
759
+ try {
760
+ await stream.close();
761
+ }
762
+ catch { /* ignore */ }
763
+ }
764
+ }
765
+ }
766
+ /** Announce our DID to all currently connected peers. */
767
+ async announceToAll() {
768
+ const peers = this.p2p.getConnections();
769
+ for (const peerId of peers) {
770
+ await this.announceDidToPeer(peerId);
771
+ }
772
+ }
773
+ /**
774
+ * Deliver to multiple targets concurrently with bounded concurrency.
775
+ * Uses Promise.allSettled so one failure doesn't block others.
776
+ * Supports per-recipient E2E encryption when recipientKeys are provided.
777
+ */
778
+ async deliverMulticast(targetDids, topic, sharedPayloadBytes, sharedStoragePayload, ttlSec, priority = MessagePriority.NORMAL, compressed = false, recipientKeys, idempotencyKey) {
779
+ const results = [];
780
+ // Process in batches of MULTICAST_CONCURRENCY
781
+ for (let i = 0; i < targetDids.length; i += MULTICAST_CONCURRENCY) {
782
+ const batch = targetDids.slice(i, i + MULTICAST_CONCURRENCY);
783
+ const settled = await Promise.allSettled(batch.map(async (targetDid) => {
784
+ // Per-recipient E2E encryption if a key is provided for this target
785
+ let payloadBytes = sharedPayloadBytes;
786
+ let storagePayload = sharedStoragePayload;
787
+ let encrypted = false;
788
+ const recipientKeyHex = recipientKeys?.[targetDid];
789
+ if (recipientKeyHex) {
790
+ const perRecipient = this.encodePayload(Buffer.from(sharedPayloadBytes).toString('utf-8'), { encryptForKeyHex: recipientKeyHex, compress: false });
791
+ payloadBytes = perRecipient.payloadBytes;
792
+ storagePayload = perRecipient.storagePayload;
793
+ encrypted = perRecipient.encrypted;
794
+ }
795
+ const peerId = this.didToPeerId.get(targetDid);
796
+ if (peerId) {
797
+ const delivered = await this.deliverDirect(peerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
798
+ if (delivered) {
799
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
800
+ }
801
+ // Delivery failed — if mapping is stale, try re-resolving
802
+ if (this.isStalePeerMapping(targetDid)) {
803
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
804
+ if (resolvedPeerId && resolvedPeerId !== peerId) {
805
+ this.registerDidPeer(targetDid, resolvedPeerId);
806
+ try {
807
+ await this.p2p.dialPeer(resolvedPeerId);
808
+ }
809
+ catch { /* ignore */ }
810
+ const reDelivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
811
+ if (reDelivered) {
812
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
813
+ }
814
+ }
815
+ }
816
+ }
817
+ // DID unknown — try resolve via connected peers
818
+ if (!peerId) {
819
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
820
+ if (resolvedPeerId) {
821
+ this.registerDidPeer(targetDid, resolvedPeerId);
822
+ try {
823
+ await this.p2p.dialPeer(resolvedPeerId);
824
+ }
825
+ catch { /* ignore */ }
826
+ const delivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
827
+ if (delivered) {
828
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
829
+ }
830
+ }
831
+ }
832
+ const messageId = this.store.addToOutbox({ targetDid, topic, payload: storagePayload, ttlSec, priority });
833
+ return { targetDid, messageId, delivered: false, compressed, encrypted };
834
+ }));
835
+ for (const result of settled) {
836
+ if (result.status === 'fulfilled') {
837
+ results.push(result.value);
838
+ }
839
+ else {
840
+ this.log.warn('multicast delivery error', { error: String(result.reason) });
841
+ }
842
+ }
843
+ }
844
+ return results;
845
+ }
846
+ // ── Private: DID → PeerId Persistence ──────────────────────────
847
+ /** Update in-memory maps AND persist the DID→PeerId mapping to SQLite. */
848
+ registerDidPeer(did, peerId) {
849
+ this.didToPeerId.set(did, peerId);
850
+ this.peerIdToDid.set(peerId, did);
851
+ this.didPeerUpdatedAt.set(did, Date.now());
852
+ try {
853
+ this.store.upsertDidPeer(did, peerId);
854
+ }
855
+ catch {
856
+ // Best-effort persistence — in-memory map is authoritative
857
+ }
858
+ }
859
+ /** Check if a DID→PeerId mapping is older than the TTL threshold. */
860
+ isStalePeerMapping(did) {
861
+ const updatedAt = this.didPeerUpdatedAt.get(did) ?? 0;
862
+ return (Date.now() - updatedAt) > DID_PEER_TTL_MS;
863
+ }
864
+ // ── Private: Delivery Receipt Protocol ─────────────────────────
865
+ /** Send a delivery receipt to the sender after receiving a message. */
866
+ async sendDeliveryReceipt(peerId, messageId, recipientDid) {
867
+ let stream = null;
868
+ try {
869
+ stream = await this.p2p.newStream(peerId, PROTO_RECEIPT);
870
+ const bytes = encodeDeliveryReceiptBytes({
871
+ type: 0 /* ReceiptType.Delivered */,
872
+ messageId,
873
+ recipientDid: this.localDid,
874
+ senderDid: recipientDid,
875
+ deliveredAtMs: BigInt(Date.now()),
876
+ });
877
+ await writeBinaryStream(stream.sink, bytes);
878
+ await stream.close();
879
+ this.log.info('delivery receipt sent', { peerId, messageId });
880
+ }
881
+ catch {
882
+ // Best-effort — receipts are not critical
883
+ if (stream) {
884
+ try {
885
+ await stream.close();
886
+ }
887
+ catch { /* ignore */ }
888
+ }
889
+ }
890
+ }
891
+ /** Handle an incoming delivery receipt from a remote peer. */
892
+ async handleDeliveryReceipt(incoming) {
893
+ const { stream, connection } = incoming;
894
+ try {
895
+ // Rate limit receipts to prevent receipt flooding
896
+ const remotePeerId = connection.remotePeer?.toString();
897
+ if (remotePeerId) {
898
+ try {
899
+ this.enforceInboundRateLimit(remotePeerId);
900
+ this.enforceGlobalInboundRateLimit();
901
+ }
902
+ catch {
903
+ this.log.warn('receipt rate limit exceeded, dropping', { peerId: remotePeerId });
904
+ try {
905
+ await stream.close();
906
+ }
907
+ catch { /* ignore */ }
908
+ return;
909
+ }
910
+ }
911
+ const raw = await readStream(stream.source);
912
+ await stream.close();
913
+ const receipt = decodeDeliveryReceiptBytes(new Uint8Array(raw));
914
+ if (receipt.type === 0 /* ReceiptType.Delivered */ && receipt.messageId) {
915
+ // Remove from outbox if it was queued
916
+ this.store.removeFromOutbox(receipt.messageId);
917
+ this.log.info('delivery receipt received', {
918
+ messageId: receipt.messageId,
919
+ recipientDid: receipt.recipientDid,
920
+ });
921
+ // Notify subscribers about the receipt (convert to JSON string for backward compat)
922
+ const receiptPayload = JSON.stringify({
923
+ type: 'delivered',
924
+ messageId: receipt.messageId,
925
+ recipientDid: receipt.recipientDid,
926
+ senderDid: receipt.senderDid,
927
+ deliveredAtMs: Number(receipt.deliveredAtMs),
928
+ });
929
+ this.notifySubscribers({
930
+ messageId: receipt.messageId,
931
+ sourceDid: receipt.recipientDid ?? '',
932
+ topic: RECEIPT_TOPIC,
933
+ payload: receiptPayload,
934
+ receivedAtMs: Number(receipt.deliveredAtMs) || Date.now(),
935
+ priority: MessagePriority.NORMAL,
936
+ seq: 0, // Receipts don't have inbox seq
937
+ });
938
+ }
939
+ }
940
+ catch {
941
+ try {
942
+ await stream.close();
943
+ }
944
+ catch { /* ignore */ }
945
+ }
946
+ }
947
+ }
948
+ // ── Rate Limit Error ─────────────────────────────────────────────
949
+ export class RateLimitError extends Error {
950
+ did;
951
+ limit;
952
+ constructor(did, limit) {
953
+ super(`Rate limit exceeded for ${did}: max ${limit} messages/minute`);
954
+ this.name = 'RateLimitError';
955
+ this.did = did;
956
+ this.limit = limit;
957
+ }
958
+ }
959
+ //# sourceMappingURL=messaging-service.js.map