@claw-network/node 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,20 +9,12 @@
9
9
  * - Periodic TTL cleanup of expired messages
10
10
  */
11
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
12
  import { createLogger } from '../logger.js';
14
13
  import { gzipSync, gunzipSync } from 'node:zlib';
15
14
  // ── Constants ────────────────────────────────────────────────────
16
15
  const PROTO_DM = '/clawnet/1.0.0/dm';
17
16
  const PROTO_DID_ANNOUNCE = '/clawnet/1.0.0/did-announce';
18
17
  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
18
  /** Maximum payload size in bytes (64 KB). */
27
19
  const MAX_PAYLOAD_BYTES = 65_536;
28
20
  /** Cleanup interval for expired messages (5 minutes). */
@@ -35,10 +27,8 @@ const RATE_LIMIT_PER_MIN = 600;
35
27
  const RATE_LIMIT_WINDOW_MS = 60_000;
36
28
  /** Inbound P2P rate limit: max inbound messages per peer per minute. */
37
29
  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;
30
+ /** Interval at which empty rate-limit buckets are pruned (2 minutes). */
31
+ const RATE_BUCKET_GC_INTERVAL_MS = 2 * 60_000;
42
32
  /** Maximum concurrency for multicast delivery. */
43
33
  const MULTICAST_CONCURRENCY = 20;
44
34
  /** Base delay for exponential backoff in outbox retry (ms). */
@@ -51,8 +41,6 @@ const DID_PATTERN = /^did:claw:z[1-9A-HJ-NP-Za-km-z]{32,64}$/;
51
41
  const COMPRESSION_THRESHOLD_BYTES = 1024;
52
42
  /** HKDF info tag for E2E messaging encryption. */
53
43
  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
44
  /** Priority levels — higher number = higher priority. */
57
45
  export var MessagePriority;
58
46
  (function (MessagePriority) {
@@ -62,33 +50,25 @@ export var MessagePriority;
62
50
  MessagePriority[MessagePriority["URGENT"] = 3] = "URGENT";
63
51
  })(MessagePriority || (MessagePriority = {}));
64
52
  // ── 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) {
53
+ /** Read all data from a stream source into a single Buffer, enforcing a size limit. */
54
+ async function readStream(source, maxBytes = MAX_PAYLOAD_BYTES * 2) {
67
55
  const chunks = [];
68
56
  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));
57
+ for await (const chunk of source) {
58
+ const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
59
+ total += bytes.length;
60
+ if (total > maxBytes) {
61
+ throw new Error(`Stream exceeded size limit: ${total} > ${maxBytes}`);
81
62
  }
82
- }
83
- finally {
84
- clearTimeout(timer);
63
+ chunks.push(Buffer.from(bytes));
85
64
  }
86
65
  return Buffer.concat(chunks);
87
66
  }
88
- /** Write raw binary data to a stream sink. */
89
- async function writeBinaryStream(sink, data) {
67
+ /** Write a UTF-8 JSON string to a stream sink. */
68
+ async function writeStream(sink, data) {
69
+ const encoded = Buffer.from(data, 'utf-8');
90
70
  await sink((async function* () {
91
- yield data;
71
+ yield encoded;
92
72
  })());
93
73
  }
94
74
  // ── Service ──────────────────────────────────────────────────────
@@ -100,14 +80,19 @@ export class MessagingService {
100
80
  cleanupTimer;
101
81
  /**
102
82
  * DID → PeerId mapping. Populated via the did-announce protocol when
103
- * peers connect. Persisted to SQLite and restored on startup.
83
+ * peers connect. This is a best-effort cache; entries are never evicted
84
+ * but may become stale when peers go offline.
104
85
  */
105
86
  didToPeerId = new Map();
106
87
  peerIdToDid = new Map();
107
- /** Tracks when each DID→PeerId mapping was last confirmed (for TTL-based re-resolve). */
108
- didPeerUpdatedAt = new Map();
109
88
  /** WebSocket subscribers that receive real-time inbox pushes. */
110
89
  subscribers = new Set();
90
+ /** Sliding-window rate limiter: DID → array of timestamps (ms). */
91
+ rateBuckets = new Map();
92
+ /** Inbound rate limiter: peerId → array of timestamps. */
93
+ inboundRateBuckets = new Map();
94
+ /** Timer for periodic rate-bucket garbage collection. */
95
+ rateBucketGcTimer;
111
96
  constructor(p2p, store, localDid) {
112
97
  this.log = createLogger({ level: 'info' });
113
98
  this.p2p = p2p;
@@ -116,43 +101,36 @@ export class MessagingService {
116
101
  }
117
102
  // ── Lifecycle ──────────────────────────────────────────────────
118
103
  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
104
+ // Register stream protocol handlers
127
105
  await this.p2p.handleProtocol(PROTO_DM, (incoming) => {
128
106
  void this.handleInboundMessage(incoming);
129
- }, { maxInboundStreams: 256 });
107
+ });
130
108
  await this.p2p.handleProtocol(PROTO_DID_ANNOUNCE, (incoming) => {
131
109
  void this.handleDidAnnounce(incoming);
132
- }, { maxInboundStreams: 64 });
110
+ });
133
111
  await this.p2p.handleProtocol(PROTO_RECEIPT, (incoming) => {
134
112
  void this.handleDeliveryReceipt(incoming);
135
- }, { maxInboundStreams: 64 });
136
- await this.p2p.handleProtocol(PROTO_DID_RESOLVE, (incoming) => {
137
- void this.handleDidResolve(incoming);
138
- }, { maxInboundStreams: 128 });
113
+ });
139
114
  // When a new peer connects, exchange DID announcements
140
115
  this.p2p.onPeerDisconnect(() => {
141
116
  // No-op for now; outbox delivery is handled via flush on connect.
142
117
  });
143
118
  // Announce our DID to all currently connected peers
144
119
  void this.announceToAll();
145
- // Periodic cleanup of expired messages and stale rate-limit entries
120
+ // Periodic cleanup of expired messages
146
121
  this.cleanupTimer = setInterval(() => {
147
122
  try {
148
123
  this.store.cleanupInbox();
149
124
  this.store.cleanupOutbox();
150
- this.store.pruneRateEvents(Date.now() - RATE_LIMIT_WINDOW_MS);
151
125
  }
152
126
  catch {
153
127
  /* best-effort */
154
128
  }
155
129
  }, CLEANUP_INTERVAL_MS);
130
+ // Periodic GC for rate-limit buckets to prevent memory leaks
131
+ this.rateBucketGcTimer = setInterval(() => {
132
+ this.pruneRateBuckets();
133
+ }, RATE_BUCKET_GC_INTERVAL_MS);
156
134
  this.log.info('[messaging] service started', { localDid: this.localDid });
157
135
  }
158
136
  async stop() {
@@ -160,6 +138,10 @@ export class MessagingService {
160
138
  clearInterval(this.cleanupTimer);
161
139
  this.cleanupTimer = undefined;
162
140
  }
141
+ if (this.rateBucketGcTimer) {
142
+ clearInterval(this.rateBucketGcTimer);
143
+ this.rateBucketGcTimer = undefined;
144
+ }
163
145
  try {
164
146
  await this.p2p.unhandleProtocol(PROTO_DM);
165
147
  }
@@ -172,10 +154,6 @@ export class MessagingService {
172
154
  await this.p2p.unhandleProtocol(PROTO_RECEIPT);
173
155
  }
174
156
  catch { /* ignore */ }
175
- try {
176
- await this.p2p.unhandleProtocol(PROTO_DID_RESOLVE);
177
- }
178
- catch { /* ignore */ }
179
157
  this.subscribers.clear();
180
158
  }
181
159
  // ── Public API ─────────────────────────────────────────────────
@@ -190,51 +168,22 @@ export class MessagingService {
190
168
  // Rate limit check
191
169
  this.enforceRateLimit(this.localDid);
192
170
  // Apply compression + encryption to payload
193
- const { payloadBytes, storagePayload, compressed, encrypted } = this.encodePayload(payload, opts);
171
+ const { encoded, compressed, encrypted } = this.encodePayload(payload, opts);
194
172
  // 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})`);
173
+ const payloadBytes = Buffer.byteLength(encoded, 'utf-8');
174
+ if (payloadBytes > MAX_PAYLOAD_BYTES) {
175
+ throw new Error(`Payload too large: ${payloadBytes} bytes (max ${MAX_PAYLOAD_BYTES})`);
197
176
  }
198
177
  const peerId = this.didToPeerId.get(targetDid);
199
178
  if (peerId) {
200
179
  // Try direct delivery
201
- const delivered = await this.deliverDirect(peerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
180
+ const delivered = await this.deliverDirect(peerId, targetDid, topic, encoded, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
202
181
  if (delivered) {
203
182
  return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
204
183
  }
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
184
  }
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 });
185
+ // Queue in outbox for later delivery
186
+ const messageId = this.store.addToOutbox({ targetDid, topic, payload: encoded, ttlSec, priority });
238
187
  this.log.info('message queued in outbox', { messageId, targetDid, topic });
239
188
  return { messageId, delivered: false, compressed, encrypted };
240
189
  }
@@ -247,14 +196,14 @@ export class MessagingService {
247
196
  const priority = opts.priority ?? MessagePriority.NORMAL;
248
197
  // Rate limit check (counts as 1 call for rate-limit purposes)
249
198
  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})`);
199
+ // Apply compression (encryption not applied in multicast — each recipient needs their own key)
200
+ const { encoded, compressed, encrypted } = this.encodePayload(payload, { ...opts, encryptForKeyHex: undefined });
201
+ const payloadBytes = Buffer.byteLength(encoded, 'utf-8');
202
+ if (payloadBytes > MAX_PAYLOAD_BYTES) {
203
+ throw new Error(`Payload too large: ${payloadBytes} bytes (max ${MAX_PAYLOAD_BYTES})`);
254
204
  }
255
205
  // 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);
206
+ const results = await this.deliverMulticast(targetDids, topic, encoded, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
258
207
  return { results };
259
208
  }
260
209
  /** Query the local inbox. */
@@ -265,44 +214,30 @@ export class MessagingService {
265
214
  ackMessage(messageId) {
266
215
  return this.store.consumeMessage(messageId);
267
216
  }
268
- /** Flush outbox: attempt to deliver all pending messages for a specific DID with bounded concurrency. */
217
+ /** Flush outbox: attempt to deliver all pending messages for a specific DID with exponential backoff. */
269
218
  async flushOutboxForDid(targetDid) {
270
219
  const peerId = this.didToPeerId.get(targetDid);
271
220
  if (!peerId)
272
221
  return 0;
273
222
  const entries = this.store.getOutboxForTarget(targetDid);
223
+ let delivered = 0;
274
224
  const now = Date.now();
275
- // Pre-filter: separate eligible entries from those still in backoff or over limit
276
- const eligible = [];
277
225
  for (const entry of entries) {
278
226
  if (entry.attempts > MAX_DELIVERY_ATTEMPTS) {
279
227
  this.store.removeFromOutbox(entry.id);
280
228
  continue;
281
229
  }
230
+ // Exponential backoff: skip if too soon since last attempt
282
231
  const backoff = Math.min(OUTBOX_RETRY_BASE_MS * (2 ** entry.attempts), OUTBOX_RETRY_MAX_MS);
283
232
  const lastAttempt = entry.lastAttempt ?? 0;
284
233
  if (lastAttempt > 0 && now - lastAttempt < backoff) {
285
234
  continue;
286
235
  }
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++;
236
+ this.store.recordAttempt(entry.id);
237
+ const ok = await this.deliverDirect(peerId, targetDid, entry.topic, entry.payload, entry.ttlSec);
238
+ if (ok) {
239
+ this.store.removeFromOutbox(entry.id);
240
+ delivered++;
306
241
  }
307
242
  }
308
243
  return delivered;
@@ -352,64 +287,93 @@ export class MessagingService {
352
287
  });
353
288
  }
354
289
  }
355
- // ── Rate Limiting (SQLite-backed for multi-instance support) ───
290
+ // ── Rate Limiting ──────────────────────────────────────────────
356
291
  /**
357
292
  * Check rate limit for a DID. Throws if limit exceeded.
358
- * Uses SQLite-backed sliding window for cross-process correctness.
293
+ * Uses a sliding window of timestamps with binary search eviction.
359
294
  */
360
295
  enforceRateLimit(did) {
361
- this.checkRateBucket(`out:${did}`, RATE_LIMIT_PER_MIN);
296
+ this.checkRateBucket(this.rateBuckets, did, RATE_LIMIT_PER_MIN);
362
297
  }
363
298
  /** Check if a DID is currently rate-limited (without consuming a slot). */
364
299
  isRateLimited(did) {
365
- const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
366
- return this.store.countRateEvents(`out:${did}`, windowStart) >= RATE_LIMIT_PER_MIN;
300
+ const now = Date.now();
301
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
302
+ const timestamps = this.rateBuckets.get(did);
303
+ if (!timestamps)
304
+ return false;
305
+ const startIdx = this.bisectLeft(timestamps, windowStart);
306
+ return (timestamps.length - startIdx) >= RATE_LIMIT_PER_MIN;
367
307
  }
368
308
  /**
369
309
  * Enforce inbound rate limit for a peerId. Throws if limit exceeded.
370
310
  * Prevents P2P peers from spamming without limit.
371
311
  */
372
312
  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);
313
+ this.checkRateBucket(this.inboundRateBuckets, peerId, INBOUND_RATE_LIMIT);
381
314
  }
382
315
  /**
383
- * Core rate-limit check: count events in the sliding window via SQLite,
384
- * record a new event, throw if over limit.
316
+ * Core rate-limit check: evict expired entries via binary search,
317
+ * push new timestamp, throw if over limit.
385
318
  */
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);
319
+ checkRateBucket(buckets, key, limit) {
320
+ const now = Date.now();
321
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
322
+ let timestamps = buckets.get(key);
323
+ if (!timestamps) {
324
+ timestamps = [];
325
+ buckets.set(key, timestamps);
326
+ }
327
+ // Binary search eviction — O(log n) instead of O(n)
328
+ const startIdx = this.bisectLeft(timestamps, windowStart);
329
+ if (startIdx > 0) {
330
+ timestamps.splice(0, startIdx);
331
+ }
332
+ if (timestamps.length >= limit) {
333
+ throw new RateLimitError(key, limit);
334
+ }
335
+ timestamps.push(now);
336
+ }
337
+ /** Binary search: find first index where timestamps[i] >= target. */
338
+ bisectLeft(arr, target) {
339
+ let lo = 0, hi = arr.length;
340
+ while (lo < hi) {
341
+ const mid = (lo + hi) >>> 1;
342
+ if (arr[mid] < target)
343
+ lo = mid + 1;
344
+ else
345
+ hi = mid;
346
+ }
347
+ return lo;
348
+ }
349
+ /** Remove empty and stale rate-limit buckets to prevent memory leaks. */
350
+ pruneRateBuckets() {
351
+ const now = Date.now();
352
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
353
+ for (const [key, ts] of this.rateBuckets) {
354
+ if (ts.length === 0 || ts[ts.length - 1] < windowStart) {
355
+ this.rateBuckets.delete(key);
356
+ }
357
+ }
358
+ for (const [key, ts] of this.inboundRateBuckets) {
359
+ if (ts.length === 0 || ts[ts.length - 1] < windowStart) {
360
+ this.inboundRateBuckets.delete(key);
361
+ }
391
362
  }
392
- this.store.recordRateEvent(bucket);
393
363
  }
394
364
  // ── Private: Payload Encoding (compression + encryption) ────────
395
365
  /**
396
366
  * 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
367
+ * Returns the encoded string and flags indicating what was applied.
402
368
  */
403
369
  encodePayload(payload, opts) {
404
- let data = Buffer.from(payload, 'utf-8');
405
- let storagePayload = payload;
370
+ let data = payload;
406
371
  let compressed = false;
407
372
  let encrypted = false;
408
373
  // 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') });
374
+ if (opts.compress !== false && Buffer.byteLength(data, 'utf-8') > COMPRESSION_THRESHOLD_BYTES) {
375
+ const gzipped = gzipSync(Buffer.from(data, 'utf-8'));
376
+ data = gzipped.toString('base64');
413
377
  compressed = true;
414
378
  }
415
379
  // E2E Encryption: X25519 ECDH + HKDF + AES-256-GCM
@@ -418,16 +382,9 @@ export class MessagingService {
418
382
  const ephemeral = generateX25519Keypair();
419
383
  const shared = x25519SharedSecret(ephemeral.privateKey, recipientPubKey);
420
384
  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({
385
+ const plainBytes = Buffer.from(data, 'utf-8');
386
+ const enc = encryptAes256Gcm(derived, new Uint8Array(plainBytes));
387
+ data = JSON.stringify({
431
388
  _e2e: 1,
432
389
  pk: bytesToHex(ephemeral.publicKey),
433
390
  n: enc.nonceHex,
@@ -436,7 +393,7 @@ export class MessagingService {
436
393
  });
437
394
  encrypted = true;
438
395
  }
439
- return { payloadBytes: new Uint8Array(data), storagePayload, compressed, encrypted };
396
+ return { encoded: data, compressed, encrypted };
440
397
  }
441
398
  /**
442
399
  * Decrypt an E2E-encrypted payload using the local node's X25519 private key.
@@ -484,19 +441,19 @@ export class MessagingService {
484
441
  let stream = null;
485
442
  try {
486
443
  stream = await this.p2p.newStream(peerId, PROTO_DM);
487
- const bytes = encodeDirectMessageBytes({
444
+ const message = JSON.stringify({
488
445
  sourceDid: this.localDid,
489
446
  targetDid,
490
447
  topic,
491
448
  payload,
492
449
  ttlSec,
493
- sentAtMs: BigInt(Date.now()),
450
+ sentAtMs: Date.now(),
494
451
  priority,
495
452
  compressed,
496
453
  encrypted,
497
- idempotencyKey: idempotencyKey ?? '',
454
+ idempotencyKey,
498
455
  });
499
- await writeBinaryStream(stream.sink, bytes);
456
+ await writeStream(stream.sink, message);
500
457
  await stream.close();
501
458
  this.log.info('message delivered', { peerId, targetDid, topic });
502
459
  return true;
@@ -525,7 +482,6 @@ export class MessagingService {
525
482
  if (remotePeer) {
526
483
  try {
527
484
  this.enforceInboundRateLimit(remotePeer);
528
- this.enforceGlobalInboundRateLimit();
529
485
  }
530
486
  catch {
531
487
  this.log.warn('inbound rate limit exceeded, dropping stream', { peerId: remotePeer });
@@ -539,47 +495,27 @@ export class MessagingService {
539
495
  // readStream enforces size limit before reading all into memory
540
496
  const raw = await readStream(stream.source);
541
497
  await stream.close();
542
- const msg = decodeDirectMessageBytes(new Uint8Array(raw));
543
- if (!msg.sourceDid || !msg.topic || msg.payload.length === 0) {
498
+ const msg = JSON.parse(raw.toString('utf-8'));
499
+ if (!msg.sourceDid || !msg.topic || !msg.payload) {
544
500
  this.log.warn('inbound message missing required fields');
545
501
  return;
546
502
  }
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
503
  // Store in inbox (deduplication handled by store if idempotencyKey is present)
569
504
  const messageId = this.store.addToInbox({
570
505
  sourceDid: msg.sourceDid,
571
- targetDid: msg.targetDid || this.localDid,
506
+ targetDid: msg.targetDid ?? this.localDid,
572
507
  topic: msg.topic,
573
- payload: storagePayload,
574
- ttlSec: msg.ttlSec || undefined,
575
- sentAtMs: msg.sentAtMs ? Number(msg.sentAtMs) : undefined,
508
+ payload: msg.payload,
509
+ ttlSec: msg.ttlSec,
510
+ sentAtMs: msg.sentAtMs,
576
511
  priority: msg.priority ?? MessagePriority.NORMAL,
577
- idempotencyKey: msg.idempotencyKey || undefined,
512
+ idempotencyKey: msg.idempotencyKey,
578
513
  });
579
- // Record DID → PeerId mapping from the sender (persisted to SQLite)
514
+ // Record DID → PeerId mapping from the sender
580
515
  const remotePeerId = connection.remotePeer?.toString();
581
516
  if (remotePeerId && msg.sourceDid) {
582
- this.registerDidPeer(msg.sourceDid, remotePeerId);
517
+ this.didToPeerId.set(msg.sourceDid, remotePeerId);
518
+ this.peerIdToDid.set(remotePeerId, msg.sourceDid);
583
519
  }
584
520
  this.log.info('message received', { messageId, sourceDid: msg.sourceDid, topic: msg.topic });
585
521
  // Push to WebSocket subscribers
@@ -588,7 +524,7 @@ export class MessagingService {
588
524
  messageId,
589
525
  sourceDid: msg.sourceDid,
590
526
  topic: msg.topic,
591
- payload: storagePayload,
527
+ payload: msg.payload,
592
528
  receivedAtMs: Date.now(),
593
529
  priority: msg.priority ?? MessagePriority.NORMAL,
594
530
  seq: currentSeq,
@@ -610,32 +546,18 @@ export class MessagingService {
610
546
  async handleDidAnnounce(incoming) {
611
547
  const { stream, connection } = incoming;
612
548
  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
549
  const raw = await readStream(stream.source, 1024); // DID announces are tiny
630
550
  await stream.close();
631
- const msg = decodeDidAnnounceBytes(new Uint8Array(raw));
551
+ const msg = JSON.parse(raw.toString('utf-8'));
552
+ const remotePeerId = connection.remotePeer?.toString();
632
553
  // Validate DID format to prevent spoofing / garbage entries
633
554
  if (msg.did && !DID_PATTERN.test(msg.did)) {
634
555
  this.log.warn('invalid DID in announce, ignoring', { did: msg.did, peerId: remotePeerId });
635
556
  return;
636
557
  }
637
558
  if (msg.did && remotePeerId) {
638
- this.registerDidPeer(msg.did, remotePeerId);
559
+ this.didToPeerId.set(msg.did, remotePeerId);
560
+ this.peerIdToDid.set(remotePeerId, msg.did);
639
561
  this.log.info('peer DID registered', { did: msg.did, peerId: remotePeerId });
640
562
  // Flush any pending outbox messages for this DID
641
563
  const flushed = await this.flushOutboxForDid(msg.did);
@@ -652,105 +574,12 @@ export class MessagingService {
652
574
  catch { /* ignore */ }
653
575
  }
654
576
  }
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
577
  /** Announce our DID to a specific peer. */
748
578
  async announceDidToPeer(peerId) {
749
579
  let stream = null;
750
580
  try {
751
581
  stream = await this.p2p.newStream(peerId, PROTO_DID_ANNOUNCE);
752
- const bytes = encodeDidAnnounceBytes({ did: this.localDid });
753
- await writeBinaryStream(stream.sink, bytes);
582
+ await writeStream(stream.sink, JSON.stringify({ did: this.localDid }));
754
583
  await stream.close();
755
584
  }
756
585
  catch {
@@ -773,108 +602,48 @@ export class MessagingService {
773
602
  /**
774
603
  * Deliver to multiple targets concurrently with bounded concurrency.
775
604
  * Uses Promise.allSettled so one failure doesn't block others.
776
- * Supports per-recipient E2E encryption when recipientKeys are provided.
777
605
  */
778
- async deliverMulticast(targetDids, topic, sharedPayloadBytes, sharedStoragePayload, ttlSec, priority = MessagePriority.NORMAL, compressed = false, recipientKeys, idempotencyKey) {
606
+ async deliverMulticast(targetDids, topic, payload, ttlSec, priority = MessagePriority.NORMAL, compressed = false, encrypted = false, idempotencyKey) {
779
607
  const results = [];
780
608
  // Process in batches of MULTICAST_CONCURRENCY
781
609
  for (let i = 0; i < targetDids.length; i += MULTICAST_CONCURRENCY) {
782
610
  const batch = targetDids.slice(i, i + MULTICAST_CONCURRENCY);
783
611
  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
612
  const peerId = this.didToPeerId.get(targetDid);
796
613
  if (peerId) {
797
- const delivered = await this.deliverDirect(peerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
614
+ const delivered = await this.deliverDirect(peerId, targetDid, topic, payload, ttlSec, priority, compressed, encrypted, idempotencyKey);
798
615
  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
- }
616
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true };
830
617
  }
831
618
  }
832
- const messageId = this.store.addToOutbox({ targetDid, topic, payload: storagePayload, ttlSec, priority });
833
- return { targetDid, messageId, delivered: false, compressed, encrypted };
619
+ const messageId = this.store.addToOutbox({ targetDid, topic, payload, ttlSec, priority });
620
+ return { targetDid, messageId, delivered: false };
834
621
  }));
835
622
  for (const result of settled) {
836
623
  if (result.status === 'fulfilled') {
837
624
  results.push(result.value);
838
625
  }
839
626
  else {
627
+ // This shouldn't normally happen since deliverDirect catches its own errors
840
628
  this.log.warn('multicast delivery error', { error: String(result.reason) });
841
629
  }
842
630
  }
843
631
  }
844
632
  return results;
845
633
  }
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
634
  // ── Private: Delivery Receipt Protocol ─────────────────────────
865
635
  /** Send a delivery receipt to the sender after receiving a message. */
866
636
  async sendDeliveryReceipt(peerId, messageId, recipientDid) {
867
637
  let stream = null;
868
638
  try {
869
639
  stream = await this.p2p.newStream(peerId, PROTO_RECEIPT);
870
- const bytes = encodeDeliveryReceiptBytes({
871
- type: 0 /* ReceiptType.Delivered */,
640
+ await writeStream(stream.sink, JSON.stringify({
641
+ type: 'delivered',
872
642
  messageId,
873
643
  recipientDid: this.localDid,
874
644
  senderDid: recipientDid,
875
- deliveredAtMs: BigInt(Date.now()),
876
- });
877
- await writeBinaryStream(stream.sink, bytes);
645
+ deliveredAtMs: Date.now(),
646
+ }));
878
647
  await stream.close();
879
648
  this.log.info('delivery receipt sent', { peerId, messageId });
880
649
  }
@@ -890,48 +659,25 @@ export class MessagingService {
890
659
  }
891
660
  /** Handle an incoming delivery receipt from a remote peer. */
892
661
  async handleDeliveryReceipt(incoming) {
893
- const { stream, connection } = incoming;
662
+ const { stream } = incoming;
894
663
  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
664
  const raw = await readStream(stream.source);
912
665
  await stream.close();
913
- const receipt = decodeDeliveryReceiptBytes(new Uint8Array(raw));
914
- if (receipt.type === 0 /* ReceiptType.Delivered */ && receipt.messageId) {
666
+ const receipt = JSON.parse(raw.toString('utf-8'));
667
+ if (receipt.type === 'delivered' && receipt.messageId) {
915
668
  // Remove from outbox if it was queued
916
669
  this.store.removeFromOutbox(receipt.messageId);
917
670
  this.log.info('delivery receipt received', {
918
671
  messageId: receipt.messageId,
919
672
  recipientDid: receipt.recipientDid,
920
673
  });
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
- });
674
+ // Notify subscribers about the receipt
929
675
  this.notifySubscribers({
930
676
  messageId: receipt.messageId,
931
677
  sourceDid: receipt.recipientDid ?? '',
932
- topic: RECEIPT_TOPIC,
933
- payload: receiptPayload,
934
- receivedAtMs: Number(receipt.deliveredAtMs) || Date.now(),
678
+ topic: '_receipt',
679
+ payload: JSON.stringify(receipt),
680
+ receivedAtMs: receipt.deliveredAtMs ?? Date.now(),
935
681
  priority: MessagePriority.NORMAL,
936
682
  seq: 0, // Receipts don't have inbox seq
937
683
  });