@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
@@ -0,0 +1,92 @@
1
+ # Nostr Relay Discovery & Routing
2
+
3
+ Nostr is the fount-network fallback transport (`link` level −∞, discovery `priority` 100). This document covers the **relay pool**, NIP-66 discovery, health scoring, advert relay-field signing, and handshake routing. Top-level surface: [signaling.md](signaling.md) · [transports.md](transports.md) · [mesh.md](mesh.md).
4
+
5
+ Source layout (`discovery/nostr/`):
6
+ - `index.mjs` — provider (presence / signal / advert subscriptions)
7
+ - `relays.mjs` — pool, health, NIP-66 discovery, normalization, persistence
8
+ - `selection.mjs` — handshake routing, backoff, fanout
9
+ - `session.mjs` — shared relay WebSocket sessions / subscribe primitives
10
+ - `census.mjs` — population census worker (kind 30789, HT estimate)
11
+ - `census_math.mjs` — census pure functions (inclusion-probability update, HT estimator)
12
+ - `constants.mjs` — hard limits
13
+
14
+ ## Three-tier sets
15
+
16
+ `relayPool` (all known) → `workingRelays` (top-`WORKING_RELAYS_COUNT` by health) → `listenRelays` (top-`LISTEN_RELAYS_COUNT` working subset used for publish/listen).
17
+
18
+ - `public`/`manual` entries are **pinned**: always in `working`/`listen` and never evicted by `clearStale` or pool-cap. Pin count may exceed the nominal caps.
19
+ - `nip66`/`peer` entries are disposable: evicted when stale (`PROBE_STALE_MS`) or when the pool exceeds `POOL_CAP`.
20
+ - Fresh nodes are seeded with `DEFAULT_RELAY_URLS` (`source: 'public'`) so `listenRelays` is never empty at cold start.
21
+ - Persistent connections stay bounded by `workingRelays` (`WORKING_RELAYS_COUNT`, max 32); pins influence selection, not simultaneous connections.
22
+
23
+ ## URL normalization (single ingress)
24
+
25
+ `normalizeNostrRelayUrl` (in `relays.mjs`) is the only entry for every inbound URL (NIP-66 `d` tag, manual config, peer advert):
26
+
27
+ - `wss://` always; `ws://` only for loopback/private hosts (local dev/tests).
28
+ - hostname lowercased, default port removed, trailing slashes removed, non-empty path kept.
29
+ - Invalid → `null` → dropped with an audit log (`nodeDebug('invalidRelayUrl', { url, reason })`), never silently cleaned.
30
+
31
+ ## Health score
32
+
33
+ ```text
34
+ failureRate = failureCount / (successCount + failureCount)
35
+ rtt = clamp(rttMs ?? DEFAULT_RTT_MS, 1, MAX_RTT_MS)
36
+ score = rtt * (1 + failureRate * FAILURE_WEIGHT) // FAILURE_WEIGHT = 4
37
+ score *= STALE_PENALTY // ×2 if lastProbe older than PROBE_STALE_MS
38
+ ```
39
+
40
+ Lower is better. `recordProbeSuccess` / `recordProbeFailure` / `recordPublishResult` share the same counters. Writes to `nodeDir/nostr/relays.json` are **throttled** (2s debounce).
41
+
42
+ ## Persistence
43
+
44
+ `nodeDir/nostr/relays.json`:
45
+
46
+ ```text
47
+ {
48
+ "updatedAt": 1234567890,
49
+ "nostrRelays": [{ "url", "rttMs", "successCount", "failureCount", "lastSuccess", "lastFailure", "lastProbe", "firstSeen", "lastSeen", "source", "nips", "clearnet", "monitorCount" }],
50
+ "peerRoutes": { "<nodeHash64>": { "listenRelays", "peerPool", "lastGoodNostrRelays", "lastSeen" } }
51
+ }
52
+ ```
53
+
54
+ `peerRoutes` is a local-only cache (never shared).
55
+
56
+ ## NIP-66 discovery
57
+
58
+ - Bootstrap: `NIP66_BOOTSTRAP_RELAYS` + all `public`/`manual` as fallback, parallel, 10s connect timeout; refreshed every `NIP66_REFRESH_MS` (6h).
59
+ - `REQ` kind 30166 (limit 500) + optional 10166; parse `d` (url), `n` (clearnet only), `N` (must include NIP-01 or be absent), `rtt-open/read/write`.
60
+ - **Trust layering**: same URL reported by ≥2 distinct pubkeys → `monitorCount ≥ 2`; single report is untrusted. All candidates are probed; a successful probe upserts the entry (`source: 'nip66'`, real `monitorCount`), a failed probe drops it. Probes per round are bounded (`MAX_NIP66_PROBES_PER_ROUND`).
61
+ - Discovery is non-blocking (first round deferred to the next macrotask) and cancellable (AbortSignal tears down sockets + NIP-11 fetches), so it never blocks `ensureRuntime` or shutdown.
62
+ - `setNostrRelayDiscoveryEnabledForTests(false)` / `setNip66BootstrapRelaysForTests(urls)` are test-only hooks.
63
+
64
+ ## Advert relay fields & signature
65
+
66
+ `link/handshake.mjs` extends the advert signature domain: after `lanHosts`, append `\0relays:<hex>` where `<hex>` = UTF-8→hex of `{ p: pool, l: listen }` (both sorted by url, `pool` items `{url, rtt}`).
67
+
68
+ - `sanitizeAdvertRelayFields` (verify/inbound path): pool ≤ `MAX_ADVERT_RELAY_POOL` (16), rtt ∈ [0, `MAX_RTT_MS`], deduped; listen ≤ `MAX_ADVERT_LISTEN_RELAYS` (32), deduped; dropped entries logged. `buildSignedAdvert` (outbound) uses a strict variant that throws on invalid locally-supplied relay fields instead of dropping them.
69
+ - `verifySignedAdvert` now returns `{ nodeHash, relayPool, listenRelays }` (sanitized). `ingest*Advert` passes these through; consumers must use the verified values, never the raw body.
70
+ - **Any tampering with relay fields invalidates the signature** → advert rejected.
71
+
72
+ ## Handshake routing (`selection.mjs`)
73
+
74
+ `handshakeTargets(nodeHash, attempt)`:
75
+ - **Round 0**: peer-claimed `listenRelays` top 4 by composite score (own health + peer rtt); else local `workingRelays` top 4; else pinned top 4.
76
+ - **Round ≥1**: backoff `min(2000 · 2^(attempt−1), 60000)`; base on `lastGoodNostrRelays` (expanded via `expandFromHistory` ≤ 16), or weighted-random sample of `workingRelays` (weight `1/score`); round-0 core always included; fanout capped at `MAX_ROUTING_FANOUT` (64).
77
+ - Retries ≤ `MAX_ROUTING_ATTEMPTS` (4).
78
+
79
+ `routePublishEvent(toNodeHash, event, signal)`: publishes to the current round's targets in parallel via shared sessions; any `OK` records `lastGoodNostrRelays` (last 16) + success; all-fail records failures and backs off. `sendNodeSignal` uses routing; an explicit relay override (test/user pin) publishes directly.
80
+
81
+ ## Census (population estimate)
82
+
83
+ `census.mjs` implements a population census over nostr (event kind 30789, `t=fount` / `x=census`, content = base64 of the signed packet). It is driven by the `features.census` switch (`setP2PFeatures`).
84
+
85
+ - Each node keeps an inclusion probability `p`; per window it publishes an Ed25519-signed packet `{ nodeHash, nodePubKey, ts, p, sig }` (message `fount-census\0ts\0nodeHash\0p`) when `rand < p`.
86
+ - The multiplier updates `p' = p·(T/E)` (T = `CENSUS_TARGET_EVENTS`, E = observed window events); E == 0 probes upward by `CENSUS_GROW_FACTOR`. `clampP` keeps `p` in `[CENSUS_MIN_P, 1]`.
87
+ - Readers estimate online nodes with the HT estimator `M̂ = Σ(1/p)` (`estimatePopulation` in `census_math.mjs`), subtract the self-event's weight (`−1/p_self`) and add exactly 1 for the self node.
88
+ - Ingress packets are untrusted: `verifyCensusPacket` canonicalizes, checks `ts` within `CENSUS_TTL_MS`, requires `p ∈ [CENSUS_MIN_P, 1]`, and verifies `nodeHash = sha256(nodePubKey)` plus the Ed25519 signature before `noteCensusEvent` stores the event (deduped by `nodeHash`).
89
+
90
+ ## Config
91
+
92
+ `channels.nostr.relay` (non-empty array) **overrides** the whole relay set — published/subscribed relays come from that list, not the pool. Without it, `resolveNostrRelayUrls()` returns `getListenRelays()`.
@@ -60,8 +60,8 @@ export function attachTrustGraphFedChunkResponder(room, fedOut, guardGet, rtcLim
60
60
  void handleIncomingManifestGet(data, sendManifest, peerId)
61
61
  })
62
62
 
63
- getManifestData(data => {
63
+ getManifestData((data, peerId) => {
64
64
  if (!data?.requestId) return
65
- resolvePendingManifestFetch(data)
65
+ resolvePendingManifestFetch({ ...data, senderNodeHash: peerId })
66
66
  })
67
67
  }
package/files/evfs.mjs CHANGED
@@ -222,8 +222,8 @@ export async function putFileManifestFromStream(parameters) {
222
222
  * @returns {Promise<Buffer | null>} 明文或 null
223
223
  */
224
224
  export async function readPublicFile(replicaUsername, entityHash, logicalPath, options = {}) {
225
- const { fetchPublicManifest } = await import('./manifest/fetch.mjs')
226
- const manifest = await fetchPublicManifest({
225
+ const { fetchManifest } = await import('./manifest/fetch.mjs')
226
+ const manifest = await fetchManifest({
227
227
  username: options.username || replicaUsername,
228
228
  ownerEntityHash: entityHash,
229
229
  logicalPath,
@@ -13,6 +13,7 @@ import { fanoutFedFetch } from '../fetch_fanout.mjs'
13
13
  * action: string,
14
14
  * registerWait: (requestId: string) => { done: Promise<T | null>, cancel: () => void },
15
15
  * buildPayload: (requestId: string, nodeHash: string) => object,
16
+ * fanoutTargets?: string[],
16
17
  * }} options 选项
17
18
  * @returns {Promise<T | null> | null} 共享 Promise;队满为 null
18
19
  */
@@ -23,6 +24,7 @@ export function beginFedFanoutFetch({
23
24
  action,
24
25
  registerWait,
25
26
  buildPayload,
27
+ fanoutTargets,
26
28
  }) {
27
29
  return inflight.acquire(inflightKey, () => {
28
30
  const requestId = randomUUID()
@@ -30,7 +32,7 @@ export function beginFedFanoutFetch({
30
32
  void (async () => {
31
33
  try {
32
34
  const { nodeHash } = await resolveNodeHash(username)
33
- await fanoutFedFetch(username, action, buildPayload(requestId, nodeHash))
35
+ await fanoutFedFetch(username, action, buildPayload(requestId, nodeHash), fanoutTargets)
34
36
  }
35
37
  catch { /* pending wait 超时/cancel 负责 settle */ }
36
38
  })()
@@ -39,9 +39,10 @@ export function attachNodeScopeFedResponder(wire) {
39
39
  },
40
40
  /**
41
41
  * @param {unknown} data manifest 响应载荷
42
+ * @param {string} peerId 对端 nodeHash
42
43
  */
43
- fed_manifest_data(data) {
44
- void resolvePendingManifestFetch(data)
44
+ fed_manifest_data(data, peerId) {
45
+ void resolvePendingManifestFetch({ ...data, senderNodeHash: peerId })
45
46
  },
46
47
  })
47
48
  }
@@ -1,4 +1,5 @@
1
1
  import { FEDERATION_CHUNK_FETCH_FANOUT_K } from '../core/constants.mjs'
2
+ import { isHex64 } from '../core/hexIds.mjs'
2
3
  import { loadNetwork } from '../node/network.mjs'
3
4
  import { ensureLinkToNode, listLinks } from '../transport/link_registry.mjs'
4
5
  import { DEFAULT_TRUST_GRAPH_OWNER, requireTrustGraphProvider } from '../trust_graph/registry.mjs'
@@ -19,20 +20,40 @@ function fetchPeerTargets() {
19
20
  return [...targets]
20
21
  }
21
22
 
23
+ /**
24
+ * 规范化目标节点集:过滤非 64hex、去重(保持插入序)。fanout 与 manifest in-flight key 共用。
25
+ * @param {string[]} [targets] 目标节点集
26
+ * @returns {string[]} 规范化后的目标节点集
27
+ */
28
+ export function canonicalizeFanoutTargets(targets) {
29
+ return targets ? [...new Set(targets.filter(isHex64))] : []
30
+ }
31
+
22
32
  /**
23
33
  * 全局 miss 请求扇出:先向已知 peer 定向发送,再 trust-graph top-K fanout。
24
34
  * 已直连 / follow hint peer 可能不在 trust-graph top-K(非成员 emoji CAS / Social 预览路径),故先定向发送。
25
35
  * @param {string} username 用户
26
36
  * @param {string} action wire action 名
27
37
  * @param {object} payload 请求载荷
38
+ * @param {string[]} [fanoutTargets] 显式目标节点集(非 public manifest 的授权边界);提供时(含空/全非法集)只发目标集,不走 node-scope
28
39
  * @returns {Promise<void>}
29
40
  */
30
- export async function fanoutFedFetch(username, action, payload) {
31
- const tg = requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER)
32
- const graph = await tg.buildMergedGraph(username)
41
+ export async function fanoutFedFetch(username, action, payload, fanoutTargets) {
42
+ const trustGraph = requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER)
43
+ if (fanoutTargets !== undefined) {
44
+ // 定向:只发显式目标集(非 public manifest 的授权边界)。
45
+ // 空/全非法集也视为定向——调用方显式提供目标集即声明授权边界,不发 node-scope。
46
+ // 依赖已有链路/群房间投递,不主动拨号——目标本就是已授权成员,无通道即不应服务。
47
+ const graph = await trustGraph.buildMergedGraph(username)
48
+ for (const nodeHash of canonicalizeFanoutTargets(fanoutTargets))
49
+ void trustGraph.sendToNode(username, nodeHash, action, payload, graph)
50
+ return
51
+ }
52
+
53
+ const graph = await trustGraph.buildMergedGraph(username)
33
54
  const peerTargets = fetchPeerTargets()
34
55
  await Promise.all(peerTargets.map(nodeHash => ensureLinkToNode(nodeHash).catch(() => null)))
35
56
  for (const nodeHash of peerTargets)
36
- void tg.sendToNode(username, nodeHash, action, payload, graph)
37
- await tg.fanoutToTopNodes(username, action, payload, FEDERATION_CHUNK_FETCH_FANOUT_K)
57
+ void trustGraph.sendToNode(username, nodeHash, action, payload, graph)
58
+ await trustGraph.fanoutToTopNodes(username, action, payload, FEDERATION_CHUNK_FETCH_FANOUT_K)
38
59
  }
@@ -6,7 +6,9 @@ import { ms } from '../../utils/duration.mjs'
6
6
  import { createInflightTable } from '../../utils/inflight_table.mjs'
7
7
  import { loadFileManifest, saveFileManifest } from '../evfs.mjs'
8
8
  import { beginFedFanoutFetch } from '../fed/fetch_shared.mjs'
9
+ import { canonicalizeFanoutTargets } from '../fetch_fanout.mjs'
9
10
 
11
+ import { resolveManifestAclType } from './acl_registry.mjs'
10
12
  import { normalizeFileManifest } from './normalize.mjs'
11
13
  import {
12
14
  manifestFetchExpectedKey,
@@ -14,23 +16,26 @@ import {
14
16
  registerManifestFetchWait,
15
17
  } from './pending.mjs'
16
18
  import { shouldPreferIncomingPublicManifest } from './public.mjs'
19
+ import { getManifestServicer } from './servicer_registry.mjs'
17
20
 
18
21
  const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS = ms('8s')
19
22
 
20
- /** 同 username+owner+path 共享一次 fanout;队满只丢已超基础超时的队首。 */
23
+ /** 同 username+owner+path+模式 共享一次 fanout;队满只丢已超基础超时的队首。 */
21
24
  const manifestInflight = createInflightTable({
22
25
  maxSize: MAX_PENDING_MANIFEST_FETCHES,
23
26
  baseTimeoutMs: DEFAULT_MANIFEST_FETCH_TIMEOUT_MS,
24
27
  })
25
28
 
26
29
  /**
27
- * 拉取公开 manifest;默认不写盘。`cache: true` 或 `cachePublicManifest` 才缓存。
28
- * 本地已有 publicSig 时立即返回,并后台 fanout;`cache: true` 时按 publishedAt 择新写盘。
29
- * 冷 miss 仍等 fanout。同 key in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
30
- * @param {{ username: string, ownerEntityHash: string, logicalPath: string, cache?: boolean, timeoutMs?: number }} context - 拉取上下文
31
- * @returns {Promise<import('./normalize.mjs').FileManifest | null>} 验签后的 manifest,失败为 null
30
+ * 拉取 manifest。默认走 node-scope public 语义(仅接受验签公开 manifest);
31
+ * 传入 `fanoutTargets` 时只向目标集 fanout,并接受无签名的非 public manifest(信任边界 = 目标集 + 服务端 servicer)。
32
+ * 非 public 命中默认写盘;public 按 `cache: true` 择新写盘。
33
+ * 本地已验签公开缓存立即返回(`cache: true` 时后台 fanout 刷新);非 public / 未验签 public 本地命中仅 targeted 模式返回,
34
+ * public 模式拒绝。同 key(含规范化目标集)in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
35
+ * @param {{ username: string, ownerEntityHash: string, logicalPath: string, fanoutTargets?: string[], cache?: boolean, timeoutMs?: number }} context - 拉取上下文
36
+ * @returns {Promise<import('./normalize.mjs').FileManifest | null>} 校验后的 manifest,失败为 null
32
37
  */
33
- export async function fetchPublicManifest(context) {
38
+ export async function fetchManifest(context) {
34
39
  const { ownerEntityHash, username } = context
35
40
  const logicalPath = context.logicalPath.replace(/^\/+/, '')
36
41
  if (!ownerEntityHash || !logicalPath || !username) return null
@@ -40,54 +45,67 @@ export async function fetchPublicManifest(context) {
40
45
  : DEFAULT_MANIFEST_FETCH_TIMEOUT_MS
41
46
  const expectedKey = manifestFetchExpectedKey(ownerEntityHash, logicalPath)
42
47
  const wantCache = context.cache === true
48
+ const targeted = Array.isArray(context.fanoutTargets)
49
+ const fanoutTargets = targeted ? canonicalizeFanoutTargets(context.fanoutTargets) : undefined
43
50
 
44
- // 先挂 in-flight(同步),再读本地 — 避免并发调用在 await 间隙各自 start
51
+ // 先同步挂 in-flight,再读本地 — 避免并发调用在 await 间隙各自 start(本地慢读可能晚于对端结算)。
45
52
  const localPromise = loadFileManifest(ownerEntityHash, logicalPath)
46
53
  const shared = beginFedFanoutFetch({
47
54
  inflight: manifestInflight,
48
- inflightKey: `${username}\0${expectedKey}`,
55
+ inflightKey: `${username}\0${expectedKey}\0${targeted ? 'targeted' : 'public'}` + (fanoutTargets?.length ? `\0${fanoutTargets.join('\0')}` : ''),
49
56
  username,
50
57
  action: 'fed_manifest_get',
51
58
  /**
52
59
  * @param {string} requestId 请求 id
53
60
  * @returns {{ done: Promise<import('./normalize.mjs').FileManifest | null>, cancel: () => void }} 等待句柄
54
61
  */
55
- registerWait: requestId => registerManifestFetchWait(requestId, expectedKey, timeoutMs),
62
+ registerWait: requestId => registerManifestFetchWait(
63
+ requestId,
64
+ expectedKey,
65
+ timeoutMs,
66
+ targeted ? { allowNonPublic: true, targetNodeHashes: fanoutTargets } : undefined,
67
+ ),
56
68
  /**
57
69
  * @param {string} requestId 请求 id
58
- * @param {string} nodeHash 目标节点 hash
59
70
  * @returns {object} fanout 载荷
60
71
  */
61
- buildPayload: (requestId, nodeHash) => ({
72
+ buildPayload: requestId => ({
62
73
  requestId,
63
- nodeHash,
64
74
  ownerEntityHash,
65
75
  logicalPath,
66
76
  }),
77
+ fanoutTargets,
67
78
  })
68
-
69
79
  const local = await localPromise
70
- const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
71
- if (!shared) return hasLocalPublic ? local : null
72
-
73
- if (hasLocalPublic) {
74
- if (wantCache)
75
- void shared.then(async result => {
76
- if (result && shouldPreferIncomingPublicManifest(local, result))
77
- await cachePublicManifest(ownerEntityHash, logicalPath, result)
78
- })
79
-
80
+
81
+ // 本地已验签公开命中:立即返回;cache: true 时后台 fanout 择新刷新。
82
+ const isLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
83
+ if (isLocalPublic) {
84
+ if (wantCache && shared) void shared.then(async result => {
85
+ if (result && shouldPreferIncomingPublicManifest(local, result))
86
+ await cachePublicManifest(ownerEntityHash, logicalPath, result)
87
+ })
80
88
  return local
81
89
  }
82
90
 
91
+ // 本地非 public / 未验签 public 命中:仅 targeted 模式返回(授权边界 = 目标集);public 模式拒绝。
92
+ if (local) return targeted ? local : null
93
+
94
+ if (!shared) return null
95
+
83
96
  const result = await shared
84
97
  if (!result) return null
85
- if (wantCache) await cachePublicManifest(ownerEntityHash, logicalPath, result)
98
+ if (wantCache || targeted)
99
+ if (result.transferKeyDescriptor.type === 'public')
100
+ await cachePublicManifest(ownerEntityHash, logicalPath, result)
101
+ else if (!isWritableLocalEntity(ownerEntityHash))
102
+ await saveFileManifest(result)
103
+
86
104
  return result
87
105
  }
88
106
 
89
107
  /**
90
- * 将已验签公开 manifest 写入本地缓存(显式 apply;`fetchPublicManifest` 默认不调用)。
108
+ * 将已验签公开 manifest 写入本地缓存(显式 apply;`fetchManifest` public 模式默认不调用)。
91
109
  * @param {string} ownerEntityHash owner
92
110
  * @param {string} logicalPath 路径
93
111
  * @param {import('./normalize.mjs').FileManifest} incoming 已验签入站清单
@@ -101,10 +119,10 @@ export async function cachePublicManifest(ownerEntityHash, logicalPath, incoming
101
119
  }
102
120
 
103
121
  /**
104
- * 若本机有已签名公开 manifest 则响应 fed_manifest_get。
122
+ * 响应 fed_manifest_get:public 只回验签公开清单;非 public 经 ACL servicer 授权后回完整清单。
105
123
  * @param {object} payload 请求
106
124
  * @param {(response: object, peerId: string) => void} sendResponse 发送
107
- * @param {string} peerId 对端
125
+ * @param {string} peerId 对端(传输层认证的发送方 nodeHash,即 requesterNodeHash)
108
126
  * @returns {Promise<void>}
109
127
  */
110
128
  export async function handleIncomingManifestGet(payload, sendResponse, peerId) {
@@ -122,14 +140,31 @@ export async function handleIncomingManifestGet(payload, sendResponse, peerId) {
122
140
  const raw = await getEntityStore().readManifest(ownerEntityHash, logicalPath)
123
141
  const manifest = normalizeFileManifest(raw)
124
142
  if (!manifest) return
125
- if (manifest.transferKeyDescriptor.type !== 'public') return
126
- if (!raw?.meta?.publicSig) return
127
-
128
- sendResponse({
129
- requestId,
130
- manifest: {
131
- ...manifest,
132
- meta: { publicSig: raw.meta.publicSig },
133
- },
134
- }, peerId)
143
+
144
+ if (manifest.transferKeyDescriptor.type === 'public') {
145
+ if (!raw?.meta?.publicSig) return
146
+ sendResponse({
147
+ requestId,
148
+ manifest: {
149
+ ...manifest,
150
+ meta: { publicSig: raw.meta.publicSig },
151
+ },
152
+ }, peerId)
153
+ return
154
+ }
155
+
156
+ // 非 public:ACL servicer 授权后回完整 manifest(读侧解密依赖 meta.dagParts / groupId)。
157
+ const aclType = resolveManifestAclType(manifest, ownerEntityHash) || manifest.transferKeyDescriptor.type
158
+ const servicer = getManifestServicer(aclType)
159
+ if (!servicer) return
160
+ const allowed = await servicer({
161
+ manifest,
162
+ ownerEntityHash,
163
+ logicalPath,
164
+ requesterNodeHash: peerId,
165
+ peerId,
166
+ payload,
167
+ })
168
+ if (!allowed) return
169
+ sendResponse({ requestId, manifest }, peerId)
135
170
  }
@@ -1,6 +1,7 @@
1
1
  import { compositeKey } from '../../core/composite_key.mjs'
2
2
  import { createFetchWaitTable } from '../../utils/fetch_wait.mjs'
3
3
 
4
+ import { normalizeFileManifest } from './normalize.mjs'
4
5
  import { verifySignedPublicManifest } from './public.mjs'
5
6
 
6
7
  /** 并发 pending manifest fetch 上限。 */
@@ -24,14 +25,15 @@ export function manifestFetchExpectedKey(ownerEntityHash, logicalPath) {
24
25
  * @param {string} key requestId
25
26
  * @param {string} expectedKey owner+path 复合键
26
27
  * @param {number} timeoutMs 超时毫秒
28
+ * @param {{ allowNonPublic?: boolean, targetNodeHashes?: string[] }} [options] allowNonPublic 时接受无签名的非 public manifest(sender 须在 targetNodeHashes 内)
27
29
  * @returns {{ done: Promise<object | null>, cancel: () => void }} 等待 Promise 与取消
28
30
  */
29
- export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
30
- return table.register(key, expectedKey, timeoutMs)
31
+ export function registerManifestFetchWait(key, expectedKey, timeoutMs, options) {
32
+ return table.register(key, expectedKey, timeoutMs, options)
31
33
  }
32
34
 
33
35
  /**
34
- * 处理 fed_manifest_data:验签通过后 resolve pending。
36
+ * 处理 fed_manifest_data:public 验签通过或(allowNonPublic 槽位)非 public 校验通过后 resolve pending。
35
37
  * @param {object} payload 入站载荷
36
38
  * @returns {Promise<boolean>} 是否命中并完成等待
37
39
  */
@@ -42,9 +44,21 @@ export async function resolvePendingManifestFetch(payload) {
42
44
  if (!entry) return false
43
45
 
44
46
  const verified = await verifySignedPublicManifest(payload?.manifest)
45
- if (!verified) return false
46
- const key = manifestFetchExpectedKey(verified.ownerEntityHash, verified.logicalPath)
47
- if (key !== entry.expectedKey) return false
47
+ if (verified) {
48
+ const key = manifestFetchExpectedKey(verified.ownerEntityHash, verified.logicalPath)
49
+ if (key !== entry.expectedKey) return false
50
+ return table.settle(requestId, verified)
51
+ }
48
52
 
49
- return table.settle(requestId, verified)
53
+ if (entry.options?.allowNonPublic) {
54
+ const normalized = normalizeFileManifest(payload?.manifest)
55
+ if (!normalized) return false
56
+ const key = manifestFetchExpectedKey(normalized.ownerEntityHash, normalized.logicalPath)
57
+ if (key !== entry.expectedKey) return false
58
+ // 授权边界 = 目标集:无签名 manifest 仅接受来自目标节点集的响应。
59
+ if (!entry.options?.targetNodeHashes?.includes(payload?.senderNodeHash)) return false
60
+ return table.settle(requestId, normalized)
61
+ }
62
+
63
+ return false
50
64
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Manifest servicer 注册表:按 ACL 类型路由,决定跨节点是否对外提供非 public manifest。
3
+ * 服务端缺省 deny;未注册的类型直接不响应 fed_manifest_get。
4
+ */
5
+
6
+ /** @type {Map<string, { ownerId: string, handler: (context: ManifestServicerContext) => Promise<boolean> | boolean }>} */
7
+ const servicersByType = new Map()
8
+
9
+ /**
10
+ * @typedef {{
11
+ * manifest: import('./normalize.mjs').FileManifest,
12
+ * ownerEntityHash: string,
13
+ * logicalPath: string,
14
+ * requesterNodeHash: string,
15
+ * peerId: string,
16
+ * payload: object,
17
+ * }} ManifestServicerContext
18
+ */
19
+
20
+ /**
21
+ * @param {string} type ACL 类型(如 vault-wrap、file-master-key-wrap,或 resolveManifestAclType 匹配结果)
22
+ * @param {string} ownerId 注册方
23
+ * @param {(context: ManifestServicerContext) => Promise<boolean> | boolean} handler 授权判定
24
+ * @returns {void}
25
+ */
26
+ export function registerManifestServicer(type, ownerId, handler) {
27
+ const existing = servicersByType.get(type)
28
+ if (existing && existing.ownerId !== ownerId)
29
+ throw new Error(`manifest servicer for '${type}' already registered by '${existing.ownerId}'`)
30
+ servicersByType.set(type, { ownerId, handler })
31
+ }
32
+
33
+ /**
34
+ * @param {string} type ACL 类型
35
+ * @param {string} ownerId 注册方
36
+ * @returns {void}
37
+ */
38
+ export function unregisterManifestServicer(type, ownerId) {
39
+ const existing = servicersByType.get(type)
40
+ if (!existing) return
41
+ if (existing.ownerId === ownerId)
42
+ servicersByType.delete(type)
43
+ }
44
+
45
+ /**
46
+ * @param {string} type ACL 类型
47
+ * @returns {(context: ManifestServicerContext) => Promise<boolean> | boolean | undefined} 已注册 handler
48
+ */
49
+ export function getManifestServicer(type) {
50
+ return servicersByType.get(type)?.handler
51
+ }
@@ -104,7 +104,7 @@ function governanceAuthzScoreForTip(tipId, byId, reputationBySender) {
104
104
  for (const eventId of ancestorClosureFromTip(tipId, byId)) {
105
105
  const event = byId.get(eventId)
106
106
  if (!event || !getGovernanceAuthzTypes().has(event.type)) continue
107
- const sender = event.sender
107
+ const { sender } = event
108
108
  if (sender) score += Number(reputationBySender[sender] ?? 0)
109
109
  }
110
110
  return score
@@ -103,8 +103,8 @@ export function verifyJoinPow(powSolution, options) {
103
103
  const anchorRef = String(powSolution.anchorRef ?? '')
104
104
  const { nonce } = powSolution
105
105
  const epoch = Number(powSolution.epoch)
106
- const joinerNodeHash = powSolution.joinerNodeHash ?? options.senderNodeHash
107
- const senderNodeHash = options.senderNodeHash
106
+ const { senderNodeHash } = options
107
+ const joinerNodeHash = powSolution.joinerNodeHash ?? senderNodeHash
108
108
 
109
109
  if (!anchorRef || nonce == null || !Number.isFinite(epoch)) return { ok: false, achievedBits: 0 }
110
110
  if (!joinerNodeHash || joinerNodeHash !== senderNodeHash) return { ok: false, achievedBits: 0 }
package/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  * Federation P2P 门面:fount 网络引导与房间/发现入口。
3
3
  */
4
4
  import { registerDiscoveryProvider } from './discovery/index.mjs'
5
+ import { getNodePopulationEstimate } from './discovery/nostr/census.mjs'
5
6
  import {
6
7
  isInfraRunning,
7
8
  setInfraPriority,
@@ -13,9 +14,11 @@ import { ensureNodeDefaults, getNodeHash } from './node/identity.mjs'
13
14
  import {
14
15
  closeNode,
15
16
  getNodeDir,
17
+ getP2PFeatures,
16
18
  initNode,
17
19
  isNodeInitialized,
18
20
  setNodeLogger,
21
+ setP2PFeatures,
19
22
  setSignalingRuntimeConfig,
20
23
  } from './node/instance.mjs'
21
24
  import { setConnectivityDebug } from './node/log.mjs'
@@ -73,6 +76,8 @@ export {
73
76
  getLinkRegistry,
74
77
  getNodeDir,
75
78
  getNodeHash,
79
+ getNodePopulationEstimate,
80
+ getP2PFeatures,
76
81
  getPeerHealth,
77
82
  getReputationExportAllowlist,
78
83
  getReputationLocks,
@@ -94,6 +99,7 @@ export {
94
99
  setConnectivityDebug,
95
100
  setInfraPriority,
96
101
  setNodeLogger,
102
+ setP2PFeatures,
97
103
  setReputationExportAllowlist,
98
104
  setReputationTable,
99
105
  setRoutingProfile,
@@ -105,20 +111,21 @@ export {
105
111
  }
106
112
 
107
113
  /**
108
- * @param {{ nodeDir?: string, entityStore?: import('./node/entity_store.mjs').EntityStore, logger?: object | null, signaling?: import('./node/signaling_config.mjs').SignalingRuntimeConfig }} [options] - 首次 init 时的节点选项
114
+ * @param {{ nodeDir?: string, entityStore?: import('./node/entity_store.mjs').EntityStore, logger?: object | null, signaling?: import('./node/signaling_config.mjs').SignalingRuntimeConfig, features?: Record<string, boolean> }} [options] - 首次 init 时的节点选项
109
115
  * @returns {Promise<void>}
110
116
  */
111
117
  export async function startNode(options = {}) {
112
118
  if (!isNodeInitialized()) {
113
- const { nodeDir, entityStore, logger, signaling, ...rest } = options
119
+ const { nodeDir, entityStore, logger, signaling, features, ...rest } = options
114
120
  if (Object.keys(rest).length)
115
121
  throw new Error('p2p: startNode unknown options')
116
122
  initNode({ nodeDir, entityStore })
117
123
  if (logger !== undefined) setNodeLogger(logger)
118
124
  if (signaling !== undefined) setSignalingRuntimeConfig(signaling)
125
+ if (features !== undefined) setP2PFeatures(features)
119
126
  }
120
- else if (options?.nodeDir || options?.entityStore || options?.logger !== undefined || options?.signaling)
121
- throw new Error('p2p: startNode options ignored after initNode — use setNodeLogger / setSignalingRuntimeConfig')
127
+ else if (options?.nodeDir || options?.entityStore || options?.logger !== undefined || options?.signaling || options?.features)
128
+ throw new Error('p2p: startNode options ignored after initNode — use setNodeLogger / setSignalingRuntimeConfig / setP2PFeatures')
122
129
 
123
130
  ensureNodeDefaults()
124
131
  await getLinkRegistry().ensureRuntime()