@steve02081504/fount-p2p 0.0.2 → 0.0.3

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.
@@ -187,3 +187,22 @@ export async function putFileManifestFromStream(params) {
187
187
  await saveFileManifest(manifest)
188
188
  return manifest
189
189
  }
190
+
191
+ /**
192
+ * 读取实体公开文件:本地 miss 时经网络取回签名 manifest,chunk miss 走既有 fetchChunk。
193
+ * @param {string} replicaUsername 副本用户名
194
+ * @param {string} entityHash owner entityHash
195
+ * @param {string} logicalPath EVFS 逻辑路径
196
+ * @param {{ username?: string, fetchChunk?: Function }} [opts] miss 拉取
197
+ * @returns {Promise<Buffer | null>} 明文或 null
198
+ */
199
+ export async function readPublicFile(replicaUsername, entityHash, logicalPath, opts = {}) {
200
+ const { fetchPublicManifest } = await import('../../files/manifest_fetch.mjs')
201
+ const manifest = await fetchPublicManifest({
202
+ username: opts.username || replicaUsername,
203
+ ownerEntityHash: entityHash,
204
+ logicalPath,
205
+ })
206
+ if (!manifest) return null
207
+ return readManifestPlaintext(replicaUsername, manifest, opts)
208
+ }
@@ -0,0 +1,90 @@
1
+ import { verifySignedPublicManifest } from '../files/public_manifest.mjs'
2
+
3
+ /** @type {Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, resolve: (v: object | null) => void }>} */
4
+ export const pendingManifestFetches = new Map()
5
+
6
+ /**
7
+ *
8
+ */
9
+ export const MAX_PENDING_MANIFEST_FETCHES = 512
10
+
11
+ /**
12
+ * @param {string} ownerEntityHash owner
13
+ * @param {string} logicalPath 路径
14
+ * @returns {string} 期望键
15
+ */
16
+ export function manifestFetchExpectedKey(ownerEntityHash, logicalPath) {
17
+ return `${String(ownerEntityHash || '').trim().toLowerCase()}\0${String(logicalPath || '').trim().replace(/^\/+/, '')}`
18
+ }
19
+
20
+ /**
21
+ * @param {string} key requestId
22
+ * @param {string} expectedKey owner+path 复合键
23
+ * @param {number} timeoutMs 超时毫秒
24
+ * @returns {{ done: Promise<object | null>, cancel: () => void }} 等待 Promise 与取消
25
+ */
26
+ export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
27
+ if (!key || pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES)
28
+ return {
29
+ done: Promise.resolve(null),
30
+ /**
31
+ *
32
+ */
33
+ cancel: () => { },
34
+ }
35
+
36
+ /** @type {(value: object | null) => void} */
37
+ let settle
38
+ const done = new Promise(resolve => {
39
+ settle = resolve
40
+ })
41
+ const timer = setTimeout(() => {
42
+ pendingManifestFetches.delete(key)
43
+ settle(null)
44
+ }, timeoutMs)
45
+
46
+ pendingManifestFetches.set(key, {
47
+ expectedKey,
48
+ timer,
49
+ /**
50
+ * @param {object | null} value 验签后的 manifest,超时/取消为 null
51
+ */
52
+ resolve: value => {
53
+ clearTimeout(timer)
54
+ pendingManifestFetches.delete(key)
55
+ settle(value)
56
+ },
57
+ })
58
+
59
+ return {
60
+ done,
61
+ /**
62
+ *
63
+ */
64
+ cancel: () => {
65
+ clearTimeout(timer)
66
+ pendingManifestFetches.delete(key)
67
+ settle(null)
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 处理 fed_manifest_data:验签通过后 resolve pending。
74
+ * @param {object} payload 入站载荷
75
+ * @returns {Promise<boolean>} 是否命中并完成等待
76
+ */
77
+ export async function resolvePendingManifestFetch(payload) {
78
+ const requestId = String(payload?.requestId || '')
79
+ if (!requestId) return false
80
+ const entry = pendingManifestFetches.get(requestId)
81
+ if (!entry) return false
82
+
83
+ const verified = await verifySignedPublicManifest(payload?.manifest)
84
+ if (!verified) return false
85
+ const key = manifestFetchExpectedKey(verified.ownerEntityHash, verified.logicalPath)
86
+ if (key !== entry.expectedKey) return false
87
+
88
+ entry.resolve(verified)
89
+ return true
90
+ }
@@ -1,19 +1,16 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
3
  import { u8ToB64 } from '../core/bytes_codec.mjs'
4
- import { FEDERATION_CHUNK_FETCH_FANOUT_K } from '../core/constants.mjs'
5
4
  import {
6
5
  MAX_PENDING_CHUNK_FETCHES,
7
6
  pendingChunkFetches,
8
7
  registerChunkFetchWait,
9
8
  } from '../federation/chunk_fetch_pending.mjs'
10
- import { loadNetwork } from '../node/network.mjs'
11
- import { ensureLinkToNode, listLinks } from '../transport/link_registry.mjs'
12
- import { DEFAULT_TRUST_GRAPH_OWNER, requireTrustGraphProvider } from '../trust_graph/registry.mjs'
13
9
 
14
10
  import { verifiedChunkBytes } from './chunk_fetch_verify.mjs'
15
11
  import { fetchFederationChunk, resolveNodeHash } from './chunk_provider_registry.mjs'
16
12
  import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
13
+ import { fanoutFedFetch } from './fetch_fanout.mjs'
17
14
 
18
15
  /**
19
16
  * @typedef {{
@@ -24,22 +21,6 @@ import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
24
21
  * }} FetchChunkContext
25
22
  */
26
23
 
27
- /**
28
- * @returns {string[]} 全局 CAS miss 时应尝试拨号/发送的 nodeHash 列表
29
- */
30
- function chunkFetchPeerTargets() {
31
- /** @type {Set<string>} */
32
- const targets = new Set()
33
- for (const { nodeHash } of listLinks())
34
- if (nodeHash) targets.add(String(nodeHash).toLowerCase())
35
- const net = loadNetwork()
36
- for (const nodeHash of [...net.trustedPeers || [], ...net.explorePeers || []])
37
- if (nodeHash) targets.add(String(nodeHash).toLowerCase())
38
- for (const hint of net.hints || [])
39
- if (hint?.nodeHash) targets.add(String(hint.nodeHash).toLowerCase())
40
- return [...targets]
41
- }
42
-
43
24
  /**
44
25
  * @param {FetchChunkContext} context 上下文
45
26
  * @returns {Promise<Uint8Array | null>} 密文块
@@ -72,19 +53,7 @@ export async function fetchChunk(context) {
72
53
  chunkHash: hash,
73
54
  ownerEntityHash: context.ownerEntityHash,
74
55
  }
75
- const graph = await requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER).buildMergedGraph(username)
76
- const peerTargets = chunkFetchPeerTargets()
77
- await Promise.all(peerTargets.map(nodeHash => ensureLinkToNode(nodeHash).catch(() => null)))
78
- const tg = requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER)
79
- // 已直连 / follow hint peer 可能不在 trust-graph top-K(非成员 emoji CAS / Social 预览路径)。
80
- for (const nodeHash of peerTargets)
81
- void tg.sendToNode(username, nodeHash, 'fed_chunk_get', payload, graph)
82
- await tg.fanoutToTopNodes(
83
- username,
84
- 'fed_chunk_get',
85
- payload,
86
- FEDERATION_CHUNK_FETCH_FANOUT_K,
87
- )
56
+ await fanoutFedFetch(username, 'fed_chunk_get', payload)
88
57
  const result = await done
89
58
  const verified = verifiedChunkBytes(hash, result)
90
59
  if (verified) {
@@ -1,6 +1,8 @@
1
1
  import { resolvePendingChunkFetch } from '../federation/chunk_fetch_pending.mjs'
2
+ import { resolvePendingManifestFetch } from '../federation/manifest_fetch_pending.mjs'
2
3
 
3
4
  import { handleIncomingChunkGet } from './chunk_fetch.mjs'
5
+ import { handleIncomingManifestGet } from './manifest_fetch.mjs'
4
6
 
5
7
  /**
6
8
  * @param {string} username 副本用户名 用户名
@@ -22,7 +24,26 @@ export function handleFedChunkDataIngress(data) {
22
24
  }
23
25
 
24
26
  /**
25
- * node scope user-room wire:注册 fed_chunk_get / fed_chunk_data。
27
+ * @param {string} username 副本用户名
28
+ * @param {object} data 入站 fed_manifest_get
29
+ * @param {string} peerId 对端 id
30
+ * @param {(resp: object, peerId: string) => void} sendManifestData 发送 fed_manifest_data
31
+ * @returns {Promise<void>}
32
+ */
33
+ export async function handleFedManifestGetIngress(username, data, peerId, sendManifestData) {
34
+ await handleIncomingManifestGet(username, data, sendManifestData, peerId)
35
+ }
36
+
37
+ /**
38
+ * @param {object} data 入站 fed_manifest_data
39
+ * @returns {Promise<void>}
40
+ */
41
+ export async function handleFedManifestDataIngress(data) {
42
+ await resolvePendingManifestFetch(data)
43
+ }
44
+
45
+ /**
46
+ * node scope user-room wire:注册 fed_chunk_* + fed_manifest_*。
26
47
  * @param {string} username 副本用户名 用户名
27
48
  * @param {{ on: (name: string, handler: (payload: unknown, peerId: string) => void) => void, send: (name: string, payload: unknown, peerId: string | null) => void }} wire action 表
28
49
  * @returns {void}
@@ -35,10 +56,19 @@ export function attachNodeScopeFedChunkResponder(username, wire) {
35
56
  })
36
57
  })
37
58
  wire.on('fed_chunk_data', handleFedChunkDataIngress)
59
+ wire.on('fed_manifest_get', (data, peerId) => {
60
+ void handleFedManifestGetIngress(username, data, peerId, (resp, pid) => {
61
+ try { wire.send('fed_manifest_data', resp, pid) }
62
+ catch { /* disconnected */ }
63
+ })
64
+ })
65
+ wire.on('fed_manifest_data', data => {
66
+ void handleFedManifestDataIngress(data)
67
+ })
38
68
  }
39
69
 
40
70
  /**
41
- * Trystero room:注册带 requestId 的 fed_chunk_get / fed_chunk_data(TrustGraph 全局 miss)。
71
+ * Trystero room:注册带 requestId 的 fed_chunk_* + fed_manifest_*(TrustGraph 全局 miss)。
42
72
  * @param {string} username 用户
43
73
  * @param {object} room Trystero room
44
74
  * @param {{ enqueue: (prio: number, fn: () => void) => void }} [fedOut] 出站队列
@@ -50,6 +80,8 @@ export function attachNodeScopeFedChunkResponder(username, wire) {
50
80
  export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardGet, rtcLimits = {}, roomKey = '') {
51
81
  const [sendChunkData, getChunkData] = room.makeAction('fed_chunk_data')
52
82
  const [, getChunkGet] = room.makeAction('fed_chunk_get')
83
+ const [sendManifestData, getManifestData] = room.makeAction('fed_manifest_data')
84
+ const [, getManifestGet] = room.makeAction('fed_manifest_get')
53
85
 
54
86
  getChunkGet((data, peerId) => {
55
87
  if (guardGet && !guardGet(roomKey, 'fed_chunk_get', rtcLimits)) return
@@ -76,4 +108,30 @@ export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardG
76
108
  if (!data || typeof data !== 'object' || !data.requestId) return
77
109
  handleFedChunkDataIngress(data)
78
110
  })
111
+
112
+ getManifestGet((data, peerId) => {
113
+ if (guardGet && !guardGet(roomKey, 'fed_manifest_get', rtcLimits)) return
114
+ void (async () => {
115
+ if (!data || typeof data !== 'object') return
116
+ if (!String(data.requestId || '')) return
117
+ await handleFedManifestGetIngress(username, data, peerId, (resp, pid) => {
118
+ /**
119
+ *
120
+ */
121
+ const send = () => {
122
+ try { sendManifestData(resp, pid) }
123
+ catch (error) {
124
+ console.warn('federation: trust-graph manifest response failed', error)
125
+ }
126
+ }
127
+ if (fedOut) fedOut.enqueue(6, send)
128
+ else send()
129
+ })
130
+ })().catch(error => console.warn('federation: trust-graph manifest handler failed', error))
131
+ })
132
+
133
+ getManifestData(data => {
134
+ if (!data || typeof data !== 'object' || !data.requestId) return
135
+ void handleFedManifestDataIngress(data)
136
+ })
79
137
  }
@@ -0,0 +1,38 @@
1
+ import { FEDERATION_CHUNK_FETCH_FANOUT_K } from '../core/constants.mjs'
2
+ import { loadNetwork } from '../node/network.mjs'
3
+ import { ensureLinkToNode, listLinks } from '../transport/link_registry.mjs'
4
+ import { DEFAULT_TRUST_GRAPH_OWNER, requireTrustGraphProvider } from '../trust_graph/registry.mjs'
5
+
6
+ /**
7
+ * @returns {string[]} 全局 miss 时应尝试拨号/发送的 nodeHash 列表
8
+ */
9
+ function fetchPeerTargets() {
10
+ /** @type {Set<string>} */
11
+ const targets = new Set()
12
+ for (const { nodeHash } of listLinks())
13
+ if (nodeHash) targets.add(String(nodeHash).toLowerCase())
14
+ const net = loadNetwork()
15
+ for (const nodeHash of [...net.trustedPeers || [], ...net.explorePeers || []])
16
+ if (nodeHash) targets.add(String(nodeHash).toLowerCase())
17
+ for (const hint of net.hints || [])
18
+ if (hint?.nodeHash) targets.add(String(hint.nodeHash).toLowerCase())
19
+ return [...targets]
20
+ }
21
+
22
+ /**
23
+ * 全局 miss 请求扇出:先向已知 peer 定向发送,再 trust-graph top-K fanout。
24
+ * 已直连 / follow hint peer 可能不在 trust-graph top-K(非成员 emoji CAS / Social 预览路径),故先定向发送。
25
+ * @param {string} username 用户
26
+ * @param {string} action wire action 名
27
+ * @param {object} payload 请求载荷
28
+ * @returns {Promise<void>}
29
+ */
30
+ export async function fanoutFedFetch(username, action, payload) {
31
+ const tg = requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER)
32
+ const graph = await tg.buildMergedGraph(username)
33
+ const peerTargets = fetchPeerTargets()
34
+ await Promise.all(peerTargets.map(nodeHash => ensureLinkToNode(nodeHash).catch(() => null)))
35
+ 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)
38
+ }
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { loadFileManifest, saveFileManifest } from '../entity/files/evfs.mjs'
4
+ import { isWritableLocalEntity } from '../entity/replica.mjs'
5
+ import {
6
+ manifestFetchExpectedKey,
7
+ MAX_PENDING_MANIFEST_FETCHES,
8
+ pendingManifestFetches,
9
+ registerManifestFetchWait,
10
+ } from '../federation/manifest_fetch_pending.mjs'
11
+ import { getEntityStore } from '../node/instance.mjs'
12
+
13
+ import { resolveNodeHash } from './chunk_provider_registry.mjs'
14
+ import { fanoutFedFetch } from './fetch_fanout.mjs'
15
+ import { normalizeFileManifest } from './manifest.mjs'
16
+ import { shouldPreferIncomingPublicManifest } from './public_manifest.mjs'
17
+
18
+ /**
19
+ * @param {{ username: string, ownerEntityHash: string, logicalPath: string }} context 上下文
20
+ * @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签后的公开清单
21
+ */
22
+ export async function fetchPublicManifest(context) {
23
+ const ownerEntityHash = String(context.ownerEntityHash || '').trim().toLowerCase()
24
+ const logicalPath = String(context.logicalPath || '').trim().replace(/^\/+/, '')
25
+ const { username } = context
26
+ if (!ownerEntityHash || !logicalPath || !username) return null
27
+
28
+ const local = await loadFileManifest(ownerEntityHash, logicalPath)
29
+ if (local?.transferKeyDescriptor?.type === 'public' && local?.meta?.publicSig)
30
+ return local
31
+
32
+ if (pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES) return null
33
+
34
+ const requestId = randomUUID()
35
+ const { done } = registerManifestFetchWait(
36
+ requestId,
37
+ manifestFetchExpectedKey(ownerEntityHash, logicalPath),
38
+ 8000,
39
+ )
40
+ const { nodeHash } = await resolveNodeHash(username)
41
+ const payload = {
42
+ requestId,
43
+ nodeHash,
44
+ ownerEntityHash,
45
+ logicalPath,
46
+ }
47
+ await fanoutFedFetch(username, 'fed_manifest_get', payload)
48
+ const result = await done
49
+ if (!result) return null
50
+
51
+ await maybeCacheIncomingPublicManifest(ownerEntityHash, logicalPath, result)
52
+ return result
53
+ }
54
+
55
+ /**
56
+ * @param {string} ownerEntityHash owner
57
+ * @param {string} logicalPath 路径
58
+ * @param {import('./manifest.mjs').FileManifest} incoming 已验签入站清单
59
+ * @returns {Promise<void>}
60
+ */
61
+ async function maybeCacheIncomingPublicManifest(ownerEntityHash, logicalPath, incoming) {
62
+ if (isWritableLocalEntity(ownerEntityHash)) return
63
+ const store = getEntityStore()
64
+ const existing = await store.readManifest(ownerEntityHash, logicalPath)
65
+ if (existing && !shouldPreferIncomingPublicManifest(existing, incoming)) return
66
+ await saveFileManifest(incoming)
67
+ }
68
+
69
+ /**
70
+ * 若本机有已签名公开 manifest 则响应 fed_manifest_get。
71
+ * @param {string} username 用户
72
+ * @param {object} payload 请求
73
+ * @param {(response: object, peerId: string) => void} sendResponse 发送
74
+ * @param {string} peerId 对端
75
+ * @returns {Promise<void>}
76
+ */
77
+ export async function handleIncomingManifestGet(username, payload, sendResponse, peerId) {
78
+ void username
79
+ const ownerEntityHash = String(payload?.ownerEntityHash || '').trim().toLowerCase()
80
+ const logicalPath = String(payload?.logicalPath || '').trim().replace(/^\/+/, '')
81
+ const requestId = String(payload?.requestId || '')
82
+ if (!ownerEntityHash || !logicalPath || !requestId) return
83
+
84
+ const raw = await getEntityStore().readManifest(ownerEntityHash, logicalPath)
85
+ const manifest = normalizeFileManifest(raw)
86
+ if (!manifest) return
87
+ if (manifest.transferKeyDescriptor.type !== 'public') return
88
+ if (!raw?.meta?.publicSig) return
89
+
90
+ sendResponse({
91
+ requestId,
92
+ manifest: {
93
+ ...manifest,
94
+ meta: { publicSig: raw.meta.publicSig },
95
+ },
96
+ }, peerId)
97
+ }
@@ -0,0 +1,153 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ import { canonicalStringify } from '../core/canonical_json.mjs'
4
+ import { hashFromPubKeyHex, parseEntityHash } from '../core/entity_id.mjs'
5
+ import { assertSafeEvfsLogicalPath } from '../core/evfs_logical_path.mjs'
6
+ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
7
+ import { sign, verify } from '../crypto/crypto.mjs'
8
+
9
+ import { normalizeFileManifest, publicTransferKeyDescriptor } from './manifest.mjs'
10
+
11
+ /** 实体公开 manifest 签名域 */
12
+ export const ENTITY_PUBLIC_MANIFEST_DOMAIN = 'fount-entity-public-manifest'
13
+
14
+ /**
15
+ * @param {object} fields 待签名字段
16
+ * @returns {Buffer} 签名消息字节
17
+ */
18
+ export function publicManifestSignBytes(fields) {
19
+ return Buffer.from(canonicalStringify([
20
+ ENTITY_PUBLIC_MANIFEST_DOMAIN,
21
+ String(fields.ownerEntityHash || '').trim().toLowerCase(),
22
+ assertSafeEvfsLogicalPath(fields.logicalPath),
23
+ Number(fields.publishedAt) || 0,
24
+ String(fields.contentHash || '').trim().toLowerCase(),
25
+ Number(fields.size) || 0,
26
+ String(fields.mimeType || ''),
27
+ String(fields.name || ''),
28
+ String(fields.ceMode || ''),
29
+ fields.parts || [],
30
+ ]), 'utf8')
31
+ }
32
+
33
+ /**
34
+ * @param {import('./manifest.mjs').FileManifest} manifest 清单
35
+ * @param {number} publishedAt 发布时间
36
+ * @param {Uint8Array | Buffer} entitySecretKey recovery 私钥种子
37
+ * @param {string} entityPubKeyHex recovery 公钥 hex
38
+ * @returns {Promise<import('./manifest.mjs').FileManifest>} 带 publicSig 的清单
39
+ */
40
+ export async function attachPublicManifestSig(manifest, publishedAt, entitySecretKey, entityPubKeyHex) {
41
+ const pubKeyHex = normalizeHex64(entityPubKeyHex)
42
+ const message = publicManifestSignBytes({
43
+ ownerEntityHash: manifest.ownerEntityHash,
44
+ logicalPath: manifest.logicalPath,
45
+ publishedAt,
46
+ contentHash: manifest.contentHash,
47
+ size: manifest.size,
48
+ mimeType: manifest.mimeType,
49
+ name: manifest.name,
50
+ ceMode: manifest.ceMode,
51
+ parts: manifest.parts,
52
+ })
53
+ const sigHex = Buffer.from(await sign(message, entitySecretKey)).toString('hex')
54
+ return {
55
+ ...manifest,
56
+ meta: {
57
+ ...manifest.meta || {},
58
+ publicSig: { publishedAt, pubKeyHex, sigHex },
59
+ },
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {unknown} input 原始 manifest
65
+ * @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签通过的清单;非法为 null
66
+ */
67
+ export async function verifySignedPublicManifest(input) {
68
+ const manifest = normalizeFileManifest(input)
69
+ if (!manifest) return null
70
+ if (manifest.transferKeyDescriptor.type !== 'public') return null
71
+
72
+ const publicSig = input?.meta?.publicSig
73
+ if (!publicSig || typeof publicSig !== 'object') return null
74
+ const publishedAt = Number(publicSig.publishedAt) || 0
75
+ const pubKeyHex = normalizeHex64(publicSig.pubKeyHex)
76
+ const sigHex = String(publicSig.sigHex || '').trim().toLowerCase()
77
+ if (!isHex64(pubKeyHex) || !/^[\da-f]{128}$/u.test(sigHex) || publishedAt <= 0) return null
78
+
79
+ const parsed = parseEntityHash(manifest.ownerEntityHash)
80
+ if (!parsed) return null
81
+ if (hashFromPubKeyHex(pubKeyHex) !== parsed.subjectHash) return null
82
+
83
+ const message = publicManifestSignBytes({
84
+ ownerEntityHash: manifest.ownerEntityHash,
85
+ logicalPath: manifest.logicalPath,
86
+ publishedAt,
87
+ contentHash: manifest.contentHash,
88
+ size: manifest.size,
89
+ mimeType: manifest.mimeType,
90
+ name: manifest.name,
91
+ ceMode: manifest.ceMode,
92
+ parts: manifest.parts,
93
+ })
94
+ const ok = await verify(Buffer.from(sigHex, 'hex'), message, Buffer.from(pubKeyHex, 'hex'))
95
+ if (!ok) return null
96
+
97
+ // 签名只覆盖内容字段:入站 meta 一律丢弃,仅保留 publicSig,
98
+ // 防止中继注入 dagParts/groupId 等本地扩展改写读取路径。
99
+ return {
100
+ ...manifest,
101
+ meta: { publicSig: { publishedAt, pubKeyHex, sigHex } },
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @param {object | null | undefined} localManifest 本地已有清单
107
+ * @param {import('./manifest.mjs').FileManifest} incoming 入站已验签清单
108
+ * @returns {boolean} 是否应以 incoming 覆盖本地缓存
109
+ */
110
+ export function shouldPreferIncomingPublicManifest(localManifest, incoming) {
111
+ const incomingAt = Number(incoming?.meta?.publicSig?.publishedAt) || 0
112
+ if (incomingAt <= 0) return false
113
+ const localAt = Number(localManifest?.meta?.publicSig?.publishedAt) || 0
114
+ return incomingAt > localAt
115
+ }
116
+
117
+ /**
118
+ * @param {object} params 参数
119
+ * @param {string} params.ownerEntityHash owner
120
+ * @param {string} params.logicalPath 路径
121
+ * @param {Buffer | Uint8Array} params.plaintext 明文
122
+ * @param {string} [params.name] 文件名
123
+ * @param {string} [params.mimeType] MIME
124
+ * @param {Uint8Array | Buffer} params.entitySecretKey recovery 私钥种子
125
+ * @param {string} params.entityPubKeyHex recovery 公钥 hex
126
+ * @param {number} [params.publishedAt] 发布时间(默认 Date.now)
127
+ * @returns {Promise<import('./manifest.mjs').FileManifest>} 已签名并落盘的公开清单
128
+ */
129
+ export async function publishPublicFile(params) {
130
+ const {
131
+ ownerEntityHash,
132
+ logicalPath,
133
+ plaintext,
134
+ name,
135
+ mimeType,
136
+ entitySecretKey,
137
+ entityPubKeyHex,
138
+ publishedAt = Date.now(),
139
+ } = params
140
+ const { putFileManifest, saveFileManifest } = await import('../entity/files/evfs.mjs')
141
+ const base = await putFileManifest({
142
+ ownerEntityHash,
143
+ logicalPath,
144
+ plaintext,
145
+ name,
146
+ mimeType,
147
+ ceMode: 'convergent',
148
+ transferKeyDescriptor: publicTransferKeyDescriptor(),
149
+ })
150
+ const signed = await attachPublicManifestSig(base, publishedAt, entitySecretKey, entityPubKeyHex)
151
+ await saveFileManifest(signed)
152
+ return signed
153
+ }
@@ -43,6 +43,8 @@ const DEFAULT_ACTION_PRIORITIES = Object.freeze({
43
43
  fed_chunk_get: 5,
44
44
  fed_chunk_data: 5,
45
45
  fed_chunk_ack: 5,
46
+ fed_manifest_get: 5,
47
+ fed_manifest_data: 5,
46
48
  fed_partition_bridge: 5,
47
49
  fed_volatile: 10,
48
50
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -159,6 +159,8 @@ const NON_CRITICAL_FED_ACTIONS = new Set([
159
159
  'fed_chunk_get',
160
160
  'fed_chunk_data',
161
161
  'fed_chunk_ack',
162
+ 'fed_manifest_get',
163
+ 'fed_manifest_data',
162
164
  'part_invoke',
163
165
  'discovery_announce',
164
166
  'discovery_query',
@@ -1,19 +0,0 @@
1
- /**
2
- * EVFS 文件 URL 辅助。
3
- * @param {string} entityHash 128 位十六进制
4
- * @param {string} logicalPath EVFS 逻辑路径
5
- * @returns {string} HTTP 地址
6
- */
7
- export function entityFileUrl(entityHash, logicalPath) {
8
- const path = String(logicalPath || '').trim().replace(/^\/+/, '')
9
- return `/api/p2p/entities/${encodeURIComponent(entityHash)}/files/${path.split('/').map(encodeURIComponent).join('/')}`
10
- }
11
-
12
- /**
13
- * profile 头像 EVFS 路径 URL。
14
- * @param {string} entityHash 128 位十六进制
15
- * @returns {string} profile 头像 HTTP 地址
16
- */
17
- export function profileAvatarFileUrl(entityHash) {
18
- return entityFileUrl(entityHash, 'profile/avatar')
19
- }
@@ -1,141 +0,0 @@
1
- import { isEntityHash128 } from '../core/entity_id.mjs'
2
-
3
- import { profileAvatarFileUrl } from './files/url.mjs'
4
-
5
- /**
6
- * @param {unknown} linkItem 链接项
7
- * @returns {object | null} 规范化链接项或 null
8
- */
9
- function normalizeLink(linkItem) {
10
- const url = String(linkItem?.url || '').trim()
11
- if (!url) return null
12
- return {
13
- icon: String(linkItem.icon || '').trim(),
14
- name: String(linkItem.name || '').trim(),
15
- url,
16
- }
17
- }
18
-
19
- /**
20
- * @param {unknown} localizedInput 原始 localized 字段
21
- * @returns {Record<string, object>} locale → 切片
22
- */
23
- export function normalizeLocalizedMap(localizedInput) {
24
- if (!localizedInput) return {}
25
- /** @type {Record<string, object>} */
26
- const out = {}
27
- for (const [key, value] of Object.entries(localizedInput)) {
28
- const localeKey = String(key || '').trim()
29
- if (!localeKey || !value) continue
30
- const tags = Array.isArray(value.tags)
31
- ? value.tags.map(t => String(t).trim()).filter(Boolean)
32
- : undefined
33
- const links = Array.isArray(value.links)
34
- ? value.links.map(normalizeLink).filter(Boolean)
35
- : undefined
36
- /** @type {Record<string, unknown>} */
37
- const slice = {}
38
- if (value.name != null) slice.name = String(value.name).trim()
39
- if (value.avatar) slice.avatar = String(value.avatar).trim()
40
- if (value.description != null) slice.description = String(value.description)
41
- if (value.description_markdown != null) slice.description_markdown = String(value.description_markdown)
42
- if (value.version) slice.version = String(value.version).trim()
43
- if (value.author) slice.author = String(value.author).trim()
44
- if (value.home_page) slice.home_page = String(value.home_page).trim()
45
- if (value.issue_page) slice.issue_page = String(value.issue_page).trim()
46
- if (tags?.length) slice.tags = tags
47
- if (links?.length) slice.links = links
48
- if (Object.keys(slice).length) out[localeKey] = slice
49
- }
50
- return out
51
- }
52
-
53
- /**
54
- * @param {Record<string, object>} localized 多语言切片
55
- * @param {string[]} locales 区域设置优先级
56
- * @returns {object | null} 匹配的语言切片
57
- */
58
- function pickLocalizedSlice(localized, locales) {
59
- const keys = Object.keys(localized || {})
60
- if (!keys.length) return null
61
- for (const locale of locales || []) {
62
- if (localized[locale]) return localized[locale]
63
- const prefix = String(locale).split('-')[0]
64
- const hit = keys.find(k => k === prefix || k.startsWith(`${prefix}-`))
65
- if (hit) return localized[hit]
66
- }
67
- return localized[keys[0]]
68
- }
69
-
70
- /**
71
- * @param {string} displayName 展示名
72
- * @param {{ subjectHash?: string }} profile 资料对象
73
- * @returns {boolean} 是否为占位展示名
74
- */
75
- export function isPlaceholderDisplayName(displayName, profile) {
76
- const name = String(displayName || '').trim()
77
- if (!name) return true
78
- const subjectHash = String(profile?.subjectHash || '').trim().toLowerCase()
79
- if (!subjectHash || subjectHash.length < 12) return false
80
- const placeholder = `${subjectHash.slice(0, 8)}…${subjectHash.slice(-4)}`
81
- return name === placeholder
82
- }
83
-
84
- /**
85
- * @param {object} stored 磁盘上的 profile 对象
86
- * @param {string[]} locales 查看者区域设置
87
- * @param {object} infoDefaults part 默认
88
- * @returns {object} 合并后的展示字段
89
- */
90
- export function resolveProfilePresentation(stored, locales, infoDefaults) {
91
- const localized = normalizeLocalizedMap(stored?.localized)
92
- const slice = pickLocalizedSlice(localized, locales) || {}
93
-
94
- let name = slice.name?.trim() || infoDefaults.name
95
- if (name && isPlaceholderDisplayName(name, stored))
96
- name = infoDefaults.name
97
-
98
- const description = slice.description != null
99
- ? String(slice.description)
100
- : infoDefaults.description
101
- const description_markdown = slice.description_markdown != null
102
- ? String(slice.description_markdown)
103
- : slice.description != null ? String(slice.description) : infoDefaults.description_markdown
104
-
105
- const tags = slice.tags?.length ? slice.tags : infoDefaults.tags
106
- const links = slice.links?.length ? slice.links : infoDefaults.links
107
-
108
- let avatar = slice.avatar?.trim() || infoDefaults.avatar
109
- if (avatar && !avatar.startsWith('http') && isEntityHash128(stored?.entityHash))
110
- avatar = profileAvatarFileUrl(stored.entityHash)
111
- else if (!avatar && isEntityHash128(stored?.entityHash) && stored?.localized)
112
- avatar = profileAvatarFileUrl(stored.entityHash)
113
-
114
- return {
115
- name: name || infoDefaults.name,
116
- avatar: avatar || '',
117
- description: description || '',
118
- description_markdown: description_markdown || '',
119
- version: slice.version?.trim() || infoDefaults.version || '',
120
- author: slice.author?.trim() || infoDefaults.author || '',
121
- home_page: slice.home_page?.trim() || infoDefaults.home_page || '',
122
- issue_page: slice.issue_page?.trim() || infoDefaults.issue_page || '',
123
- tags: [...tags],
124
- links: [...links],
125
- }
126
- }
127
-
128
- /**
129
- * @param {Record<string, object>} localized 多语言表
130
- * @param {string} avatarUrl 头像 URL
131
- * @returns {Record<string, object>} 带头像 URL 的多语言表
132
- */
133
- export function applyAvatarToAllLocales(localized, avatarUrl) {
134
- const keys = Object.keys(localized)
135
- if (!keys.length) return { '': { avatar: avatarUrl } }
136
- /** @type {Record<string, object>} */
137
- const out = {}
138
- for (const key of keys)
139
- out[key] = { ...localized[key], avatar: avatarUrl }
140
- return out
141
- }
@@ -1,44 +0,0 @@
1
- /** @typedef {{ query?: { locales?: string | string[] } }} ExpressLikeRequest */
2
-
3
- /** @type {((replicaUsername: string, entityHash: string, locales: string[]) => Promise<object>) | null} */
4
- let infoDefaultsProvider = null
5
-
6
- /** @type {((req: ExpressLikeRequest, replicaUsername: string) => string[]) | null} */
7
- let localesFromRequestProvider = null
8
-
9
- /**
10
- * @param {(replicaUsername: string, entityHash: string, locales: string[]) => Promise<object>} provider 资料默认值解析器
11
- * @returns {void}
12
- */
13
- export function registerEntityPresentationProvider(provider) {
14
- infoDefaultsProvider = provider
15
- }
16
-
17
- /**
18
- * @param {(req: ExpressLikeRequest, replicaUsername: string) => string[]} provider 请求区域设置解析器
19
- * @returns {void}
20
- */
21
- export function registerLocalesFromRequestProvider(provider) {
22
- localesFromRequestProvider = provider
23
- }
24
-
25
- /**
26
- * @param {string} replicaUsername 查看者 replica
27
- * @param {string} entityHash 目标实体
28
- * @param {string[]} locales 区域设置优先级
29
- * @returns {Promise<object | null>} 展示默认值或 null
30
- */
31
- export async function resolveInfoDefaultsForEntity(replicaUsername, entityHash, locales) {
32
- if (!infoDefaultsProvider) return null
33
- return infoDefaultsProvider(replicaUsername, entityHash, locales)
34
- }
35
-
36
- /**
37
- * @param {ExpressLikeRequest} req HTTP 请求
38
- * @param {string} replicaUsername 查看者 replica
39
- * @returns {string[]} 区域设置列表
40
- */
41
- export function localesFromRequest(req, replicaUsername) {
42
- if (localesFromRequestProvider) return localesFromRequestProvider(req, replicaUsername)
43
- return ['zh-CN', 'en-UK']
44
- }
@@ -1,256 +0,0 @@
1
- import { parseEntityHash } from '../core/entity_id.mjs'
2
- import { getEntityStore } from '../node/instance.mjs'
3
-
4
- import { profileAvatarFileUrl } from './files/url.mjs'
5
- import {
6
- applyAvatarToAllLocales,
7
- normalizeLocalizedMap,
8
- resolveProfilePresentation,
9
- } from './localized_core.mjs'
10
- import { resolveInfoDefaultsForEntity } from './presentation_registry.mjs'
11
- import { isWritableLocalEntity } from './replica.mjs'
12
-
13
- /** 超过该毫秒未心跳则视为离线 */
14
- const HEARTBEAT_STALE_MS = 120_000
15
-
16
- const MANUAL_STATUSES = new Set(['online', 'idle', 'dnd', 'invisible', 'away', 'busy', 'offline'])
17
- const PROFILE_JSON = 'profile.json'
18
-
19
- /**
20
- * @param {string} entityHash 128 位 entityHash
21
- * @param {{ nodeHash: string, subjectHash: string }} parsed parseEntityHash 结果
22
- * @returns {object} 默认资料
23
- */
24
- function getDefaultProfile(entityHash, parsed) {
25
- return {
26
- entityHash,
27
- nodeHash: parsed.nodeHash,
28
- subjectHash: parsed.subjectHash,
29
- localized: {},
30
- status: 'online',
31
- customStatus: '',
32
- lastSeenAt: 0,
33
- stats: {
34
- joinedAt: Date.now(),
35
- messageCount: 0,
36
- groupCount: 0,
37
- channelCount: 0,
38
- },
39
- }
40
- }
41
-
42
- /**
43
- * @param {object} profileData 原始对象
44
- * @returns {object} 可写入磁盘的资料
45
- */
46
- function toStoredProfile(profileData) {
47
- return {
48
- entityHash: profileData.entityHash,
49
- nodeHash: profileData.nodeHash,
50
- subjectHash: profileData.subjectHash,
51
- localized: normalizeLocalizedMap(profileData.localized),
52
- status: profileData.status || 'online',
53
- customStatus: String(profileData.customStatus || '').trim(),
54
- lastSeenAt: profileData.lastSeenAt || 0,
55
- stats: {
56
- joinedAt: profileData.stats?.joinedAt || Date.now(),
57
- messageCount: profileData.stats?.messageCount || 0,
58
- groupCount: profileData.stats?.groupCount || 0,
59
- channelCount: profileData.stats?.channelCount || 0,
60
- },
61
- }
62
- }
63
-
64
- /**
65
- * @param {object} profile 用户资料
66
- * @param {string} [viewerEntityHash] 查看者 entityHash
67
- * @param {{ isSelf?: boolean }} [options] isSelf 为 true 时隐身对本人可见
68
- * @returns {string} 有效状态
69
- */
70
- export function computeEffectiveStatus(profile, viewerEntityHash, options = {}) {
71
- const stored = String(profile?.status || 'online')
72
- const isSelf = options.isSelf
73
- ?? (viewerEntityHash && profile?.entityHash === viewerEntityHash)
74
- const lastSeen = profile?.lastSeenAt || 0
75
- const recentlySeen = lastSeen > 0 && Date.now() - lastSeen < HEARTBEAT_STALE_MS
76
-
77
- if (stored === 'invisible')
78
- return isSelf ? 'invisible' : 'offline'
79
-
80
- if (!recentlySeen)
81
- return 'offline'
82
-
83
- return stored
84
- }
85
-
86
- /**
87
- * @param {string} entityHash 128 位 entityHash
88
- * @param {string | null} [replicaUsername] 展示默认字段用
89
- * @param {{ groupId?: string, skipPresentation?: boolean, locales?: string[], infoDefaults?: object }} [options] 选项
90
- * @returns {Promise<object>} 资料对象
91
- */
92
- export async function getProfile(entityHash, replicaUsername = null, options = {}) {
93
- const parsed = parseEntityHash(entityHash)
94
- if (!parsed) throw new Error('invalid entityHash')
95
-
96
- const store = getEntityStore()
97
- const defaultProfile = getDefaultProfile(parsed.entityHash, parsed)
98
- let stored = defaultProfile
99
-
100
- const onDisk = await store.readEntityJson(parsed.entityHash, PROFILE_JSON)
101
- if (onDisk)
102
- stored = toStoredProfile({ ...defaultProfile, ...onDisk })
103
- else if (isWritableLocalEntity(parsed.entityHash))
104
- await store.writeEntityJson(parsed.entityHash, PROFILE_JSON, stored)
105
-
106
- const locales = options.locales || ['zh-CN', 'en-UK']
107
- const merged = {
108
- ...stored,
109
- entityHash: parsed.entityHash,
110
- nodeHash: parsed.nodeHash,
111
- subjectHash: parsed.subjectHash,
112
- }
113
-
114
- if (options.skipPresentation) return merged
115
-
116
- let { infoDefaults } = options
117
- if (!infoDefaults && replicaUsername)
118
- infoDefaults = await resolveInfoDefaultsForEntity(replicaUsername, parsed.entityHash, locales)
119
- if (!infoDefaults)
120
- infoDefaults = { name: `${parsed.subjectHash.slice(0, 8)}…${parsed.subjectHash.slice(-4)}`, avatar: '', description: '', description_markdown: '', version: '', author: '', home_page: '', issue_page: '', tags: [], links: [] }
121
-
122
- const resolved = resolveProfilePresentation(merged, locales, infoDefaults)
123
- return {
124
- ...merged,
125
- ...resolved,
126
- infoDefaults,
127
- localeKeys: Object.keys(merged.localized),
128
- }
129
- }
130
-
131
- /**
132
- * @param {string} replicaUsername 副本用户名 所有者
133
- * @param {string} entityHash 128 位 entityHash
134
- * @returns {Promise<void>}
135
- */
136
- export async function recordHeartbeat(replicaUsername, entityHash) {
137
- void replicaUsername
138
- const profile = await getProfile(entityHash, null, { skipPresentation: true })
139
- profile.lastSeenAt = Date.now()
140
- await getEntityStore().writeEntityJson(entityHash, PROFILE_JSON, toStoredProfile(profile))
141
- return { lastSeenAt: profile.lastSeenAt }
142
- }
143
-
144
- /**
145
- * @param {string} replicaUsername 副本用户名 所有者
146
- * @param {string} entityHash 128 位 entityHash
147
- * @param {object} updates 更新内容
148
- * @param {{ groupId?: string, skipPresentation?: boolean, locales?: string[] }} [options] 选项
149
- * @returns {Promise<object>} 更新后的资料
150
- */
151
- export async function updateProfile(replicaUsername, entityHash, updates, options = {}) {
152
- if (!isWritableLocalEntity(entityHash))
153
- throw new Error('entity not writable on this replica')
154
-
155
- const profile = await getProfile(entityHash, replicaUsername, {
156
- groupId: options.groupId,
157
- skipPresentation: true,
158
- })
159
- const parsed = parseEntityHash(entityHash)
160
-
161
- const localized = updates.localized != null
162
- ? normalizeLocalizedMap(updates.localized)
163
- : profile.localized
164
-
165
- const updatedProfile = toStoredProfile({
166
- ...profile,
167
- entityHash: parsed.entityHash,
168
- nodeHash: parsed.nodeHash,
169
- subjectHash: parsed.subjectHash,
170
- localized,
171
- status: updates.status != null ? updates.status : profile.status,
172
- customStatus: updates.customStatus != null ? updates.customStatus : profile.customStatus,
173
- lastSeenAt: updates.lastSeenAt != null ? updates.lastSeenAt : profile.lastSeenAt,
174
- stats: updates.stats ? { ...profile.stats, ...updates.stats } : profile.stats,
175
- })
176
-
177
- await getEntityStore().writeEntityJson(entityHash, PROFILE_JSON, updatedProfile)
178
- if (options.skipPresentation) return updatedProfile
179
- const locales = options.locales || ['zh-CN', 'en-UK']
180
- const infoDefaults = await resolveInfoDefaultsForEntity(replicaUsername, entityHash, locales)
181
- const resolved = resolveProfilePresentation(updatedProfile, locales, infoDefaults)
182
- return { ...updatedProfile, ...resolved, infoDefaults, localeKeys: Object.keys(updatedProfile.localized) }
183
- }
184
-
185
- /**
186
- * @param {string} replicaUsername 副本用户名 所有者
187
- * @param {string} entityHash 128 位 entityHash
188
- * @param {Buffer} fileBuffer 文件缓冲区
189
- * @param {string} filename 文件名
190
- * @returns {Promise<string>} 头像 URL
191
- */
192
- export async function uploadAvatar(replicaUsername, entityHash, fileBuffer, filename) {
193
- if (!isWritableLocalEntity(entityHash))
194
- throw new Error('entity not writable on this replica')
195
-
196
- const { putFileManifest } = await import('./files/evfs.mjs')
197
- await putFileManifest({
198
- ownerEntityHash: entityHash,
199
- logicalPath: 'profile/avatar',
200
- plaintext: fileBuffer,
201
- name: filename || 'avatar',
202
- mimeType: 'image/png',
203
- ceMode: 'convergent',
204
- })
205
-
206
- const avatarUrl = profileAvatarFileUrl(entityHash)
207
- const profile = await getProfile(entityHash, replicaUsername, { skipPresentation: true })
208
- await updateProfile(replicaUsername, entityHash, {
209
- localized: applyAvatarToAllLocales(profile.localized, avatarUrl),
210
- }, { skipPresentation: true })
211
- return avatarUrl
212
- }
213
-
214
- /**
215
- * @param {string} entityHash 128 位 entityHash
216
- * @returns {Promise<object>} 统计字段
217
- */
218
- export async function getStats(entityHash) {
219
- const profile = await getProfile(entityHash)
220
- return profile.stats
221
- }
222
-
223
- /**
224
- * @param {string} replicaUsername 副本用户名 所有者
225
- * @param {string} entityHash 128 位 entityHash
226
- * @param {string} status 状态
227
- * @param {string} [customStatus] 自定义状态
228
- * @returns {Promise<{ status: string, customStatus: string, lastSeenAt: number }>} 更新后的状态字段
229
- */
230
- export async function updateStatus(replicaUsername, entityHash, status, customStatus = '') {
231
- if (!MANUAL_STATUSES.has(status))
232
- throw new Error('invalid status')
233
- const updated = await updateProfile(replicaUsername, entityHash, {
234
- status,
235
- customStatus,
236
- lastSeenAt: Date.now(),
237
- }, { skipPresentation: true })
238
- return {
239
- status: updated.status,
240
- customStatus: updated.customStatus,
241
- lastSeenAt: updated.lastSeenAt,
242
- }
243
- }
244
-
245
- /**
246
- * 确保本节点操作者实体目录存在。
247
- * @param {string} replicaUsername 副本用户名 所有者
248
- * @param {string} entityHash 128 位 entityHash
249
- * @returns {Promise<object>} 本节点实体资料
250
- */
251
- export async function ensureLocalEntityProfile(replicaUsername, entityHash) {
252
- void replicaUsername
253
- if (!isWritableLocalEntity(entityHash))
254
- throw new Error('entity not on local node')
255
- return getProfile(entityHash, replicaUsername, { skipPresentation: true })
256
- }