@relay-federation/bridge 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -6,6 +6,7 @@ import { PeerManager } from './lib/peer-manager.js'
6
6
  import { HeaderRelay } from './lib/header-relay.js'
7
7
  import { TxRelay } from './lib/tx-relay.js'
8
8
  import { DataRelay } from './lib/data-relay.js'
9
+ import { SessionRelay } from './lib/session-relay.js'
9
10
  import { StatusServer } from './lib/status-server.js'
10
11
  // network.js import removed — register/deregister now use local UTXOs + P2P broadcast
11
12
 
@@ -404,6 +405,7 @@ async function cmdStart () {
404
405
  const headerRelay = new HeaderRelay(peerManager)
405
406
  const txRelay = new TxRelay(peerManager)
406
407
  const dataRelay = new DataRelay(peerManager)
408
+ const sessionRelay = new SessionRelay(peerManager, store)
407
409
 
408
410
  // ── 2b. Phase 2: Security layer ────────────────────────────
409
411
  const { PeerScorer } = await import('./lib/peer-scorer.js')
@@ -960,6 +962,12 @@ async function cmdStart () {
960
962
  statusServer.addLog(msg)
961
963
  })
962
964
 
965
+ sessionRelay.on('sessions:sync', ({ pubkeyHex, address, added, total }) => {
966
+ const msg = `Synced ${added} sessions for ${address.slice(0, 12)}... from ${pubkeyHex.slice(0, 16)}... (total: ${total})`
967
+ console.log(msg)
968
+ statusServer.addLog(msg)
969
+ })
970
+
963
971
  txRelay.on('tx:new', ({ txid }) => {
964
972
  const msg = `New tx: ${txid}`
965
973
  console.log(msg)
@@ -65,6 +65,16 @@ export class BSVNodeClient extends EventEmitter {
65
65
  // Track best height across all peers
66
66
  this._bestHeight = this._checkpoint.height
67
67
  this._bestHash = this._checkpoint.hash
68
+
69
+ // Shared header store — all peers reference these instead of creating their own
70
+ this._sharedHeaderHashes = new Map()
71
+ this._sharedHashToHeight = new Map()
72
+ this._sharedHeaderHashes.set(this._checkpoint.height, this._checkpoint.hash)
73
+ this._sharedHashToHeight.set(this._checkpoint.hash, this._checkpoint.height)
74
+ if (this._checkpoint.prevHash) {
75
+ this._sharedHeaderHashes.set(this._checkpoint.height - 1, this._checkpoint.prevHash)
76
+ this._sharedHashToHeight.set(this._checkpoint.prevHash, this._checkpoint.height - 1)
77
+ }
68
78
  }
69
79
 
70
80
  /**
@@ -231,7 +241,9 @@ export class BSVNodeClient extends EventEmitter {
231
241
  const peer = new BSVPeer({
232
242
  checkpoint: this._checkpoint,
233
243
  syncIntervalMs: this._syncIntervalMs,
234
- pingIntervalMs: this._pingIntervalMs
244
+ pingIntervalMs: this._pingIntervalMs,
245
+ headerHashes: this._sharedHeaderHashes,
246
+ hashToHeight: this._sharedHashToHeight
235
247
  })
236
248
 
237
249
  this._peers.set(host, peer)
package/lib/bsv-peer.js CHANGED
@@ -118,6 +118,8 @@ export class BSVPeer extends EventEmitter {
118
118
  * @param {{ height: number, hash: string, prevHash: string }} [opts.checkpoint]
119
119
  * @param {number} [opts.syncIntervalMs] — Header sync interval (default 30s)
120
120
  * @param {number} [opts.pingIntervalMs] — Keepalive ping interval (default 120s)
121
+ * @param {Map} [opts.headerHashes] — Shared height→hash Map (from BSVNodeClient)
122
+ * @param {Map} [opts.hashToHeight] — Shared hash→height Map (from BSVNodeClient)
121
123
  */
122
124
  constructor (opts = {}) {
123
125
  super()
@@ -136,13 +138,21 @@ export class BSVPeer extends EventEmitter {
136
138
  this._syncTimer = null
137
139
  this._pingTimer = null
138
140
 
139
- // Header tracking
141
+ // Header tracking — use shared Maps if provided, otherwise create own
140
142
  this._bestHeight = this._checkpoint.height
141
143
  this._bestHash = this._checkpoint.hash
142
- this._headerHashes = new Map()
143
- this._headerHashes.set(this._checkpoint.height, this._checkpoint.hash)
144
- if (this._checkpoint.prevHash) {
145
- this._headerHashes.set(this._checkpoint.height - 1, this._checkpoint.prevHash)
144
+ if (opts.headerHashes && opts.hashToHeight) {
145
+ this._headerHashes = opts.headerHashes
146
+ this._hashToHeight = opts.hashToHeight
147
+ } else {
148
+ this._headerHashes = new Map()
149
+ this._hashToHeight = new Map()
150
+ this._headerHashes.set(this._checkpoint.height, this._checkpoint.hash)
151
+ this._hashToHeight.set(this._checkpoint.hash, this._checkpoint.height)
152
+ if (this._checkpoint.prevHash) {
153
+ this._headerHashes.set(this._checkpoint.height - 1, this._checkpoint.prevHash)
154
+ this._hashToHeight.set(this._checkpoint.prevHash, this._checkpoint.height - 1)
155
+ }
146
156
  }
147
157
 
148
158
  // Peer info
@@ -248,6 +258,7 @@ export class BSVPeer extends EventEmitter {
248
258
  */
249
259
  seedHeader (height, hash) {
250
260
  this._headerHashes.set(height, hash)
261
+ this._hashToHeight.set(hash, height)
251
262
  if (height > this._bestHeight) {
252
263
  this._bestHeight = height
253
264
  this._bestHash = hash
@@ -509,13 +520,8 @@ export class BSVPeer extends EventEmitter {
509
520
  const blockHash = internalToHash(sha256d(rawHeader))
510
521
  const prevHash = internalToHash(prevHashBuf)
511
522
 
512
- let height = -1
513
- for (const [h, hash] of this._headerHashes) {
514
- if (hash === prevHash) {
515
- height = h + 1
516
- break
517
- }
518
- }
523
+ const prevHeight = this._hashToHeight.get(prevHash)
524
+ let height = prevHeight !== undefined ? prevHeight + 1 : -1
519
525
 
520
526
  if (height < 0) {
521
527
  offset += HEADER_BYTES
@@ -527,6 +533,7 @@ export class BSVPeer extends EventEmitter {
527
533
  }
528
534
 
529
535
  this._headerHashes.set(height, blockHash)
536
+ this._hashToHeight.set(blockHash, height)
530
537
  if (height > this._bestHeight) {
531
538
  this._bestHeight = height
532
539
  this._bestHash = blockHash
@@ -42,6 +42,7 @@ export class PersistentStore extends EventEmitter {
42
42
  this._txBlock = null
43
43
  this._content = null
44
44
  this._tokens = null
45
+ this._sessions = null
45
46
  this._contentDir = join(dataDir, 'content')
46
47
  }
47
48
 
@@ -61,6 +62,7 @@ export class PersistentStore extends EventEmitter {
61
62
  this._txBlock = this.db.sublevel('txBlock', { valueEncoding: 'json' })
62
63
  this._content = this.db.sublevel('content', { valueEncoding: 'json' })
63
64
  this._tokens = this.db.sublevel('tokens', { valueEncoding: 'json' })
65
+ this._sessions = this.db.sublevel('sessions', { valueEncoding: 'json' })
64
66
  await mkdir(this._contentDir, { recursive: true })
65
67
  this.emit('open')
66
68
  }
@@ -273,6 +275,107 @@ export class PersistentStore extends EventEmitter {
273
275
  return matches
274
276
  }
275
277
 
278
+ // ── Sessions (Indelible) ───────────────────────────────────
279
+
280
+ /**
281
+ * Store a session metadata record with sort index.
282
+ * PK: s!{address}!{txId} SK: t!{address}!{revTs}!{txId}
283
+ * @param {object} session — must have txId and address
284
+ */
285
+ async putSession (session) {
286
+ const { txId, address } = session
287
+ if (!txId || !address) throw new Error('txId and address required')
288
+ const pk = `s!${address}!${txId}`
289
+ const ts = session.timestamp || new Date().toISOString()
290
+ const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
291
+ const sk = `t!${address}!${revTs}!${txId}`
292
+ const record = {
293
+ txId, address,
294
+ session_id: session.session_id || null,
295
+ prev_session_id: session.prev_session_id || null,
296
+ summary: session.summary || '',
297
+ message_count: session.message_count || 0,
298
+ save_type: session.save_type || 'full',
299
+ timestamp: ts,
300
+ receivedAt: new Date().toISOString()
301
+ }
302
+ await this._sessions.batch([
303
+ { type: 'put', key: pk, value: record },
304
+ { type: 'put', key: sk, value: txId }
305
+ ])
306
+ return record
307
+ }
308
+
309
+ /**
310
+ * Get sessions for an address, newest first.
311
+ * @param {string} address
312
+ * @param {number} [limit=200]
313
+ * @returns {Promise<Array>}
314
+ */
315
+ async getSessions (address, limit = 200) {
316
+ const prefix = `t!${address}!`
317
+ const results = []
318
+ for await (const [, txId] of this._sessions.iterator({
319
+ gte: prefix, lt: prefix + '~', limit
320
+ })) {
321
+ const record = await this._safeGet(this._sessions, `s!${address}!${txId}`)
322
+ if (record) results.push(record)
323
+ }
324
+ return results
325
+ }
326
+
327
+ /**
328
+ * Batch import sessions (for backfill).
329
+ * @param {Array} sessions — array of session objects
330
+ * @returns {Promise<number>} count imported
331
+ */
332
+ async putSessionsBatch (sessions) {
333
+ const ops = []
334
+ for (const session of sessions) {
335
+ const { txId, address } = session
336
+ if (!txId || !address) continue
337
+ const pk = `s!${address}!${txId}`
338
+ const ts = session.timestamp || new Date().toISOString()
339
+ const revTs = String(9999999999999 - new Date(ts).getTime()).padStart(13, '0')
340
+ const sk = `t!${address}!${revTs}!${txId}`
341
+ const record = {
342
+ txId, address,
343
+ session_id: session.session_id || null,
344
+ prev_session_id: session.prev_session_id || null,
345
+ summary: session.summary || '',
346
+ message_count: session.message_count || 0,
347
+ save_type: session.save_type || 'full',
348
+ timestamp: ts,
349
+ receivedAt: new Date().toISOString()
350
+ }
351
+ ops.push({ type: 'put', key: pk, value: record })
352
+ ops.push({ type: 'put', key: sk, value: txId })
353
+ }
354
+ if (ops.length > 0) await this._sessions.batch(ops)
355
+ return ops.length / 2
356
+ }
357
+
358
+ /**
359
+ * Get summary of all addresses with sessions (for peer sync announce).
360
+ * @returns {Promise<Array<{ address: string, count: number, latest: string }>>}
361
+ */
362
+ async getSessionAddresses () {
363
+ const map = new Map() // address → { count, latest }
364
+ for await (const [key, value] of this._sessions.iterator({
365
+ gte: 's!', lt: 's!~'
366
+ })) {
367
+ const addr = key.split('!')[1]
368
+ const entry = map.get(addr)
369
+ if (!entry) {
370
+ map.set(addr, { count: 1, latest: value.timestamp || '' })
371
+ } else {
372
+ entry.count++
373
+ if (value.timestamp > entry.latest) entry.latest = value.timestamp
374
+ }
375
+ }
376
+ return [...map].map(([address, { count, latest }]) => ({ address, count, latest }))
377
+ }
378
+
276
379
  // ── Metadata ─────────────────────────────────────────────
277
380
 
278
381
  /**
@@ -0,0 +1,179 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ /**
4
+ * SessionRelay — syncs Indelible session metadata between peers.
5
+ *
6
+ * Uses the PeerManager's message infrastructure to:
7
+ * - Announce session counts per address to new peers (triggered by hello)
8
+ * - Request missing sessions from peers that have more
9
+ * - Respond to session requests with batches
10
+ * - Re-announce to all peers after syncing new sessions
11
+ *
12
+ * Message types:
13
+ * sessions_announce — { type, summaries: [{ address, count, latest }] }
14
+ * sessions_request — { type, address, beforeTimestamp, limit }
15
+ * sessions — { type, address, sessions: [...], hasMore }
16
+ *
17
+ * Events:
18
+ * 'sessions:sync' — { pubkeyHex, address, added, total }
19
+ */
20
+ export class SessionRelay extends EventEmitter {
21
+ /**
22
+ * @param {import('./peer-manager.js').PeerManager} peerManager
23
+ * @param {import('./persistent-store.js').PersistentStore} store
24
+ * @param {object} [opts]
25
+ * @param {number} [opts.maxBatch=500] — Max sessions per response
26
+ */
27
+ constructor (peerManager, store, opts = {}) {
28
+ super()
29
+ this.peerManager = peerManager
30
+ this.store = store
31
+ this._maxBatch = opts.maxBatch || 500
32
+ this._syncing = new Set() // track in-progress syncs: "pubkey:address"
33
+
34
+ this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
35
+ this._handleMessage(pubkeyHex, message)
36
+ })
37
+ }
38
+
39
+ /** @private */
40
+ async _announceToPeer (pubkeyHex) {
41
+ const conn = this.peerManager.peers.get(pubkeyHex)
42
+ if (!conn) return
43
+ try {
44
+ const summaries = await this.store.getSessionAddresses()
45
+ conn.send({ type: 'sessions_announce', summaries })
46
+ } catch (err) {
47
+ // Store not ready yet — skip announce
48
+ }
49
+ }
50
+
51
+ /** @private */
52
+ _handleMessage (pubkeyHex, message) {
53
+ switch (message.type) {
54
+ case 'hello':
55
+ this._announceToPeer(pubkeyHex)
56
+ break
57
+ case 'sessions_announce':
58
+ this._onSessionsAnnounce(pubkeyHex, message)
59
+ break
60
+ case 'sessions_request':
61
+ this._onSessionsRequest(pubkeyHex, message)
62
+ break
63
+ case 'sessions':
64
+ this._onSessions(pubkeyHex, message)
65
+ break
66
+ }
67
+ }
68
+
69
+ /** @private */
70
+ async _onSessionsAnnounce (pubkeyHex, msg) {
71
+ if (!Array.isArray(msg.summaries)) return
72
+ const ourSummaries = await this.store.getSessionAddresses()
73
+ const ourMap = new Map(ourSummaries.map(s => [s.address, s]))
74
+
75
+ for (const remote of msg.summaries) {
76
+ const local = ourMap.get(remote.address)
77
+ const ourCount = local ? local.count : 0
78
+
79
+ if (remote.count > ourCount) {
80
+ // Peer has more sessions for this address — request what we're missing
81
+ const syncKey = `${pubkeyHex}:${remote.address}`
82
+ if (this._syncing.has(syncKey)) continue // already syncing
83
+ this._syncing.add(syncKey)
84
+
85
+ const conn = this.peerManager.peers.get(pubkeyHex)
86
+ if (conn) {
87
+ conn.send({
88
+ type: 'sessions_request',
89
+ address: remote.address,
90
+ beforeTimestamp: '',
91
+ limit: this._maxBatch
92
+ })
93
+ }
94
+ } else if (remote.count < ourCount) {
95
+ // We have more — announce back so they can sync from us
96
+ this._announceToPeer(pubkeyHex)
97
+ break // one announce covers all addresses
98
+ }
99
+ }
100
+ }
101
+
102
+ /** @private */
103
+ async _onSessionsRequest (pubkeyHex, msg) {
104
+ if (!msg.address) return
105
+ const limit = Math.min(msg.limit || this._maxBatch, this._maxBatch)
106
+ const allSessions = await this.store.getSessions(msg.address, 5000)
107
+
108
+ // Filter: getSessions returns newest-first, so paginate by "before" timestamp
109
+ let sessions = allSessions
110
+ if (msg.beforeTimestamp) {
111
+ sessions = allSessions.filter(s => s.timestamp < msg.beforeTimestamp)
112
+ }
113
+
114
+ // Paginate
115
+ const batch = sessions.slice(0, limit)
116
+ const hasMore = sessions.length > limit
117
+
118
+ const conn = this.peerManager.peers.get(pubkeyHex)
119
+ if (conn) {
120
+ conn.send({
121
+ type: 'sessions',
122
+ address: msg.address,
123
+ sessions: batch,
124
+ hasMore
125
+ })
126
+ }
127
+ }
128
+
129
+ /** @private */
130
+ async _onSessions (pubkeyHex, msg) {
131
+ if (!msg.address || !Array.isArray(msg.sessions)) return
132
+ const syncKey = `${pubkeyHex}:${msg.address}`
133
+
134
+ // Deduplicate: only import sessions we don't already have
135
+ const existing = await this.store.getSessions(msg.address, 5000)
136
+ const existingTxIds = new Set(existing.map(s => s.txId))
137
+ const newSessions = msg.sessions.filter(s => !existingTxIds.has(s.txId))
138
+
139
+ let added = 0
140
+ if (newSessions.length > 0) {
141
+ added = await this.store.putSessionsBatch(newSessions)
142
+ }
143
+
144
+ if (added > 0) {
145
+ const total = existing.length + added
146
+ this.emit('sessions:sync', {
147
+ pubkeyHex,
148
+ address: msg.address,
149
+ added,
150
+ total
151
+ })
152
+
153
+ // Re-announce to all peers except the source
154
+ const summaries = await this.store.getSessionAddresses()
155
+ this.peerManager.broadcast({
156
+ type: 'sessions_announce',
157
+ summaries
158
+ }, pubkeyHex)
159
+ }
160
+
161
+ // If peer has more, request next batch (oldest from current batch = cursor)
162
+ if (msg.hasMore && msg.sessions.length > 0) {
163
+ const oldest = msg.sessions.reduce((min, s) =>
164
+ !min || s.timestamp < min ? s.timestamp : min, ''
165
+ )
166
+ const conn = this.peerManager.peers.get(pubkeyHex)
167
+ if (conn) {
168
+ conn.send({
169
+ type: 'sessions_request',
170
+ address: msg.address,
171
+ beforeTimestamp: oldest,
172
+ limit: this._maxBatch
173
+ })
174
+ }
175
+ } else {
176
+ this._syncing.delete(syncKey)
177
+ }
178
+ }
179
+ }
@@ -793,7 +793,7 @@ export class StatusServer {
793
793
  return
794
794
  }
795
795
 
796
- // GET /address/:addr/history — transaction history for an address (via WoC)
796
+ // GET /address/:addr/history — local sessions first, WoC fallback
797
797
  const addrMatch = path.match(/^\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
798
798
  if (req.method === 'GET' && addrMatch) {
799
799
  const addr = addrMatch[1]
@@ -804,12 +804,30 @@ export class StatusServer {
804
804
  return
805
805
  }
806
806
  try {
807
- const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
808
- if (!resp.ok) throw new Error('WoC returned ' + resp.status)
809
- const data = await resp.json()
810
- const history = Array.isArray(data) ? data : (data.result || [])
807
+ // Local sessions from LevelDB (source of truth)
808
+ const localSessions = await this._store.getSessions(addr, 2000)
809
+ const seen = new Set(localSessions.map(s => s.txId))
810
+ const history = localSessions.map(s => ({ tx_hash: s.txId, height: -1 }))
811
+
812
+ // WoC fallback for older txs + block heights
813
+ try {
814
+ const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
815
+ if (resp.ok) {
816
+ const data = await resp.json()
817
+ const wocHistory = Array.isArray(data) ? data : (data.result || [])
818
+ for (const entry of wocHistory) {
819
+ if (seen.has(entry.tx_hash)) {
820
+ const match = history.find(h => h.tx_hash === entry.tx_hash)
821
+ if (match && entry.height > 0) match.height = entry.height
822
+ } else {
823
+ history.push(entry)
824
+ seen.add(entry.tx_hash)
825
+ }
826
+ }
827
+ }
828
+ } catch {} // WoC failure doesn't block response
829
+
811
830
  this._addressCache.set(addr, { data: history, time: Date.now() })
812
- // Prune cache if it grows too large
813
831
  if (this._addressCache.size > 100) {
814
832
  const oldest = this._addressCache.keys().next().value
815
833
  this._addressCache.delete(oldest)
@@ -817,7 +835,7 @@ export class StatusServer {
817
835
  res.writeHead(200, { 'Content-Type': 'application/json' })
818
836
  res.end(JSON.stringify({ address: addr, history, cached: false }))
819
837
  } catch (err) {
820
- res.writeHead(502, { 'Content-Type': 'application/json' })
838
+ res.writeHead(500, { 'Content-Type': 'application/json' })
821
839
  res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
822
840
  }
823
841
  return
@@ -1188,7 +1206,7 @@ export class StatusServer {
1188
1206
  return
1189
1207
  }
1190
1208
 
1191
- // GET /api/address/:addr/history — proxy to WoC confirmed/history (web app compat)
1209
+ // GET /api/address/:addr/history — local sessions first, WoC fallback (web app compat)
1192
1210
  const apiHistMatch = path.match(/^\/api\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
1193
1211
  if (req.method === 'GET' && apiHistMatch) {
1194
1212
  const addr = apiHistMatch[1]
@@ -1199,10 +1217,29 @@ export class StatusServer {
1199
1217
  return
1200
1218
  }
1201
1219
  try {
1202
- const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
1203
- if (!resp.ok) throw new Error('WoC returned ' + resp.status)
1204
- const data = await resp.json()
1205
- const history = Array.isArray(data) ? data : (data.result || [])
1220
+ // Local sessions from LevelDB (source of truth)
1221
+ const localSessions = await this._store.getSessions(addr, 2000)
1222
+ const seen = new Set(localSessions.map(s => s.txId))
1223
+ const history = localSessions.map(s => ({ tx_hash: s.txId, height: -1 }))
1224
+
1225
+ // WoC fallback for older txs + block heights
1226
+ try {
1227
+ const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/confirmed/history', { signal: AbortSignal.timeout(10000) })
1228
+ if (resp.ok) {
1229
+ const data = await resp.json()
1230
+ const wocHistory = Array.isArray(data) ? data : (data.result || [])
1231
+ for (const entry of wocHistory) {
1232
+ if (seen.has(entry.tx_hash)) {
1233
+ const match = history.find(h => h.tx_hash === entry.tx_hash)
1234
+ if (match && entry.height > 0) match.height = entry.height
1235
+ } else {
1236
+ history.push(entry)
1237
+ seen.add(entry.tx_hash)
1238
+ }
1239
+ }
1240
+ }
1241
+ } catch {} // WoC failure doesn't block response
1242
+
1206
1243
  this._addressCache.set(addr, { data: history, time: Date.now() })
1207
1244
  if (this._addressCache.size > 100) {
1208
1245
  const oldest = this._addressCache.keys().next().value
@@ -1211,7 +1248,7 @@ export class StatusServer {
1211
1248
  res.writeHead(200, { 'Content-Type': 'application/json' })
1212
1249
  res.end(JSON.stringify(history))
1213
1250
  } catch (err) {
1214
- res.writeHead(502, { 'Content-Type': 'application/json' })
1251
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1215
1252
  res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
1216
1253
  }
1217
1254
  return
@@ -1263,6 +1300,75 @@ export class StatusServer {
1263
1300
  return
1264
1301
  }
1265
1302
 
1303
+ // ── Session Storage (Indelible) ─────────────────────────
1304
+
1305
+ // POST /api/sessions/index — MCP/CLI pushes session metadata after broadcast (open, like /api/broadcast)
1306
+ if (req.method === 'POST' && path === '/api/sessions/index') {
1307
+ try {
1308
+ const body = await this._readBody(req)
1309
+ if (!body.txId || !body.address) {
1310
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1311
+ res.end(JSON.stringify({ error: 'txId and address required' }))
1312
+ return
1313
+ }
1314
+ const record = await this._store.putSession(body)
1315
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1316
+ res.end(JSON.stringify({ success: true, txId: record.txId }))
1317
+ } catch (err) {
1318
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1319
+ res.end(JSON.stringify({ error: err.message }))
1320
+ }
1321
+ return
1322
+ }
1323
+
1324
+ // GET /api/sessions/{addr} — read session list for an address
1325
+ if (req.method === 'GET' && path.startsWith('/api/sessions/')) {
1326
+ const addr = path.slice('/api/sessions/'.length)
1327
+ if (!addr) {
1328
+ res.writeHead(400, { 'Content-Type': 'application/json' })
1329
+ res.end(JSON.stringify({ error: 'address required' }))
1330
+ return
1331
+ }
1332
+ try {
1333
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '200', 10), 2000)
1334
+ const sessions = await this._store.getSessions(addr, limit)
1335
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1336
+ res.end(JSON.stringify({ sessions, count: sessions.length }))
1337
+ } catch (err) {
1338
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1339
+ res.end(JSON.stringify({ error: err.message }))
1340
+ }
1341
+ return
1342
+ }
1343
+
1344
+ // POST /api/sessions/backfill — bulk import for migration
1345
+ if (req.method === 'POST' && path === '/api/sessions/backfill') {
1346
+ if (!authenticated) {
1347
+ res.writeHead(401, { 'Content-Type': 'application/json' })
1348
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
1349
+ return
1350
+ }
1351
+ try {
1352
+ const body = await this._readBody(req)
1353
+ const sessions = (body.sessions || []).map(s => ({
1354
+ txId: s.txId, address: body.address || s.address,
1355
+ session_id: s.session_id || s.sessionId || null,
1356
+ prev_session_id: s.prev_session_id || s.prevTxId || null,
1357
+ summary: s.summary || '',
1358
+ message_count: s.message_count || s.messageCount || 0,
1359
+ save_type: s.save_type || s.saveType || 'full',
1360
+ timestamp: s.timestamp || null
1361
+ }))
1362
+ const imported = await this._store.putSessionsBatch(sessions)
1363
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1364
+ res.end(JSON.stringify({ success: true, imported }))
1365
+ } catch (err) {
1366
+ res.writeHead(500, { 'Content-Type': 'application/json' })
1367
+ res.end(JSON.stringify({ error: err.message }))
1368
+ }
1369
+ return
1370
+ }
1371
+
1266
1372
  res.writeHead(404)
1267
1373
  res.end('Not Found')
1268
1374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relay-federation/bridge",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {