@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/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.on('error', (err) => {
198
- this.emit('error', err)
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
+ }