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