@steve02081504/fount-p2p 0.0.37 → 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 +2 -2
- package/docs/evfs.md +15 -6
- 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 +69 -32
- package/files/manifest/pending.mjs +21 -7
- package/files/manifest/servicer_registry.mjs +51 -0
- package/package.json +1 -1
- package/utils/fetch_wait.mjs +12 -7
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_*`,
|
|
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
|
|
61
|
+
- **Manifest ACL / transfer owner / servicer:** shells register matchers and `registerManifestServicer`; core does not hard-code chat/social types. Non-public manifests are served cross-node only via a registered servicer (default deny).
|
|
62
62
|
|
|
63
63
|
### Node / network
|
|
64
64
|
|
package/docs/evfs.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# EVFS
|
|
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,26 @@ 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
|
-
## `
|
|
19
|
+
## `fetchManifest`
|
|
20
20
|
|
|
21
21
|
| Case | Behavior |
|
|
22
22
|
| --- | --- |
|
|
23
|
-
| Default options |
|
|
24
|
-
| Local hit with `publicSig` | Return immediately; fanout revalidates in the background. With `cache: true`, write only when remote `publishedAt` is newer |
|
|
25
|
-
|
|
|
26
|
-
|
|
|
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
|
|
|
31
|
+
## Non-public (ACL-gated) manifests
|
|
32
|
+
|
|
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 type. Shells call `registerManifestServicer(type, ownerId, handler)`; 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
|
+
|
|
30
39
|
## ACL
|
|
31
40
|
|
|
32
41
|
Shells register matchers via the ACL registry. 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 {
|
|
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,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
|
-
//
|
|
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
|
-
const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
|
|
71
|
-
if (!shared) return hasLocalPublic ? local : null
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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;`
|
|
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
|
-
*
|
|
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
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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)
|
|
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
|
|
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/package.json
CHANGED
package/utils/fetch_wait.mjs
CHANGED
|
@@ -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?:
|
|
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 {
|
|
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)
|