@relay-federation/bridge 0.3.5 → 0.3.7
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/cli.js +1073 -1064
- package/lib/bsv-peer.js +6 -4
- package/lib/data-endpoints.js +90 -0
- package/lib/data-relay.js +375 -0
- package/lib/status-server.js +1120 -1065
- package/package.json +43 -43
package/lib/bsv-peer.js
CHANGED
|
@@ -192,11 +192,13 @@ export class BSVPeer extends EventEmitter {
|
|
|
192
192
|
|
|
193
193
|
const onHandshake = (info) => {
|
|
194
194
|
clearTimeout(timer)
|
|
195
|
-
this._socket.removeListener('error', onError)
|
|
195
|
+
if (this._socket) this._socket.removeListener('error', onError)
|
|
196
196
|
// Replace with soft error handler post-handshake
|
|
197
|
-
this._socket
|
|
198
|
-
this.
|
|
199
|
-
|
|
197
|
+
if (this._socket) {
|
|
198
|
+
this._socket.on('error', (err) => {
|
|
199
|
+
this.emit('error', err)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
200
202
|
resolve(info)
|
|
201
203
|
}
|
|
202
204
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data endpoint handlers for the bridge HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from status-server.js to keep endpoint groups manageable.
|
|
5
|
+
* These handlers implement the Doc 06 HTTP contract for data envelope operations.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* POST /data — submit a signed data envelope for relay
|
|
9
|
+
* GET /data/topics — list topics with cached data (summary objects)
|
|
10
|
+
* GET /data/:topic — query cached envelopes with since/limit/hasMore
|
|
11
|
+
*
|
|
12
|
+
* Payment (BRC-105):
|
|
13
|
+
* Not yet implemented. When added:
|
|
14
|
+
* - POST /data and GET /data/:topic should support HTTP 402 flow
|
|
15
|
+
* - Add middleware before these handlers that checks x-bsv-payment header
|
|
16
|
+
* - Use BRC-103/104 for mutual authentication, BRC-29 for derivation
|
|
17
|
+
* - GET /pricing endpoint should be added here (Doc 06 contract)
|
|
18
|
+
* - Bridge-to-bridge payment uses BRC-105 over the status server (port 9333)
|
|
19
|
+
* - See ARCHITECTURE_LOCK_BRC_GAP_ANALYSIS.md for rationale
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Handle POST /data — submit a signed data envelope.
|
|
24
|
+
* @param {import('./data-relay.js').DataRelay} dataRelay
|
|
25
|
+
* @param {object} body — parsed JSON request body
|
|
26
|
+
* @param {import('node:http').ServerResponse} res
|
|
27
|
+
*/
|
|
28
|
+
export function handlePostData (dataRelay, body, res) {
|
|
29
|
+
// TODO(brc-105): insert payment middleware here — 402 if bridge charges for propagation
|
|
30
|
+
if (!body.topic || !body.payload || !body.pubkeyHex ||
|
|
31
|
+
!body.timestamp || !body.ttl || !body.signature) {
|
|
32
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
33
|
+
res.end(JSON.stringify({ error: 'missing_fields' }))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = dataRelay.injectEnvelope({
|
|
38
|
+
type: 'data',
|
|
39
|
+
topic: body.topic,
|
|
40
|
+
payload: body.payload,
|
|
41
|
+
pubkeyHex: body.pubkeyHex,
|
|
42
|
+
timestamp: body.timestamp,
|
|
43
|
+
ttl: body.ttl,
|
|
44
|
+
signature: body.signature
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (result.accepted) {
|
|
48
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
49
|
+
res.end(JSON.stringify({ accepted: true, topic: body.topic }))
|
|
50
|
+
} else {
|
|
51
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
52
|
+
res.end(JSON.stringify({ accepted: false, error: result.error }))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handle GET /data/topics — list topics with summary objects.
|
|
58
|
+
* @param {import('./data-relay.js').DataRelay} dataRelay
|
|
59
|
+
* @param {import('node:http').ServerResponse} res
|
|
60
|
+
*/
|
|
61
|
+
export function handleGetTopics (dataRelay, res) {
|
|
62
|
+
const topics = dataRelay.getTopicSummaries()
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
|
64
|
+
res.end(JSON.stringify({ count: topics.length, topics }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle GET /data/:topic — query envelopes with since, limit, hasMore.
|
|
69
|
+
* @param {import('./data-relay.js').DataRelay} dataRelay
|
|
70
|
+
* @param {string} topic — decoded topic string
|
|
71
|
+
* @param {URLSearchParams} params — query parameters
|
|
72
|
+
* @param {import('node:http').ServerResponse} res
|
|
73
|
+
*/
|
|
74
|
+
export function handleGetData (dataRelay, topic, params, res) {
|
|
75
|
+
// TODO(brc-105): insert payment middleware here — 402 if bridge charges for queries
|
|
76
|
+
const rawSince = parseInt(params.get('since'), 10)
|
|
77
|
+
const since = Number.isNaN(rawSince) ? 0 : rawSince
|
|
78
|
+
const rawLimit = parseInt(params.get('limit'), 10)
|
|
79
|
+
const limit = Number.isNaN(rawLimit) ? 10 : rawLimit
|
|
80
|
+
const { envelopes, hasMore } = dataRelay.queryEnvelopes(topic, { since, limit })
|
|
81
|
+
|
|
82
|
+
if (envelopes.length === 0) {
|
|
83
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
84
|
+
res.end(JSON.stringify({ topic, count: 0, envelopes: [], hasMore: false }))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
|
89
|
+
res.end(JSON.stringify({ topic, count: envelopes.length, envelopes, hasMore }))
|
|
90
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { verifyHash } from '@relay-federation/common/crypto'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DataRelay — relays ephemeral signed data envelopes between peers.
|
|
7
|
+
*
|
|
8
|
+
* Handles four message types:
|
|
9
|
+
* data — signed envelope broadcast (gossip push)
|
|
10
|
+
* topics — peer interest declaration (gossip announce)
|
|
11
|
+
* data_request — pull-based catch-up query (local cache only)
|
|
12
|
+
* data_response — response to data_request
|
|
13
|
+
*
|
|
14
|
+
* Events:
|
|
15
|
+
* 'data:new' — { topic, payload, pubkeyHex, timestamp, ttl } — new valid envelope received
|
|
16
|
+
*/
|
|
17
|
+
export class DataRelay extends EventEmitter {
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('./peer-manager.js').PeerManager} peerManager
|
|
20
|
+
* @param {object} [opts]
|
|
21
|
+
* @param {number} [opts.maxEnvelopesPerTopic=100] — Ring buffer size per topic
|
|
22
|
+
* @param {number} [opts.maxPayloadBytes=4096] — Max payload size in bytes
|
|
23
|
+
* @param {number} [opts.maxTtl=3600] — Max TTL in seconds
|
|
24
|
+
* @param {number} [opts.maxFutureSecs=30] — Max seconds a timestamp can be in the future
|
|
25
|
+
* @param {number} [opts.maxSeenSize=10000] — Max entries in dedup set before FIFO eviction
|
|
26
|
+
*/
|
|
27
|
+
constructor (peerManager, opts = {}) {
|
|
28
|
+
super()
|
|
29
|
+
this.peerManager = peerManager
|
|
30
|
+
this._maxPerTopic = opts.maxEnvelopesPerTopic || 100
|
|
31
|
+
this._maxPayloadBytes = opts.maxPayloadBytes || 4096
|
|
32
|
+
this._maxTtl = opts.maxTtl || 3600
|
|
33
|
+
this._maxFutureSecs = opts.maxFutureSecs || 30
|
|
34
|
+
this._maxSeenSize = opts.maxSeenSize || 10000
|
|
35
|
+
|
|
36
|
+
/** @type {Map<string, object[]>} topic → envelope ring buffer */
|
|
37
|
+
this._topicBuffers = new Map()
|
|
38
|
+
|
|
39
|
+
/** @type {Map<string, number>} hash → insertion order for bounded FIFO dedup */
|
|
40
|
+
this._seen = new Map()
|
|
41
|
+
this._seenCounter = 0
|
|
42
|
+
|
|
43
|
+
/** @type {Map<string, string[]>} peerPubkeyHex → interest prefixes */
|
|
44
|
+
this._peerInterests = new Map()
|
|
45
|
+
|
|
46
|
+
this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
|
|
47
|
+
this._handleMessage(pubkeyHex, message)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get cached envelopes for a topic (all live envelopes, no filtering).
|
|
53
|
+
* @param {string} topic
|
|
54
|
+
* @returns {object[]}
|
|
55
|
+
*/
|
|
56
|
+
getEnvelopes (topic) {
|
|
57
|
+
this._pruneExpired(topic)
|
|
58
|
+
return this._topicBuffers.get(topic) || []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Query envelopes with filtering and pagination.
|
|
63
|
+
* @param {string} topic
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {number} [opts.since=0] — return envelopes newer than this Unix timestamp
|
|
66
|
+
* @param {number} [opts.limit=10] — max envelopes to return (capped at 100)
|
|
67
|
+
* @returns {{ envelopes: object[], hasMore: boolean }}
|
|
68
|
+
*/
|
|
69
|
+
queryEnvelopes (topic, opts = {}) {
|
|
70
|
+
const since = opts.since || 0
|
|
71
|
+
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 100)
|
|
72
|
+
const all = this.getEnvelopes(topic)
|
|
73
|
+
const filtered = since > 0 ? all.filter(e => e.timestamp > since) : all
|
|
74
|
+
return {
|
|
75
|
+
envelopes: filtered.slice(0, limit),
|
|
76
|
+
hasMore: filtered.length > limit
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get topic summaries with count and latest timestamp.
|
|
82
|
+
* @returns {{ topic: string, count: number, latestTimestamp: number }[]}
|
|
83
|
+
*/
|
|
84
|
+
getTopicSummaries () {
|
|
85
|
+
const now = Math.floor(Date.now() / 1000)
|
|
86
|
+
const summaries = []
|
|
87
|
+
for (const [topic, buffer] of this._topicBuffers) {
|
|
88
|
+
const live = buffer.filter(e => e.timestamp + e.ttl >= now)
|
|
89
|
+
if (live.length > 0) {
|
|
90
|
+
summaries.push({
|
|
91
|
+
topic,
|
|
92
|
+
count: live.length,
|
|
93
|
+
latestTimestamp: Math.max(...live.map(e => e.timestamp))
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return summaries
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get all topics that have cached data.
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
getTopics () {
|
|
105
|
+
return this.getTopicSummaries().map(s => s.topic)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Inject an envelope from the local HTTP API (for app submission).
|
|
110
|
+
* @param {object} envelope — full data envelope object
|
|
111
|
+
* @returns {{ accepted: boolean, error?: string }}
|
|
112
|
+
*/
|
|
113
|
+
injectEnvelope (envelope) {
|
|
114
|
+
const result = this._validateAndStore(envelope)
|
|
115
|
+
if (result.accepted) {
|
|
116
|
+
this._forward(null, envelope)
|
|
117
|
+
}
|
|
118
|
+
return result
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @private */
|
|
122
|
+
_handleMessage (pubkeyHex, message) {
|
|
123
|
+
switch (message.type) {
|
|
124
|
+
case 'data':
|
|
125
|
+
this._onData(pubkeyHex, message)
|
|
126
|
+
break
|
|
127
|
+
case 'topics':
|
|
128
|
+
this._onTopics(pubkeyHex, message)
|
|
129
|
+
break
|
|
130
|
+
case 'data_request':
|
|
131
|
+
this._onDataRequest(pubkeyHex, message)
|
|
132
|
+
break
|
|
133
|
+
case 'data_response':
|
|
134
|
+
this._onDataResponse(pubkeyHex, message)
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate an envelope (field presence, size, TTL, freshness, dedup, signature).
|
|
141
|
+
* If valid, marks as seen and stores. Does NOT forward.
|
|
142
|
+
* @private
|
|
143
|
+
* @param {object} msg
|
|
144
|
+
* @returns {{ accepted: boolean, error?: string }}
|
|
145
|
+
*/
|
|
146
|
+
_validateAndStore (msg) {
|
|
147
|
+
if (!msg.topic || !msg.payload || !msg.pubkeyHex ||
|
|
148
|
+
!msg.timestamp || !msg.ttl || !msg.signature) {
|
|
149
|
+
return { accepted: false, error: 'missing_fields' }
|
|
150
|
+
}
|
|
151
|
+
if (Buffer.byteLength(msg.payload, 'utf8') > this._maxPayloadBytes) {
|
|
152
|
+
return { accepted: false, error: 'payload_too_large' }
|
|
153
|
+
}
|
|
154
|
+
if (msg.ttl > this._maxTtl) {
|
|
155
|
+
return { accepted: false, error: 'ttl_too_large' }
|
|
156
|
+
}
|
|
157
|
+
const now = Math.floor(Date.now() / 1000)
|
|
158
|
+
if (msg.timestamp > now + this._maxFutureSecs) {
|
|
159
|
+
return { accepted: false, error: 'timestamp_future' }
|
|
160
|
+
}
|
|
161
|
+
if (msg.timestamp + msg.ttl < now) {
|
|
162
|
+
return { accepted: false, error: 'expired_ttl' }
|
|
163
|
+
}
|
|
164
|
+
const dedupKey = this._envelopeHash(msg)
|
|
165
|
+
if (this._seen.has(dedupKey)) {
|
|
166
|
+
return { accepted: false, error: 'duplicate' }
|
|
167
|
+
}
|
|
168
|
+
if (!this._verifyEnvelope(msg)) {
|
|
169
|
+
return { accepted: false, error: 'invalid_signature' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._addSeen(dedupKey)
|
|
173
|
+
this._store(msg)
|
|
174
|
+
this.emit('data:new', {
|
|
175
|
+
topic: msg.topic,
|
|
176
|
+
payload: msg.payload,
|
|
177
|
+
pubkeyHex: msg.pubkeyHex,
|
|
178
|
+
timestamp: msg.timestamp,
|
|
179
|
+
ttl: msg.ttl
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return { accepted: true }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Process an incoming data envelope (gossip push).
|
|
187
|
+
* Validates, stores, and forwards to interested peers.
|
|
188
|
+
* @private
|
|
189
|
+
* @param {string|null} sourcePubkey — peer that sent it (null = local injection)
|
|
190
|
+
* @param {object} msg
|
|
191
|
+
* @returns {boolean} true if accepted
|
|
192
|
+
*/
|
|
193
|
+
_onData (sourcePubkey, msg) {
|
|
194
|
+
const result = this._validateAndStore(msg)
|
|
195
|
+
if (!result.accepted) return false
|
|
196
|
+
|
|
197
|
+
this._forward(sourcePubkey, msg)
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Process a topics declaration from a peer.
|
|
203
|
+
* @private
|
|
204
|
+
*/
|
|
205
|
+
_onTopics (pubkeyHex, msg) {
|
|
206
|
+
if (!Array.isArray(msg.interests) || !msg.pubkeyHex ||
|
|
207
|
+
!msg.timestamp || !msg.signature) {
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Verify signature
|
|
212
|
+
const preimage = `${msg.interests.join(',')}${msg.timestamp}`
|
|
213
|
+
const dataHex = Buffer.from(preimage, 'utf8').toString('hex')
|
|
214
|
+
try {
|
|
215
|
+
if (!verifyHash(dataHex, msg.signature, msg.pubkeyHex)) return
|
|
216
|
+
} catch {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this._peerInterests.set(pubkeyHex, msg.interests)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Respond to a data_request with local cached envelopes.
|
|
225
|
+
* @private
|
|
226
|
+
*/
|
|
227
|
+
_onDataRequest (pubkeyHex, msg) {
|
|
228
|
+
if (!msg.topic) return
|
|
229
|
+
|
|
230
|
+
const since = msg.since || 0
|
|
231
|
+
const limit = Math.min(Math.max(msg.limit ?? 10, 1), 100)
|
|
232
|
+
|
|
233
|
+
this._pruneExpired(msg.topic)
|
|
234
|
+
const buffer = this._topicBuffers.get(msg.topic) || []
|
|
235
|
+
const filtered = buffer.filter(e => e.timestamp > since)
|
|
236
|
+
const envelopes = filtered.slice(0, limit)
|
|
237
|
+
|
|
238
|
+
const conn = this.peerManager.peers.get(pubkeyHex)
|
|
239
|
+
if (conn) {
|
|
240
|
+
conn.send({
|
|
241
|
+
type: 'data_response',
|
|
242
|
+
topic: msg.topic,
|
|
243
|
+
envelopes,
|
|
244
|
+
hasMore: filtered.length > limit
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Request catch-up data from a peer.
|
|
251
|
+
* @param {string} peerPubkey — peer to query
|
|
252
|
+
* @param {string} topic — topic to catch up on
|
|
253
|
+
* @param {number} [since=0] — Unix timestamp, return envelopes newer than this
|
|
254
|
+
* @param {number} [limit=10]
|
|
255
|
+
*/
|
|
256
|
+
requestData (peerPubkey, topic, since = 0, limit = 10) {
|
|
257
|
+
const conn = this.peerManager.peers.get(peerPubkey)
|
|
258
|
+
if (conn) {
|
|
259
|
+
conn.send({ type: 'data_request', topic, since, limit })
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Process an incoming data_response — ingest valid envelopes from catch-up.
|
|
265
|
+
* @private
|
|
266
|
+
*/
|
|
267
|
+
_onDataResponse (pubkeyHex, msg) {
|
|
268
|
+
if (!msg.topic || !Array.isArray(msg.envelopes)) return
|
|
269
|
+
let ingested = 0
|
|
270
|
+
for (const envelope of msg.envelopes) {
|
|
271
|
+
// Validate and store only — do NOT forward (catch-up is point-to-point)
|
|
272
|
+
const result = this._validateAndStore({ ...envelope, type: 'data' })
|
|
273
|
+
if (result.accepted) {
|
|
274
|
+
ingested++
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (ingested > 0) {
|
|
278
|
+
this.emit('data:catchup', { topic: msg.topic, count: ingested, from: pubkeyHex })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Remove expired envelopes from a topic buffer.
|
|
284
|
+
* @private
|
|
285
|
+
*/
|
|
286
|
+
_pruneExpired (topic) {
|
|
287
|
+
const buffer = this._topicBuffers.get(topic)
|
|
288
|
+
if (!buffer) return
|
|
289
|
+
const now = Math.floor(Date.now() / 1000)
|
|
290
|
+
const live = buffer.filter(e => e.timestamp + e.ttl >= now)
|
|
291
|
+
if (live.length === 0) {
|
|
292
|
+
this._topicBuffers.delete(topic)
|
|
293
|
+
} else if (live.length < buffer.length) {
|
|
294
|
+
this._topicBuffers.set(topic, live)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Add a hash to the bounded dedup set with FIFO eviction.
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
_addSeen (hash) {
|
|
303
|
+
this._seen.set(hash, this._seenCounter++)
|
|
304
|
+
if (this._seen.size > this._maxSeenSize) {
|
|
305
|
+
// Evict the oldest entry (first key in insertion-order Map)
|
|
306
|
+
const oldest = this._seen.keys().next().value
|
|
307
|
+
this._seen.delete(oldest)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Forward an envelope to all peers with matching interest, except source.
|
|
313
|
+
* @private
|
|
314
|
+
*/
|
|
315
|
+
_forward (sourcePubkey, msg) {
|
|
316
|
+
for (const [peerPub, conn] of this.peerManager.peers) {
|
|
317
|
+
if (peerPub === sourcePubkey) continue
|
|
318
|
+
if (!this._peerMatchesTopic(peerPub, msg.topic)) continue
|
|
319
|
+
conn.send(msg)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if a peer has declared interest in a topic.
|
|
325
|
+
* @private
|
|
326
|
+
*/
|
|
327
|
+
_peerMatchesTopic (peerPub, topic) {
|
|
328
|
+
const interests = this._peerInterests.get(peerPub)
|
|
329
|
+
if (!interests) return false
|
|
330
|
+
for (const prefix of interests) {
|
|
331
|
+
if (prefix === '*') return true
|
|
332
|
+
if (topic.startsWith(prefix)) return true
|
|
333
|
+
}
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Store an envelope in the per-topic ring buffer.
|
|
339
|
+
* @private
|
|
340
|
+
*/
|
|
341
|
+
_store (envelope) {
|
|
342
|
+
let buffer = this._topicBuffers.get(envelope.topic)
|
|
343
|
+
if (!buffer) {
|
|
344
|
+
buffer = []
|
|
345
|
+
this._topicBuffers.set(envelope.topic, buffer)
|
|
346
|
+
}
|
|
347
|
+
buffer.push(envelope)
|
|
348
|
+
while (buffer.length > this._maxPerTopic) {
|
|
349
|
+
buffer.shift()
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Verify the ECDSA signature on a data envelope.
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
_verifyEnvelope (msg) {
|
|
358
|
+
const preimage = `${msg.topic}${msg.payload}${msg.timestamp}${msg.ttl}`
|
|
359
|
+
const dataHex = Buffer.from(preimage, 'utf8').toString('hex')
|
|
360
|
+
try {
|
|
361
|
+
return verifyHash(dataHex, msg.signature, msg.pubkeyHex)
|
|
362
|
+
} catch {
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Compute a dedup hash for an envelope.
|
|
369
|
+
* @private
|
|
370
|
+
*/
|
|
371
|
+
_envelopeHash (msg) {
|
|
372
|
+
const input = `${msg.pubkeyHex}:${msg.topic}:${msg.payload}:${msg.timestamp}`
|
|
373
|
+
return createHash('sha256').update(input).digest('hex')
|
|
374
|
+
}
|
|
375
|
+
}
|