@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.
- package/AGENTS.md +3 -3
- package/discovery/adverts.mjs +16 -8
- package/discovery/nostr/census.mjs +281 -0
- package/discovery/nostr/census_math.mjs +54 -0
- package/discovery/nostr/constants.mjs +43 -0
- package/discovery/nostr/index.mjs +612 -0
- package/discovery/nostr/relays.mjs +1071 -0
- package/discovery/nostr/selection.mjs +224 -0
- package/discovery/nostr/session.mjs +584 -0
- package/docs/evfs.md +15 -6
- package/docs/nostr_relay_discovery.md +92 -0
- package/files/chunk/responder.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/files/fed/fetch_shared.mjs +3 -1
- package/files/fed/responder.mjs +3 -2
- package/files/fetch_fanout.mjs +26 -5
- package/files/manifest/fetch.mjs +73 -38
- package/files/manifest/pending.mjs +21 -7
- package/files/manifest/servicer_registry.mjs +51 -0
- package/governance/branch.mjs +1 -1
- package/governance/join_pow.mjs +2 -2
- package/index.mjs +11 -4
- package/link/handshake.mjs +135 -23
- package/link/providers/link_id_pipe.mjs +1 -1
- package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
- package/mailbox/deliver_or_store.mjs +3 -4
- package/mailbox/store.mjs +1 -1
- package/node/feature_config.mjs +30 -0
- package/node/instance.mjs +21 -0
- package/node/reputation_store.mjs +1 -2
- package/node/storage.mjs +23 -0
- package/package.json +10 -4
- package/pages/app.mjs +172 -0
- package/pages/index.html +36 -0
- package/reputation/engine.mjs +1 -2
- package/transport/runtime_bootstrap.mjs +39 -3
- package/utils/fetch_wait.mjs +12 -7
- 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 {
|
|
226
|
-
const manifest = await
|
|
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
|
})()
|
package/files/fed/responder.mjs
CHANGED
|
@@ -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
|
}
|
package/files/fetch_fanout.mjs
CHANGED
|
@@ -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
|
|
32
|
-
|
|
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
|
|
37
|
-
await
|
|
57
|
+
void trustGraph.sendToNode(username, nodeHash, action, payload, graph)
|
|
58
|
+
await trustGraph.fanoutToTopNodes(username, action, payload, FEDERATION_CHUNK_FETCH_FANOUT_K)
|
|
38
59
|
}
|
package/files/manifest/fetch.mjs
CHANGED
|
@@ -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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
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:
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
if (wantCache)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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;`
|
|
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
|
-
*
|
|
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
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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 (
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/governance/branch.mjs
CHANGED
|
@@ -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
|
|
107
|
+
const { sender } = event
|
|
108
108
|
if (sender) score += Number(reputationBySender[sender] ?? 0)
|
|
109
109
|
}
|
|
110
110
|
return score
|
package/governance/join_pow.mjs
CHANGED
|
@@ -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
|
|
107
|
-
const
|
|
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()
|