@steve02081504/fount-p2p 0.0.36 → 0.0.38

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.
Files changed (38) hide show
  1. package/AGENTS.md +3 -3
  2. package/discovery/adverts.mjs +16 -8
  3. package/discovery/nostr/census.mjs +281 -0
  4. package/discovery/nostr/census_math.mjs +54 -0
  5. package/discovery/nostr/constants.mjs +43 -0
  6. package/discovery/nostr/index.mjs +612 -0
  7. package/discovery/nostr/relays.mjs +1071 -0
  8. package/discovery/nostr/selection.mjs +224 -0
  9. package/discovery/nostr/session.mjs +584 -0
  10. package/docs/evfs.md +15 -6
  11. package/docs/nostr_relay_discovery.md +92 -0
  12. package/files/chunk/responder.mjs +2 -2
  13. package/files/evfs.mjs +2 -2
  14. package/files/fed/fetch_shared.mjs +3 -1
  15. package/files/fed/responder.mjs +3 -2
  16. package/files/fetch_fanout.mjs +26 -5
  17. package/files/manifest/fetch.mjs +73 -38
  18. package/files/manifest/pending.mjs +21 -7
  19. package/files/manifest/servicer_registry.mjs +51 -0
  20. package/governance/branch.mjs +1 -1
  21. package/governance/join_pow.mjs +2 -2
  22. package/index.mjs +11 -4
  23. package/link/handshake.mjs +135 -23
  24. package/link/providers/link_id_pipe.mjs +1 -1
  25. package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
  26. package/mailbox/deliver_or_store.mjs +3 -4
  27. package/mailbox/store.mjs +1 -1
  28. package/node/feature_config.mjs +30 -0
  29. package/node/instance.mjs +21 -0
  30. package/node/reputation_store.mjs +1 -2
  31. package/node/storage.mjs +23 -0
  32. package/package.json +10 -4
  33. package/pages/app.mjs +172 -0
  34. package/pages/index.html +36 -0
  35. package/reputation/engine.mjs +1 -2
  36. package/transport/runtime_bootstrap.mjs +39 -3
  37. package/utils/fetch_wait.mjs +12 -7
  38. package/discovery/nostr.mjs +0 -1073
@@ -5,7 +5,10 @@ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
5
5
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
6
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
7
7
  import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
8
+ import { MAX_ADVERT_LISTEN_RELAYS, MAX_ADVERT_RELAY_POOL, MAX_RTT_MS } from '../discovery/nostr/constants.mjs'
9
+ import { normalizeNostrRelayUrl } from '../discovery/nostr/relays.mjs'
8
10
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
11
+ import { nodeDebug } from '../node/log.mjs'
9
12
 
10
13
  import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
11
14
 
@@ -20,9 +23,7 @@ export const LINK_HANDSHAKE_DOMAIN = 'fount-link'
20
23
  * @returns {string | null} 规范化 binding,无效时 null
21
24
  */
22
25
  export function normalizeLinkBinding(value) {
23
- const dtls = normalizeDtlsFingerprint(value)
24
- if (dtls) return dtls
25
- return isHex64(value)
26
+ return normalizeDtlsFingerprint(value) || isHex64(value)
26
27
  }
27
28
 
28
29
  /**
@@ -33,16 +34,14 @@ export function normalizeLinkBinding(value) {
33
34
  * @returns {Uint8Array} 待签名消息字节
34
35
  */
35
36
  export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
36
- const nonce = peerNonce
37
37
  const binding = normalizeLinkBinding(localBinding)
38
- const nodeHash = localNodeHash
39
- if (!/^[\da-f]{64}$/u.test(nonce))
38
+ if (!/^[\da-f]{64}$/u.test(peerNonce))
40
39
  throw new Error('p2p: auth nonce must be 64 hex characters')
41
40
  if (!binding)
42
41
  throw new Error('p2p: link binding missing or invalid')
43
- if (!isHex64(nodeHash))
42
+ if (!isHex64(localNodeHash))
44
43
  throw new Error('p2p: nodeHash must be 64 hex characters')
45
- return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${binding}\0${nodeHash}`, 'utf8')
44
+ return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${peerNonce}\0${binding}\0${localNodeHash}`, 'utf8')
46
45
  }
47
46
 
48
47
  /**
@@ -137,23 +136,130 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
137
136
  * @param {string} nodeHash 节点 nodeHash
138
137
  * @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
139
138
  * @param {unknown} [lanHosts=null] 可选 LAN IPv4 列表(签入消息)
139
+ * @param {string | null} [relayBlobHex=null] 可选规范化 relay 字段 hex 段(签入消息)
140
140
  * @returns {Uint8Array} 待签名消息字节
141
141
  */
142
- export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null) {
143
- const base = `fount-advert\0${rendezvousKey}\0${ts}\0${nodeHash}`
142
+ export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null, relayBlobHex = null) {
143
+ let message = `fount-advert\0${rendezvousKey}\0${ts}\0${nodeHash}`
144
144
  const port = normalizeTcpPort(tcpPort)
145
- let message = port ? `${base}\0${port}` : base
145
+ if (port) message += `\0${port}`
146
146
  const hosts = normalizeLanHosts(lanHosts)
147
147
  if (hosts.length) message += `\0${hosts.join(',')}`
148
+ if (relayBlobHex) message += `\0relays:${relayBlobHex}`
148
149
  return Buffer.from(message, 'utf8')
149
150
  }
150
151
 
151
152
  /**
152
- * 构造带签名的 discovery advert(tcpPort / lanHosts 一并签入消息)。
153
+ * 规范化并裁剪入站(不可信)advert 携带的 relay 字段:无效项丢弃并记审计日志,不抛错。
154
+ * - pool:url 经 normalize 有效且 rttMs∈[0,MAX_RTT_MS],取整、去重、裁剪前 MAX_ADVERT_RELAY_POOL。
155
+ * - listen:url normalize 有效、去重、裁剪前 MAX_ADVERT_LISTEN_RELAYS。
156
+ * - 每个丢弃项记审计日志(含原始值),不静默。
157
+ * @param {unknown} rawPool 原始 pool([{url, rttMs}])
158
+ * @param {unknown} rawListen 原始 listen([url])
159
+ * @returns {{ pool: Array<{ url: string, rtt: number }>, listen: string[] }} 规范化结果
160
+ */
161
+ export function sanitizeAdvertRelayFields(rawPool, rawListen) {
162
+ /** @type {Array<{ url: string, rtt: number }>} */
163
+ const pool = []
164
+ /** @type {Set<string>} */
165
+ const seenPool = new Set()
166
+ if (Array.isArray(rawPool)) for (const item of rawPool) {
167
+ const url = normalizeNostrRelayUrl(item?.url)
168
+ if (!url) {
169
+ nodeDebug('invalidRelayUrl', { url: String(item?.url), reason: 'advert-pool-invalid-url' })
170
+ continue
171
+ }
172
+ const rtt = Number(item?.rttMs ?? item?.rtt)
173
+ if (!Number.isFinite(rtt) || rtt < 0 || rtt > MAX_RTT_MS) {
174
+ nodeDebug('invalidRelayUrl', { url, reason: 'advert-pool-invalid-rtt', rttMs: item?.rttMs ?? item?.rtt })
175
+ continue
176
+ }
177
+ if (seenPool.has(url)) continue
178
+ seenPool.add(url)
179
+ pool.push({ url, rtt: Math.round(rtt) })
180
+ if (pool.length >= MAX_ADVERT_RELAY_POOL) break
181
+ }
182
+
183
+ /** @type {string[]} */
184
+ const listen = []
185
+ /** @type {Set<string>} */
186
+ const seenListen = new Set()
187
+ if (Array.isArray(rawListen)) for (const raw of rawListen) {
188
+ const url = normalizeNostrRelayUrl(raw)
189
+ if (!url) {
190
+ nodeDebug('invalidRelayUrl', { url: String(raw), reason: 'advert-listen-invalid-url' })
191
+ continue
192
+ }
193
+ if (seenListen.has(url)) continue
194
+ seenListen.add(url)
195
+ listen.push(url)
196
+ if (listen.length >= MAX_ADVERT_LISTEN_RELAYS) break
197
+ }
198
+
199
+ return { pool, listen }
200
+ }
201
+
202
+ /**
203
+ * 构建规范化 relay 字段的 canonical blob(pool/listen 各按 url 排序 → `{p,l}` JSON → UTF-8 → hex)。
204
+ * @param {Array<{ url: string, rtt: number }>} pool 规范化 pool
205
+ * @param {string[]} listen 规范化 listen
206
+ * @returns {string} hex 编码 blob
207
+ */
208
+ export function canonicalAdvertRelayBlob(pool, listen) {
209
+ return Buffer.from(JSON.stringify({
210
+ p: [...pool].sort((a, b) => a.url < b.url ? -1 : a.url > b.url ? 1 : 0),
211
+ l: [...listen].sort(),
212
+ }), 'utf8').toString('hex')
213
+ }
214
+
215
+ /**
216
+ * 严格规范化本机(出站)提供的 relay 字段:无效数据立即抛错,不静默丢弃。
217
+ * 本地调用方应提供已规范化字段;入站校验仍走 lenient 的 sanitizeAdvertRelayFields。
218
+ * @param {unknown} rawPool 原始 pool([{url, rttMs}])
219
+ * @param {unknown} rawListen 原始 listen([url])
220
+ * @returns {{ pool: Array<{ url: string, rtt: number }>, listen: string[] }} 规范化结果
221
+ */
222
+ function normalizeLocalRelayFields(rawPool, rawListen) {
223
+ /** @type {Array<{ url: string, rtt: number }>} */
224
+ const pool = []
225
+ /** @type {Set<string>} */
226
+ const seenPool = new Set()
227
+ if (rawPool != null && !Array.isArray(rawPool)) throw new Error('p2p: advert pool invalid type')
228
+ if (Array.isArray(rawPool)) for (const item of rawPool) {
229
+ const url = normalizeNostrRelayUrl(item?.url)
230
+ if (!url) throw new Error('p2p: advert pool invalid url')
231
+ const rtt = Number(item?.rttMs ?? item?.rtt)
232
+ if (!Number.isFinite(rtt) || rtt < 0 || rtt > MAX_RTT_MS)
233
+ throw new Error('p2p: advert pool invalid rtt')
234
+ if (seenPool.has(url)) continue
235
+ seenPool.add(url)
236
+ pool.push({ url, rtt: Math.round(rtt) })
237
+ if (pool.length >= MAX_ADVERT_RELAY_POOL) break
238
+ }
239
+
240
+ /** @type {string[]} */
241
+ const listen = []
242
+ /** @type {Set<string>} */
243
+ const seenListen = new Set()
244
+ if (rawListen != null && !Array.isArray(rawListen)) throw new Error('p2p: advert listen invalid type')
245
+ if (Array.isArray(rawListen)) for (const raw of rawListen) {
246
+ const url = normalizeNostrRelayUrl(raw)
247
+ if (!url) throw new Error('p2p: advert listen invalid url')
248
+ if (seenListen.has(url)) continue
249
+ seenListen.add(url)
250
+ listen.push(url)
251
+ if (listen.length >= MAX_ADVERT_LISTEN_RELAYS) break
252
+ }
253
+
254
+ return { pool, listen }
255
+ }
256
+
257
+ /**
258
+ * 构造带签名的 discovery advert(tcpPort / lanHosts / relay 字段一并签入消息)。
153
259
  * @param {string} rendezvousKey discovery 内部汇合键
154
260
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
155
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown } | null} [options] 签名身份;可选 LAN 监听端口与本机 IPv4 列表
156
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[] }>} 签名 advert
261
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown, nostrRelayPool?: unknown, listenNostrRelays?: unknown } | null} [options] 签名身份;可选 LAN 监听端口、本机 IPv4 列表与 relay 字段
262
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[], nostrRelayPool?: Array<{ url: string, rtt: number }>, listenNostrRelays?: string[] }>} 签名 advert
157
263
  */
158
264
  export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
159
265
  const seed = options?.secretKey
@@ -168,7 +274,10 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
168
274
  if (options?.tcpPort && !tcpPort)
169
275
  throw new Error('p2p: advert tcpPort invalid')
170
276
  const lanHosts = normalizeLanHosts(options?.lanHosts)
171
- const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort, lanHosts)
277
+ // 出站字段要求已规范化,无效数据直接抛错(入站路径才用 lenient sanitize)。
278
+ const normalized = normalizeLocalRelayFields(options?.nostrRelayPool, options?.listenNostrRelays)
279
+ const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort, lanHosts,
280
+ canonicalAdvertRelayBlob(normalized.pool, normalized.listen))
172
281
  const sig = await sign(message, secretKey)
173
282
  const advert = {
174
283
  nodeHash,
@@ -178,16 +287,18 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
178
287
  }
179
288
  if (tcpPort) advert.tcpPort = tcpPort
180
289
  if (lanHosts.length) advert.lanHosts = lanHosts
290
+ if (normalized.pool.length) advert.nostrRelayPool = normalized.pool
291
+ if (normalized.listen.length) advert.listenNostrRelays = normalized.listen
181
292
  return advert
182
293
  }
183
294
 
184
295
  /**
185
- * 验证 discovery advert 签名与时间戳,成功返回发布者 nodeHash。
296
+ * 验证 discovery advert 签名与时间戳,成功返回发布者 nodeHash 与规范化后的 relay 字段。
186
297
  * @param {string} rendezvousKey 期望的汇合键
187
298
  * @param {unknown} advert 原始 advert 载荷
188
299
  * @param {number} [now=Date.now()] 当前时间(毫秒)
189
300
  * @param {number} [maxSkewMs=10 * 60_000] 允许的最大时钟偏差(毫秒)
190
- * @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
301
+ * @returns {Promise<{ nodeHash: string, relayPool: Array<{ url: string, rtt: number }>, listenRelays: string[] } | null>} 验证通过返回 nodeHash 与规范化 relay 字段,失败返回 null
191
302
  */
192
303
  export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now(), maxSkewMs = 10 * 60_000) {
193
304
  const parsedHello = parseHello({ nodeHash: advert?.nodeHash, nodePubKey: advert?.nodePubKey, nonce: '0'.repeat(64) })
@@ -195,11 +306,12 @@ export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now()
195
306
  const ts = Number(advert?.ts)
196
307
  const sig = isSignatureHex128(advert?.sig)
197
308
  if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !sig) return null
198
- const hasTcpPortField = !!advert?.tcpPort
199
309
  const tcpPort = normalizeTcpPort(advert?.tcpPort)
200
- if (hasTcpPortField && !tcpPort) return null
201
- const lanHosts = normalizeLanHosts(advert?.lanHosts)
202
- const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort, lanHosts)
203
- const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
204
- return ok ? parsedHello.nodeHash : null
310
+ if (advert?.tcpPort != null && !tcpPort) return null
311
+ const sanitized = sanitizeAdvertRelayFields(advert?.nostrRelayPool, advert?.listenNostrRelays)
312
+ if (!await verify(Buffer.from(sig, 'hex'),
313
+ buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort, normalizeLanHosts(advert?.lanHosts),
314
+ canonicalAdvertRelayBlob(sanitized.pool, sanitized.listen)),
315
+ Buffer.from(parsedHello.nodePubKey, 'hex'))) return null
316
+ return { nodeHash: parsedHello.nodeHash, relayPool: sanitized.pool, listenRelays: sanitized.listen }
205
317
  }
@@ -34,7 +34,7 @@ export function parseLinkOpen(raw) {
34
34
  * @returns {ReturnType<typeof createLinkPipe>} link pipe
35
35
  */
36
36
  export function createLinkIdBoundPipe(options) {
37
- const linkId = options.linkId
37
+ const { linkId } = options
38
38
  if (!linkId) throw new Error(`p2p: ${options.providerId || 'link'} linkId required`)
39
39
  return createLinkPipe({
40
40
  ...options,
@@ -1,17 +1,16 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
- import { base64ToBytes, bytesToBase64 } from '../../core/bytes_codec.mjs'
5
- import { isHex64 } from '../../core/hexIds.mjs'
6
- import { getDiscoveryProvider, sendNodeSignalPacket } from '../../discovery/index.mjs'
7
- import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../discovery/nostr.mjs'
8
- import { ms } from '../../utils/duration.mjs'
9
- import { createLruMap } from '../../utils/lru.mjs'
10
- import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../frame.mjs'
11
- import { asLinkHandle } from '../pipe.mjs'
12
-
13
- import { LINK_LEVEL_NOSTR } from './levels.mjs'
14
- import { createLinkIdBoundPipe } from './link_id_pipe.mjs'
4
+ import { base64ToBytes, bytesToBase64 } from '../../../core/bytes_codec.mjs'
5
+ import { isHex64 } from '../../../core/hexIds.mjs'
6
+ import { getDiscoveryProvider, sendNodeSignalPacket } from '../../../discovery/index.mjs'
7
+ import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../../discovery/nostr/index.mjs'
8
+ import { ms } from '../../../utils/duration.mjs'
9
+ import { createLruMap } from '../../../utils/lru.mjs'
10
+ import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../../frame.mjs'
11
+ import { asLinkHandle } from '../../pipe.mjs'
12
+ import { LINK_LEVEL_NOSTR } from '../levels.mjs'
13
+ import { createLinkIdBoundPipe } from '../link_id_pipe.mjs'
15
14
 
16
15
  /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
17
16
  * 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
@@ -317,8 +316,7 @@ export function createNostrLinkProvider(options = {}) {
317
316
  */
318
317
  async function deliverPacket(packet) {
319
318
  if (packet?.type !== 'link') return
320
- const linkId = packet.linkId
321
- const from = packet.from
319
+ const { linkId, from } = packet
322
320
  if (!isHex64(linkId) || !isHex64(from)) return
323
321
  if (localIdentity?.nodeHash && from === localIdentity.nodeHash) return
324
322
 
@@ -398,8 +396,15 @@ export function createNostrLinkProvider(options = {}) {
398
396
  ensureListening(handlers) {
399
397
  onInbound = handlers.onInbound
400
398
  localIdentity = handlers.localIdentity
401
- void refreshPayloadCap(resolveRelayUrls())
399
+ // 探测延迟到启动路径之外(首个 macrotask),避免同步 fetch 阻塞 startup 预算。
400
+ const capTimer = setTimeout(() => {
401
+ let relayUrls
402
+ try { relayUrls = resolveRelayUrls() } catch { return }
403
+ void refreshPayloadCap(relayUrls).catch(() => { })
404
+ }, 0)
405
+ capTimer.unref?.()
402
406
  return () => {
407
+ clearTimeout(capTimer)
403
408
  onInbound = null
404
409
  for (const session of sessions.values())
405
410
  void session.pipe.close('listen-stop')
@@ -62,7 +62,7 @@ async function resolveRouting(username) {
62
62
  */
63
63
  export async function deliverOrStoreMailboxPut(username, options) {
64
64
  const routing = await resolveRouting(username)
65
- const toPubKeyHash = options.toPubKeyHash
65
+ const { toPubKeyHash } = options
66
66
  if (!isHex64(toPubKeyHash)) return { stored: false, delivered: false, relayed: 0 }
67
67
  const hop = normalizeMailboxHop(options.hop)
68
68
  if (hop >= routing.maxHop) return { stored: false, delivered: false, relayed: 0 }
@@ -76,9 +76,8 @@ export async function deliverOrStoreMailboxPut(username, options) {
76
76
  fromNodeHash: options.record?.fromNodeHash || nodeHash,
77
77
  }
78
78
  const stored = await storeMailboxRecord(record)
79
- const toNodeHash = options.toNodeHash
80
- const delivered = isHex64(toNodeHash) && isMailboxRecordWithinSizeLimit(record)
81
- ? await requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER).sendToNode(username, toNodeHash, 'mailbox_put', { nodeHash, record })
79
+ const delivered = isHex64(options.toNodeHash) && isMailboxRecordWithinSizeLimit(record)
80
+ ? await requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER).sendToNode(username, options.toNodeHash, 'mailbox_put', { nodeHash, record })
82
81
  : false
83
82
 
84
83
  let relayed = 0
package/mailbox/store.mjs CHANGED
@@ -150,7 +150,7 @@ export function mailboxEnvelopeId(envelope) {
150
150
  */
151
151
  export async function storeMailboxRecord(record) {
152
152
  if (!isMailboxRecordWithinSizeLimit(record)) return false
153
- const toPubKeyHash = record.toPubKeyHash
153
+ const { toPubKeyHash } = record
154
154
  if (!isHex64(toPubKeyHash)) return false
155
155
  const hop = normalizeMailboxHop(record.hop)
156
156
  const { tier } = record
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 通用包级行为开关(布尔 feature map):`{ census: true, ... }`。
3
+ * 新增功能只需加入新布尔 key,无需新增专属设置界面。
4
+ */
5
+
6
+ /** 默认 feature:census 默认开启(人口统计,基于 nostr)。 */
7
+ const DEFAULT_P2P_FEATURES = { census: true }
8
+
9
+ /**
10
+ * @returns {Record<string, boolean>} 默认 feature map 快照
11
+ */
12
+ export function defaultP2PFeatures() {
13
+ return { ...DEFAULT_P2P_FEATURES }
14
+ }
15
+
16
+ /**
17
+ * 归一化 feature map:已知 key 用默认值,patch 中未知 key 原样透传(值必须为 boolean)。
18
+ * @param {Record<string, boolean>} [patch] 部分 feature 覆盖
19
+ * @returns {Record<string, boolean>} 完整 boolean feature map
20
+ */
21
+ export function resolveP2PFeatures(patch = {}) {
22
+ /** @type {Record<string, boolean>} */
23
+ const out = defaultP2PFeatures()
24
+ for (const [key, value] of Object.entries(patch)) {
25
+ if (value !== true && value !== false)
26
+ throw new Error(`p2p: feature "${key}" must be boolean`)
27
+ out[key] = value
28
+ }
29
+ return out
30
+ }
package/node/instance.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
 
3
3
  import { createFsEntityStore } from './entity_store.mjs'
4
+ import { resolveP2PFeatures } from './feature_config.mjs'
4
5
  import { closeAllFileStreams } from './handles.mjs'
5
6
  import { defaultSignalingRuntimeConfig, resolveSignalingRuntimeConfig } from './signaling_config.mjs'
6
7
 
@@ -14,6 +15,7 @@ import { defaultSignalingRuntimeConfig, resolveSignalingRuntimeConfig } from './
14
15
  * entityStore: import('./entity_store.mjs').EntityStore
15
16
  * logger: NodeLogger | null
16
17
  * signaling: SignalingRuntimeConfig
18
+ * features: Record<string, boolean>
17
19
  * }} NodeRuntime
18
20
  */
19
21
 
@@ -49,6 +51,7 @@ export function initNode(options) {
49
51
  entityStore,
50
52
  logger: console,
51
53
  signaling: resolveSignalingRuntimeConfig(),
54
+ features: resolveP2PFeatures(),
52
55
  }
53
56
  return runtime
54
57
  }
@@ -101,6 +104,24 @@ export function getSignalingRuntimeConfig() {
101
104
  return runtime?.signaling ?? defaultSignalingRuntimeConfig()
102
105
  }
103
106
 
107
+ /**
108
+ * 设置包级行为开关(布尔 feature map);运行时切换,生效于对应 feature 的下一轮。
109
+ * @param {Record<string, boolean>} config - feature 补丁
110
+ * @returns {void}
111
+ */
112
+ export function setP2PFeatures(config) {
113
+ if (!runtime) throw new Error('p2p: setP2PFeatures requires initNode')
114
+ runtime.features = resolveP2PFeatures({ ...runtime.features, ...config })
115
+ emitNodeChange('features-changed', runtime.features)
116
+ }
117
+
118
+ /**
119
+ * @returns {Record<string, boolean>} 当前包级 feature map
120
+ */
121
+ export function getP2PFeatures() {
122
+ return { ...getNode().features }
123
+ }
124
+
104
125
  /**
105
126
  * @returns {string} 节点数据目录绝对路径
106
127
  */
@@ -228,12 +228,11 @@ export async function applySubjectiveSlashFromEvent(username, groupId, event, re
228
228
  if (event.type !== 'reputation_slash') return
229
229
  const { content } = event
230
230
  const target = content.targetPubKeyHash
231
- const sender = event.sender
232
231
 
233
232
  await mutateReputation(async data => {
234
233
  const verified = content.verified && await verifySlashProof(username, groupId, content, readEvents)
235
234
  const claim = Number(content.claim ?? (verified ? reputationTunables.slashVerifiedDefaultClaim : reputationTunables.slashUnverifiedDefaultClaim))
236
- applySubjectiveSlashPure(data, target, sender, claim, verified)
235
+ applySubjectiveSlashPure(data, target, event.sender, claim, verified)
237
236
  })
238
237
  }
239
238
 
package/node/storage.mjs CHANGED
@@ -28,3 +28,26 @@ export function readNodeJsonSync(name) {
28
28
  export function writeNodeJsonSync(name, data) {
29
29
  writeJsonFileSync(nodeJsonPath(name), data)
30
30
  }
31
+
32
+ /**
33
+ * Nostr relay 池 / peer 路由持久化文件路径(`nodeDir/nostr/relays.json`)。
34
+ * @returns {string} 绝对路径
35
+ */
36
+ export function nostrRelayJsonPath() {
37
+ return path.join(getNodeDir(), 'nostr', 'relays.json')
38
+ }
39
+
40
+ /**
41
+ * @returns {object | null} 解析后的 Nostr relay JSON 或 null
42
+ */
43
+ export function readNostrRelaysJsonSync() {
44
+ return readJsonFileSync(nostrRelayJsonPath())
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} data 数据
49
+ * @returns {void}
50
+ */
51
+ export function writeNostrRelaysJsonSync(data) {
52
+ writeJsonFileSync(nostrRelayJsonPath(), data)
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -21,10 +21,11 @@
21
21
  "node": ">=20"
22
22
  },
23
23
  "scripts": {
24
- "test": "node --test --test-concurrency=1 --test-force-exit test/pure/*.test.mjs test/integration/*.test.mjs",
24
+ "test": "node --test --test-concurrency=1 --test-force-exit test/pure/*.test.mjs test/integration/*.test.mjs test/frontend/*.test.mjs",
25
25
  "test:fount": "deno test -A --config deno.json test/fount/",
26
26
  "test:live": "node --test --test-concurrency=1 --test-force-exit test/live/*.test.mjs",
27
- "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs"
27
+ "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs",
28
+ "test:frontend": "node --test --test-concurrency=1 --test-force-exit test/frontend/*.test.mjs"
28
29
  },
29
30
  "bin": {
30
31
  "fount-p2p": "infra/cli.mjs"
@@ -33,6 +34,8 @@
33
34
  ".": "./index.mjs",
34
35
  "./infra": "./infra/service.mjs",
35
36
  "./link": "./link/providers/index.mjs",
37
+ "./link/providers/nostr": "./link/providers/nostr/index.mjs",
38
+ "./link/providers/nostr/*": "./link/providers/nostr/*.mjs",
36
39
  "./core/*": "./core/*.mjs",
37
40
  "./crypto": "./crypto/crypto.mjs",
38
41
  "./crypto/*": "./crypto/*.mjs",
@@ -43,6 +46,8 @@
43
46
  "./discovery": "./discovery/index.mjs",
44
47
  "./discovery/bt": "./discovery/bt/index.mjs",
45
48
  "./discovery/bt/*": "./discovery/bt/*.mjs",
49
+ "./discovery/nostr": "./discovery/nostr/index.mjs",
50
+ "./discovery/nostr/*": "./discovery/nostr/*.mjs",
46
51
  "./discovery/*": "./discovery/*.mjs",
47
52
  "./transport/node_scope/*": "./transport/node_scope/*.mjs",
48
53
  "./transport/*": "./transport/*.mjs",
@@ -73,7 +78,8 @@
73
78
  "ws": "latest"
74
79
  },
75
80
  "devDependencies": {
76
- "@steve02081504/exec": "latest"
81
+ "@steve02081504/exec": "latest",
82
+ "playwright-core": "latest"
77
83
  },
78
84
  "optionalDependencies": {
79
85
  "@stoprocent/bleno": "latest",