@@ -4,7 +4,7 @@ import { access, mkdir, appendFile, readFile, readdir, rm, writeFile } from 'nod
44import { dirname , join } from 'node:path'
55
66import { geti18n } from '../../../../../../scripts/i18n.mjs'
7- import { buildCheckpointPayload , buildFileFoldersSnapshot } from '../../../../../../scripts/p2p/checkpoint.mjs'
7+ import { buildCheckpointPayload , buildFileFoldersSnapshot , signCheckpoint } from '../../../../../../scripts/p2p/checkpoint.mjs'
88import { DEFAULT_MAX_CATCHUP_EVENTS , EPOCH_CHAIN_MAX } from '../../../../../../scripts/p2p/constants.mjs'
99import { pubKeyHash , sign , verify } from '../../../../../../scripts/p2p/crypto.mjs'
1010import {
@@ -117,11 +117,30 @@ function getFederationConfig(username) {
117117 return { enabled, appId, password }
118118}
119119
120- /** @type {Map<string, Promise<{ room: any, sendDag: (payload: unknown, peerId: string | null) => void } | null>> } */
120+ /** @type {Map<string, Promise<{ room: any, sendDag: (payload: unknown, peerId: string | null) => void, sendGossipRequest: (payload: unknown, peerId: string | null) => void, sendGossipResponse: (payload: unknown, peerId: string | null) => void } | null>> } */
121121const federationRoomInflight = new Map ( )
122- /** @type {Map<string, { room: any, sendDag: (payload: unknown, peerId: string | null) => void } | null> } */
122+ /** @type {Map<string, { room: any, sendDag: (payload: unknown, peerId: string | null) => void, sendGossipRequest: (payload: unknown, peerId: string | null) => void, sendGossipResponse: (payload: unknown, peerId: string | null) => void } | null> } */
123123const federationRooms = new Map ( )
124124
125+ const gossipRequestDedupe = new Map ( )
126+ const GOSSIP_DEDUPE_MS = 30_000
127+
128+ /**
129+ * @param {string } dedupeKey 去重键(请求者、wantIds、ttl)
130+ * @returns {boolean } 若为首次处理则返回 true
131+ */
132+ function takeGossipRequestSlot ( dedupeKey ) {
133+ const now = Date . now ( )
134+ if ( gossipRequestDedupe . size > 2000 )
135+ for ( const [ k , t ] of gossipRequestDedupe )
136+ if ( t < now - GOSSIP_DEDUPE_MS ) gossipRequestDedupe . delete ( k )
137+
138+
139+ if ( gossipRequestDedupe . has ( dedupeKey ) ) return false
140+ gossipRequestDedupe . set ( dedupeKey , now )
141+ return true
142+ }
143+
125144/**
126145 * 联邦 MQTT 连接在进程内缓存使用的复合键。
127146 * @param {string } username 用户名
@@ -136,7 +155,7 @@ function federationRoomKey(username, chatId) {
136155 * 按需加入 `fount-fed-${chatId}` MQTT 房间并订阅 `dag_event`;未启用或失败时返回 null。
137156 * @param {string } username 用户名
138157 * @param {string } chatId 群组 ID
139- * @returns {Promise<{ room: any, sendDag: (payload: unknown, peerId: string | null) => void } | null> } 房间句柄与 DAG 发送函数,或 null
158+ * @returns {Promise<{ room: any, sendDag: (payload: unknown, peerId: string | null) => void, sendGossipRequest: (payload: unknown, peerId: string | null) => void, sendGossipResponse: (payload: unknown, peerId: string | null) => void } | null> } 房间句柄与 DAG 发送函数,或 null
140159 */
141160async function ensureFederationRoom ( username , chatId ) {
142161 const { enabled, appId, password } = getFederationConfig ( username )
@@ -158,7 +177,48 @@ async function ensureFederationRoom(username, chatId) {
158177 getDag ( ( data , _peerId ) => {
159178 void ingestRemoteEvent ( username , chatId , data ) . catch ( e => console . error ( e ) )
160179 } )
161- const slot = { room, sendDag }
180+ const [ sendGossipRequest , getGossipRequest ] = room . makeAction ( 'gossip_request' )
181+ const [ sendGossipResponse , getGossipResponse ] = room . makeAction ( 'gossip_response' )
182+ getGossipRequest ( ( data , peerId ) => {
183+ void ( async ( ) => {
184+ if ( ! data || typeof data !== 'object' ) return
185+ const rawWant = /** @type {{ wantIds?: unknown, ttl?: unknown, requesterId?: unknown } } */ data . wantIds
186+ const wantIds = Array . isArray ( rawWant )
187+ ? [ ...new Set ( rawWant . filter ( id => typeof id === 'string' && / ^ [ 0 - 9 a - f ] { 64 } $ / iu. test ( id ) ) ) ]
188+ : [ ]
189+ if ( ! wantIds . length ) return
190+ const ttl = Number ( /** @type {{ ttl?: unknown } } */ data . ttl )
191+ const requesterId = /** @type {{ requesterId?: unknown } } */ data . requesterId
192+ if ( ! Number . isFinite ( ttl ) || typeof requesterId !== 'string' || ! requesterId ) return
193+ if ( requesterId === NODE_ID ) return
194+ const dedupeKey = `${ requesterId } \0${ wantIds . slice ( ) . sort ( ) . join ( ',' ) } \0${ ttl } `
195+ if ( ! takeGossipRequestSlot ( dedupeKey ) ) return
196+ const path = eventsPath ( username , chatId )
197+ const prev = await readJsonl ( path )
198+ const byId = new Map ( prev . map ( e => [ e . id , e ] ) )
199+ const events = wantIds . map ( id => byId . get ( id ) ) . filter ( Boolean )
200+ if ( events . length && peerId )
201+ try {
202+ sendGossipResponse ( { events, requesterId } , peerId )
203+ }
204+ catch ( e ) {
205+ console . error ( 'federation: gossip_response failed' , e )
206+ }
207+
208+ if ( ttl > 0 )
209+ try {
210+ sendGossipRequest ( { wantIds, ttl : ttl - 1 , requesterId } , null )
211+ }
212+ catch ( e ) {
213+ console . error ( 'federation: gossip_request forward failed' , e )
214+ }
215+
216+ } ) ( ) . catch ( e => console . error ( e ) )
217+ } )
218+ getGossipResponse ( ( data , _peerId ) => {
219+ void handleGossipResponse ( username , chatId , data ) . catch ( e => console . error ( e ) )
220+ } )
221+ const slot = { room, sendDag, sendGossipRequest, sendGossipResponse }
162222 federationRooms . set ( key , slot )
163223 return slot
164224 }
@@ -225,7 +285,7 @@ async function appendValidatedRemoteEvent(username, chatId, signPayload, opts =
225285 }
226286
227287 await appendJsonl ( path , signPayload )
228- await broadcastAndPersist ( username , chatId , /** @type {object } */ signPayload )
288+ await broadcastAndPersist ( username , chatId , /** @type {object } */ signPayload , { } )
229289 return 'ok'
230290}
231291
@@ -246,6 +306,29 @@ async function ingestRemoteEvent(username, chatId, payload) {
246306 await appendValidatedRemoteEvent ( username , chatId , /** @type {object } */ signPayload , { logFailures : true } )
247307}
248308
309+ /**
310+ * @param {string } username 用户名
311+ * @param {string } chatId 群组 ID
312+ * @param {unknown } data `gossip_response` 载荷
313+ */
314+ async function handleGossipResponse ( username , chatId , data ) {
315+ if ( ! data || typeof data !== 'object' ) return
316+ const requesterId = /** @type {{ requesterId?: unknown } } */ data . requesterId
317+ if ( requesterId !== NODE_ID ) return
318+ const rawList = /** @type {{ events?: unknown } } */ data . events
319+ if ( ! Array . isArray ( rawList ) ) return
320+ for ( const rawEv of rawList ) {
321+ /** @type {unknown } */
322+ let ev = rawEv
323+ if ( rawEv && typeof rawEv === 'object' && 'event' in /** @type {object } */ rawEv ) {
324+ const wrapped = /** @type {{ event?: object } } */ rawEv . event
325+ if ( wrapped && typeof wrapped === 'object' ) ev = wrapped
326+ }
327+ if ( ! ev || typeof ev !== 'object' ) continue
328+ await appendValidatedRemoteEvent ( username , chatId , /** @type {object } */ ev , { logFailures : false } )
329+ }
330+ }
331+
249332// ─── 写入频道消息流的事件类型 ─────────────────────────────────────────────────
250333
251334const PERSIST_MESSAGE_TYPES = new Set ( [
@@ -259,12 +342,13 @@ const PERSIST_MESSAGE_TYPES = new Set([
259342 * @param {string } username 用户名
260343 * @param {string } chatId 群组 ID
261344 * @param {object } signPayload 已持久化的签名事件对象
345+ * @param {{ checkpointOwnerSecretKey?: Uint8Array } } [persistOpts] 可选群主私钥供 checkpoint 签名
262346 * @returns {Promise<void> } 广播与派生持久化完成
263347 */
264- async function broadcastAndPersist ( username , chatId , signPayload ) {
348+ async function broadcastAndPersist ( username , chatId , signPayload , persistOpts = { } ) {
265349 broadcastEvent ( chatId , { type : 'dag_event' , event : signPayload } )
266350 if ( ! PERSIST_MESSAGE_TYPES . has ( signPayload . type ) ) {
267- await rebuildAndSaveCheckpoint ( username , chatId )
351+ await rebuildAndSaveCheckpoint ( username , chatId , persistOpts )
268352 return
269353 }
270354 const ch = signPayload . channelId || signPayload . content ?. channelId || 'default'
@@ -281,7 +365,7 @@ async function broadcastAndPersist(username, chatId, signPayload) {
281365 await mkdir ( join ( mp , '..' ) , { recursive : true } )
282366 await appendFile ( mp , `${ JSON . stringify ( msgLine ) } \n` , 'utf8' )
283367 broadcastEvent ( chatId , { type : 'channel_message' , channelId : ch , message : msgLine } )
284- await rebuildAndSaveCheckpoint ( username , chatId )
368+ await rebuildAndSaveCheckpoint ( username , chatId , persistOpts )
285369 if ( signPayload . type === 'message' )
286370 void maybeAutoTriggerCharReply ( username , chatId , ch ) . catch ( ( ) => { } )
287371}
@@ -558,9 +642,10 @@ function foldPinOverlay(events) {
558642 * 重放 DAG 授权类事件并写回 `checkpoint.json`。
559643 * @param {string } username 用户名
560644 * @param {string } chatId 群组 ID
645+ * @param {{ checkpointOwnerSecretKey?: Uint8Array } } [opts] 可选群主私钥
561646 * @returns {Promise<object | null> } 新生成的检查点对象;无事件时返回 `null`
562647 */
563- export async function rebuildAndSaveCheckpoint ( username , chatId ) {
648+ export async function rebuildAndSaveCheckpoint ( username , chatId , opts = { } ) {
564649 const { events, state, order } = await getState ( username , chatId , { forceFullReplay : true } )
565650 if ( ! events . length ) return null
566651 const last = events [ events . length - 1 ]
@@ -608,7 +693,7 @@ export async function rebuildAndSaveCheckpoint(username, chatId) {
608693 const pins = foldPinOverlay ( events )
609694 const fileIdx = Object . fromEntries ( state . fileIndex ?? new Map ( ) )
610695 const fileFolders = buildFileFoldersSnapshot ( state . fileIndex )
611- const cp = buildCheckpointPayload ( {
696+ let cp = buildCheckpointPayload ( {
612697 home_node_id : home ,
613698 materialized : state ,
614699 epoch_id,
@@ -618,11 +703,28 @@ export async function rebuildAndSaveCheckpoint(username, chatId) {
618703 fileFolders,
619704 epoch_chain,
620705 } )
706+ const sk = opts . checkpointOwnerSecretKey
707+ if ( sk && await canUseSecretKeyForCheckpointSignature ( state , sk ) )
708+ cp = await signCheckpoint ( cp , sk )
621709 await mkdir ( chatDir ( username , chatId ) , { recursive : true } )
622710 await writeFile ( checkpointPath ( username , chatId ) , JSON . stringify ( cp , null , '\t' ) , 'utf8' )
623711 return cp
624712}
625713
714+ /**
715+ * @param {ReturnType<typeof emptyMaterializedState> } state 当前物化状态
716+ * @param {Uint8Array } secretKey 候选 Ed25519 私钥
717+ * @returns {Promise<boolean> } 是否可为 checkpoint 代群主签名
718+ */
719+ async function canUseSecretKeyForCheckpointSignature ( state , secretKey ) {
720+ if ( ! secretKey || secretKey . length < 32 ) return false
721+ const { getPublicKey } = await import ( 'npm:@noble/ed25519' )
722+ const h = pubKeyHash ( getPublicKey ( secretKey . slice ( 0 , 32 ) ) )
723+ const del = state . delegatedOwnerPubKeyHash
724+ if ( del ) return h === del
725+ return adminPubKeyHashes ( state ) . has ( h )
726+ }
727+
626728// ─── DAG 事件追加 ─────────────────────────────────────────────────────────────
627729
628730/**
@@ -726,7 +828,7 @@ export async function appendEvent(username, chatId, event, secretKey) {
726828 await validateSignature ( username , chatId , body , signPayload , event , secretKey )
727829
728830 await appendJsonl ( eventsPath ( username , chatId ) , signPayload )
729- await broadcastAndPersist ( username , chatId , signPayload )
831+ await broadcastAndPersist ( username , chatId , signPayload , { checkpointOwnerSecretKey : secretKey } )
730832 await publishEventToFederation ( username , chatId , signPayload )
731833
732834 return signPayload
@@ -1254,5 +1356,20 @@ export async function requestMissingEventsGossip(username, chatId, query = {}) {
12541356 const stillMissing = wantIds . filter ( id => ! byId . has ( id ) )
12551357 const found = wantIds . length === 0 || stillMissing . length === 0
12561358
1359+ if ( stillMissing . length )
1360+ void ( async ( ) => {
1361+ const slot = await ensureFederationRoom ( username , chatId )
1362+ if ( ! slot ?. sendGossipRequest ) return
1363+ try {
1364+ slot . sendGossipRequest ( { wantIds : [ ...stillMissing ] , ttl : 2 , requesterId : NODE_ID } , null )
1365+ }
1366+ catch ( e ) {
1367+ console . error ( 'federation: gossip_request failed' , e )
1368+ return
1369+ }
1370+ await new Promise ( resolve => setTimeout ( resolve , 3000 ) )
1371+ } ) ( ) . catch ( e => console . error ( e ) )
1372+
1373+
12571374 return { found, events : filled , stillMissing, mergedFromPeer }
12581375}
0 commit comments