@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.
@@ -1,1065 +1,1120 @@
1
- import { createServer } from 'node:http'
2
- import { createHash } from 'node:crypto'
3
- import { readFileSync } from 'node:fs'
4
- import { join, dirname } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import https from 'node:https'
7
- import { parseTx } from './output-parser.js'
8
- import { scanAddress } from './address-scanner.js'
9
-
10
- /**
11
- * StatusServer — localhost-only HTTP server exposing bridge status.
12
- *
13
- * Started by `relay-bridge start`, queried by `relay-bridge status`.
14
- * Binds to 127.0.0.1 only not accessible from outside the machine.
15
- *
16
- * Endpoints:
17
- * GET / — HTML dashboard (auto-refreshes every 5s)
18
- * GET /status — JSON object with bridge state
19
- */
20
-
21
- const __dirname = dirname(fileURLToPath(import.meta.url))
22
- const DASHBOARD_HTML = readFileSync(join(__dirname, '..', 'dashboard', 'index.html'), 'utf8')
23
- export class StatusServer {
24
- /**
25
- * @param {object} opts
26
- * @param {number} [opts.port=9333] — HTTP port for status endpoint
27
- * @param {import('./peer-manager.js').PeerManager} [opts.peerManager]
28
- * @param {import('./header-relay.js').HeaderRelay} [opts.headerRelay]
29
- * @param {import('./tx-relay.js').TxRelay} [opts.txRelay]
30
- * @param {object} [opts.config] — Bridge config (pubkeyHex, endpoint, meshId)
31
- * @param {object} [opts.bsvNodeClient] — BSV P2P node client (2.26)
32
- * @param {object} [opts.store] — PersistentStore for wallet balance (2.27)
33
- */
34
- constructor (opts = {}) {
35
- this._port = opts.port || 9333
36
- this._peerManager = opts.peerManager || null
37
- this._headerRelay = opts.headerRelay || null
38
- this._txRelay = opts.txRelay || null
39
- this._config = opts.config || {}
40
- this._scorer = opts.scorer || null
41
- this._peerHealth = opts.peerHealth || null
42
- this._bsvNodeClient = opts.bsvNodeClient || null
43
- this._store = opts.store || null
44
- this._performOutboundHandshake = opts.performOutboundHandshake || null
45
- this._registeredPubkeys = opts.registeredPubkeys || null
46
- this._gossipManager = opts.gossipManager || null
47
- this._startedAt = Date.now()
48
- this._server = null
49
-
50
- // Job system for async actions (register, deregister)
51
- this._jobs = new Map()
52
- this._jobCounter = 0
53
-
54
- // Log ring buffer — max 500 entries
55
- this._logs = []
56
- this._logListeners = new Set()
57
- this._maxLogs = 500
58
-
59
- // App monitoring state
60
- this._appChecks = new Map()
61
- this._requestTracker = new Map()
62
- this._appSSLCache = new Map()
63
- this._appBridgeDomains = new Set()
64
- this._appCheckInterval = null
65
- this._addressCache = new Map()
66
- if (this._config.apps) {
67
- for (const app of this._config.apps) {
68
- this._appChecks.set(app.url, { checks: [], lastError: null })
69
- if (app.bridgeDomain) {
70
- this._appBridgeDomains.add(app.bridgeDomain)
71
- this._requestTracker.set(app.bridgeDomain, { total: 0, endpoints: {}, lastSeen: null })
72
- }
73
- try { this._appBridgeDomains.add(new URL(app.url).hostname) } catch {}
74
- }
75
- }
76
- }
77
-
78
- /**
79
- * Build the status object from current bridge state.
80
- * @param {object} [opts]
81
- * @param {boolean} [opts.authenticated=false] Include operator-only fields
82
- * @returns {Promise<object>}
83
- */
84
- async getStatus ({ authenticated = false } = {}) {
85
- const peers = []
86
- if (this._peerManager) {
87
- for (const [pubkeyHex, conn] of this._peerManager.peers) {
88
- const entry = {
89
- pubkeyHex,
90
- endpoint: conn.endpoint,
91
- connected: !!conn.connected
92
- }
93
- if (this._scorer) {
94
- entry.score = Math.round(this._scorer.getScore(pubkeyHex) * 100) / 100
95
- const metrics = this._scorer.getMetrics(pubkeyHex)
96
- if (metrics) {
97
- entry.scoreBreakdown = {
98
- uptime: Math.round(metrics.uptime * 100) / 100,
99
- responseTime: Math.round(metrics.responseTime * 100) / 100,
100
- dataAccuracy: Math.round(metrics.dataAccuracy * 100) / 100,
101
- stakeAge: Math.round(metrics.stakeAge * 100) / 100,
102
- raw: metrics.raw
103
- }
104
- }
105
- }
106
- if (this._peerHealth) {
107
- entry.health = this._peerHealth.getStatus(pubkeyHex)
108
- }
109
- peers.push(entry)
110
- }
111
- }
112
-
113
- const status = {
114
- bridge: {
115
- name: this._config.name || null,
116
- pubkeyHex: this._config.pubkeyHex || null,
117
- meshId: this._config.meshId || null,
118
- uptimeSeconds: Math.floor((Date.now() - this._startedAt) / 1000)
119
- },
120
- peers: {
121
- connected: this._peerManager ? this._peerManager.connectedCount() : 0,
122
- list: peers
123
- },
124
- headers: {
125
- bestHeight: this._headerRelay ? this._headerRelay.bestHeight : -1,
126
- bestHash: this._headerRelay ? this._headerRelay.bestHash : null,
127
- count: this._headerRelay ? this._headerRelay.headers.size : 0
128
- },
129
- txs: {
130
- mempool: this._txRelay ? this._txRelay.mempool.size : 0,
131
- seen: this._txRelay ? this._txRelay.seen.size : 0
132
- },
133
- bsvNode: {
134
- connected: this._bsvNodeClient ? this._bsvNodeClient.connectedCount > 0 : false,
135
- peers: this._bsvNodeClient ? this._bsvNodeClient.connectedCount : 0,
136
- height: this._bsvNodeClient ? this._bsvNodeClient.bestHeight : null
137
- }
138
- }
139
-
140
- // Operator-only fields
141
- if (authenticated) {
142
- status.operator = true
143
- status.bridge.endpoint = this._config.endpoint || null
144
- status.bridge.domains = this._config.domains || []
145
- try {
146
- const { PrivateKey } = await import('@bsv/sdk')
147
- status.bridge.address = PrivateKey.fromWif(this._config.wif).toPublicKey().toAddress()
148
- } catch {
149
- status.bridge.address = this._config.address || null
150
- }
151
- status.wallet = { balanceSats: null, utxoCount: 0 }
152
- if (this._store) {
153
- try { status.wallet.balanceSats = await this._store.getBalance() } catch {}
154
- try { status.wallet.utxoCount = (await this._store.getUnspentUtxos()).length } catch {}
155
- }
156
- }
157
-
158
- return status
159
- }
160
-
161
- /**
162
- * Check if a request is authenticated via statusSecret.
163
- * @param {import('node:http').IncomingMessage} req
164
- * @returns {boolean}
165
- */
166
- _checkAuth (req) {
167
- const secret = this._config.statusSecret
168
- if (!secret) return false
169
-
170
- // Check ?auth= query param
171
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
172
- const authParam = url.searchParams.get('auth')
173
- if (authParam === secret) return true
174
-
175
- // Check Authorization: Bearer header
176
- const authHeader = req.headers.authorization
177
- if (authHeader && authHeader.startsWith('Bearer ') && authHeader.slice(7) === secret) return true
178
-
179
- return false
180
- }
181
-
182
- /**
183
- * Add a log entry to the ring buffer and notify SSE listeners.
184
- * @param {string} message
185
- */
186
- addLog (message) {
187
- const entry = { timestamp: Date.now(), message }
188
- this._logs.push(entry)
189
- if (this._logs.length > this._maxLogs) {
190
- this._logs.shift()
191
- }
192
- // Notify SSE listeners
193
- for (const listener of this._logListeners) {
194
- listener(entry)
195
- }
196
- }
197
-
198
- /**
199
- * Create a job for tracking async actions.
200
- * @returns {{ jobId: string, log: function }}
201
- */
202
- _createJob () {
203
- const jobId = `job_${++this._jobCounter}_${Date.now()}`
204
- const job = { status: 'running', events: [], done: false, listeners: new Set() }
205
- this._jobs.set(jobId, job)
206
-
207
- // Auto-cleanup after 5 minutes
208
- setTimeout(() => this._jobs.delete(jobId), 5 * 60 * 1000)
209
-
210
- const log = (type, message, data) => {
211
- const event = { type, message, data, timestamp: Date.now() }
212
- job.events.push(event)
213
- if (type === 'done' || type === 'error') {
214
- job.status = type === 'error' ? 'failed' : 'completed'
215
- job.done = true
216
- }
217
- // Notify SSE listeners
218
- for (const listener of job.listeners) {
219
- listener(event)
220
- }
221
- }
222
-
223
- return { jobId, log }
224
- }
225
-
226
- /**
227
- * Read the full JSON body from a request.
228
- * @param {import('node:http').IncomingMessage} req
229
- * @returns {Promise<object>}
230
- */
231
- _readBody (req) {
232
- return new Promise((resolve, reject) => {
233
- let body = ''
234
- req.on('data', chunk => { body += chunk })
235
- req.on('end', () => {
236
- try { resolve(body ? JSON.parse(body) : {}) } catch (e) { reject(e) }
237
- })
238
- req.on('error', reject)
239
- })
240
- }
241
-
242
- /**
243
- * Check SSL certificate for a hostname.
244
- */
245
- _checkSSL (hostname) {
246
- return new Promise((resolve) => {
247
- const req = https.request({ hostname, port: 443, method: 'HEAD', rejectUnauthorized: false, timeout: 5000 }, (res) => {
248
- const cert = res.socket.getPeerCertificate()
249
- if (!cert || !cert.valid_to) { resolve(null); req.destroy(); return }
250
- resolve({
251
- valid: res.socket.authorized,
252
- issuer: cert.issuer?.O || cert.issuer?.CN || 'Unknown',
253
- expiresAt: new Date(cert.valid_to).toISOString(),
254
- daysRemaining: Math.floor((new Date(cert.valid_to) - Date.now()) / 86400000)
255
- })
256
- req.destroy()
257
- })
258
- req.on('error', () => resolve(null))
259
- req.setTimeout(5000, () => { req.destroy(); resolve(null) })
260
- req.end()
261
- })
262
- }
263
-
264
- /**
265
- * Health-check a single app.
266
- */
267
- async _checkApp (app) {
268
- const entry = this._appChecks.get(app.url)
269
- if (!entry) return
270
- const start = Date.now()
271
- let statusCode = 0
272
- let up = false
273
- let errorMsg = null
274
- try {
275
- const controller = new AbortController()
276
- const timeout = setTimeout(() => controller.abort(), 5000)
277
- const res = await fetch(app.healthUrl || app.url, { method: app.healthUrl ? 'GET' : 'HEAD', signal: controller.signal, redirect: 'follow' })
278
- clearTimeout(timeout)
279
- statusCode = res.status
280
- up = statusCode >= 200 && statusCode < 400
281
- } catch (err) {
282
- errorMsg = err.message || 'Request failed'
283
- }
284
- const check = { timestamp: new Date().toISOString(), up, statusCode, responseTimeMs: Date.now() - start }
285
- entry.checks.push(check)
286
- if (entry.checks.length > 100) entry.checks.shift()
287
- if (!up) entry.lastError = { message: errorMsg || `HTTP ${statusCode}`, timestamp: check.timestamp }
288
- }
289
-
290
- /**
291
- * Run health checks on all configured apps.
292
- */
293
- async _checkAllApps () {
294
- if (!this._config.apps) return
295
- for (const app of this._config.apps) {
296
- await this._checkApp(app)
297
- }
298
- }
299
-
300
- /**
301
- * Start background app health monitoring (30s interval).
302
- */
303
- startAppMonitoring () {
304
- if (!this._config.apps || this._config.apps.length === 0) return
305
- this._checkAllApps()
306
- this._appCheckInterval = setInterval(() => this._checkAllApps(), 30000)
307
- }
308
-
309
- /**
310
- * Stop background app health monitoring.
311
- */
312
- stopAppMonitoring () {
313
- if (this._appCheckInterval) {
314
- clearInterval(this._appCheckInterval)
315
- this._appCheckInterval = null
316
- }
317
- }
318
-
319
- /**
320
- * Start the HTTP server on localhost.
321
- * @returns {Promise<void>}
322
- */
323
- start () {
324
- return new Promise((resolve, reject) => {
325
- this._server = createServer((req, res) => {
326
- // CORS headers for federation dashboard
327
- res.setHeader('Access-Control-Allow-Origin', '*')
328
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
329
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
330
-
331
- if (req.method === 'OPTIONS') {
332
- res.writeHead(204)
333
- res.end()
334
- return
335
- }
336
-
337
- this._handleRequest(req, res).catch(() => {
338
- res.writeHead(500)
339
- res.end('Internal Server Error')
340
- })
341
- })
342
-
343
- this._server.listen(this._port, '0.0.0.0', () => resolve())
344
- this._server.on('error', reject)
345
- })
346
- }
347
-
348
- /**
349
- * Route incoming HTTP requests.
350
- * @param {import('node:http').IncomingMessage} req
351
- * @param {import('node:http').ServerResponse} res
352
- */
353
- async _handleRequest (req, res) {
354
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
355
- const path = url.pathname
356
- const authenticated = this._checkAuth(req)
357
-
358
- // Track requests from known app domains
359
- const origin = req.headers.origin || req.headers.referer || ''
360
- const host = (req.headers.host || '').split(':')[0]
361
- let trackDomain = null
362
- if (origin) { try { trackDomain = new URL(origin).hostname } catch {} }
363
- if (!trackDomain && host && this._appBridgeDomains.has(host)) trackDomain = host
364
- if (trackDomain && this._appBridgeDomains.has(trackDomain)) {
365
- let bridgeDomain = trackDomain
366
- if (this._config.apps) {
367
- for (const app of this._config.apps) {
368
- try { if (trackDomain === new URL(app.url).hostname) { bridgeDomain = app.bridgeDomain; break } } catch {}
369
- }
370
- }
371
- const data = this._requestTracker.get(bridgeDomain)
372
- if (data) {
373
- data.total++
374
- let ep = path
375
- if (path.startsWith('/tx/')) ep = '/tx/:txid'
376
- else if (path.startsWith('/inscription/')) ep = '/inscription/:content'
377
- else if (path.startsWith('/jobs/')) ep = '/jobs/:id'
378
- data.endpoints[ep] = (data.endpoints[ep] || 0) + 1
379
- data.lastSeen = new Date().toISOString()
380
- }
381
- }
382
-
383
- // GET /status public or operator status
384
- if (req.method === 'GET' && path === '/status') {
385
- const status = await this.getStatus({ authenticated })
386
- res.writeHead(200, { 'Content-Type': 'application/json' })
387
- res.end(JSON.stringify(status))
388
- return
389
- }
390
-
391
- // GET /mempool — public decoded mempool transactions
392
- if (req.method === 'GET' && path === '/mempool') {
393
- const txs = []
394
- if (this._txRelay) {
395
- for (const [txid, rawHex] of this._txRelay.mempool) {
396
- try {
397
- const parsed = parseTx(rawHex)
398
- txs.push({
399
- txid,
400
- size: rawHex.length / 2,
401
- inputs: parsed.inputs,
402
- outputs: parsed.outputs.map(o => ({
403
- vout: o.vout,
404
- satoshis: o.satoshis,
405
- isP2PKH: o.isP2PKH,
406
- hash160: o.hash160,
407
- type: o.type,
408
- data: o.data,
409
- protocol: o.protocol,
410
- parsed: o.parsed
411
- }))
412
- })
413
- } catch {
414
- txs.push({ txid, size: rawHex.length / 2, inputs: [], outputs: [], error: 'decode failed' })
415
- }
416
- }
417
- }
418
- res.writeHead(200, { 'Content-Type': 'application/json' })
419
- res.end(JSON.stringify({ count: txs.length, txs }))
420
- return
421
- }
422
-
423
- // GET /discover — public list of all known bridges in the mesh
424
- if (req.method === 'GET' && path === '/discover') {
425
- const bridges = []
426
- // Add self
427
- bridges.push({
428
- name: this._config.name || null,
429
- pubkeyHex: this._config.pubkeyHex || null,
430
- endpoint: this._config.endpoint || null,
431
- meshId: this._config.meshId || null,
432
- statusUrl: 'http://' + (req.headers.host || '127.0.0.1:' + this._port) + '/status'
433
- })
434
- // Add gossip directory (all known peers)
435
- if (this._gossipManager) {
436
- for (const peer of this._gossipManager.getDirectory()) {
437
- // Derive statusUrl from ws endpoint: ws://host:8333 http://host:9333
438
- let statusUrl = null
439
- try {
440
- const u = new URL(peer.endpoint)
441
- const statusPort = parseInt(u.port, 10) + 1000
442
- statusUrl = 'http://' + u.hostname + ':' + statusPort + '/status'
443
- } catch {}
444
- bridges.push({
445
- pubkeyHex: peer.pubkeyHex,
446
- endpoint: peer.endpoint,
447
- meshId: peer.meshId || null,
448
- statusUrl
449
- })
450
- }
451
- }
452
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
453
- res.end(JSON.stringify({ count: bridges.length, bridges }))
454
- return
455
- }
456
-
457
- // GET / or /dashboard — built-in HTML dashboard
458
- if (req.method === 'GET' && (path === '/' || path === '/dashboard')) {
459
- res.writeHead(200, { 'Content-Type': 'text/html' })
460
- res.end(DASHBOARD_HTML)
461
- return
462
- }
463
-
464
- // POST /broadcast — relay a raw tx to peers
465
- if (req.method === 'POST' && path === '/broadcast') {
466
- const body = await this._readBody(req)
467
- const { rawHex } = body
468
- if (!rawHex || typeof rawHex !== 'string') {
469
- res.writeHead(400, { 'Content-Type': 'application/json' })
470
- res.end(JSON.stringify({ error: 'rawHex required' }))
471
- return
472
- }
473
- const buf = Buffer.from(rawHex, 'hex')
474
- const hash = createHash('sha256').update(createHash('sha256').update(buf).digest()).digest()
475
- const txid = Buffer.from(hash).reverse().toString('hex')
476
- const sent = this._txRelay ? this._txRelay.broadcastTx(txid, rawHex) : 0
477
- res.writeHead(200, { 'Content-Type': 'application/json' })
478
- res.end(JSON.stringify({ txid, peers: sent }))
479
- return
480
- }
481
-
482
- // GET /tx/:txid — fetch and parse transaction with full protocol support
483
- if (req.method === 'GET' && path.startsWith('/tx/')) {
484
- const txid = path.slice(4)
485
- if (!txid || txid.length !== 64) {
486
- res.writeHead(400, { 'Content-Type': 'application/json' })
487
- res.end(JSON.stringify({ error: 'Invalid txid' }))
488
- return
489
- }
490
-
491
- let rawHex = null
492
- let source = null
493
-
494
- // Check mempool first
495
- if (this._txRelay && this._txRelay.mempool.has(txid)) {
496
- rawHex = this._txRelay.mempool.get(txid)
497
- source = 'mempool'
498
- }
499
-
500
- // Try P2P
501
- if (!rawHex && this._bsvNodeClient) {
502
- try {
503
- const result = await this._bsvNodeClient.getTx(txid, 5000)
504
- rawHex = result.rawHex
505
- source = 'p2p'
506
- } catch {}
507
- }
508
-
509
- // Fall back to WoC
510
- if (!rawHex) {
511
- try {
512
- const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
513
- if (!resp.ok) throw new Error(`WoC ${resp.status}`)
514
- rawHex = await resp.text()
515
- source = 'woc'
516
- } catch (err) {
517
- res.writeHead(404, { 'Content-Type': 'application/json' })
518
- res.end(JSON.stringify({ error: `tx not found: ${err.message}` }))
519
- return
520
- }
521
- }
522
-
523
- // Parse with full protocol support
524
- try {
525
- const parsed = parseTx(rawHex)
526
- res.writeHead(200, { 'Content-Type': 'application/json' })
527
- res.end(JSON.stringify({
528
- txid: parsed.txid,
529
- source,
530
- size: rawHex.length / 2,
531
- inputs: parsed.inputs,
532
- outputs: parsed.outputs
533
- }))
534
- } catch (err) {
535
- res.writeHead(200, { 'Content-Type': 'application/json' })
536
- res.end(JSON.stringify({ txid, source, size: rawHex.length / 2, error: 'parse failed: ' + err.message }))
537
- }
538
- return
539
- }
540
-
541
- // POST /register — operator: start async registration
542
- if (req.method === 'POST' && path === '/register') {
543
- if (!authenticated) {
544
- res.writeHead(401, { 'Content-Type': 'application/json' })
545
- res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
546
- return
547
- }
548
- const { runRegister } = await import('./actions.js')
549
- const { jobId, log } = this._createJob()
550
- res.writeHead(202, { 'Content-Type': 'application/json' })
551
- res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
552
- // Run async — don't await
553
- runRegister({ config: this._config, store: this._store, log }).catch(err => {
554
- log('error', err.message)
555
- })
556
- return
557
- }
558
-
559
- // POST /deregister — operator: start async deregistration
560
- if (req.method === 'POST' && path === '/deregister') {
561
- if (!authenticated) {
562
- res.writeHead(401, { 'Content-Type': 'application/json' })
563
- res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
564
- return
565
- }
566
- const { runDeregister } = await import('./actions.js')
567
- const body = await this._readBody(req)
568
- const { jobId, log } = this._createJob()
569
- res.writeHead(202, { 'Content-Type': 'application/json' })
570
- res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
571
- runDeregister({ config: this._config, store: this._store, reason: body.reason || 'shutdown', log }).catch(err => {
572
- log('error', err.message)
573
- })
574
- return
575
- }
576
-
577
- // POST /fund — operator: store a funding tx (synchronous)
578
- if (req.method === 'POST' && path === '/fund') {
579
- if (!authenticated) {
580
- res.writeHead(401, { 'Content-Type': 'application/json' })
581
- res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
582
- return
583
- }
584
- const { runFund } = await import('./actions.js')
585
- const body = await this._readBody(req)
586
- if (!body.rawHex) {
587
- res.writeHead(400, { 'Content-Type': 'application/json' })
588
- res.end(JSON.stringify({ error: 'rawHex required' }))
589
- return
590
- }
591
- try {
592
- const result = await runFund({ config: this._config, store: this._store, rawHex: body.rawHex, log: () => {} })
593
- res.writeHead(200, { 'Content-Type': 'application/json' })
594
- res.end(JSON.stringify(result))
595
- } catch (err) {
596
- res.writeHead(400, { 'Content-Type': 'application/json' })
597
- res.end(JSON.stringify({ error: err.message }))
598
- }
599
- return
600
- }
601
-
602
- // POST /connect — operator: connect to a peer endpoint
603
- if (req.method === 'POST' && path === '/connect') {
604
- if (!authenticated) {
605
- res.writeHead(401, { 'Content-Type': 'application/json' })
606
- res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
607
- return
608
- }
609
- const body = await this._readBody(req)
610
- if (!body.endpoint) {
611
- res.writeHead(400, { 'Content-Type': 'application/json' })
612
- res.end(JSON.stringify({ error: 'endpoint required (e.g. ws://host:port)' }))
613
- return
614
- }
615
- if (!this._peerManager || !this._performOutboundHandshake) {
616
- res.writeHead(500, { 'Content-Type': 'application/json' })
617
- res.end(JSON.stringify({ error: 'Bridge not running — peer manager unavailable' }))
618
- return
619
- }
620
- try {
621
- const conn = this._peerManager.connectToPeer({ endpoint: body.endpoint })
622
- if (conn) {
623
- conn.on('open', () => this._performOutboundHandshake(conn))
624
- res.writeHead(200, { 'Content-Type': 'application/json' })
625
- res.end(JSON.stringify({ endpoint: body.endpoint, status: 'connecting' }))
626
- } else {
627
- res.writeHead(200, { 'Content-Type': 'application/json' })
628
- res.end(JSON.stringify({ endpoint: body.endpoint, status: 'already_connected_or_failed' }))
629
- }
630
- } catch (err) {
631
- res.writeHead(400, { 'Content-Type': 'application/json' })
632
- res.end(JSON.stringify({ error: err.message }))
633
- }
634
- return
635
- }
636
-
637
- // POST /send — operator: send BSV from bridge wallet
638
- if (req.method === 'POST' && path === '/send') {
639
- if (!authenticated) {
640
- res.writeHead(401, { 'Content-Type': 'application/json' })
641
- res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
642
- return
643
- }
644
- const { runSend } = await import('./actions.js')
645
- const body = await this._readBody(req)
646
- if (!body.toAddress || !body.amount) {
647
- res.writeHead(400, { 'Content-Type': 'application/json' })
648
- res.end(JSON.stringify({ error: 'toAddress and amount required' }))
649
- return
650
- }
651
- const { jobId, log } = this._createJob()
652
- res.writeHead(202, { 'Content-Type': 'application/json' })
653
- res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
654
- runSend({ config: this._config, store: this._store, toAddress: body.toAddress, amount: Number(body.amount), log }).catch(err => {
655
- log('error', err.message)
656
- })
657
- return
658
- }
659
-
660
- // GET /jobs/:id — SSE stream for job progress
661
- if (req.method === 'GET' && path.startsWith('/jobs/')) {
662
- const jobId = path.slice(6)
663
- const job = this._jobs.get(jobId)
664
- if (!job) {
665
- res.writeHead(404, { 'Content-Type': 'application/json' })
666
- res.end(JSON.stringify({ error: 'Job not found' }))
667
- return
668
- }
669
- res.writeHead(200, {
670
- 'Content-Type': 'text/event-stream',
671
- 'Cache-Control': 'no-cache',
672
- Connection: 'keep-alive'
673
- })
674
- // Replay past events
675
- for (const event of job.events) {
676
- res.write(`data: ${JSON.stringify(event)}\n\n`)
677
- }
678
- if (job.done) {
679
- res.write(`data: ${JSON.stringify({ type: 'end', status: job.status })}\n\n`)
680
- res.end()
681
- return
682
- }
683
- // Stream new events
684
- const listener = (event) => {
685
- res.write(`data: ${JSON.stringify(event)}\n\n`)
686
- if (event.type === 'done' || event.type === 'error') {
687
- res.write(`data: ${JSON.stringify({ type: 'end', status: event.type === 'error' ? 'failed' : 'completed' })}\n\n`)
688
- res.end()
689
- job.listeners.delete(listener)
690
- }
691
- }
692
- job.listeners.add(listener)
693
- req.on('close', () => job.listeners.delete(listener))
694
- return
695
- }
696
-
697
- // GET /logs — SSE stream of live bridge logs
698
- if (req.method === 'GET' && path === '/logs') {
699
- res.writeHead(200, {
700
- 'Content-Type': 'text/event-stream',
701
- 'Cache-Control': 'no-cache',
702
- Connection: 'keep-alive'
703
- })
704
- // Replay buffer
705
- for (const entry of this._logs) {
706
- res.write(`data: ${JSON.stringify(entry)}\n\n`)
707
- }
708
- // Stream new
709
- const listener = (entry) => {
710
- res.write(`data: ${JSON.stringify(entry)}\n\n`)
711
- }
712
- this._logListeners.add(listener)
713
- req.on('close', () => this._logListeners.delete(listener))
714
- return
715
- }
716
-
717
- // GET /inscriptions — query indexed inscriptions
718
- if (req.method === 'GET' && path === '/inscriptions') {
719
- if (!this._store) {
720
- res.writeHead(500, { 'Content-Type': 'application/json' })
721
- res.end(JSON.stringify({ error: 'Store not available' }))
722
- return
723
- }
724
- const mime = url.searchParams.get('mime')
725
- const address = url.searchParams.get('address')
726
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 200)
727
- try {
728
- const inscriptions = await this._store.getInscriptions({ mime, address, limit })
729
- const total = await this._store.getInscriptionCount()
730
- res.writeHead(200, { 'Content-Type': 'application/json' })
731
- res.end(JSON.stringify({ total, count: inscriptions.length, inscriptions, filters: { mime: mime || null, address: address || null } }))
732
- } catch (err) {
733
- res.writeHead(500, { 'Content-Type': 'application/json' })
734
- res.end(JSON.stringify({ error: err.message }))
735
- }
736
- return
737
- }
738
-
739
- // GET /address/:addr/history transaction history for an address (via WoC)
740
- const addrMatch = path.match(/^\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
741
- if (req.method === 'GET' && addrMatch) {
742
- const addr = addrMatch[1]
743
- const cached = this._addressCache.get(addr)
744
- if (cached && Date.now() - cached.time < 60000) {
745
- res.writeHead(200, { 'Content-Type': 'application/json' })
746
- res.end(JSON.stringify({ address: addr, history: cached.data, cached: true }))
747
- return
748
- }
749
- try {
750
- const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/history', { signal: AbortSignal.timeout(10000) })
751
- if (!resp.ok) throw new Error('WoC returned ' + resp.status)
752
- const history = await resp.json()
753
- this._addressCache.set(addr, { data: history, time: Date.now() })
754
- // Prune cache if it grows too large
755
- if (this._addressCache.size > 100) {
756
- const oldest = this._addressCache.keys().next().value
757
- this._addressCache.delete(oldest)
758
- }
759
- res.writeHead(200, { 'Content-Type': 'application/json' })
760
- res.end(JSON.stringify({ address: addr, history, cached: false }))
761
- } catch (err) {
762
- res.writeHead(502, { 'Content-Type': 'application/json' })
763
- res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
764
- }
765
- return
766
- }
767
-
768
- // GET /price — cached BSV/USD exchange rate
769
- if (req.method === 'GET' && path === '/price') {
770
- const now = Date.now()
771
- if (!this._priceCache || now - this._priceCache.timestamp > 60000) {
772
- try {
773
- const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/exchangerate')
774
- if (resp.ok) {
775
- const data = await resp.json()
776
- this._priceCache = { data, timestamp: now }
777
- }
778
- } catch {}
779
- }
780
- if (this._priceCache) {
781
- res.writeHead(200, { 'Content-Type': 'application/json' })
782
- res.end(JSON.stringify({
783
- usd: this._priceCache.data.rate || this._priceCache.data.USD,
784
- currency: 'USD',
785
- source: 'whatsonchain',
786
- cached: this._priceCache.timestamp,
787
- ttl: 60000
788
- }))
789
- return
790
- }
791
- res.writeHead(503, { 'Content-Type': 'application/json' })
792
- res.end(JSON.stringify({ error: 'Price unavailable' }))
793
- return
794
- }
795
-
796
- // GET /tokens list all deployed tokens
797
- if (req.method === 'GET' && path === '/tokens') {
798
- if (!this._store) {
799
- res.writeHead(500, { 'Content-Type': 'application/json' })
800
- res.end(JSON.stringify({ error: 'Store not available' }))
801
- return
802
- }
803
- const tokens = await this._store.listTokens()
804
- res.writeHead(200, { 'Content-Type': 'application/json' })
805
- res.end(JSON.stringify({ tokens }))
806
- return
807
- }
808
-
809
- // GET /token/:tick token deploy info
810
- const tokenMatch = path.match(/^\/token\/([^/]+)$/)
811
- if (req.method === 'GET' && tokenMatch) {
812
- if (!this._store) {
813
- res.writeHead(500, { 'Content-Type': 'application/json' })
814
- res.end(JSON.stringify({ error: 'Store not available' }))
815
- return
816
- }
817
- const token = await this._store.getToken(decodeURIComponent(tokenMatch[1]))
818
- if (!token) {
819
- res.writeHead(404, { 'Content-Type': 'application/json' })
820
- res.end(JSON.stringify({ error: 'Token not found' }))
821
- return
822
- }
823
- res.writeHead(200, { 'Content-Type': 'application/json' })
824
- res.end(JSON.stringify(token))
825
- return
826
- }
827
-
828
- // GET /token/:tick/balance/:scriptHash — token balance for owner
829
- const balMatch = path.match(/^\/token\/([^/]+)\/balance\/([0-9a-f]{64})$/)
830
- if (req.method === 'GET' && balMatch) {
831
- if (!this._store) {
832
- res.writeHead(500, { 'Content-Type': 'application/json' })
833
- res.end(JSON.stringify({ error: 'Store not available' }))
834
- return
835
- }
836
- const tick = decodeURIComponent(balMatch[1])
837
- const ownerScriptHash = balMatch[2]
838
- const balance = await this._store.getTokenBalance(tick, ownerScriptHash)
839
- res.writeHead(200, { 'Content-Type': 'application/json' })
840
- res.end(JSON.stringify({ tick, ownerScriptHash, balance }))
841
- return
842
- }
843
-
844
- // GET /tx/:txid/status — tx lifecycle state
845
- const statusMatch = path.match(/^\/tx\/([0-9a-f]{64})\/status$/)
846
- if (req.method === 'GET' && statusMatch) {
847
- if (!this._store) {
848
- res.writeHead(500, { 'Content-Type': 'application/json' })
849
- res.end(JSON.stringify({ error: 'Store not available' }))
850
- return
851
- }
852
- const txid = statusMatch[1]
853
- const status = await this._store.getTxStatus(txid)
854
- const block = await this._store.getTxBlock(txid)
855
- if (!status) {
856
- res.writeHead(404, { 'Content-Type': 'application/json' })
857
- res.end(JSON.stringify({ error: 'Transaction not found' }))
858
- return
859
- }
860
- res.writeHead(200, { 'Content-Type': 'application/json' })
861
- res.end(JSON.stringify({ txid, ...status, block: block || undefined }))
862
- return
863
- }
864
-
865
- // GET /proof/:txid — merkle proof for confirmed tx
866
- const proofMatch = path.match(/^\/proof\/([0-9a-f]{64})$/)
867
- if (req.method === 'GET' && proofMatch) {
868
- if (!this._store) {
869
- res.writeHead(500, { 'Content-Type': 'application/json' })
870
- res.end(JSON.stringify({ error: 'Store not available' }))
871
- return
872
- }
873
- const txid = proofMatch[1]
874
- const block = await this._store.getTxBlock(txid)
875
- if (!block || !block.proof) {
876
- res.writeHead(404, { 'Content-Type': 'application/json' })
877
- res.end(JSON.stringify({ error: 'Proof not available' }))
878
- return
879
- }
880
- res.writeHead(200, { 'Content-Type': 'application/json' })
881
- res.end(JSON.stringify({ txid, blockHash: block.blockHash, height: block.height, proof: block.proof }))
882
- return
883
- }
884
-
885
- // GET /inscription/:txid/:vout/content serve raw inscription content
886
- const inscMatch = path.match(/^\/inscription\/([0-9a-f]{64})\/(\d+)\/content$/)
887
- if (req.method === 'GET' && inscMatch) {
888
- if (!this._store) {
889
- res.writeHead(500, { 'Content-Type': 'text/plain' })
890
- res.end('Store not available')
891
- return
892
- }
893
- try {
894
- const record = await this._store.getInscription(inscMatch[1], parseInt(inscMatch[2], 10))
895
- if (!record) {
896
- res.writeHead(404, { 'Content-Type': 'text/plain' })
897
- res.end('Not found')
898
- return
899
- }
900
- // Resolve content: inline hex first, then CAS fallback
901
- let buf = record.content ? Buffer.from(record.content, 'hex') : null
902
- if (!buf && record.contentHash) {
903
- buf = await this._store.getContentBytes(record.contentHash)
904
- }
905
- if (!buf) {
906
- res.writeHead(404, { 'Content-Type': 'text/plain' })
907
- res.end('Content not available')
908
- return
909
- }
910
- res.writeHead(200, {
911
- 'Content-Type': record.contentType || 'application/octet-stream',
912
- 'Content-Length': buf.length,
913
- 'Cache-Control': 'public, max-age=31536000, immutable'
914
- })
915
- res.end(buf)
916
- } catch (err) {
917
- res.writeHead(500, { 'Content-Type': 'text/plain' })
918
- res.end(err.message)
919
- }
920
- return
921
- }
922
-
923
- // POST /scan-address — scan an address for inscriptions via WhatsOnChain
924
- if (req.method === 'POST' && path === '/scan-address') {
925
- if (!this._store) {
926
- res.writeHead(500, { 'Content-Type': 'application/json' })
927
- res.end(JSON.stringify({ error: 'Store not available' }))
928
- return
929
- }
930
- let body = ''
931
- req.on('data', chunk => { body += chunk })
932
- req.on('end', async () => {
933
- try {
934
- const { address } = JSON.parse(body)
935
- if (!address || typeof address !== 'string' || address.length < 25 || address.length > 35) {
936
- res.writeHead(400, { 'Content-Type': 'application/json' })
937
- res.end(JSON.stringify({ error: 'Invalid address' }))
938
- return
939
- }
940
-
941
- // Stream progress via SSE
942
- res.writeHead(200, {
943
- 'Content-Type': 'text/event-stream',
944
- 'Cache-Control': 'no-cache',
945
- 'Connection': 'keep-alive',
946
- 'Access-Control-Allow-Origin': '*'
947
- })
948
-
949
- const result = await scanAddress(address, this._store, (progress) => {
950
- res.write('data: ' + JSON.stringify(progress) + '\n\n')
951
- })
952
-
953
- res.write('data: ' + JSON.stringify({ phase: 'complete', result }) + '\n\n')
954
- res.end()
955
- } catch (err) {
956
- if (!res.headersSent) {
957
- res.writeHead(500, { 'Content-Type': 'application/json' })
958
- res.end(JSON.stringify({ error: err.message }))
959
- } else {
960
- res.write('data: ' + JSON.stringify({ phase: 'error', error: err.message }) + '\n\n')
961
- res.end()
962
- }
963
- }
964
- })
965
- return
966
- }
967
-
968
- // POST /rebuild-inscription-index — deduplicate and rebuild secondary indexes
969
- if (req.method === 'POST' && path === '/rebuild-inscription-index') {
970
- if (!this._store) {
971
- res.writeHead(500, { 'Content-Type': 'application/json' })
972
- res.end(JSON.stringify({ error: 'Store not available' }))
973
- return
974
- }
975
- try {
976
- const count = await this._store.rebuildInscriptionIndex()
977
- res.writeHead(200, { 'Content-Type': 'application/json' })
978
- res.end(JSON.stringify({ rebuilt: count }))
979
- } catch (err) {
980
- res.writeHead(500, { 'Content-Type': 'application/json' })
981
- res.end(JSON.stringify({ error: err.message }))
982
- }
983
- return
984
- }
985
-
986
- // GET /apps app health, SSL, and usage data
987
- if (req.method === 'GET' && path === '/apps') {
988
- const apps = []
989
- if (this._config.apps) {
990
- for (const app of this._config.apps) {
991
- const entry = this._appChecks.get(app.url) || { checks: [], lastError: null }
992
- const checks = entry.checks
993
- const checksUp = checks.filter(c => c.up).length
994
- const latest = checks.length > 0 ? checks[checks.length - 1] : null
995
-
996
- let ssl = null
997
- try {
998
- const hostname = new URL(app.url).hostname
999
- const cached = this._appSSLCache.get(hostname)
1000
- if (cached && cached.data && Date.now() - cached.checkedAt < 3600000) {
1001
- ssl = cached.data
1002
- } else {
1003
- ssl = await this._checkSSL(hostname)
1004
- this._appSSLCache.set(hostname, { data: ssl, checkedAt: Date.now() })
1005
- }
1006
- } catch {}
1007
-
1008
- const usage = this._requestTracker.get(app.bridgeDomain) || { total: 0, endpoints: {}, lastSeen: null }
1009
-
1010
- apps.push({
1011
- name: app.name,
1012
- url: app.url,
1013
- bridgeDomain: app.bridgeDomain,
1014
- health: {
1015
- status: latest ? (latest.up ? 'online' : 'offline') : 'unknown',
1016
- statusCode: latest ? latest.statusCode : 0,
1017
- responseTimeMs: latest ? latest.responseTimeMs : 0,
1018
- lastCheck: latest ? latest.timestamp : null,
1019
- lastError: entry.lastError,
1020
- uptimePercent: checks.length > 0 ? Math.round((checksUp / checks.length) * 1000) / 10 : 0,
1021
- checksTotal: checks.length,
1022
- checksUp
1023
- },
1024
- ssl,
1025
- usage: {
1026
- totalRequests: usage.total,
1027
- endpoints: { ...usage.endpoints },
1028
- lastSeen: usage.lastSeen
1029
- }
1030
- })
1031
- }
1032
- }
1033
- res.writeHead(200, { 'Content-Type': 'application/json' })
1034
- res.end(JSON.stringify({ apps }))
1035
- return
1036
- }
1037
-
1038
- res.writeHead(404)
1039
- res.end('Not Found')
1040
- }
1041
-
1042
- /**
1043
- * Stop the HTTP server.
1044
- * @returns {Promise<void>}
1045
- */
1046
- stop () {
1047
- this.stopAppMonitoring()
1048
- return new Promise((resolve) => {
1049
- if (this._server) {
1050
- this._server.close(() => resolve())
1051
- this._server = null
1052
- } else {
1053
- resolve()
1054
- }
1055
- })
1056
- }
1057
-
1058
- /**
1059
- * Get the port this server is configured to use.
1060
- * @returns {number}
1061
- */
1062
- get port () {
1063
- return this._port
1064
- }
1065
- }
1
+ import { createServer } from 'node:http'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync } from 'node:fs'
4
+ import { join, dirname } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import https from 'node:https'
7
+ import { parseTx } from './output-parser.js'
8
+ import { scanAddress } from './address-scanner.js'
9
+ import { handlePostData, handleGetTopics, handleGetData } from './data-endpoints.js'
10
+
11
+ /**
12
+ * StatusServer — public-facing HTTP server exposing bridge status and APIs.
13
+ *
14
+ * Started by `relay-bridge start`, queried by `relay-bridge status`.
15
+ * Binds to 0.0.0.0 — accessible from outside the machine.
16
+ * Operator-only endpoints are gated by statusSecret authentication.
17
+ *
18
+ * Endpoints:
19
+ * GET / — HTML dashboard (auto-refreshes every 5s)
20
+ * GET /status — JSON object with bridge state
21
+ * GET /discover — Known bridges in the mesh
22
+ * POST /broadcast — Relay a raw transaction
23
+ * POST /data — Submit a signed data envelope
24
+ * GET /data/topics — List topics with cached data
25
+ * GET /data/:topic — Query cached envelopes by topic
26
+ */
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url))
29
+ const DASHBOARD_HTML = readFileSync(join(__dirname, '..', 'dashboard', 'index.html'), 'utf8')
30
+ export class StatusServer {
31
+ /**
32
+ * @param {object} opts
33
+ * @param {number} [opts.port=9333] — HTTP port for status endpoint
34
+ * @param {import('./peer-manager.js').PeerManager} [opts.peerManager]
35
+ * @param {import('./header-relay.js').HeaderRelay} [opts.headerRelay]
36
+ * @param {import('./tx-relay.js').TxRelay} [opts.txRelay]
37
+ * @param {object} [opts.config] Bridge config (pubkeyHex, endpoint, meshId)
38
+ * @param {object} [opts.bsvNodeClient] BSV P2P node client (2.26)
39
+ * @param {object} [opts.store] PersistentStore for wallet balance (2.27)
40
+ */
41
+ constructor (opts = {}) {
42
+ this._port = opts.port || 9333
43
+ this._peerManager = opts.peerManager || null
44
+ this._headerRelay = opts.headerRelay || null
45
+ this._txRelay = opts.txRelay || null
46
+ this._dataRelay = opts.dataRelay || null
47
+ this._config = opts.config || {}
48
+ this._scorer = opts.scorer || null
49
+ this._peerHealth = opts.peerHealth || null
50
+ this._bsvNodeClient = opts.bsvNodeClient || null
51
+ this._store = opts.store || null
52
+ this._performOutboundHandshake = opts.performOutboundHandshake || null
53
+ this._registeredPubkeys = opts.registeredPubkeys || null
54
+ this._gossipManager = opts.gossipManager || null
55
+ this._startedAt = Date.now()
56
+ this._server = null
57
+
58
+ // Job system for async actions (register, deregister)
59
+ this._jobs = new Map()
60
+ this._jobCounter = 0
61
+
62
+ // Log ring buffer — max 500 entries
63
+ this._logs = []
64
+ this._logListeners = new Set()
65
+ this._maxLogs = 500
66
+
67
+ // App monitoring state
68
+ this._appChecks = new Map()
69
+ this._requestTracker = new Map()
70
+ this._appSSLCache = new Map()
71
+ this._appBridgeDomains = new Set()
72
+ this._appCheckInterval = null
73
+ this._addressCache = new Map()
74
+ if (this._config.apps) {
75
+ for (const app of this._config.apps) {
76
+ this._appChecks.set(app.url, { checks: [], lastError: null })
77
+ if (app.bridgeDomain) {
78
+ this._appBridgeDomains.add(app.bridgeDomain)
79
+ this._requestTracker.set(app.bridgeDomain, { total: 0, endpoints: {}, lastSeen: null })
80
+ }
81
+ try { this._appBridgeDomains.add(new URL(app.url).hostname) } catch {}
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Build the status object from current bridge state.
88
+ * @param {object} [opts]
89
+ * @param {boolean} [opts.authenticated=false] — Include operator-only fields
90
+ * @returns {Promise<object>}
91
+ */
92
+ async getStatus ({ authenticated = false } = {}) {
93
+ const peers = []
94
+ if (this._peerManager) {
95
+ for (const [pubkeyHex, conn] of this._peerManager.peers) {
96
+ const entry = {
97
+ pubkeyHex,
98
+ endpoint: conn.endpoint,
99
+ connected: !!conn.connected
100
+ }
101
+ if (this._scorer) {
102
+ entry.score = Math.round(this._scorer.getScore(pubkeyHex) * 100) / 100
103
+ const metrics = this._scorer.getMetrics(pubkeyHex)
104
+ if (metrics) {
105
+ entry.scoreBreakdown = {
106
+ uptime: Math.round(metrics.uptime * 100) / 100,
107
+ responseTime: Math.round(metrics.responseTime * 100) / 100,
108
+ dataAccuracy: Math.round(metrics.dataAccuracy * 100) / 100,
109
+ stakeAge: Math.round(metrics.stakeAge * 100) / 100,
110
+ raw: metrics.raw
111
+ }
112
+ }
113
+ }
114
+ if (this._peerHealth) {
115
+ entry.health = this._peerHealth.getStatus(pubkeyHex)
116
+ }
117
+ peers.push(entry)
118
+ }
119
+ }
120
+
121
+ const status = {
122
+ bridge: {
123
+ name: this._config.name || null,
124
+ pubkeyHex: this._config.pubkeyHex || null,
125
+ meshId: this._config.meshId || null,
126
+ uptimeSeconds: Math.floor((Date.now() - this._startedAt) / 1000)
127
+ },
128
+ peers: {
129
+ connected: this._peerManager ? this._peerManager.connectedCount() : 0,
130
+ list: peers
131
+ },
132
+ headers: {
133
+ bestHeight: this._headerRelay ? this._headerRelay.bestHeight : -1,
134
+ bestHash: this._headerRelay ? this._headerRelay.bestHash : null,
135
+ count: this._headerRelay ? this._headerRelay.headers.size : 0
136
+ },
137
+ txs: {
138
+ mempool: this._txRelay ? this._txRelay.mempool.size : 0,
139
+ seen: this._txRelay ? this._txRelay.seen.size : 0
140
+ },
141
+ bsvNode: {
142
+ connected: this._bsvNodeClient ? this._bsvNodeClient.connectedCount > 0 : false,
143
+ peers: this._bsvNodeClient ? this._bsvNodeClient.connectedCount : 0,
144
+ height: this._bsvNodeClient ? this._bsvNodeClient.bestHeight : null
145
+ }
146
+ }
147
+
148
+ // Operator-only fields
149
+ if (authenticated) {
150
+ status.operator = true
151
+ status.bridge.endpoint = this._config.endpoint || null
152
+ status.bridge.domains = this._config.domains || []
153
+ try {
154
+ const { PrivateKey } = await import('@bsv/sdk')
155
+ status.bridge.address = PrivateKey.fromWif(this._config.wif).toPublicKey().toAddress()
156
+ } catch {
157
+ status.bridge.address = this._config.address || null
158
+ }
159
+ status.wallet = { balanceSats: null, utxoCount: 0 }
160
+ if (this._store) {
161
+ try { status.wallet.balanceSats = await this._store.getBalance() } catch {}
162
+ try { status.wallet.utxoCount = (await this._store.getUnspentUtxos()).length } catch {}
163
+ }
164
+ }
165
+
166
+ return status
167
+ }
168
+
169
+ /**
170
+ * Check if a request is authenticated via statusSecret.
171
+ * @param {import('node:http').IncomingMessage} req
172
+ * @returns {boolean}
173
+ */
174
+ _checkAuth (req) {
175
+ const secret = this._config.statusSecret
176
+ if (!secret) return false
177
+
178
+ // Check ?auth= query param
179
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
180
+ const authParam = url.searchParams.get('auth')
181
+ if (authParam === secret) return true
182
+
183
+ // Check Authorization: Bearer header
184
+ const authHeader = req.headers.authorization
185
+ if (authHeader && authHeader.startsWith('Bearer ') && authHeader.slice(7) === secret) return true
186
+
187
+ return false
188
+ }
189
+
190
+ /**
191
+ * Add a log entry to the ring buffer and notify SSE listeners.
192
+ * @param {string} message
193
+ */
194
+ addLog (message) {
195
+ const entry = { timestamp: Date.now(), message }
196
+ this._logs.push(entry)
197
+ if (this._logs.length > this._maxLogs) {
198
+ this._logs.shift()
199
+ }
200
+ // Notify SSE listeners
201
+ for (const listener of this._logListeners) {
202
+ listener(entry)
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Create a job for tracking async actions.
208
+ * @returns {{ jobId: string, log: function }}
209
+ */
210
+ _createJob () {
211
+ const jobId = `job_${++this._jobCounter}_${Date.now()}`
212
+ const job = { status: 'running', events: [], done: false, listeners: new Set() }
213
+ this._jobs.set(jobId, job)
214
+
215
+ // Auto-cleanup after 5 minutes
216
+ setTimeout(() => this._jobs.delete(jobId), 5 * 60 * 1000)
217
+
218
+ const log = (type, message, data) => {
219
+ const event = { type, message, data, timestamp: Date.now() }
220
+ job.events.push(event)
221
+ if (type === 'done' || type === 'error') {
222
+ job.status = type === 'error' ? 'failed' : 'completed'
223
+ job.done = true
224
+ }
225
+ // Notify SSE listeners
226
+ for (const listener of job.listeners) {
227
+ listener(event)
228
+ }
229
+ }
230
+
231
+ return { jobId, log }
232
+ }
233
+
234
+ /**
235
+ * Read the full JSON body from a request.
236
+ * @param {import('node:http').IncomingMessage} req
237
+ * @returns {Promise<object>}
238
+ */
239
+ _readBody (req) {
240
+ return new Promise((resolve, reject) => {
241
+ let body = ''
242
+ req.on('data', chunk => { body += chunk })
243
+ req.on('end', () => {
244
+ try { resolve(body ? JSON.parse(body) : {}) } catch (e) { reject(e) }
245
+ })
246
+ req.on('error', reject)
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Check SSL certificate for a hostname.
252
+ */
253
+ _checkSSL (hostname) {
254
+ return new Promise((resolve) => {
255
+ const req = https.request({ hostname, port: 443, method: 'HEAD', rejectUnauthorized: false, timeout: 5000 }, (res) => {
256
+ const cert = res.socket.getPeerCertificate()
257
+ if (!cert || !cert.valid_to) { resolve(null); req.destroy(); return }
258
+ resolve({
259
+ valid: res.socket.authorized,
260
+ issuer: cert.issuer?.O || cert.issuer?.CN || 'Unknown',
261
+ expiresAt: new Date(cert.valid_to).toISOString(),
262
+ daysRemaining: Math.floor((new Date(cert.valid_to) - Date.now()) / 86400000)
263
+ })
264
+ req.destroy()
265
+ })
266
+ req.on('error', () => resolve(null))
267
+ req.setTimeout(5000, () => { req.destroy(); resolve(null) })
268
+ req.end()
269
+ })
270
+ }
271
+
272
+ /**
273
+ * Health-check a single app.
274
+ */
275
+ async _checkApp (app) {
276
+ const entry = this._appChecks.get(app.url)
277
+ if (!entry) return
278
+ const start = Date.now()
279
+ let statusCode = 0
280
+ let up = false
281
+ let errorMsg = null
282
+ try {
283
+ const controller = new AbortController()
284
+ const timeout = setTimeout(() => controller.abort(), 5000)
285
+ const res = await fetch(app.healthUrl || app.url, { method: app.healthUrl ? 'GET' : 'HEAD', signal: controller.signal, redirect: 'follow' })
286
+ clearTimeout(timeout)
287
+ statusCode = res.status
288
+ up = statusCode >= 200 && statusCode < 400
289
+ } catch (err) {
290
+ errorMsg = err.message || 'Request failed'
291
+ }
292
+ const check = { timestamp: new Date().toISOString(), up, statusCode, responseTimeMs: Date.now() - start }
293
+ entry.checks.push(check)
294
+ if (entry.checks.length > 100) entry.checks.shift()
295
+ if (!up) entry.lastError = { message: errorMsg || `HTTP ${statusCode}`, timestamp: check.timestamp }
296
+ }
297
+
298
+ /**
299
+ * Run health checks on all configured apps.
300
+ */
301
+ async _checkAllApps () {
302
+ if (!this._config.apps) return
303
+ for (const app of this._config.apps) {
304
+ await this._checkApp(app)
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Start background app health monitoring (30s interval).
310
+ */
311
+ startAppMonitoring () {
312
+ if (!this._config.apps || this._config.apps.length === 0) return
313
+ this._checkAllApps()
314
+ this._appCheckInterval = setInterval(() => this._checkAllApps(), 30000)
315
+ }
316
+
317
+ /**
318
+ * Stop background app health monitoring.
319
+ */
320
+ stopAppMonitoring () {
321
+ if (this._appCheckInterval) {
322
+ clearInterval(this._appCheckInterval)
323
+ this._appCheckInterval = null
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Start the HTTP server on localhost.
329
+ * @returns {Promise<void>}
330
+ */
331
+ start () {
332
+ return new Promise((resolve, reject) => {
333
+ this._server = createServer((req, res) => {
334
+ // CORS headers for federation dashboard
335
+ res.setHeader('Access-Control-Allow-Origin', '*')
336
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
337
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
338
+
339
+ if (req.method === 'OPTIONS') {
340
+ res.writeHead(204)
341
+ res.end()
342
+ return
343
+ }
344
+
345
+ this._handleRequest(req, res).catch(() => {
346
+ res.writeHead(500)
347
+ res.end('Internal Server Error')
348
+ })
349
+ })
350
+
351
+ this._server.listen(this._port, '0.0.0.0', () => resolve())
352
+ this._server.on('error', reject)
353
+ })
354
+ }
355
+
356
+ /**
357
+ * Route incoming HTTP requests.
358
+ * @param {import('node:http').IncomingMessage} req
359
+ * @param {import('node:http').ServerResponse} res
360
+ */
361
+ async _handleRequest (req, res) {
362
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
363
+ const path = url.pathname
364
+ const authenticated = this._checkAuth(req)
365
+
366
+ // Track requests from known app domains
367
+ const origin = req.headers.origin || req.headers.referer || ''
368
+ const host = (req.headers.host || '').split(':')[0]
369
+ let trackDomain = null
370
+ if (origin) { try { trackDomain = new URL(origin).hostname } catch {} }
371
+ if (!trackDomain && host && this._appBridgeDomains.has(host)) trackDomain = host
372
+ if (trackDomain && this._appBridgeDomains.has(trackDomain)) {
373
+ let bridgeDomain = trackDomain
374
+ if (this._config.apps) {
375
+ for (const app of this._config.apps) {
376
+ try { if (trackDomain === new URL(app.url).hostname) { bridgeDomain = app.bridgeDomain; break } } catch {}
377
+ }
378
+ }
379
+ const data = this._requestTracker.get(bridgeDomain)
380
+ if (data) {
381
+ data.total++
382
+ let ep = path
383
+ if (path.startsWith('/tx/')) ep = '/tx/:txid'
384
+ else if (path.startsWith('/inscription/')) ep = '/inscription/:content'
385
+ else if (path.startsWith('/jobs/')) ep = '/jobs/:id'
386
+ data.endpoints[ep] = (data.endpoints[ep] || 0) + 1
387
+ data.lastSeen = new Date().toISOString()
388
+ }
389
+ }
390
+
391
+ // GET /status — public or operator status
392
+ if (req.method === 'GET' && path === '/status') {
393
+ const status = await this.getStatus({ authenticated })
394
+ res.writeHead(200, { 'Content-Type': 'application/json' })
395
+ res.end(JSON.stringify(status))
396
+ return
397
+ }
398
+
399
+ // GET /mempool — public decoded mempool transactions
400
+ if (req.method === 'GET' && path === '/mempool') {
401
+ const txs = []
402
+ if (this._txRelay) {
403
+ for (const [txid, rawHex] of this._txRelay.mempool) {
404
+ try {
405
+ const parsed = parseTx(rawHex)
406
+ txs.push({
407
+ txid,
408
+ size: rawHex.length / 2,
409
+ inputs: parsed.inputs,
410
+ outputs: parsed.outputs.map(o => ({
411
+ vout: o.vout,
412
+ satoshis: o.satoshis,
413
+ isP2PKH: o.isP2PKH,
414
+ hash160: o.hash160,
415
+ type: o.type,
416
+ data: o.data,
417
+ protocol: o.protocol,
418
+ parsed: o.parsed
419
+ }))
420
+ })
421
+ } catch {
422
+ txs.push({ txid, size: rawHex.length / 2, inputs: [], outputs: [], error: 'decode failed' })
423
+ }
424
+ }
425
+ }
426
+ res.writeHead(200, { 'Content-Type': 'application/json' })
427
+ res.end(JSON.stringify({ count: txs.length, txs }))
428
+ return
429
+ }
430
+
431
+ // GET /discover — public list of all known bridges in the mesh
432
+ if (req.method === 'GET' && path === '/discover') {
433
+ const bridges = []
434
+ // Add self
435
+ bridges.push({
436
+ name: this._config.name || null,
437
+ pubkeyHex: this._config.pubkeyHex || null,
438
+ endpoint: this._config.endpoint || null,
439
+ meshId: this._config.meshId || null,
440
+ statusUrl: 'http://' + (req.headers.host || '127.0.0.1:' + this._port) + '/status'
441
+ })
442
+ // Add gossip directory (all known peers)
443
+ if (this._gossipManager) {
444
+ for (const peer of this._gossipManager.getDirectory()) {
445
+ // Derive statusUrl from ws endpoint: ws://host:8333 → http://host:9333
446
+ let statusUrl = null
447
+ try {
448
+ const u = new URL(peer.endpoint)
449
+ const statusPort = parseInt(u.port, 10) + 1000
450
+ statusUrl = 'http://' + u.hostname + ':' + statusPort + '/status'
451
+ } catch {}
452
+ bridges.push({
453
+ pubkeyHex: peer.pubkeyHex,
454
+ endpoint: peer.endpoint,
455
+ meshId: peer.meshId || null,
456
+ statusUrl
457
+ })
458
+ }
459
+ }
460
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
461
+ res.end(JSON.stringify({ count: bridges.length, bridges }))
462
+ return
463
+ }
464
+
465
+ // GET / or /dashboard built-in HTML dashboard
466
+ if (req.method === 'GET' && (path === '/' || path === '/dashboard')) {
467
+ res.writeHead(200, { 'Content-Type': 'text/html' })
468
+ res.end(DASHBOARD_HTML)
469
+ return
470
+ }
471
+
472
+ // POST /broadcast — relay a raw tx to peers
473
+ if (req.method === 'POST' && path === '/broadcast') {
474
+ const body = await this._readBody(req)
475
+ const { rawHex } = body
476
+ if (!rawHex || typeof rawHex !== 'string') {
477
+ res.writeHead(400, { 'Content-Type': 'application/json' })
478
+ res.end(JSON.stringify({ error: 'rawHex required' }))
479
+ return
480
+ }
481
+ const buf = Buffer.from(rawHex, 'hex')
482
+ const hash = createHash('sha256').update(createHash('sha256').update(buf).digest()).digest()
483
+ const txid = Buffer.from(hash).reverse().toString('hex')
484
+ const sent = this._txRelay ? this._txRelay.broadcastTx(txid, rawHex) : 0
485
+ res.writeHead(200, { 'Content-Type': 'application/json' })
486
+ res.end(JSON.stringify({ txid, peers: sent }))
487
+ return
488
+ }
489
+
490
+ // POST /data — submit a signed data envelope for relay
491
+ if (req.method === 'POST' && path === '/data') {
492
+ if (!this._dataRelay) {
493
+ res.writeHead(503, { 'Content-Type': 'application/json' })
494
+ res.end(JSON.stringify({ error: 'Data relay not available' }))
495
+ return
496
+ }
497
+ let body
498
+ try {
499
+ body = await this._readBody(req)
500
+ } catch {
501
+ res.writeHead(400, { 'Content-Type': 'application/json' })
502
+ res.end(JSON.stringify({ error: 'invalid_json' }))
503
+ return
504
+ }
505
+ handlePostData(this._dataRelay, body, res)
506
+ return
507
+ }
508
+
509
+ // GET /data/topics list topics with summary objects
510
+ if (req.method === 'GET' && path === '/data/topics') {
511
+ if (!this._dataRelay) {
512
+ res.writeHead(503, { 'Content-Type': 'application/json' })
513
+ res.end(JSON.stringify({ error: 'Data relay not available' }))
514
+ return
515
+ }
516
+ handleGetTopics(this._dataRelay, res)
517
+ return
518
+ }
519
+
520
+ // GET /data/:topic — query cached envelopes with since/limit/hasMore
521
+ if (req.method === 'GET' && path.startsWith('/data/')) {
522
+ if (!this._dataRelay) {
523
+ res.writeHead(503, { 'Content-Type': 'application/json' })
524
+ res.end(JSON.stringify({ error: 'Data relay not available' }))
525
+ return
526
+ }
527
+ const topic = decodeURIComponent(path.slice(6))
528
+ if (!topic) {
529
+ res.writeHead(400, { 'Content-Type': 'application/json' })
530
+ res.end(JSON.stringify({ error: 'Topic required' }))
531
+ return
532
+ }
533
+ handleGetData(this._dataRelay, topic, url.searchParams, res)
534
+ return
535
+ }
536
+
537
+ // GET /tx/:txid — fetch and parse transaction with full protocol support
538
+ if (req.method === 'GET' && path.startsWith('/tx/')) {
539
+ const txid = path.slice(4)
540
+ if (!txid || txid.length !== 64) {
541
+ res.writeHead(400, { 'Content-Type': 'application/json' })
542
+ res.end(JSON.stringify({ error: 'Invalid txid' }))
543
+ return
544
+ }
545
+
546
+ let rawHex = null
547
+ let source = null
548
+
549
+ // Check mempool first
550
+ if (this._txRelay && this._txRelay.mempool.has(txid)) {
551
+ rawHex = this._txRelay.mempool.get(txid)
552
+ source = 'mempool'
553
+ }
554
+
555
+ // Try P2P
556
+ if (!rawHex && this._bsvNodeClient) {
557
+ try {
558
+ const result = await this._bsvNodeClient.getTx(txid, 5000)
559
+ rawHex = result.rawHex
560
+ source = 'p2p'
561
+ } catch {}
562
+ }
563
+
564
+ // Fall back to WoC
565
+ if (!rawHex) {
566
+ try {
567
+ const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
568
+ if (!resp.ok) throw new Error(`WoC ${resp.status}`)
569
+ rawHex = await resp.text()
570
+ source = 'woc'
571
+ } catch (err) {
572
+ res.writeHead(404, { 'Content-Type': 'application/json' })
573
+ res.end(JSON.stringify({ error: `tx not found: ${err.message}` }))
574
+ return
575
+ }
576
+ }
577
+
578
+ // Parse with full protocol support
579
+ try {
580
+ const parsed = parseTx(rawHex)
581
+ res.writeHead(200, { 'Content-Type': 'application/json' })
582
+ res.end(JSON.stringify({
583
+ txid: parsed.txid,
584
+ source,
585
+ size: rawHex.length / 2,
586
+ inputs: parsed.inputs,
587
+ outputs: parsed.outputs
588
+ }))
589
+ } catch (err) {
590
+ res.writeHead(200, { 'Content-Type': 'application/json' })
591
+ res.end(JSON.stringify({ txid, source, size: rawHex.length / 2, error: 'parse failed: ' + err.message }))
592
+ }
593
+ return
594
+ }
595
+
596
+ // POST /register — operator: start async registration
597
+ if (req.method === 'POST' && path === '/register') {
598
+ if (!authenticated) {
599
+ res.writeHead(401, { 'Content-Type': 'application/json' })
600
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
601
+ return
602
+ }
603
+ const { runRegister } = await import('./actions.js')
604
+ const { jobId, log } = this._createJob()
605
+ res.writeHead(202, { 'Content-Type': 'application/json' })
606
+ res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
607
+ // Run async — don't await
608
+ runRegister({ config: this._config, store: this._store, log }).catch(err => {
609
+ log('error', err.message)
610
+ })
611
+ return
612
+ }
613
+
614
+ // POST /deregister — operator: start async deregistration
615
+ if (req.method === 'POST' && path === '/deregister') {
616
+ if (!authenticated) {
617
+ res.writeHead(401, { 'Content-Type': 'application/json' })
618
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
619
+ return
620
+ }
621
+ const { runDeregister } = await import('./actions.js')
622
+ const body = await this._readBody(req)
623
+ const { jobId, log } = this._createJob()
624
+ res.writeHead(202, { 'Content-Type': 'application/json' })
625
+ res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
626
+ runDeregister({ config: this._config, store: this._store, reason: body.reason || 'shutdown', log }).catch(err => {
627
+ log('error', err.message)
628
+ })
629
+ return
630
+ }
631
+
632
+ // POST /fund — operator: store a funding tx (synchronous)
633
+ if (req.method === 'POST' && path === '/fund') {
634
+ if (!authenticated) {
635
+ res.writeHead(401, { 'Content-Type': 'application/json' })
636
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
637
+ return
638
+ }
639
+ const { runFund } = await import('./actions.js')
640
+ const body = await this._readBody(req)
641
+ if (!body.rawHex) {
642
+ res.writeHead(400, { 'Content-Type': 'application/json' })
643
+ res.end(JSON.stringify({ error: 'rawHex required' }))
644
+ return
645
+ }
646
+ try {
647
+ const result = await runFund({ config: this._config, store: this._store, rawHex: body.rawHex, log: () => {} })
648
+ res.writeHead(200, { 'Content-Type': 'application/json' })
649
+ res.end(JSON.stringify(result))
650
+ } catch (err) {
651
+ res.writeHead(400, { 'Content-Type': 'application/json' })
652
+ res.end(JSON.stringify({ error: err.message }))
653
+ }
654
+ return
655
+ }
656
+
657
+ // POST /connect — operator: connect to a peer endpoint
658
+ if (req.method === 'POST' && path === '/connect') {
659
+ if (!authenticated) {
660
+ res.writeHead(401, { 'Content-Type': 'application/json' })
661
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
662
+ return
663
+ }
664
+ const body = await this._readBody(req)
665
+ if (!body.endpoint) {
666
+ res.writeHead(400, { 'Content-Type': 'application/json' })
667
+ res.end(JSON.stringify({ error: 'endpoint required (e.g. ws://host:port)' }))
668
+ return
669
+ }
670
+ if (!this._peerManager || !this._performOutboundHandshake) {
671
+ res.writeHead(500, { 'Content-Type': 'application/json' })
672
+ res.end(JSON.stringify({ error: 'Bridge not running — peer manager unavailable' }))
673
+ return
674
+ }
675
+ try {
676
+ const conn = this._peerManager.connectToPeer({ endpoint: body.endpoint })
677
+ if (conn) {
678
+ conn.on('open', () => this._performOutboundHandshake(conn))
679
+ res.writeHead(200, { 'Content-Type': 'application/json' })
680
+ res.end(JSON.stringify({ endpoint: body.endpoint, status: 'connecting' }))
681
+ } else {
682
+ res.writeHead(200, { 'Content-Type': 'application/json' })
683
+ res.end(JSON.stringify({ endpoint: body.endpoint, status: 'already_connected_or_failed' }))
684
+ }
685
+ } catch (err) {
686
+ res.writeHead(400, { 'Content-Type': 'application/json' })
687
+ res.end(JSON.stringify({ error: err.message }))
688
+ }
689
+ return
690
+ }
691
+
692
+ // POST /send — operator: send BSV from bridge wallet
693
+ if (req.method === 'POST' && path === '/send') {
694
+ if (!authenticated) {
695
+ res.writeHead(401, { 'Content-Type': 'application/json' })
696
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
697
+ return
698
+ }
699
+ const { runSend } = await import('./actions.js')
700
+ const body = await this._readBody(req)
701
+ if (!body.toAddress || !body.amount) {
702
+ res.writeHead(400, { 'Content-Type': 'application/json' })
703
+ res.end(JSON.stringify({ error: 'toAddress and amount required' }))
704
+ return
705
+ }
706
+ const { jobId, log } = this._createJob()
707
+ res.writeHead(202, { 'Content-Type': 'application/json' })
708
+ res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
709
+ runSend({ config: this._config, store: this._store, toAddress: body.toAddress, amount: Number(body.amount), log }).catch(err => {
710
+ log('error', err.message)
711
+ })
712
+ return
713
+ }
714
+
715
+ // GET /jobs/:id — SSE stream for job progress
716
+ if (req.method === 'GET' && path.startsWith('/jobs/')) {
717
+ const jobId = path.slice(6)
718
+ const job = this._jobs.get(jobId)
719
+ if (!job) {
720
+ res.writeHead(404, { 'Content-Type': 'application/json' })
721
+ res.end(JSON.stringify({ error: 'Job not found' }))
722
+ return
723
+ }
724
+ res.writeHead(200, {
725
+ 'Content-Type': 'text/event-stream',
726
+ 'Cache-Control': 'no-cache',
727
+ Connection: 'keep-alive'
728
+ })
729
+ // Replay past events
730
+ for (const event of job.events) {
731
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
732
+ }
733
+ if (job.done) {
734
+ res.write(`data: ${JSON.stringify({ type: 'end', status: job.status })}\n\n`)
735
+ res.end()
736
+ return
737
+ }
738
+ // Stream new events
739
+ const listener = (event) => {
740
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
741
+ if (event.type === 'done' || event.type === 'error') {
742
+ res.write(`data: ${JSON.stringify({ type: 'end', status: event.type === 'error' ? 'failed' : 'completed' })}\n\n`)
743
+ res.end()
744
+ job.listeners.delete(listener)
745
+ }
746
+ }
747
+ job.listeners.add(listener)
748
+ req.on('close', () => job.listeners.delete(listener))
749
+ return
750
+ }
751
+
752
+ // GET /logs SSE stream of live bridge logs
753
+ if (req.method === 'GET' && path === '/logs') {
754
+ res.writeHead(200, {
755
+ 'Content-Type': 'text/event-stream',
756
+ 'Cache-Control': 'no-cache',
757
+ Connection: 'keep-alive'
758
+ })
759
+ // Replay buffer
760
+ for (const entry of this._logs) {
761
+ res.write(`data: ${JSON.stringify(entry)}\n\n`)
762
+ }
763
+ // Stream new
764
+ const listener = (entry) => {
765
+ res.write(`data: ${JSON.stringify(entry)}\n\n`)
766
+ }
767
+ this._logListeners.add(listener)
768
+ req.on('close', () => this._logListeners.delete(listener))
769
+ return
770
+ }
771
+
772
+ // GET /inscriptions — query indexed inscriptions
773
+ if (req.method === 'GET' && path === '/inscriptions') {
774
+ if (!this._store) {
775
+ res.writeHead(500, { 'Content-Type': 'application/json' })
776
+ res.end(JSON.stringify({ error: 'Store not available' }))
777
+ return
778
+ }
779
+ const mime = url.searchParams.get('mime')
780
+ const address = url.searchParams.get('address')
781
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 200)
782
+ try {
783
+ const inscriptions = await this._store.getInscriptions({ mime, address, limit })
784
+ const total = await this._store.getInscriptionCount()
785
+ res.writeHead(200, { 'Content-Type': 'application/json' })
786
+ res.end(JSON.stringify({ total, count: inscriptions.length, inscriptions, filters: { mime: mime || null, address: address || null } }))
787
+ } catch (err) {
788
+ res.writeHead(500, { 'Content-Type': 'application/json' })
789
+ res.end(JSON.stringify({ error: err.message }))
790
+ }
791
+ return
792
+ }
793
+
794
+ // GET /address/:addr/history — transaction history for an address (via WoC)
795
+ const addrMatch = path.match(/^\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
796
+ if (req.method === 'GET' && addrMatch) {
797
+ const addr = addrMatch[1]
798
+ const cached = this._addressCache.get(addr)
799
+ if (cached && Date.now() - cached.time < 60000) {
800
+ res.writeHead(200, { 'Content-Type': 'application/json' })
801
+ res.end(JSON.stringify({ address: addr, history: cached.data, cached: true }))
802
+ return
803
+ }
804
+ try {
805
+ const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/history', { signal: AbortSignal.timeout(10000) })
806
+ if (!resp.ok) throw new Error('WoC returned ' + resp.status)
807
+ const history = await resp.json()
808
+ this._addressCache.set(addr, { data: history, time: Date.now() })
809
+ // Prune cache if it grows too large
810
+ if (this._addressCache.size > 100) {
811
+ const oldest = this._addressCache.keys().next().value
812
+ this._addressCache.delete(oldest)
813
+ }
814
+ res.writeHead(200, { 'Content-Type': 'application/json' })
815
+ res.end(JSON.stringify({ address: addr, history, cached: false }))
816
+ } catch (err) {
817
+ res.writeHead(502, { 'Content-Type': 'application/json' })
818
+ res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
819
+ }
820
+ return
821
+ }
822
+
823
+ // GET /price — cached BSV/USD exchange rate
824
+ if (req.method === 'GET' && path === '/price') {
825
+ const now = Date.now()
826
+ if (!this._priceCache || now - this._priceCache.timestamp > 60000) {
827
+ try {
828
+ const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/exchangerate')
829
+ if (resp.ok) {
830
+ const data = await resp.json()
831
+ this._priceCache = { data, timestamp: now }
832
+ }
833
+ } catch {}
834
+ }
835
+ if (this._priceCache) {
836
+ res.writeHead(200, { 'Content-Type': 'application/json' })
837
+ res.end(JSON.stringify({
838
+ usd: this._priceCache.data.rate || this._priceCache.data.USD,
839
+ currency: 'USD',
840
+ source: 'whatsonchain',
841
+ cached: this._priceCache.timestamp,
842
+ ttl: 60000
843
+ }))
844
+ return
845
+ }
846
+ res.writeHead(503, { 'Content-Type': 'application/json' })
847
+ res.end(JSON.stringify({ error: 'Price unavailable' }))
848
+ return
849
+ }
850
+
851
+ // GET /tokens — list all deployed tokens
852
+ if (req.method === 'GET' && path === '/tokens') {
853
+ if (!this._store) {
854
+ res.writeHead(500, { 'Content-Type': 'application/json' })
855
+ res.end(JSON.stringify({ error: 'Store not available' }))
856
+ return
857
+ }
858
+ const tokens = await this._store.listTokens()
859
+ res.writeHead(200, { 'Content-Type': 'application/json' })
860
+ res.end(JSON.stringify({ tokens }))
861
+ return
862
+ }
863
+
864
+ // GET /token/:tick — token deploy info
865
+ const tokenMatch = path.match(/^\/token\/([^/]+)$/)
866
+ if (req.method === 'GET' && tokenMatch) {
867
+ if (!this._store) {
868
+ res.writeHead(500, { 'Content-Type': 'application/json' })
869
+ res.end(JSON.stringify({ error: 'Store not available' }))
870
+ return
871
+ }
872
+ const token = await this._store.getToken(decodeURIComponent(tokenMatch[1]))
873
+ if (!token) {
874
+ res.writeHead(404, { 'Content-Type': 'application/json' })
875
+ res.end(JSON.stringify({ error: 'Token not found' }))
876
+ return
877
+ }
878
+ res.writeHead(200, { 'Content-Type': 'application/json' })
879
+ res.end(JSON.stringify(token))
880
+ return
881
+ }
882
+
883
+ // GET /token/:tick/balance/:scriptHash — token balance for owner
884
+ const balMatch = path.match(/^\/token\/([^/]+)\/balance\/([0-9a-f]{64})$/)
885
+ if (req.method === 'GET' && balMatch) {
886
+ if (!this._store) {
887
+ res.writeHead(500, { 'Content-Type': 'application/json' })
888
+ res.end(JSON.stringify({ error: 'Store not available' }))
889
+ return
890
+ }
891
+ const tick = decodeURIComponent(balMatch[1])
892
+ const ownerScriptHash = balMatch[2]
893
+ const balance = await this._store.getTokenBalance(tick, ownerScriptHash)
894
+ res.writeHead(200, { 'Content-Type': 'application/json' })
895
+ res.end(JSON.stringify({ tick, ownerScriptHash, balance }))
896
+ return
897
+ }
898
+
899
+ // GET /tx/:txid/status — tx lifecycle state
900
+ const statusMatch = path.match(/^\/tx\/([0-9a-f]{64})\/status$/)
901
+ if (req.method === 'GET' && statusMatch) {
902
+ if (!this._store) {
903
+ res.writeHead(500, { 'Content-Type': 'application/json' })
904
+ res.end(JSON.stringify({ error: 'Store not available' }))
905
+ return
906
+ }
907
+ const txid = statusMatch[1]
908
+ const status = await this._store.getTxStatus(txid)
909
+ const block = await this._store.getTxBlock(txid)
910
+ if (!status) {
911
+ res.writeHead(404, { 'Content-Type': 'application/json' })
912
+ res.end(JSON.stringify({ error: 'Transaction not found' }))
913
+ return
914
+ }
915
+ res.writeHead(200, { 'Content-Type': 'application/json' })
916
+ res.end(JSON.stringify({ txid, ...status, block: block || undefined }))
917
+ return
918
+ }
919
+
920
+ // GET /proof/:txid — merkle proof for confirmed tx
921
+ const proofMatch = path.match(/^\/proof\/([0-9a-f]{64})$/)
922
+ if (req.method === 'GET' && proofMatch) {
923
+ if (!this._store) {
924
+ res.writeHead(500, { 'Content-Type': 'application/json' })
925
+ res.end(JSON.stringify({ error: 'Store not available' }))
926
+ return
927
+ }
928
+ const txid = proofMatch[1]
929
+ const block = await this._store.getTxBlock(txid)
930
+ if (!block || !block.proof) {
931
+ res.writeHead(404, { 'Content-Type': 'application/json' })
932
+ res.end(JSON.stringify({ error: 'Proof not available' }))
933
+ return
934
+ }
935
+ res.writeHead(200, { 'Content-Type': 'application/json' })
936
+ res.end(JSON.stringify({ txid, blockHash: block.blockHash, height: block.height, proof: block.proof }))
937
+ return
938
+ }
939
+
940
+ // GET /inscription/:txid/:vout/content — serve raw inscription content
941
+ const inscMatch = path.match(/^\/inscription\/([0-9a-f]{64})\/(\d+)\/content$/)
942
+ if (req.method === 'GET' && inscMatch) {
943
+ if (!this._store) {
944
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
945
+ res.end('Store not available')
946
+ return
947
+ }
948
+ try {
949
+ const record = await this._store.getInscription(inscMatch[1], parseInt(inscMatch[2], 10))
950
+ if (!record) {
951
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
952
+ res.end('Not found')
953
+ return
954
+ }
955
+ // Resolve content: inline hex first, then CAS fallback
956
+ let buf = record.content ? Buffer.from(record.content, 'hex') : null
957
+ if (!buf && record.contentHash) {
958
+ buf = await this._store.getContentBytes(record.contentHash)
959
+ }
960
+ if (!buf) {
961
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
962
+ res.end('Content not available')
963
+ return
964
+ }
965
+ res.writeHead(200, {
966
+ 'Content-Type': record.contentType || 'application/octet-stream',
967
+ 'Content-Length': buf.length,
968
+ 'Cache-Control': 'public, max-age=31536000, immutable'
969
+ })
970
+ res.end(buf)
971
+ } catch (err) {
972
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
973
+ res.end(err.message)
974
+ }
975
+ return
976
+ }
977
+
978
+ // POST /scan-address — scan an address for inscriptions via WhatsOnChain
979
+ if (req.method === 'POST' && path === '/scan-address') {
980
+ if (!this._store) {
981
+ res.writeHead(500, { 'Content-Type': 'application/json' })
982
+ res.end(JSON.stringify({ error: 'Store not available' }))
983
+ return
984
+ }
985
+ let body = ''
986
+ req.on('data', chunk => { body += chunk })
987
+ req.on('end', async () => {
988
+ try {
989
+ const { address } = JSON.parse(body)
990
+ if (!address || typeof address !== 'string' || address.length < 25 || address.length > 35) {
991
+ res.writeHead(400, { 'Content-Type': 'application/json' })
992
+ res.end(JSON.stringify({ error: 'Invalid address' }))
993
+ return
994
+ }
995
+
996
+ // Stream progress via SSE
997
+ res.writeHead(200, {
998
+ 'Content-Type': 'text/event-stream',
999
+ 'Cache-Control': 'no-cache',
1000
+ 'Connection': 'keep-alive',
1001
+ 'Access-Control-Allow-Origin': '*'
1002
+ })
1003
+
1004
+ const result = await scanAddress(address, this._store, (progress) => {
1005
+ res.write('data: ' + JSON.stringify(progress) + '\n\n')
1006
+ })
1007
+
1008
+ res.write('data: ' + JSON.stringify({ phase: 'complete', result }) + '\n\n')
1009
+ res.end()
1010
+ } catch (err) {
1011
+ if (!res.headersSent) {
1012
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1013
+ res.end(JSON.stringify({ error: err.message }))
1014
+ } else {
1015
+ res.write('data: ' + JSON.stringify({ phase: 'error', error: err.message }) + '\n\n')
1016
+ res.end()
1017
+ }
1018
+ }
1019
+ })
1020
+ return
1021
+ }
1022
+
1023
+ // POST /rebuild-inscription-index — deduplicate and rebuild secondary indexes
1024
+ if (req.method === 'POST' && path === '/rebuild-inscription-index') {
1025
+ if (!this._store) {
1026
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1027
+ res.end(JSON.stringify({ error: 'Store not available' }))
1028
+ return
1029
+ }
1030
+ try {
1031
+ const count = await this._store.rebuildInscriptionIndex()
1032
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1033
+ res.end(JSON.stringify({ rebuilt: count }))
1034
+ } catch (err) {
1035
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1036
+ res.end(JSON.stringify({ error: err.message }))
1037
+ }
1038
+ return
1039
+ }
1040
+
1041
+ // GET /apps — app health, SSL, and usage data
1042
+ if (req.method === 'GET' && path === '/apps') {
1043
+ const apps = []
1044
+ if (this._config.apps) {
1045
+ for (const app of this._config.apps) {
1046
+ const entry = this._appChecks.get(app.url) || { checks: [], lastError: null }
1047
+ const checks = entry.checks
1048
+ const checksUp = checks.filter(c => c.up).length
1049
+ const latest = checks.length > 0 ? checks[checks.length - 1] : null
1050
+
1051
+ let ssl = null
1052
+ try {
1053
+ const hostname = new URL(app.url).hostname
1054
+ const cached = this._appSSLCache.get(hostname)
1055
+ if (cached && cached.data && Date.now() - cached.checkedAt < 3600000) {
1056
+ ssl = cached.data
1057
+ } else {
1058
+ ssl = await this._checkSSL(hostname)
1059
+ this._appSSLCache.set(hostname, { data: ssl, checkedAt: Date.now() })
1060
+ }
1061
+ } catch {}
1062
+
1063
+ const usage = this._requestTracker.get(app.bridgeDomain) || { total: 0, endpoints: {}, lastSeen: null }
1064
+
1065
+ apps.push({
1066
+ name: app.name,
1067
+ url: app.url,
1068
+ bridgeDomain: app.bridgeDomain,
1069
+ health: {
1070
+ status: latest ? (latest.up ? 'online' : 'offline') : 'unknown',
1071
+ statusCode: latest ? latest.statusCode : 0,
1072
+ responseTimeMs: latest ? latest.responseTimeMs : 0,
1073
+ lastCheck: latest ? latest.timestamp : null,
1074
+ lastError: entry.lastError,
1075
+ uptimePercent: checks.length > 0 ? Math.round((checksUp / checks.length) * 1000) / 10 : 0,
1076
+ checksTotal: checks.length,
1077
+ checksUp
1078
+ },
1079
+ ssl,
1080
+ usage: {
1081
+ totalRequests: usage.total,
1082
+ endpoints: { ...usage.endpoints },
1083
+ lastSeen: usage.lastSeen
1084
+ }
1085
+ })
1086
+ }
1087
+ }
1088
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1089
+ res.end(JSON.stringify({ apps }))
1090
+ return
1091
+ }
1092
+
1093
+ res.writeHead(404)
1094
+ res.end('Not Found')
1095
+ }
1096
+
1097
+ /**
1098
+ * Stop the HTTP server.
1099
+ * @returns {Promise<void>}
1100
+ */
1101
+ stop () {
1102
+ this.stopAppMonitoring()
1103
+ return new Promise((resolve) => {
1104
+ if (this._server) {
1105
+ this._server.close(() => resolve())
1106
+ this._server = null
1107
+ } else {
1108
+ resolve()
1109
+ }
1110
+ })
1111
+ }
1112
+
1113
+ /**
1114
+ * Get the port this server is configured to use.
1115
+ * @returns {number}
1116
+ */
1117
+ get port () {
1118
+ return this._port
1119
+ }
1120
+ }