@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.
- package/dist/api/routes/auth.d.ts +9 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +48 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/messaging.d.ts.map +1 -1
- package/dist/api/routes/messaging.js +1 -12
- package/dist/api/routes/messaging.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +2 -0
- package/dist/api/server.js.map +1 -1
- package/dist/api/ws-messaging.d.ts.map +1 -1
- package/dist/api/ws-messaging.js +37 -8
- package/dist/api/ws-messaging.js.map +1 -1
- package/dist/services/message-store.d.ts +0 -16
- package/dist/services/message-store.d.ts.map +1 -1
- package/dist/services/message-store.js +0 -46
- package/dist/services/message-store.js.map +1 -1
- package/dist/services/messaging-service.d.ts +17 -33
- package/dist/services/messaging-service.d.ts.map +1 -1
- package/dist/services/messaging-service.js +158 -412
- package/dist/services/messaging-service.js.map +1 -1
- package/package.json +5 -5
|
@@ -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
|
-
/**
|
|
39
|
-
const
|
|
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
|
|
66
|
-
async function readStream(source, maxBytes = MAX_PAYLOAD_BYTES * 2
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
89
|
-
async function
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
}
|
|
107
|
+
});
|
|
130
108
|
await this.p2p.handleProtocol(PROTO_DID_ANNOUNCE, (incoming) => {
|
|
131
109
|
void this.handleDidAnnounce(incoming);
|
|
132
|
-
}
|
|
110
|
+
});
|
|
133
111
|
await this.p2p.handleProtocol(PROTO_RECEIPT, (incoming) => {
|
|
134
112
|
void this.handleDeliveryReceipt(incoming);
|
|
135
|
-
}
|
|
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
|
|
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 {
|
|
171
|
+
const { encoded, compressed, encrypted } = this.encodePayload(payload, opts);
|
|
194
172
|
// Validate payload size after encoding
|
|
195
|
-
|
|
196
|
-
|
|
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,
|
|
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
|
-
//
|
|
222
|
-
|
|
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
|
-
//
|
|
251
|
-
const {
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
290
|
+
// ── Rate Limiting ──────────────────────────────────────────────
|
|
356
291
|
/**
|
|
357
292
|
* Check rate limit for a DID. Throws if limit exceeded.
|
|
358
|
-
* Uses
|
|
293
|
+
* Uses a sliding window of timestamps with binary search eviction.
|
|
359
294
|
*/
|
|
360
295
|
enforceRateLimit(did) {
|
|
361
|
-
this.checkRateBucket(
|
|
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
|
|
366
|
-
|
|
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(
|
|
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:
|
|
384
|
-
*
|
|
316
|
+
* Core rate-limit check: evict expired entries via binary search,
|
|
317
|
+
* push new timestamp, throw if over limit.
|
|
385
318
|
*/
|
|
386
|
-
checkRateBucket(
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
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 =
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
422
|
-
|
|
423
|
-
data =
|
|
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 {
|
|
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
|
|
444
|
+
const message = JSON.stringify({
|
|
488
445
|
sourceDid: this.localDid,
|
|
489
446
|
targetDid,
|
|
490
447
|
topic,
|
|
491
448
|
payload,
|
|
492
449
|
ttlSec,
|
|
493
|
-
sentAtMs:
|
|
450
|
+
sentAtMs: Date.now(),
|
|
494
451
|
priority,
|
|
495
452
|
compressed,
|
|
496
453
|
encrypted,
|
|
497
|
-
idempotencyKey
|
|
454
|
+
idempotencyKey,
|
|
498
455
|
});
|
|
499
|
-
await
|
|
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 =
|
|
543
|
-
if (!msg.sourceDid || !msg.topic || msg.payload
|
|
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
|
|
506
|
+
targetDid: msg.targetDid ?? this.localDid,
|
|
572
507
|
topic: msg.topic,
|
|
573
|
-
payload:
|
|
574
|
-
ttlSec: msg.ttlSec
|
|
575
|
-
sentAtMs: msg.sentAtMs
|
|
508
|
+
payload: msg.payload,
|
|
509
|
+
ttlSec: msg.ttlSec,
|
|
510
|
+
sentAtMs: msg.sentAtMs,
|
|
576
511
|
priority: msg.priority ?? MessagePriority.NORMAL,
|
|
577
|
-
idempotencyKey: msg.idempotencyKey
|
|
512
|
+
idempotencyKey: msg.idempotencyKey,
|
|
578
513
|
});
|
|
579
|
-
// Record DID → PeerId mapping from the sender
|
|
514
|
+
// Record DID → PeerId mapping from the sender
|
|
580
515
|
const remotePeerId = connection.remotePeer?.toString();
|
|
581
516
|
if (remotePeerId && msg.sourceDid) {
|
|
582
|
-
this.
|
|
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:
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
833
|
-
return { targetDid, messageId, delivered: false
|
|
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
|
-
|
|
871
|
-
type:
|
|
640
|
+
await writeStream(stream.sink, JSON.stringify({
|
|
641
|
+
type: 'delivered',
|
|
872
642
|
messageId,
|
|
873
643
|
recipientDid: this.localDid,
|
|
874
644
|
senderDid: recipientDid,
|
|
875
|
-
deliveredAtMs:
|
|
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
|
|
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 =
|
|
914
|
-
if (receipt.type ===
|
|
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
|
|
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:
|
|
933
|
-
payload:
|
|
934
|
-
receivedAtMs:
|
|
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
|
});
|