@steve02081504/fount-p2p 0.0.0
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/README.md +62 -0
- package/core/bytes_codec.mjs +51 -0
- package/core/canonical_json.mjs +21 -0
- package/core/composite_key.mjs +91 -0
- package/core/constants.mjs +53 -0
- package/core/entity_id.mjs +53 -0
- package/core/entity_id_parse.mjs +40 -0
- package/core/evfs_logical_path.mjs +15 -0
- package/core/hexIds.mjs +73 -0
- package/core/hlc.mjs +114 -0
- package/core/random_id.mjs +9 -0
- package/crypto/channel.mjs +101 -0
- package/crypto/checkpoint_sign.mjs +67 -0
- package/crypto/crypto.mjs +158 -0
- package/crypto/key.mjs +431 -0
- package/dag/canonicalize_row.mjs +65 -0
- package/dag/event_query.mjs +15 -0
- package/dag/index.mjs +335 -0
- package/dag/storage.mjs +333 -0
- package/dag/strip_extensions.mjs +18 -0
- package/dag/tunables.json +6 -0
- package/discovery/bt.mjs +260 -0
- package/discovery/index.mjs +134 -0
- package/discovery/mdns.mjs +138 -0
- package/discovery/nostr.mjs +273 -0
- package/entity/files/acl.mjs +38 -0
- package/entity/files/evfs.mjs +189 -0
- package/entity/files/evfs_ref.mjs +37 -0
- package/entity/files/manifest_acl_registry.mjs +93 -0
- package/entity/files/replica_host_cache.mjs +46 -0
- package/entity/files/url.mjs +19 -0
- package/entity/hosting_registry.mjs +47 -0
- package/entity/localized_core.mjs +141 -0
- package/entity/logical_entity.mjs +25 -0
- package/entity/logical_entity_id_registry.mjs +40 -0
- package/entity/node_hash.mjs +15 -0
- package/entity/presentation_registry.mjs +44 -0
- package/entity/profile.mjs +256 -0
- package/entity/replica.mjs +20 -0
- package/entity/session_snapshot_registry.mjs +38 -0
- package/federation/chunk_fetch_pending.mjs +128 -0
- package/federation/chunk_fetch_scheduler.mjs +97 -0
- package/federation/dag_order_cache.mjs +124 -0
- package/federation/dedupe_slot.mjs +33 -0
- package/federation/message_rate_limit.mjs +23 -0
- package/federation/operator_key_chain.mjs +200 -0
- package/federation/topo_order_memo.mjs +44 -0
- package/federation/volatile_streams.mjs +52 -0
- package/federation/want_ids.mjs +158 -0
- package/files/assemble.mjs +220 -0
- package/files/assemble_stream.mjs +129 -0
- package/files/chunk_fetch.mjs +113 -0
- package/files/chunk_fetch_verify.mjs +23 -0
- package/files/chunk_provider_registry.mjs +70 -0
- package/files/chunk_responder.mjs +79 -0
- package/files/chunk_store.mjs +87 -0
- package/files/manifest.mjs +99 -0
- package/files/transfer_key.mjs +87 -0
- package/files/transfer_key_registry.mjs +108 -0
- package/governance/branch.mjs +230 -0
- package/governance/join_pow.mjs +165 -0
- package/governance/owner_succession_ballot.mjs +57 -0
- package/governance/tunables.json +7 -0
- package/index.mjs +39 -0
- package/link/channel_mux.mjs +222 -0
- package/link/frame.mjs +246 -0
- package/link/handshake.mjs +175 -0
- package/link/link.mjs +617 -0
- package/link/rtc.mjs +109 -0
- package/link/sdp_fingerprint.mjs +25 -0
- package/mailbox/consumer_registry.mjs +71 -0
- package/mailbox/deliver_or_store.mjs +190 -0
- package/mailbox/importance.mjs +72 -0
- package/mailbox/parse.mjs +73 -0
- package/mailbox/prune.mjs +106 -0
- package/mailbox/rate.mjs +86 -0
- package/mailbox/settings.mjs +53 -0
- package/mailbox/store.mjs +256 -0
- package/mailbox/tunables.json +12 -0
- package/mailbox/wire.mjs +40 -0
- package/node/denylist.mjs +309 -0
- package/node/entity_store.mjs +214 -0
- package/node/identity.mjs +99 -0
- package/node/instance.mjs +115 -0
- package/node/network.mjs +306 -0
- package/node/personal_block.mjs +286 -0
- package/node/reputation_store.mjs +326 -0
- package/node/retention_policy.mjs +82 -0
- package/node/signaling_config.mjs +32 -0
- package/node/storage.mjs +30 -0
- package/node/storage_plugins.mjs +71 -0
- package/node/user_paths.mjs +10 -0
- package/overlay/index.mjs +191 -0
- package/package.json +73 -0
- package/permissions/bitmask.mjs +35 -0
- package/permissions/evaluator.mjs +90 -0
- package/permissions/index.mjs +12 -0
- package/permissions/layered.mjs +39 -0
- package/registries/action.mjs +94 -0
- package/registries/event_type.mjs +75 -0
- package/registries/inbound.mjs +90 -0
- package/registries/p2p_viewer.mjs +35 -0
- package/registries/part_path.mjs +35 -0
- package/registries/room_provider.mjs +47 -0
- package/reputation/engine.mjs +458 -0
- package/reputation/math.mjs +73 -0
- package/reputation/pick_score.mjs +11 -0
- package/reputation/relay_dedupe.mjs +22 -0
- package/reputation/tunables.json +36 -0
- package/rooms/scoped_link.mjs +185 -0
- package/schemas/discovery.mjs +110 -0
- package/schemas/federation_pull.mjs +102 -0
- package/schemas/mailbox.mjs +37 -0
- package/schemas/remote_event.mjs +17 -0
- package/timeline/append_core.mjs +54 -0
- package/timeline/materialize_runner.mjs +26 -0
- package/timeline/prune.mjs +39 -0
- package/timeline/retention.mjs +54 -0
- package/timeline/retention_runner.mjs +35 -0
- package/timeline/verify_remote.mjs +25 -0
- package/transport/group_link_set.mjs +324 -0
- package/transport/ice_servers.mjs +72 -0
- package/transport/link_registry.mjs +749 -0
- package/transport/peer_identity_maps.mjs +94 -0
- package/transport/peer_pool.mjs +355 -0
- package/transport/remote_user_room.mjs +109 -0
- package/transport/room_scopes.mjs +2 -0
- package/transport/room_wire_action.mjs +19 -0
- package/transport/rtc_connection_budget.mjs +180 -0
- package/transport/rtc_mdns_filter.mjs +118 -0
- package/transport/stale_peer_log.mjs +50 -0
- package/transport/user_room.mjs +263 -0
- package/trust_graph/build.mjs +99 -0
- package/trust_graph/cache.mjs +44 -0
- package/trust_graph/engine.mjs +184 -0
- package/trust_graph/registry.mjs +38 -0
- package/trust_graph/resolve.mjs +105 -0
- package/trust_graph/send.mjs +88 -0
- package/trust_graph/tunables.json +13 -0
- package/utils/async_mutex.mjs +69 -0
- package/utils/debug_log.mjs +16 -0
- package/utils/duration.mjs +28 -0
- package/utils/json_io.mjs +57 -0
- package/utils/lru.mjs +22 -0
- package/utils/map_pool.mjs +27 -0
- package/wire/group_part.mjs +43 -0
- package/wire/ingress.mjs +58 -0
- package/wire/part_common.mjs +55 -0
- package/wire/part_fanout.mjs +71 -0
- package/wire/part_ingress.mjs +172 -0
- package/wire/part_invoke.mjs +81 -0
- package/wire/rate_bucket.mjs +49 -0
- package/wire/volatile_signature.mjs +76 -0
- package/wire/wait.mjs +127 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import reputationTunables from './tunables.json' with { type: 'json' }
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 主观信誉标量下界(§0.3)。
|
|
5
|
+
* @type {number}
|
|
6
|
+
*/
|
|
7
|
+
export const REP_MIN = -1
|
|
8
|
+
/**
|
|
9
|
+
* 主观信誉标量上界(§0.3)。
|
|
10
|
+
* @type {number}
|
|
11
|
+
*/
|
|
12
|
+
export const REP_MAX = 1
|
|
13
|
+
/** §0.1:`rep_max_eff = max(已链邻居最大信誉, ε)` */
|
|
14
|
+
export const REP_MAX_EFF_EPS = 1e-12
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {number} x 任意标量
|
|
18
|
+
* @returns {number} clamp 到 [-1, 1]
|
|
19
|
+
*/
|
|
20
|
+
export function clampReputationScore(x) {
|
|
21
|
+
return Math.min(REP_MAX, Math.max(REP_MIN, x))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ byNodeHash?: Record<string, { score?: number }> }} data 信誉表
|
|
26
|
+
* @returns {number} `max(已链邻居最大信誉, ε)`(§0.1 `rep_max_eff`)
|
|
27
|
+
*/
|
|
28
|
+
export function computeRepMaxEff(data) {
|
|
29
|
+
let maxScore = /** @type {number | null} */ null
|
|
30
|
+
for (const nodeId of Object.keys(data.byNodeHash)) {
|
|
31
|
+
const score = Number(data.byNodeHash[nodeId]?.score)
|
|
32
|
+
if (!Number.isFinite(score)) continue
|
|
33
|
+
// rep_max_eff 仅由「可施加影响」的正信誉邻居定义,避免全负信誉集把分母压成异常值。
|
|
34
|
+
if (score <= 0) continue
|
|
35
|
+
maxScore = maxScore === null ? score : Math.max(maxScore, score)
|
|
36
|
+
}
|
|
37
|
+
return Math.max(maxScore === null ? 0 : clampReputationScore(maxScore), REP_MAX_EFF_EPS)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 不可验证 Slash 落地扣分(§0.1)。
|
|
42
|
+
* @param {number} claim 主张强度
|
|
43
|
+
* @param {number} repSender 发送方信誉
|
|
44
|
+
* @param {number} repMaxEff 分母
|
|
45
|
+
* @param {boolean} [verified] 是否可验证
|
|
46
|
+
* @param {typeof reputationTunables} [tunables] tunables
|
|
47
|
+
* @returns {number} 对目标的扣分幅度(正数)
|
|
48
|
+
*/
|
|
49
|
+
export function subjectiveSlashPenalty(claim, repSender, repMaxEff, verified = false, tunables = reputationTunables) {
|
|
50
|
+
const claimStrength = Number.isFinite(claim) ? claim : tunables.slashDefaultClaim
|
|
51
|
+
const senderInfluence = Math.max(0, Number.isFinite(repSender) ? repSender : 0)
|
|
52
|
+
return verified
|
|
53
|
+
? Math.abs(claimStrength) * tunables.slashVerifiedMultiplier
|
|
54
|
+
: Math.abs((claimStrength * senderInfluence) / repMaxEff)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* §0.3 初值:`clamp(rep_local(intro) * reputationEdge)`。
|
|
59
|
+
*
|
|
60
|
+
* `repEdge` 缺省时退回 tunable `introducerSeedEdge`(抗女巫杠杆:边信任越低,
|
|
61
|
+
* 新成员从介绍者继承的初始信誉越少,邀请链批量灌号的收益越小)。
|
|
62
|
+
* @param {number} introRep 介绍者信誉
|
|
63
|
+
* @param {number} [repEdge] 边信任;省略则用 tunable 默认
|
|
64
|
+
* @param {typeof reputationTunables} [tunables] tunables
|
|
65
|
+
* @param {number} [powBonus=0] 入群 PoW 自愿封顶加成
|
|
66
|
+
* @returns {number} 新成员初值
|
|
67
|
+
*/
|
|
68
|
+
export function seedReputationFromIntro(introRep, repEdge, tunables = reputationTunables, powBonus = 0) {
|
|
69
|
+
const edge = Number.isFinite(repEdge) ? repEdge : tunables.introducerSeedEdge
|
|
70
|
+
const base = introRep * (Number.isFinite(edge) ? clampReputationScore(edge) : 1)
|
|
71
|
+
const bonus = Number.isFinite(powBonus) && powBonus > 0 ? powBonus : 0
|
|
72
|
+
return clampReputationScore(base + bonus)
|
|
73
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯函数:从已加载的 reputation 文件体计算节点全局分(无 setting_loader 依赖)。
|
|
3
|
+
* @param {object} repFile reputation.json 内容
|
|
4
|
+
* @param {string} nodeId 64 位十六进制 节点
|
|
5
|
+
* @returns {number} 信誉分
|
|
6
|
+
*/
|
|
7
|
+
export function pickNodeScoreFromReputation(repFile, nodeId) {
|
|
8
|
+
const row = repFile?.byNodeHash?.[nodeId]
|
|
9
|
+
if (!row) return 0
|
|
10
|
+
return Number(row.score ?? 0)
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 联邦中继信誉加分去重(纯函数)。
|
|
3
|
+
*/
|
|
4
|
+
import reputationTunables from './tunables.json' with { type: 'json' }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Array<{ peerNodeHash: string, key: string, t: number }>} relayBumpSeen 已记录贡献
|
|
8
|
+
* @param {string} peerNodeHash 对端节点
|
|
9
|
+
* @param {string} dedupeKey 去重键
|
|
10
|
+
* @param {number} [now] 当前时间
|
|
11
|
+
* @param {number} [dedupeMs] 去重窗口
|
|
12
|
+
* @returns {boolean} 24h 内已计过分则为 true
|
|
13
|
+
*/
|
|
14
|
+
export function relayBumpIsDuplicate(relayBumpSeen, peerNodeHash, dedupeKey, now = Date.now(), dedupeMs = reputationTunables.relayBumpDedupeMs) {
|
|
15
|
+
if (!peerNodeHash) return true
|
|
16
|
+
const dedupe = dedupeKey || `conn:${peerNodeHash}`
|
|
17
|
+
return relayBumpSeen.some(
|
|
18
|
+
entry => entry.peerNodeHash === peerNodeHash
|
|
19
|
+
&& entry.key === dedupe
|
|
20
|
+
&& now - entry.t <= dedupeMs,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"wantUnknownWindowMs": 300000,
|
|
3
|
+
"wantUnknownThreshold": 3,
|
|
4
|
+
"penaltyUnknownWant": 0.12,
|
|
5
|
+
"penaltyMessageRate": 0.15,
|
|
6
|
+
"chunkStoreRepBump": 0.03,
|
|
7
|
+
"chunkFetchFailPenalty": 0.08,
|
|
8
|
+
"archiveServeMismatchPenalty": 0.08,
|
|
9
|
+
"relayRepBump": 0.02,
|
|
10
|
+
"defaultSlashAlertTtlMs": 86400000,
|
|
11
|
+
"maxRelayBumpSeen": 2000,
|
|
12
|
+
"relayBumpDedupeMs": 86400000,
|
|
13
|
+
"collusionLambda": 0.07,
|
|
14
|
+
"collusionDelta": 0.62,
|
|
15
|
+
"collusionMaxHop": 6,
|
|
16
|
+
"slashVerifiedMultiplier": 0.5,
|
|
17
|
+
"slashDefaultClaim": 0.2,
|
|
18
|
+
"slashVerifiedDefaultClaim": 0.35,
|
|
19
|
+
"slashUnverifiedDefaultClaim": 0.2,
|
|
20
|
+
"introducerSeedEdge": 0.5,
|
|
21
|
+
"recidivismMultiplierStep": 0.25,
|
|
22
|
+
"recidivismMax": 3,
|
|
23
|
+
"redemptionCreditPerStreakLevel": 0.08,
|
|
24
|
+
"inviteRedemptionCreditPerBad": 0.15,
|
|
25
|
+
"inviteBadEscalationStep": 0.5,
|
|
26
|
+
"inviteBadEscalationMax": 4,
|
|
27
|
+
"inviteHopBonusEvery": 2,
|
|
28
|
+
"inviteHopBonusMax": 4,
|
|
29
|
+
"inviteDeltaBoostPerBad": 0.05,
|
|
30
|
+
"inviteDeltaBoostMax": 0.3,
|
|
31
|
+
"baselineAlpha": 0.08,
|
|
32
|
+
"anomalyZThreshold": 2.8,
|
|
33
|
+
"quarantineTtlMs": 900000,
|
|
34
|
+
"quarantineTrustDamp": 0.35,
|
|
35
|
+
"baselineMinSamples": 6
|
|
36
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
|
|
2
|
+
import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
|
|
3
|
+
import {
|
|
4
|
+
groupRendezvousTopic,
|
|
5
|
+
decryptSignalPacket,
|
|
6
|
+
encryptSignalPacket,
|
|
7
|
+
getLinkRegistry,
|
|
8
|
+
} from '../transport/link_registry.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 在指定 scope 与 roomSecret 下创建 link 层房间(discovery + registry 转发)。
|
|
12
|
+
* @param {object} opts 房间选项
|
|
13
|
+
* @param {string} opts.scope link registry scope(如 `group:{id}`)
|
|
14
|
+
* @param {string} opts.roomSecret 房间 rendezvous 密钥
|
|
15
|
+
* @param {(nodeHash: string) => boolean} [opts.allowNode] 是否允许与某 nodeHash 通信
|
|
16
|
+
* @returns {{ start: () => Promise<void>, leave: () => Promise<void>, makeAction: (name: string) => [(payload: unknown, peerId?: string | string[] | null) => Promise<void>, (handler: (payload: unknown, peerId: string) => void) => void], onPeerJoin: (cb: (peerId: string) => void) => () => void, onPeerLeave: (cb: (peerId: string) => void) => () => void, getPeers: () => Record<string, true> }} 房间句柄
|
|
17
|
+
*/
|
|
18
|
+
export function createScopedLinkRoom(opts) {
|
|
19
|
+
const registry = getLinkRegistry()
|
|
20
|
+
const scope = String(opts.scope)
|
|
21
|
+
const topic = groupRendezvousTopic(opts.roomSecret)
|
|
22
|
+
const allowNode = typeof opts.allowNode === 'function' ? opts.allowNode : () => true
|
|
23
|
+
/** @type {Set<string>} */
|
|
24
|
+
const discoveredPeers = new Set()
|
|
25
|
+
/** @type {Set<string>} */
|
|
26
|
+
const announcedPeers = new Set()
|
|
27
|
+
/** @type {Set<(peerId: string) => void>} */
|
|
28
|
+
const joinListeners = new Set()
|
|
29
|
+
/** @type {Set<(peerId: string) => void>} */
|
|
30
|
+
const leaveListeners = new Set()
|
|
31
|
+
/** @type {Set<() => void>} */
|
|
32
|
+
const cleanups = new Set()
|
|
33
|
+
/** @type {Map<string, { handler: ((payload: unknown, peerId: string) => void) | null, backlog: Array<{ payload: unknown, peerId: string }> }>} */
|
|
34
|
+
const actionEntries = new Map()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @returns {string[]} 当前已连接且通过 allowNode 过滤的 peer nodeHash 列表
|
|
38
|
+
*/
|
|
39
|
+
function activePeerIds() {
|
|
40
|
+
return [...discoveredPeers].filter(nodeHash => allowNode(nodeHash) && registry.getLink(nodeHash))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Set<(peerId: string) => void>} listeners 回调集合
|
|
45
|
+
* @param {string} peerId 节点 hash
|
|
46
|
+
* @returns {void}
|
|
47
|
+
*/
|
|
48
|
+
function emit(listeners, peerId) {
|
|
49
|
+
for (const listener of listeners)
|
|
50
|
+
try { listener(peerId) } catch { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} peerId 节点 hash
|
|
55
|
+
* @returns {void}
|
|
56
|
+
*/
|
|
57
|
+
function notePeerJoin(peerId) {
|
|
58
|
+
if (!peerId || announcedPeers.has(peerId)) return
|
|
59
|
+
announcedPeers.add(peerId)
|
|
60
|
+
emit(joinListeners, peerId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} peerId 节点 hash
|
|
65
|
+
* @returns {void}
|
|
66
|
+
*/
|
|
67
|
+
function notePeerLeave(peerId) {
|
|
68
|
+
if (!peerId || !announcedPeers.has(peerId)) return
|
|
69
|
+
announcedPeers.delete(peerId)
|
|
70
|
+
emit(leaveListeners, peerId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} name action 名称
|
|
75
|
+
* @returns {{ handler: ((payload: unknown, peerId: string) => void) | null, backlog: Array<{ payload: unknown, peerId: string }> }} action 槽(含待处理 backlog)
|
|
76
|
+
*/
|
|
77
|
+
function getActionEntry(name) {
|
|
78
|
+
const key = String(name)
|
|
79
|
+
if (!actionEntries.has(key))
|
|
80
|
+
actionEntries.set(key, { handler: null, backlog: [] })
|
|
81
|
+
return actionEntries.get(key)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
/**
|
|
86
|
+
* @returns {Promise<void>}
|
|
87
|
+
*/
|
|
88
|
+
async start() {
|
|
89
|
+
await registry.ensureRuntime()
|
|
90
|
+
cleanups.add(registry.subscribeScope(scope, (senderNodeHash, envelope) => {
|
|
91
|
+
if (!allowNode(senderNodeHash)) return
|
|
92
|
+
const entry = actionEntries.get(String(envelope?.action || ''))
|
|
93
|
+
if (!entry) return
|
|
94
|
+
if (entry.handler) entry.handler(envelope.payload, senderNodeHash)
|
|
95
|
+
else entry.backlog.push({ payload: envelope.payload, peerId: senderNodeHash })
|
|
96
|
+
}))
|
|
97
|
+
cleanups.add(registry.onLinkUp(nodeHash => {
|
|
98
|
+
if (!discoveredPeers.has(nodeHash) || !allowNode(nodeHash)) return
|
|
99
|
+
notePeerJoin(nodeHash)
|
|
100
|
+
}))
|
|
101
|
+
cleanups.add(registry.onLinkDown(nodeHash => {
|
|
102
|
+
if (!discoveredPeers.has(nodeHash)) return
|
|
103
|
+
notePeerLeave(nodeHash)
|
|
104
|
+
}))
|
|
105
|
+
cleanups.add(await subscribeTopic(topic, async bytes => {
|
|
106
|
+
const packet = decryptSignalPacket(topic, bytes)
|
|
107
|
+
if (packet?.type !== 'advert' || !packet.body) return
|
|
108
|
+
const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
|
|
109
|
+
if (!verifiedNodeHash || !allowNode(verifiedNodeHash)) return
|
|
110
|
+
discoveredPeers.add(verifiedNodeHash)
|
|
111
|
+
await registry.ensureLinkToNode(verifiedNodeHash).catch(() => null)
|
|
112
|
+
if (registry.getLink(verifiedNodeHash)) notePeerJoin(verifiedNodeHash)
|
|
113
|
+
}))
|
|
114
|
+
cleanups.add(await advertiseTopic(topic, encryptSignalPacket(topic, {
|
|
115
|
+
type: 'advert',
|
|
116
|
+
body: await buildSignedAdvert(topic, Date.now(), registry.localIdentity),
|
|
117
|
+
})))
|
|
118
|
+
for (const peerId of activePeerIds())
|
|
119
|
+
notePeerJoin(peerId)
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* @returns {Promise<void>}
|
|
123
|
+
*/
|
|
124
|
+
async leave() {
|
|
125
|
+
for (const cleanup of cleanups)
|
|
126
|
+
try { cleanup() } catch { /* ignore */ }
|
|
127
|
+
cleanups.clear()
|
|
128
|
+
for (const peerId of [...announcedPeers])
|
|
129
|
+
notePeerLeave(peerId)
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} name action 名称
|
|
133
|
+
* @returns {[(payload: unknown, peerId?: string | string[] | null) => Promise<void>, (handler: (payload: unknown, peerId: string) => void) => void]} [send, onReceive] 发送与订阅元组
|
|
134
|
+
*/
|
|
135
|
+
makeAction(name) {
|
|
136
|
+
const actionName = String(name)
|
|
137
|
+
return [
|
|
138
|
+
async (payload, peerId = null) => {
|
|
139
|
+
if (Array.isArray(peerId)) {
|
|
140
|
+
await Promise.all(peerId.map(targetPeerId =>
|
|
141
|
+
registry.sendToNodeLink(targetPeerId, { scope, action: actionName, payload })))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (peerId)
|
|
145
|
+
await registry.sendToNodeLink(peerId, { scope, action: actionName, payload })
|
|
146
|
+
else
|
|
147
|
+
await Promise.all(activePeerIds().map(targetPeerId =>
|
|
148
|
+
registry.sendToNodeLink(targetPeerId, { scope, action: actionName, payload })))
|
|
149
|
+
},
|
|
150
|
+
handler => {
|
|
151
|
+
const entry = getActionEntry(actionName)
|
|
152
|
+
entry.handler = handler
|
|
153
|
+
for (const pending of entry.backlog.splice(0))
|
|
154
|
+
handler(pending.payload, pending.peerId)
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
/**
|
|
159
|
+
* @param {(peerId: string) => void} cb 新 peer 上线回调
|
|
160
|
+
* @returns {() => void} 取消订阅
|
|
161
|
+
*/
|
|
162
|
+
onPeerJoin(cb) {
|
|
163
|
+
joinListeners.add(cb)
|
|
164
|
+
for (const peerId of activePeerIds())
|
|
165
|
+
announcedPeers.add(peerId)
|
|
166
|
+
for (const peerId of announcedPeers)
|
|
167
|
+
try { cb(peerId) } catch { /* ignore */ }
|
|
168
|
+
return () => joinListeners.delete(cb)
|
|
169
|
+
},
|
|
170
|
+
/**
|
|
171
|
+
* @param {(peerId: string) => void} cb peer 离线回调
|
|
172
|
+
* @returns {() => void} 取消订阅
|
|
173
|
+
*/
|
|
174
|
+
onPeerLeave(cb) {
|
|
175
|
+
leaveListeners.add(cb)
|
|
176
|
+
return () => leaveListeners.delete(cb)
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* @returns {Record<string, true>} 当前活跃 peer 的 nodeHash 集合
|
|
180
|
+
*/
|
|
181
|
+
getPeers() {
|
|
182
|
+
return Object.fromEntries(activePeerIds().map(peerId => [peerId, true]))
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { assertHex64, isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
import { isPlainObject } from '../wire/ingress.mjs'
|
|
3
|
+
|
|
4
|
+
const MAX_DISCOVERY_ADS = 64
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {unknown} ad 单条 discovery 广告
|
|
8
|
+
* @returns {object | null} 白名单字段
|
|
9
|
+
*/
|
|
10
|
+
function sanitizeDiscoveryAdvertisement(ad) {
|
|
11
|
+
if (!isPlainObject(ad)) return null
|
|
12
|
+
const groupId = String(ad.groupId || '').trim()
|
|
13
|
+
const advertiserPubKeyHash = normalizeHex64(ad.advertiserPubKeyHash)
|
|
14
|
+
const signature = String(ad.signature || '').trim().toLowerCase()
|
|
15
|
+
if (!groupId || !isHex64(advertiserPubKeyHash) || !isSignatureHex128(signature)) return null
|
|
16
|
+
const advertiserNodeHash = normalizeHex64(ad.advertiserNodeHash)
|
|
17
|
+
const body = {
|
|
18
|
+
groupId,
|
|
19
|
+
title: String(ad.title || '').slice(0, 200),
|
|
20
|
+
blurb: String(ad.blurb || '').slice(0, 500),
|
|
21
|
+
advertiserPubKeyHash,
|
|
22
|
+
advertiserNodeHash: isHex64(advertiserNodeHash) ? advertiserNodeHash : String(ad.advertiserNodeHash || '').trim(),
|
|
23
|
+
observedAt: Number(ad.observedAt) || 0,
|
|
24
|
+
signature,
|
|
25
|
+
}
|
|
26
|
+
const memberCount = Number(ad.memberCount)
|
|
27
|
+
if (Number.isFinite(memberCount) && memberCount > 0)
|
|
28
|
+
body.memberCount = Math.floor(memberCount)
|
|
29
|
+
return body
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} ads advertisements 数组
|
|
34
|
+
* @returns {object[]} 已清扫广告
|
|
35
|
+
*/
|
|
36
|
+
function sanitizeDiscoveryAdvertisements(ads) {
|
|
37
|
+
if (!Array.isArray(ads)) return []
|
|
38
|
+
return ads.slice(0, MAX_DISCOVERY_ADS).map(sanitizeDiscoveryAdvertisement).filter(Boolean)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {unknown} nodeHash 节点 hash
|
|
43
|
+
* @returns {string} 规范化 hex64
|
|
44
|
+
*/
|
|
45
|
+
export function assertDiscoveryNodeHash(nodeHash) {
|
|
46
|
+
return assertHex64(nodeHash, 'discovery.nodeHash')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {unknown} requestId 请求 id
|
|
51
|
+
* @returns {string} 非空 trimmed 字符串
|
|
52
|
+
*/
|
|
53
|
+
export function assertDiscoveryRequestId(requestId) {
|
|
54
|
+
const id = String(requestId ?? '').trim()
|
|
55
|
+
if (!id) throw new Error('discovery.requestId required')
|
|
56
|
+
return id
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {unknown} payload 载荷
|
|
61
|
+
* @returns {{ nodeHash: string, advertisements: object[] } | null} 解析结果
|
|
62
|
+
*/
|
|
63
|
+
export function parseDiscoveryAnnounce(payload) {
|
|
64
|
+
if (!isPlainObject(payload)) return null
|
|
65
|
+
try {
|
|
66
|
+
return {
|
|
67
|
+
nodeHash: assertDiscoveryNodeHash(payload.nodeHash),
|
|
68
|
+
advertisements: sanitizeDiscoveryAdvertisements(payload.advertisements),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {unknown} payload 载荷
|
|
78
|
+
* @returns {{ nodeHash: string, requestId: string, limit: number } | null} 解析结果
|
|
79
|
+
*/
|
|
80
|
+
export function parseDiscoveryQuery(payload) {
|
|
81
|
+
if (!isPlainObject(payload)) return null
|
|
82
|
+
try {
|
|
83
|
+
return {
|
|
84
|
+
nodeHash: assertDiscoveryNodeHash(payload.nodeHash),
|
|
85
|
+
requestId: assertDiscoveryRequestId(payload.requestId),
|
|
86
|
+
limit: Math.min(64, Math.max(1, Number(payload.limit) || 32)),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {unknown} payload 载荷
|
|
96
|
+
* @returns {{ requestId: string, nodeHash: string, advertisements: object[] } | null} 解析结果
|
|
97
|
+
*/
|
|
98
|
+
export function parseDiscoveryQueryResponse(payload) {
|
|
99
|
+
if (!isPlainObject(payload)) return null
|
|
100
|
+
try {
|
|
101
|
+
return {
|
|
102
|
+
requestId: assertDiscoveryRequestId(payload.requestId),
|
|
103
|
+
nodeHash: assertDiscoveryNodeHash(payload.nodeHash),
|
|
104
|
+
advertisements: sanitizeDiscoveryAdvertisements(payload.advertisements),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 联邦补拉 attestation / HPKE 响应 wire 解析(无 attestation/envelope 即丢弃)。
|
|
3
|
+
*/
|
|
4
|
+
import { isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
|
|
5
|
+
import { EVENT_ID_HEX } from '../dag/index.mjs'
|
|
6
|
+
import { isPlainObject } from '../wire/ingress.mjs'
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ requesterPubKeyHash: string, groupId: string, requestId: string, timestamp: number, wantIds?: string[], signature: string }} PullAttestation */
|
|
9
|
+
|
|
10
|
+
const ENVELOPE_BLOB_MIN_LEN = 2
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {unknown} value 密文字段
|
|
14
|
+
* @returns {boolean} 是否像合法 ECIES blob 段
|
|
15
|
+
*/
|
|
16
|
+
function isEnvelopeBlob(value) {
|
|
17
|
+
return String(value ?? '').trim().length >= ENVELOPE_BLOB_MIN_LEN
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} attestation 载荷 attestation 字段
|
|
22
|
+
* @returns {PullAttestation | null} 解析结果
|
|
23
|
+
*/
|
|
24
|
+
export function parsePullAttestation(attestation) {
|
|
25
|
+
if (!isPlainObject(attestation)) return null
|
|
26
|
+
const requesterPubKeyHash = normalizeHex64(attestation.requesterPubKeyHash)
|
|
27
|
+
const groupId = String(attestation.groupId || '').trim()
|
|
28
|
+
const requestId = String(attestation.requestId || '').trim()
|
|
29
|
+
const timestamp = Number(attestation.timestamp)
|
|
30
|
+
const signature = String(attestation.signature || '').trim().toLowerCase()
|
|
31
|
+
if (!isHex64(requesterPubKeyHash) || !groupId || !Number.isFinite(timestamp) || !isSignatureHex128(signature))
|
|
32
|
+
return null
|
|
33
|
+
const wantIds = Array.isArray(attestation.wantIds)
|
|
34
|
+
? [...new Set(attestation.wantIds.map(id => String(id).trim().toLowerCase()).filter(id => EVENT_ID_HEX.test(id)))]
|
|
35
|
+
: undefined
|
|
36
|
+
return {
|
|
37
|
+
requesterPubKeyHash,
|
|
38
|
+
groupId,
|
|
39
|
+
requestId,
|
|
40
|
+
timestamp,
|
|
41
|
+
wantIds,
|
|
42
|
+
signature,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {unknown} envelope 响应 envelope
|
|
48
|
+
* @returns {{ requestId: string, requesterPubKeyHash: string, requesterNodeHash: string, ephemPub: string, iv: string, ciphertext: string, authTag: string } | null} 解析结果
|
|
49
|
+
*/
|
|
50
|
+
export function parsePullResponseEnvelope(envelope) {
|
|
51
|
+
if (!isPlainObject(envelope)) return null
|
|
52
|
+
const requestId = String(envelope.requestId || '').trim()
|
|
53
|
+
const requesterPubKeyHash = normalizeHex64(envelope.requesterPubKeyHash)
|
|
54
|
+
const requesterNodeHash = String(envelope.requesterNodeHash || '').trim()
|
|
55
|
+
const ephemPub = String(envelope.ephemPub || '').trim()
|
|
56
|
+
const iv = String(envelope.iv || '').trim()
|
|
57
|
+
const ciphertext = String(envelope.ciphertext || '').trim()
|
|
58
|
+
const authTag = String(envelope.authTag || '').trim()
|
|
59
|
+
if (!requestId || !isHex64(requesterPubKeyHash) || !requesterNodeHash) return null
|
|
60
|
+
if (!isEnvelopeBlob(ephemPub) || !isEnvelopeBlob(iv) || !isEnvelopeBlob(ciphertext) || !isEnvelopeBlob(authTag))
|
|
61
|
+
return null
|
|
62
|
+
return {
|
|
63
|
+
requestId,
|
|
64
|
+
requesterPubKeyHash,
|
|
65
|
+
requesterNodeHash,
|
|
66
|
+
ephemPub,
|
|
67
|
+
iv,
|
|
68
|
+
ciphertext,
|
|
69
|
+
authTag,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {unknown} data 入群快照请求载荷
|
|
75
|
+
* @returns {object | null} 解析结果
|
|
76
|
+
*/
|
|
77
|
+
export function parseJoinSnapshotRequest(data) {
|
|
78
|
+
if (!isPlainObject(data)) return null
|
|
79
|
+
const requestId = String(data.requestId || '').trim()
|
|
80
|
+
const requesterNodeHash = String(data.requesterNodeHash || '').trim()
|
|
81
|
+
const groupId = String(data.groupId || '').trim()
|
|
82
|
+
const attestation = parsePullAttestation(data.attestation)
|
|
83
|
+
if (!requestId || !requesterNodeHash || !groupId || !attestation) return null
|
|
84
|
+
if (attestation.groupId !== groupId || attestation.requestId !== requestId) return null
|
|
85
|
+
const tipsHash = String(data.tipsHash || '').trim()
|
|
86
|
+
return {
|
|
87
|
+
requestId,
|
|
88
|
+
requesterNodeHash,
|
|
89
|
+
requesterPubKeyHash: attestation.requesterPubKeyHash,
|
|
90
|
+
groupId,
|
|
91
|
+
tipsHash: tipsHash ? tipsHash.toLowerCase() : undefined,
|
|
92
|
+
attestation,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {unknown} data 入群快照响应载荷
|
|
98
|
+
* @returns {object | null} 解析结果
|
|
99
|
+
*/
|
|
100
|
+
export function parseJoinSnapshotResponse(data) {
|
|
101
|
+
return parsePullResponseEnvelope(data)
|
|
102
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { normalizeHex64, HEX_ID_64 } from '../core/hexIds.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{ ok: true, value: string }} MailboxWireOk
|
|
5
|
+
* @typedef {{ ok: false, code: string, field: string }} MailboxWireErr
|
|
6
|
+
* @typedef {MailboxWireOk | MailboxWireErr} MailboxWireResult
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ ok: true, value: object }} MailboxRecordShapeOk
|
|
11
|
+
* @typedef {MailboxWireErr} MailboxRecordShapeErr
|
|
12
|
+
* @typedef {MailboxRecordShapeOk | MailboxRecordShapeErr} MailboxRecordShapeResult
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {unknown} value 收件人 pubKeyHash
|
|
17
|
+
* @returns {MailboxWireResult} 规范化 hex64 或结构化错误
|
|
18
|
+
*/
|
|
19
|
+
export function assertMailboxPubKeyHash(value) {
|
|
20
|
+
const normalized = normalizeHex64(value)
|
|
21
|
+
if (!HEX_ID_64.test(normalized))
|
|
22
|
+
return { ok: false, code: 'invalid_hex64', field: 'toPubKeyHash' }
|
|
23
|
+
return { ok: true, value: normalized }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {unknown} record mailbox 记录
|
|
28
|
+
* @returns {MailboxRecordShapeResult} 已校验的 record 或结构化错误
|
|
29
|
+
*/
|
|
30
|
+
export function assertMailboxRecordShape(record) {
|
|
31
|
+
if (!record || typeof record !== 'object')
|
|
32
|
+
return { ok: false, code: 'required', field: 'record' }
|
|
33
|
+
const pubKey = assertMailboxPubKeyHash(record.toPubKeyHash)
|
|
34
|
+
if (!pubKey.ok)
|
|
35
|
+
return { ok: false, code: pubKey.code, field: 'record.toPubKeyHash' }
|
|
36
|
+
return { ok: true, value: record }
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { assertHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 远程入站事件形状校验(仅 federation / P2P 路径调用)。
|
|
5
|
+
* @param {object} event DAG 事件
|
|
6
|
+
* @returns {void}
|
|
7
|
+
*/
|
|
8
|
+
export function validateRemoteEventShape(event) {
|
|
9
|
+
if (!event?.type || typeof event.type !== 'string')
|
|
10
|
+
throw new Error('remote event: type required')
|
|
11
|
+
assertHex64(event.id, 'remote event.id')
|
|
12
|
+
if (!Array.isArray(event.prev_event_ids))
|
|
13
|
+
throw new Error('remote event: prev_event_ids must be array')
|
|
14
|
+
for (const parentId of event.prev_event_ids)
|
|
15
|
+
assertHex64(parentId, 'remote event.prev_event_id')
|
|
16
|
+
assertHex64(event.sender, 'remote event.sender')
|
|
17
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
|
|
3
|
+
import { nextHlc } from '../core/hlc.mjs'
|
|
4
|
+
import { publicKeyFromSeed, sign } from '../crypto/crypto.mjs'
|
|
5
|
+
import {
|
|
6
|
+
computeEventId,
|
|
7
|
+
eventBodyForSign,
|
|
8
|
+
signPayloadBytes,
|
|
9
|
+
sortedPrevEventIds,
|
|
10
|
+
} from '../dag/index.mjs'
|
|
11
|
+
import { computeDagTipIdsFromEvents } from '../governance/branch.mjs'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 为追加事件计算 HLC 与 DAG 前驱(chat 群与 social 时间线共用)。
|
|
15
|
+
* @param {object[]} previous 已有事件
|
|
16
|
+
* @param {object} event 待追加事件
|
|
17
|
+
* @param {{ multiTip?: boolean }} [opts] chat 在多 tip 时连接全部 tip
|
|
18
|
+
* @returns {{ hlc: object, prev_event_ids: string[], last: object | undefined }} HLC、前驱与末条事件
|
|
19
|
+
*/
|
|
20
|
+
export function computeAppendHlcAndPrev(previous, event, opts = {}) {
|
|
21
|
+
const last = previous[previous.length - 1]
|
|
22
|
+
const hlc = nextHlc(last?.hlc, event.timestamp ?? Date.now())
|
|
23
|
+
const tips = computeDagTipIdsFromEvents(previous)
|
|
24
|
+
let prev_event_ids
|
|
25
|
+
if (event.prev_event_ids?.length)
|
|
26
|
+
prev_event_ids = sortedPrevEventIds(event.prev_event_ids)
|
|
27
|
+
else if (opts.multiTip && tips.length > 1)
|
|
28
|
+
prev_event_ids = sortedPrevEventIds(tips)
|
|
29
|
+
else if (tips.length)
|
|
30
|
+
prev_event_ids = sortedPrevEventIds(tips)
|
|
31
|
+
else if (last?.id)
|
|
32
|
+
prev_event_ids = [last.id]
|
|
33
|
+
else
|
|
34
|
+
prev_event_ids = []
|
|
35
|
+
return { hlc, prev_event_ids, last }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 使用 eventBodyForSign 规范签名并返回完整事件(social 时间线)。
|
|
40
|
+
* @param {object} base 事件基体(含 type/groupId/sender/hlc/prev_event_ids/content/node_id)
|
|
41
|
+
* @param {Uint8Array} secretKey 签名密钥
|
|
42
|
+
* @returns {object} 签名后事件
|
|
43
|
+
*/
|
|
44
|
+
export async function signTimelineEvent(base, secretKey) {
|
|
45
|
+
const body = eventBodyForSign(base)
|
|
46
|
+
const id = computeEventId(body)
|
|
47
|
+
const signature = await sign(signPayloadBytes(body), secretKey)
|
|
48
|
+
return {
|
|
49
|
+
...base,
|
|
50
|
+
id,
|
|
51
|
+
signature: Buffer.from(signature).toString('hex'),
|
|
52
|
+
senderPubKey: Buffer.from(publicKeyFromSeed(secretKey)).toString('hex'),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { topologicalCanonicalOrder } from '../dag/index.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 拓扑序后按 reducer 表折叠事件列表。
|
|
5
|
+
* @param {object[]} events 原始事件
|
|
6
|
+
* @param {Record<string, (state: object, event: object) => object>} reducers 事件类型 → reducer
|
|
7
|
+
* @param {() => object} createInitialState 初始状态工厂
|
|
8
|
+
* @returns {{ state: object, order: string[] }} 物化结果与拓扑序
|
|
9
|
+
*/
|
|
10
|
+
export function materializeFromEvents(events, reducers, createInitialState) {
|
|
11
|
+
const order = topologicalCanonicalOrder(events.map(event => ({
|
|
12
|
+
id: event.id,
|
|
13
|
+
prev_event_ids: event.prev_event_ids,
|
|
14
|
+
hlc: event.hlc,
|
|
15
|
+
node_id: event.node_id,
|
|
16
|
+
})))
|
|
17
|
+
const byId = new Map(events.map(event => [event.id, event]))
|
|
18
|
+
let state = createInitialState()
|
|
19
|
+
for (const eventId of order) {
|
|
20
|
+
const event = byId.get(eventId)
|
|
21
|
+
if (!event) continue
|
|
22
|
+
const reducer = reducers[event.type]
|
|
23
|
+
if (reducer) state = reducer(state, event)
|
|
24
|
+
}
|
|
25
|
+
return { state, order }
|
|
26
|
+
}
|