@steve02081504/fount-p2p 0.0.37 → 0.0.39

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 CHANGED
@@ -53,12 +53,12 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
53
53
 
54
54
  ### Trust / ingress
55
55
 
56
- - **Untrusted ingress only:** discovery adverts/signals, link/overlay envelopes, group federation frames, `remoteIngest`, `part_timeline_*` / `part_invoke`, `part_query_*`, public manifest (`fed_manifest_data`). Validate / `canonicalize*` / `verifySignedPublicManifest` **only** here.
56
+ - **Untrusted ingress only:** discovery adverts/signals, link/overlay envelopes, group federation frames, `remoteIngest`, `part_timeline_*` / `part_invoke`, `part_query_*`, manifest fetch responses (`fed_manifest_data`). Validate / `canonicalize*` / `verifySignedPublicManifest` **only** here. Non-public `fed_manifest_data` is accepted only into an `allowNonPublic` pending slot (targeted fanout) and passes `normalizeFileManifest` + owner/path match — the serving node's servicer is the authorization gate. [evfs.md](docs/evfs.md)
57
57
  - **Trusted after disk:** from `events.jsonl`, only `stripDagEventLocalExtensions` — no re-canonicalization upstream.
58
58
  - **Fanout vs targeted / timed collect:** [wire.md](docs/wire.md).
59
59
  - **Channel encryption:** per-channel `K_ch`, scheme `channel-key` (`CHANNEL_KEY_SCHEME`); decrypted payloads are untrusted outside DAG Ed25519 context.
60
60
  - **Denylist vs personal lists:** node `denylist.json` vs per-entity `personal_block.json` / `personal_hide.json`.
61
- - **Manifest ACL / transfer owner:** shells register matchers; core does not hard-code chat/social types.
61
+ - **Manifest ACL / transfer owner / servicer:** ownership is routed by `registerManifestOwner` matchers only; ACL & servicer handlers are keyed by `ownerId` (a `type` may be reused across families but never acts as a routing key). Core does not hard-code chat/social types. Non-public manifests are served cross-node only via a registered servicer; unclaimed non-public is denied (no `type` fallback).
62
62
 
63
63
  ### Node / network
64
64
 
package/docs/evfs.md CHANGED
@@ -1,4 +1,4 @@
1
- # EVFS public manifests & fetch
1
+ # EVFS manifests & fetch
2
2
 
3
3
  Day-to-day package rules: [AGENTS.md](../AGENTS.md). Implementation: `files/` (`evfs`, `evfs_ref`, `chunk/*`, `manifest/*`).
4
4
 
@@ -16,17 +16,29 @@ Day-to-day package rules: [AGENTS.md](../AGENTS.md). Implementation: `files/` (`
16
16
  - Signature covers **content fields only**. After verify, drop incoming `meta` except `publicSig`.
17
17
  - Profile / avatar meaning belongs in the shell — not in this package.
18
18
 
19
- ## `fetchPublicManifest`
19
+ ## `fetchManifest`
20
20
 
21
21
  | Case | Behavior |
22
22
  | --- | --- |
23
- | Default options | No cache write |
24
- | Local hit with `publicSig` | Return immediately; fanout revalidates in the background. With `cache: true`, write only when remote `publishedAt` is newer |
25
- | Cold miss | Await fanout |
26
- | Same key in flight | Deduped via `utils/inflight_table` |
23
+ | Default options | Node-scope fanout; **only** signed public manifests accepted; no cache write |
24
+ | Local public hit with `publicSig` | Return immediately; fanout revalidates in the background. With `cache: true`, write only when remote `publishedAt` is newer |
25
+ | Local non-public hit | Return immediately (**targeted mode only**); no revalidation (no publish order to compare); public mode refuses non-public local |
26
+ | `fanoutTargets` given | Fanout **only** to that node set; accepts signed public **or** normalized non-public manifests; non-public hit is cached locally by default |
27
+ | Same key in flight | Deduped via `utils/inflight_table` (key includes `public`/`targeted` mode) |
27
28
 
28
29
  Outer caller timeouts must **not** abort the in-flight work — background fill continues after the caller gives up.
29
30
 
30
- ## ACL
31
+ ## Non-public (ACL-gated) manifests
31
32
 
32
- Shells register matchers via the ACL registry. Core does not hard-code chat/social entity types.
33
+ - `file-master-key-wrap` / `vault-wrap` / `identity-wrap` manifests carry no author signature, so the remote-acquisition trust boundary is **not** a signature it is the **fanout target set** plus the **serving node's authorization**.
34
+ - Client: the caller passes the authorized node set (group roster / cabinet members) as `fanoutTargets`. `fed_manifest_get` is sent only to those nodes; a response is accepted when it normalizes, its `ownerEntityHash`+`logicalPath` match the request, and its authenticated `senderNodeHash` belongs to the target set.
35
+ - Server: `handleIncomingManifestGet` refuses non-public manifests unless a **servicer** is registered for the matched owner family. Shells call `registerManifestOwner(ownerId, match)` to claim ownership and `registerManifestServicer(ownerId, handler)` to serve; the handler receives `{ manifest, ownerEntityHash, logicalPath, requesterNodeHash, peerId, payload }` and returns a boolean. `requesterNodeHash` is **always** the transport-authenticated sender nodeHash (`peerId`); the client no longer self-asserts a `nodeHash` in `fed_manifest_get`, so the fanout target node can never be mistaken for the requester — authorization must still be enforced from the trust graph / group membership.
36
+ - Non-public responses carry the **full manifest including `meta`** (public responses strip meta to `publicSig`): the reader needs `meta.dagParts` / `meta.groupId` to read DAG plaintext.
37
+ - Confidentiality does not rely on manifest secrecy: ciphertext blocks are content-addressed, AES-GCM authenticated, and decryption requires a wrap key held by the shell. A forged manifest can at worst fail the read (DoS), never decrypt content.
38
+
39
+ ## ACL & owner routing
40
+
41
+ - Ownership is decided **only** by matchers: `registerManifestOwner(ownerId, match)` with `match(manifest, ownerEntityHash)` returning a boolean. `transferKeyDescriptor.type` is **not** a routing key — the same type (e.g. `file-master-key-wrap`) may be reused by several families (chat group, cabinet shared, vault, …).
42
+ - Matchers must be mutually exclusive: a manifest belongs to exactly one family; resolution is first-match in registration order. A broad matcher that claims another family's entities will shadow them, so matchers should key on their own entity namespace (e.g. the `fount:chat:group:` prefix).
43
+ - Local read/write ACL (`registerManifestAcl(ownerId, handler)`) and cross-node serve (`registerManifestServicer(ownerId, handler)`) are both keyed by `ownerId` (same `ownerId` as `registerTransferKeyDependencies`). A claimed family with no handler denies (fail-closed); an unclaimed non-public manifest is denied cross-node — there is no `type` fallback.
44
+ - Core does not hard-code chat/social entity types.
@@ -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
  }
@@ -1,38 +1,67 @@
1
1
  import { isLogicalEntityHash } from '../../core/logical_entity.mjs'
2
2
  import { isWritableLocalEntity } from '../../node/identity.mjs'
3
3
 
4
- import { checkManifestAcl, resolveManifestAclType } from './acl_registry.mjs'
4
+ import { resolveManifestOwner } from './routing.mjs'
5
5
 
6
6
  /**
7
+ * @typedef {{
8
+ * replicaUsername: string,
9
+ * ownerEntityHash: string,
10
+ * manifest: import('./normalize.mjs').FileManifest | object,
11
+ * }} ManifestAclContext
12
+ */
13
+
14
+ /** @type {Map<string, (context: ManifestAclContext, logicalPath?: string) => Promise<boolean>>} */
15
+ const aclHandlers = new Map()
16
+
17
+ /**
18
+ * 注册本族 manifest 的本地读写授权判定(ownerId 须与 registerManifestOwner 一致)。
19
+ * @param {string} ownerId 注册方
20
+ * @param {(context: ManifestAclContext, logicalPath?: string) => Promise<boolean>} handler 授权判定
21
+ * @returns {void}
22
+ */
23
+ export function registerManifestAcl(ownerId, handler) {
24
+ aclHandlers.set(ownerId, handler)
25
+ }
26
+
27
+ /**
28
+ * @param {string} ownerId 注册方
29
+ * @returns {void}
30
+ */
31
+ export function unregisterManifestAcl(ownerId) {
32
+ aclHandlers.delete(ownerId)
33
+ }
34
+
35
+ /**
36
+ * 本地读授权:仅认 matcher 结果——命中族须注册 ACL handler,否则 deny。
7
37
  * @param {string} replicaUsername 请求 replica
8
38
  * @param {string} ownerEntityHash 文件 owner
9
39
  * @param {import('./normalize.mjs').FileManifest} manifest 清单
10
40
  * @returns {Promise<boolean>} 是否允许读
11
41
  */
12
42
  export async function canReadManifest(replicaUsername, ownerEntityHash, manifest) {
13
- const type = resolveManifestAclType(manifest, ownerEntityHash)
14
- if (type)
15
- return checkManifestAcl(type, { replicaUsername, ownerEntityHash, manifest })
16
-
17
- if (isLogicalEntityHash(ownerEntityHash))
43
+ const ownerId = resolveManifestOwner(manifest, ownerEntityHash)
44
+ if (ownerId) {
45
+ const handler = aclHandlers.get(ownerId)
46
+ if (handler) return handler({ replicaUsername, ownerEntityHash, manifest })
18
47
  return false
19
-
20
- return true
48
+ }
49
+ return !isLogicalEntityHash(ownerEntityHash)
21
50
  }
22
51
 
23
52
  /**
53
+ * 本地写授权:仅认 matcher 结果(写路径无 manifest,只按 ownerEntityHash 判定)。
24
54
  * @param {string} replicaUsername 请求 replica
25
55
  * @param {string} ownerEntityHash 所有者
26
56
  * @param {string} logicalPath 路径
27
57
  * @returns {Promise<boolean>} 是否允许写
28
58
  */
29
59
  export async function canWriteManifestPath(replicaUsername, ownerEntityHash, logicalPath) {
30
- const type = resolveManifestAclType(null, ownerEntityHash)
31
- if (type)
32
- return checkManifestAcl(type, { replicaUsername, ownerEntityHash, manifest: /** @type {any} */ {} }, logicalPath)
33
-
34
- if (isLogicalEntityHash(ownerEntityHash))
60
+ const ownerId = resolveManifestOwner(null, ownerEntityHash)
61
+ if (ownerId) {
62
+ const handler = aclHandlers.get(ownerId)
63
+ if (handler) return handler({ replicaUsername, ownerEntityHash, manifest: {} }, logicalPath)
35
64
  return false
36
-
37
- return isWritableLocalEntity(ownerEntityHash)
65
+ }
66
+ return !isLogicalEntityHash(ownerEntityHash) && isWritableLocalEntity(ownerEntityHash)
38
67
  }
@@ -6,6 +6,7 @@ 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
 
10
11
  import { normalizeFileManifest } from './normalize.mjs'
11
12
  import {
@@ -14,23 +15,27 @@ import {
14
15
  registerManifestFetchWait,
15
16
  } from './pending.mjs'
16
17
  import { shouldPreferIncomingPublicManifest } from './public.mjs'
18
+ import { resolveManifestOwner } from './routing.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,52 +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
80
 
73
- if (hasLocalPublic) {
74
- if (wantCache) void shared.then(async result => {
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 => {
75
85
  if (result && shouldPreferIncomingPublicManifest(local, result))
76
86
  await cachePublicManifest(ownerEntityHash, logicalPath, result)
77
87
  })
78
88
  return local
79
89
  }
80
90
 
91
+ // 本地非 public / 未验签 public 命中:仅 targeted 模式返回(授权边界 = 目标集);public 模式拒绝。
92
+ if (local) return targeted ? local : null
93
+
94
+ if (!shared) return null
95
+
81
96
  const result = await shared
82
97
  if (!result) return null
83
- 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
+
84
104
  return result
85
105
  }
86
106
 
87
107
  /**
88
- * 将已验签公开 manifest 写入本地缓存(显式 apply;`fetchPublicManifest` 默认不调用)。
108
+ * 将已验签公开 manifest 写入本地缓存(显式 apply;`fetchManifest` public 模式默认不调用)。
89
109
  * @param {string} ownerEntityHash owner
90
110
  * @param {string} logicalPath 路径
91
111
  * @param {import('./normalize.mjs').FileManifest} incoming 已验签入站清单
@@ -99,10 +119,10 @@ export async function cachePublicManifest(ownerEntityHash, logicalPath, incoming
99
119
  }
100
120
 
101
121
  /**
102
- * 若本机有已签名公开 manifest 则响应 fed_manifest_get。
122
+ * 响应 fed_manifest_get:public 只回验签公开清单;非 public 经 ACL servicer 授权后回完整清单。
103
123
  * @param {object} payload 请求
104
124
  * @param {(response: object, peerId: string) => void} sendResponse 发送
105
- * @param {string} peerId 对端
125
+ * @param {string} peerId 对端(传输层认证的发送方 nodeHash,即 requesterNodeHash)
106
126
  * @returns {Promise<void>}
107
127
  */
108
128
  export async function handleIncomingManifestGet(payload, sendResponse, peerId) {
@@ -120,14 +140,31 @@ export async function handleIncomingManifestGet(payload, sendResponse, peerId) {
120
140
  const raw = await getEntityStore().readManifest(ownerEntityHash, logicalPath)
121
141
  const manifest = normalizeFileManifest(raw)
122
142
  if (!manifest) return
123
- if (manifest.transferKeyDescriptor.type !== 'public') return
124
- if (!raw?.meta?.publicSig) return
125
-
126
- sendResponse({
127
- requestId,
128
- manifest: {
129
- ...manifest,
130
- meta: { publicSig: raw.meta.publicSig },
131
- },
132
- }, 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:仅认 matcher 命中的族 servicer(无 matcher 即 deny,不按 transferKeyDescriptor.type 兜底路由)。
157
+ const ownerId = resolveManifestOwner(manifest, ownerEntityHash)
158
+ const servicer = getManifestServicer(ownerId)
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)
133
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,38 @@
1
+ /**
2
+ * Manifest 族路由:matcher 声明「该 manifest / 实体归哪个族(ownerId)所有」。
3
+ * matcher 是唯一路由依据(注册序先命中者);`transferKeyDescriptor.type` 可被多族复用、不参与路由。
4
+ * 族间 matcher 必须互斥,否则按注册顺序先到先得。
5
+ */
6
+
7
+ /** @type {Array<{ ownerId: string, match: (manifest: import('./normalize.mjs').FileManifest | null, ownerEntityHash: string) => boolean }>} */
8
+ const owners = []
9
+
10
+ /**
11
+ * @param {string} ownerId 注册方
12
+ * @param {(manifest: import('./normalize.mjs').FileManifest | null, ownerEntityHash: string) => boolean} match 归属判定
13
+ * @returns {void}
14
+ */
15
+ export function registerManifestOwner(ownerId, match) {
16
+ owners.push({ ownerId, match })
17
+ }
18
+
19
+ /**
20
+ * @param {string} ownerId 注册方
21
+ * @returns {void}
22
+ */
23
+ export function unregisterManifestOwner(ownerId) {
24
+ for (let index = owners.length - 1; index >= 0; index--)
25
+ if (owners[index].ownerId === ownerId) owners.splice(index, 1)
26
+ }
27
+
28
+ /**
29
+ * 解析 manifest 归属族;无命中为 null(非 public 跨节点即 deny)。
30
+ * @param {import('./normalize.mjs').FileManifest | null} manifest manifest(写路径可空)
31
+ * @param {string} ownerEntityHash 所有者
32
+ * @returns {string | null} 族 id
33
+ */
34
+ export function resolveManifestOwner(manifest, ownerEntityHash) {
35
+ for (const { ownerId, match } of owners)
36
+ if (match(manifest, ownerEntityHash)) return ownerId
37
+ return null
38
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Manifest servicer 注册表:按族(ownerId)路由,决定跨节点是否对外提供非 public manifest。
3
+ * type 可被多族复用;路由只经 registerManifestOwner 的 matcher 唯一命中。服务端缺省 deny。
4
+ */
5
+
6
+ /** @type {Map<string, (context: ManifestServicerContext) => Promise<boolean> | boolean>} */
7
+ const servicersByOwner = 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} ownerId 注册方(族,须与 registerManifestOwner 一致)
22
+ * @param {(context: ManifestServicerContext) => Promise<boolean> | boolean} handler 授权判定
23
+ * @returns {void}
24
+ */
25
+ export function registerManifestServicer(ownerId, handler) {
26
+ servicersByOwner.set(ownerId, handler)
27
+ }
28
+
29
+ /**
30
+ * @param {string} ownerId 注册方
31
+ * @returns {void}
32
+ */
33
+ export function unregisterManifestServicer(ownerId) {
34
+ servicersByOwner.delete(ownerId)
35
+ }
36
+
37
+ /**
38
+ * @param {string} ownerId 族 id
39
+ * @returns {(context: ManifestServicerContext) => Promise<boolean> | boolean | undefined} 已注册 handler
40
+ */
41
+ export function getManifestServicer(ownerId) {
42
+ return servicersByOwner.get(ownerId)
43
+ }
@@ -1,3 +1,5 @@
1
+ import { resolveManifestOwner } from './manifest/routing.mjs'
2
+
1
3
  /** @type {Map<string, { getGroupFileMasterKey?: (replicaUsername: string, groupId: string, keyGeneration?: number) => Promise<Buffer | string | null>, getVaultMasterKey?: (replicaUsername: string, entityHash: string) => Promise<Buffer | string | null> }>} */
2
4
  const transferDependenciesByOwner = new Map()
3
5
 
@@ -5,14 +7,13 @@ const transferDependenciesByOwner = new Map()
5
7
  const dagPlaintextReadersByOwner = new Map()
6
8
 
7
9
  /**
8
- * @param {string} ownerId 注册方
10
+ * @param {string} ownerId 注册方(族,须与 registerManifestOwner 一致)
9
11
  * @param {{ getGroupFileMasterKey?: (replicaUsername: string, groupId: string, keyGeneration?: number) => Promise<Buffer | string | null>, getVaultMasterKey?: (replicaUsername: string, entityHash: string) => Promise<Buffer | string | null> }} dependencies 密钥源
10
12
  * @returns {void}
11
13
  */
12
14
  export function registerTransferKeyDependencies(ownerId, dependencies) {
13
- const key = ownerId
14
- const prev = transferDependenciesByOwner.get(key) || {}
15
- transferDependenciesByOwner.set(key, { ...prev, ...dependencies })
15
+ const prev = transferDependenciesByOwner.get(ownerId) || {}
16
+ transferDependenciesByOwner.set(ownerId, { ...prev, ...dependencies })
16
17
  }
17
18
 
18
19
  /**
@@ -24,48 +25,13 @@ export function registerDagManifestPlaintextReader(ownerId, reader) {
24
25
  dagPlaintextReadersByOwner.set(ownerId, reader)
25
26
  }
26
27
 
27
- /** @type {Array<{ ownerId: string, match: (manifest: import('./manifest/normalize.mjs').FileManifest) => boolean }>} */
28
- const manifestOwnerMatchers = []
29
-
30
- /**
31
- * @param {string} ownerId 注册方
32
- * @param {(manifest: import('./manifest/normalize.mjs').FileManifest) => boolean} match manifest 匹配谓词
33
- * @returns {void}
34
- */
35
- export function registerManifestOwnerMatcher(ownerId, match) {
36
- manifestOwnerMatchers.push({ ownerId, match })
37
- }
38
-
39
- /**
40
- * @param {string} ownerId 注册方
41
- * @returns {void}
42
- */
43
- export function unregisterManifestOwnerMatchers(ownerId) {
44
- const id = ownerId
45
- for (let i = manifestOwnerMatchers.length - 1; i >= 0; i--)
46
- if (manifestOwnerMatchers[i].ownerId === id) manifestOwnerMatchers.splice(i, 1)
47
- }
48
-
49
28
  /**
50
29
  * @param {string} ownerId 注册方
51
30
  * @returns {void}
52
31
  */
53
32
  export function unregisterTransferKeyDependencies(ownerId) {
54
- const key = ownerId
55
- transferDependenciesByOwner.delete(key)
56
- dagPlaintextReadersByOwner.delete(key)
57
- unregisterManifestOwnerMatchers(key)
58
- }
59
-
60
- /**
61
- * 从 manifest 推断 transfer key 注册方 id(按 registerManifestOwnerMatcher 注册顺序匹配)。
62
- * @param {import('./manifest/normalize.mjs').FileManifest} manifest 清单
63
- * @returns {string | null} 所有者 id
64
- */
65
- export function resolveManifestTransferOwnerId(manifest) {
66
- for (const { ownerId, match } of manifestOwnerMatchers)
67
- if (match(manifest)) return ownerId
68
- return null
33
+ transferDependenciesByOwner.delete(ownerId)
34
+ dagPlaintextReadersByOwner.delete(ownerId)
69
35
  }
70
36
 
71
37
  /**
@@ -74,7 +40,7 @@ export function resolveManifestTransferOwnerId(manifest) {
74
40
  * @returns {{ getGroupFileMasterKey?: (replicaUsername: string, groupId: string, keyGeneration?: number) => Promise<Buffer | string | null>, getVaultMasterKey?: (replicaUsername: string, entityHash: string) => Promise<Buffer | string | null> }} 依赖
75
41
  */
76
42
  export function resolveTransferKeyDependencies(ownerId, manifest) {
77
- const resolved = ownerId || (manifest ? resolveManifestTransferOwnerId(manifest) : null)
43
+ const resolved = ownerId || (manifest ? resolveManifestOwner(manifest, manifest.ownerEntityHash) : null)
78
44
  if (resolved) return transferDependenciesByOwner.get(resolved) || {}
79
45
  return {}
80
46
  }
@@ -85,7 +51,7 @@ export function resolveTransferKeyDependencies(ownerId, manifest) {
85
51
  * @returns {Promise<Buffer | null>} 明文;无 reader 或未命中为 null
86
52
  */
87
53
  export async function readDagManifestPlaintext(replicaUsername, manifest) {
88
- const ownerId = resolveManifestTransferOwnerId(manifest)
54
+ const ownerId = resolveManifestOwner(manifest, manifest.ownerEntityHash)
89
55
  if (ownerId) {
90
56
  const reader = dagPlaintextReadersByOwner.get(ownerId)
91
57
  if (reader)
@@ -93,9 +59,7 @@ export async function readDagManifestPlaintext(replicaUsername, manifest) {
93
59
  const buffer = await reader(replicaUsername, manifest)
94
60
  if (buffer?.length) return buffer
95
61
  }
96
- catch { /* fall through */ }
97
- return null
62
+ catch { /* 读取失败则回落到分块路径 */ }
98
63
  }
99
-
100
64
  return null
101
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -1,16 +1,21 @@
1
+ /**
2
+ * 可扩展等待选项;`rejectOnTimeout` 由本表消费,其余字段透传存储供业务读取(不在此硬编码业务字段)。
3
+ * @typedef {{ rejectOnTimeout?: boolean, [key: string]: unknown }} FetchWaitOptions
4
+ */
5
+
1
6
  /**
2
7
  * 有界 pending fetch 等待表(chunk / manifest 等入站响应槽)。
3
8
  * @template T
4
9
  * @param {{ maxSize: number }} options 容量
5
10
  * @returns {{
6
- * pending: Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void } }>,
7
- * register: (key: string, expectedKey: string, timeoutMs: number, options?: { rejectOnTimeout?: boolean }) => { done: Promise<T | null>, cancel: () => void },
8
- * peek: (key: string) => { expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void } } | undefined,
11
+ * pending: Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void }, options: FetchWaitOptions }>,
12
+ * register: (key: string, expectedKey: string, timeoutMs: number, options?: FetchWaitOptions) => { done: Promise<T | null>, cancel: () => void },
13
+ * peek: (key: string) => { expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void }, options: FetchWaitOptions } | undefined,
9
14
  * settle: (key: string, value: T | null | Error) => boolean,
10
15
  * }} 等待表 API
11
16
  */
12
17
  export function createFetchWaitTable({ maxSize }) {
13
- /** @type {Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void } }>} */
18
+ /** @type {Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void }, options: FetchWaitOptions }>} */
14
19
  const pending = new Map()
15
20
 
16
21
  /**
@@ -34,7 +39,7 @@ export function createFetchWaitTable({ maxSize }) {
34
39
  * @param {string} key 唯一等待键
35
40
  * @param {string} expectedKey 期望匹配键
36
41
  * @param {number} timeoutMs 超时
37
- * @param {{ rejectOnTimeout?: boolean }} [options] 超时是否 reject
42
+ * @param {FetchWaitOptions} [options] 可扩展等待选项(rejectOnTimeout 由本表消费)
38
43
  * @returns {{ done: Promise<T | null>, cancel: () => void }} 等待句柄
39
44
  */
40
45
  register(key, expectedKey, timeoutMs, options = {}) {
@@ -80,14 +85,14 @@ export function createFetchWaitTable({ maxSize }) {
80
85
  /** @returns {void} 取消等待 */
81
86
  cancel: () => settle(key, null),
82
87
  }
83
- pending.set(key, { expectedKey, timer, finish, handle })
88
+ pending.set(key, { expectedKey, timer, finish, handle, options })
84
89
  return handle
85
90
  },
86
91
 
87
92
  /**
88
93
  * 只读查看,不移除。
89
94
  * @param {string} key 等待键
90
- * @returns {{ expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void } } | undefined} 等待条目
95
+ * @returns {{ expectedKey: string, timer: ReturnType<typeof setTimeout>, finish: (value: T | null | Error) => void, handle: { done: Promise<T | null>, cancel: () => void }, options: FetchWaitOptions } | undefined} 等待条目
91
96
  */
92
97
  peek(key) {
93
98
  return pending.get(key)
@@ -1,84 +0,0 @@
1
- /**
2
- * Manifest ACL 注册表:按 transferKeyDescriptor.type 路由,不硬编码业务概念。
3
- */
4
-
5
- /** @type {Map<string, { ownerId: string, handler: (context: ManifestAclContext, logicalPath?: string) => Promise<boolean> }>} */
6
- const handlersByType = new Map()
7
-
8
- /** @type {Map<string, { ownerId: string, match: (manifest: import('./normalize.mjs').FileManifest | null | undefined, ownerEntityHash: string) => string | null }>} */
9
- const matchersByOwner = new Map()
10
-
11
- /**
12
- * @typedef {{
13
- * replicaUsername: string,
14
- * ownerEntityHash: string,
15
- * manifest: import('./normalize.mjs').FileManifest,
16
- * }} ManifestAclContext
17
- */
18
-
19
- /**
20
- * @param {string} type transferKeyDescriptor.type(如 vault-wrap、file-master-key-wrap)
21
- * @param {string} ownerId 注册方
22
- * @param {(context: ManifestAclContext, logicalPath?: string) => Promise<boolean>} handler ACL 检查
23
- * @returns {void}
24
- */
25
- export function registerManifestAcl(type, ownerId, handler) {
26
- const existing = handlersByType.get(type)
27
- if (existing && existing.ownerId !== ownerId)
28
- throw new Error(`manifest acl handler for '${type}' already registered by '${existing.ownerId}'`)
29
- handlersByType.set(type, { ownerId, handler })
30
- }
31
-
32
- /**
33
- * @param {string} type transferKeyDescriptor.type
34
- * @param {string} ownerId 注册方
35
- * @returns {void}
36
- */
37
- export function unregisterManifestAcl(type, ownerId) {
38
- const existing = handlersByType.get(type)
39
- if (!existing) return
40
- if (existing.ownerId === ownerId)
41
- handlersByType.delete(type)
42
- }
43
-
44
- /**
45
- * @param {string} ownerId 注册方
46
- * @param {(manifest: import('./normalize.mjs').FileManifest | null | undefined, ownerEntityHash: string) => string | null} match ACL 类型匹配
47
- * @returns {void}
48
- */
49
- export function registerManifestAclMatcher(ownerId, match) {
50
- matchersByOwner.set(ownerId, { ownerId, match })
51
- }
52
-
53
- /**
54
- * @param {string} ownerId 注册方
55
- * @returns {void}
56
- */
57
- export function unregisterManifestAclMatcher(ownerId) {
58
- matchersByOwner.delete(ownerId)
59
- }
60
-
61
- /**
62
- * @param {import('./normalize.mjs').FileManifest | null | undefined} manifest manifest
63
- * @param {string} ownerEntityHash 所有者
64
- * @returns {string | null} ACL 类型
65
- */
66
- export function resolveManifestAclType(manifest, ownerEntityHash) {
67
- for (const { match } of matchersByOwner.values()) {
68
- const type = match(manifest, ownerEntityHash)
69
- if (type) return type
70
- }
71
- return null
72
- }
73
-
74
- /**
75
- * @param {string} type transferKeyDescriptor.type
76
- * @param {ManifestAclContext} context 上下文
77
- * @param {string} [logicalPath] 写路径(仅写 ACL 需要)
78
- * @returns {Promise<boolean>} 是否允许(未注册为 false)
79
- */
80
- export async function checkManifestAcl(type, context, logicalPath) {
81
- const row = handlersByType.get(type)
82
- if (!row?.handler) return false
83
- return row.handler(context, logicalPath)
84
- }