@relay-federation/bridge 0.3.12 → 0.3.13
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 +8 -0
- package/lib/persistent-store.js +103 -0
- package/lib/session-relay.js +179 -0
- package/lib/status-server.js +119 -13
- package/package.json +1 -1
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)
|
package/lib/persistent-store.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/status-server.js
CHANGED
|
@@ -793,7 +793,7 @@ export class StatusServer {
|
|
|
793
793
|
return
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
-
// GET /address/:addr/history —
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
const history =
|
|
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(
|
|
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 —
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
const
|
|
1205
|
-
const history =
|
|
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(
|
|
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
|
}
|