@steve02081504/fount-p2p 0.0.35 → 0.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/core/entity_id.mjs +6 -6
  3. package/core/entity_id_parse.mjs +7 -7
  4. package/core/hexIds.mjs +6 -18
  5. package/crypto/checkpoint_sign.mjs +4 -4
  6. package/dag/canonicalize_row.mjs +4 -7
  7. package/discovery/adverts.mjs +17 -10
  8. package/discovery/bt/index.mjs +11 -11
  9. package/discovery/bt/peer_hints.mjs +4 -7
  10. package/discovery/index.mjs +8 -12
  11. package/discovery/internal/signal_crypto.mjs +1 -2
  12. package/discovery/lan.mjs +7 -9
  13. package/discovery/lan_peer_hints.mjs +5 -8
  14. package/discovery/nostr/census.mjs +281 -0
  15. package/discovery/nostr/census_math.mjs +54 -0
  16. package/discovery/nostr/constants.mjs +43 -0
  17. package/discovery/nostr/index.mjs +612 -0
  18. package/discovery/nostr/relays.mjs +1071 -0
  19. package/discovery/nostr/selection.mjs +224 -0
  20. package/discovery/nostr/session.mjs +584 -0
  21. package/discovery/peer_clue.mjs +1 -4
  22. package/docs/nostr_relay_discovery.md +92 -0
  23. package/federation/entity_key_chain.mjs +10 -9
  24. package/federation/message_rate_limit.mjs +1 -3
  25. package/files/manifest/fetch.mjs +4 -6
  26. package/files/manifest/normalize.mjs +2 -2
  27. package/files/manifest/public.mjs +5 -5
  28. package/governance/branch.mjs +1 -2
  29. package/governance/join_pow.mjs +2 -4
  30. package/governance/owner_succession_ballot.mjs +5 -5
  31. package/index.mjs +11 -4
  32. package/infra/priority.mjs +1 -2
  33. package/link/handshake.mjs +153 -41
  34. package/link/pipe.mjs +1 -2
  35. package/link/providers/ble_gatt.mjs +2 -3
  36. package/link/providers/lan_tcp.mjs +2 -3
  37. package/link/providers/link_id_pipe.mjs +3 -3
  38. package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
  39. package/mailbox/deliver_or_store.mjs +11 -12
  40. package/mailbox/prune.mjs +3 -4
  41. package/mailbox/rate.mjs +3 -2
  42. package/mailbox/store.mjs +6 -9
  43. package/node/denylist.mjs +25 -28
  44. package/node/feature_config.mjs +30 -0
  45. package/node/identity.mjs +6 -6
  46. package/node/instance.mjs +21 -0
  47. package/node/network.mjs +12 -12
  48. package/node/personal_block.mjs +4 -4
  49. package/node/reputation_store.mjs +13 -14
  50. package/node/reputation_sync.mjs +9 -10
  51. package/node/storage.mjs +23 -0
  52. package/package.json +10 -4
  53. package/pages/app.mjs +172 -0
  54. package/pages/index.html +36 -0
  55. package/reputation/engine.mjs +13 -16
  56. package/schemas/discovery.mjs +5 -6
  57. package/schemas/federation_pull.mjs +7 -7
  58. package/schemas/mailbox.mjs +3 -3
  59. package/schemas/part_query.mjs +5 -5
  60. package/timeline/verify_remote.mjs +7 -7
  61. package/transport/link_registry.mjs +16 -19
  62. package/transport/mesh_keepalive.mjs +8 -11
  63. package/transport/offer_answer.mjs +3 -3
  64. package/transport/peer_health.mjs +12 -15
  65. package/transport/runtime_bootstrap.mjs +39 -3
  66. package/trust_graph/send.mjs +3 -3
  67. package/discovery/nostr.mjs +0 -1074
@@ -2,7 +2,6 @@ import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import net from 'node:net'
4
4
 
5
- import { normalizeHex64 } from '../../core/hexIds.mjs'
6
5
  import { getLanPeerHint, listLanPeerHints } from '../../discovery/lan_peer_hints.mjs'
7
6
  import { asLinkHandle } from '../pipe.mjs'
8
7
 
@@ -186,7 +185,7 @@ async function openTcpPipe(options) {
186
185
  pipe.handleInbound(payload)
187
186
 
188
187
  if (options.initiator)
189
- codec.write(buildLinkOpen(normalizeHex64(options.linkId), options.localIdentity?.nodeHash))
188
+ codec.write(buildLinkOpen(options.linkId, options.localIdentity?.nodeHash))
190
189
 
191
190
  await pipe.startHandshake()
192
191
  return asLinkHandle(pipe)
@@ -198,7 +197,7 @@ async function openTcpPipe(options) {
198
197
  * @returns {Promise<import('./index.mjs').LinkHandle>} 已就绪的 link
199
198
  */
200
199
  async function dialLanTcp(options) {
201
- const remoteNodeHash = normalizeHex64(options.nodeHash)
200
+ const remoteNodeHash = options.nodeHash
202
201
  const hints = listLanPeerHints(remoteNodeHash)
203
202
  if (!hints.length) throw new Error('p2p: lan_tcp no peer hint')
204
203
 
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
 
3
- import { normalizeHex64 } from '../../core/hexIds.mjs'
3
+ import { isHex64 } from '../../core/hexIds.mjs'
4
4
  import { createLinkPipe } from '../pipe.mjs'
5
5
 
6
6
  /**
@@ -25,7 +25,7 @@ export function parseLinkOpen(raw) {
25
25
  }
26
26
  catch { return null }
27
27
  if (parsed?.type !== 'link-open' || !parsed.linkId) return null
28
- return { linkId: String(parsed.linkId), from: normalizeHex64(parsed.from) || null }
28
+ return { linkId: String(parsed.linkId), from: isHex64(parsed.from) }
29
29
  }
30
30
 
31
31
  /**
@@ -34,7 +34,7 @@ export function parseLinkOpen(raw) {
34
34
  * @returns {ReturnType<typeof createLinkPipe>} link pipe
35
35
  */
36
36
  export function createLinkIdBoundPipe(options) {
37
- const linkId = normalizeHex64(options.linkId)
37
+ const { linkId } = options
38
38
  if (!linkId) throw new Error(`p2p: ${options.providerId || 'link'} linkId required`)
39
39
  return createLinkPipe({
40
40
  ...options,
@@ -1,17 +1,16 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
- import { base64ToBytes, bytesToBase64 } from '../../core/bytes_codec.mjs'
5
- import { normalizeHex64 } from '../../core/hexIds.mjs'
6
- import { getDiscoveryProvider, sendNodeSignalPacket } from '../../discovery/index.mjs'
7
- import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../discovery/nostr.mjs'
8
- import { ms } from '../../utils/duration.mjs'
9
- import { createLruMap } from '../../utils/lru.mjs'
10
- import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../frame.mjs'
11
- import { asLinkHandle } from '../pipe.mjs'
12
-
13
- import { LINK_LEVEL_NOSTR } from './levels.mjs'
14
- import { createLinkIdBoundPipe } from './link_id_pipe.mjs'
4
+ import { base64ToBytes, bytesToBase64 } from '../../../core/bytes_codec.mjs'
5
+ import { isHex64 } from '../../../core/hexIds.mjs'
6
+ import { getDiscoveryProvider, sendNodeSignalPacket } from '../../../discovery/index.mjs'
7
+ import { NOSTR_SIGNAL_KIND, resolveNostrRelayUrls } from '../../../discovery/nostr/index.mjs'
8
+ import { ms } from '../../../utils/duration.mjs'
9
+ import { createLruMap } from '../../../utils/lru.mjs'
10
+ import { FRAME_HEADER_BYTES, maxFrameChunkBytesForPayload } from '../../frame.mjs'
11
+ import { asLinkHandle } from '../../pipe.mjs'
12
+ import { LINK_LEVEL_NOSTR } from '../levels.mjs'
13
+ import { createLinkIdBoundPipe } from '../link_id_pipe.mjs'
15
14
 
16
15
  /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。
17
16
  * 默认兜底取 2026-08 本机对默认公共 relay 的 NIP-11 `max_message_length` 非零最小值(131072 = nostr.mom)。
@@ -317,9 +316,8 @@ export function createNostrLinkProvider(options = {}) {
317
316
  */
318
317
  async function deliverPacket(packet) {
319
318
  if (packet?.type !== 'link') return
320
- const linkId = normalizeHex64(packet.linkId)
321
- const from = normalizeHex64(packet.from)
322
- if (!linkId || !from) return
319
+ const { linkId, from } = packet
320
+ if (!isHex64(linkId) || !isHex64(from)) return
323
321
  if (localIdentity?.nodeHash && from === localIdentity.nodeHash) return
324
322
 
325
323
  const op = String(packet.op || '')
@@ -375,8 +373,8 @@ export function createNostrLinkProvider(options = {}) {
375
373
  */
376
374
  async dial(dialOptions) {
377
375
  if (!isAvailable()) return null
378
- const remoteNodeHash = normalizeHex64(dialOptions.nodeHash)
379
- if (!remoteNodeHash) return null
376
+ const remoteNodeHash = dialOptions.nodeHash
377
+ if (!isHex64(remoteNodeHash)) return null
380
378
  localIdentity = dialOptions.localIdentity || localIdentity
381
379
  if (!localIdentity?.nodeHash) throw new Error('p2p: nostr dial requires localIdentity')
382
380
  await refreshPayloadCap(resolveRelayUrls())
@@ -398,8 +396,15 @@ export function createNostrLinkProvider(options = {}) {
398
396
  ensureListening(handlers) {
399
397
  onInbound = handlers.onInbound
400
398
  localIdentity = handlers.localIdentity
401
- void refreshPayloadCap(resolveRelayUrls())
399
+ // 探测延迟到启动路径之外(首个 macrotask),避免同步 fetch 阻塞 startup 预算。
400
+ const capTimer = setTimeout(() => {
401
+ let relayUrls
402
+ try { relayUrls = resolveRelayUrls() } catch { return }
403
+ void refreshPayloadCap(relayUrls).catch(() => { })
404
+ }, 0)
405
+ capTimer.unref?.()
402
406
  return () => {
407
+ clearTimeout(capTimer)
403
408
  onInbound = null
404
409
  for (const session of sessions.values())
405
410
  void session.pipe.close('listen-stop')
@@ -1,4 +1,4 @@
1
- import { normalizeHex64 } from '../core/hexIds.mjs'
1
+ import { isHex64 } from '../core/hexIds.mjs'
2
2
  import { getNodeTransportSettings, getNodeHash } from '../node/identity.mjs'
3
3
  import { activeLinkRoster, deliverToUserRoomPeers } from '../transport/user_room.mjs'
4
4
  import { DEFAULT_TRUST_GRAPH_OWNER, requireTrustGraphProvider } from '../trust_graph/registry.mjs'
@@ -25,7 +25,7 @@ import {
25
25
  async function resolveRemoteNodeHashForPeer(username, peerId) {
26
26
  if (!peerId) return null
27
27
  const entry = activeLinkRoster().find(row => row.peerId === peerId)
28
- const remote = normalizeHex64(entry?.remoteNodeHash)
28
+ const remote = entry?.remoteNodeHash
29
29
  return remote || null
30
30
  }
31
31
 
@@ -62,8 +62,8 @@ async function resolveRouting(username) {
62
62
  */
63
63
  export async function deliverOrStoreMailboxPut(username, options) {
64
64
  const routing = await resolveRouting(username)
65
- const toPubKeyHash = normalizeHex64(options.toPubKeyHash)
66
- if (!toPubKeyHash) return { stored: false, delivered: false, relayed: 0 }
65
+ const { toPubKeyHash } = options
66
+ if (!isHex64(toPubKeyHash)) return { stored: false, delivered: false, relayed: 0 }
67
67
  const hop = normalizeMailboxHop(options.hop)
68
68
  if (hop >= routing.maxHop) return { stored: false, delivered: false, relayed: 0 }
69
69
  const tier = mailboxTierFromHop(hop)
@@ -76,9 +76,8 @@ export async function deliverOrStoreMailboxPut(username, options) {
76
76
  fromNodeHash: options.record?.fromNodeHash || nodeHash,
77
77
  }
78
78
  const stored = await storeMailboxRecord(record)
79
- const toNodeHash = normalizeHex64(options.toNodeHash)
80
- const delivered = toNodeHash && isMailboxRecordWithinSizeLimit(record)
81
- ? await requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER).sendToNode(username, toNodeHash, 'mailbox_put', { nodeHash, record })
79
+ const delivered = isHex64(options.toNodeHash) && isMailboxRecordWithinSizeLimit(record)
80
+ ? await requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER).sendToNode(username, options.toNodeHash, 'mailbox_put', { nodeHash, record })
82
81
  : false
83
82
 
84
83
  let relayed = 0
@@ -114,8 +113,8 @@ export async function publishMailboxRecord(username, toPubKeyHash, record, toNod
114
113
  export async function ingestMailboxPut(wireContext, put, peerId = '') {
115
114
  const { record } = put
116
115
  if (!record?.envelope || !record?.toPubKeyHash) return
117
- const fromNode = normalizeHex64(put.nodeHash)
118
- if (!fromNode || !takeIncomingMailboxPutSlot(fromNode)) return
116
+ const fromNode = put.nodeHash
117
+ if (!isHex64(fromNode) || !takeIncomingMailboxPutSlot(fromNode)) return
119
118
  const username = wireContext.replicaUsername || ''
120
119
  if (!username) return
121
120
  if (peerId) {
@@ -143,8 +142,8 @@ export async function ingestMailboxPut(wireContext, put, peerId = '') {
143
142
  */
144
143
  export async function respondMailboxWant(want, sendGive, peerId) {
145
144
  const { getMailboxRecords, takeMailboxForRecipient } = await import('./store.mjs')
146
- const recipient = normalizeHex64(want.toPubKeyHash)
147
- if (!recipient) return
145
+ const recipient = want.toPubKeyHash
146
+ if (!isHex64(recipient)) return
148
147
  const ids = want.ids || []
149
148
  const rows = (ids.length
150
149
  ? await getMailboxRecords(ids)
@@ -179,7 +178,7 @@ export async function ingestMailboxGive(wireContext, give) {
179
178
  export async function requestMailboxFromNetwork(username, toPubKeyHash) {
180
179
  const routing = await resolveRouting(username)
181
180
  const { listMailboxIdsForRecipient } = await import('./store.mjs')
182
- const recipient = normalizeHex64(toPubKeyHash)
181
+ const recipient = toPubKeyHash
183
182
  if (!recipient) return
184
183
  await deliverToUserRoomPeers(username, 'mailbox_want', {
185
184
  toPubKeyHash: recipient,
package/mailbox/prune.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Mailbox 公平淘汰(纯函数,供 store 与单测复用)。
3
3
  */
4
- import { normalizeHex64 } from '../core/hexIds.mjs'
5
4
 
6
5
  /** 单收件人×发送者桶:条数上限 */
7
6
  export const MAX_BUCKET_ENTRIES = 10
@@ -31,9 +30,9 @@ export function mailboxRecordBytes(record) {
31
30
  * @returns {string} 公平淘汰桶键
32
31
  */
33
32
  export function mailboxBucketKey(record) {
34
- const to = normalizeHex64(record.toPubKeyHash) || ''
35
- const from = normalizeHex64(record.fromNodeHash)
36
- || normalizeHex64(record.envelope?.sender)
33
+ const to = record.toPubKeyHash || ''
34
+ const from = record.fromNodeHash
35
+ || record.envelope?.sender
37
36
  || 'unknown'
38
37
  return `${to}\0${from}`
39
38
  }
package/mailbox/rate.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Mailbox 入站 put 限速(按来源节点,节点级单例)。
3
3
  */
4
- import { normalizeHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64 } from '../core/hexIds.mjs'
5
5
 
6
6
  const DEFAULT_WINDOW_MS = 60_000
7
7
  const DEFAULT_MAX_PUTS = 20
@@ -62,7 +62,8 @@ function evictLruIfNeeded(key) {
62
62
  */
63
63
  export function takeIncomingMailboxPutSlot(fromNodeHash, limits) {
64
64
  const { windowMs, maxPuts } = resolveMailboxRateLimits(limits)
65
- const key = normalizeHex64(fromNodeHash)
65
+ const key = fromNodeHash
66
+ if (!isHex64(key)) return false
66
67
  const now = Date.now()
67
68
  if (inboundByKey.size >= MAX_KEYS) sweepExpiredEntries(now)
68
69
  evictLruIfNeeded(key)
package/mailbox/store.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile } from 'node:fs/promises'
2
2
  import { dirname } from 'node:path'
3
3
 
4
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64 } from '../core/hexIds.mjs'
5
5
  import { jsonlMutexKey, writeJsonl } from '../dag/storage.mjs'
6
6
  import { mailboxStorePath } from '../node/user_paths.mjs'
7
7
  import { withAsyncMutex } from '../utils/async_mutex.mjs'
@@ -150,7 +150,7 @@ export function mailboxEnvelopeId(envelope) {
150
150
  */
151
151
  export async function storeMailboxRecord(record) {
152
152
  if (!isMailboxRecordWithinSizeLimit(record)) return false
153
- const toPubKeyHash = normalizeHex64(record.toPubKeyHash)
153
+ const { toPubKeyHash } = record
154
154
  if (!isHex64(toPubKeyHash)) return false
155
155
  const hop = normalizeMailboxHop(record.hop)
156
156
  const { tier } = record
@@ -178,7 +178,7 @@ export async function storeMailboxRecord(record) {
178
178
  envelope: record.envelope,
179
179
  storedAt: Date.now(),
180
180
  expiresAt: Date.now() + ttlMs,
181
- fromNodeHash: normalizeHex64(record.fromNodeHash),
181
+ fromNodeHash: record.fromNodeHash,
182
182
  hop,
183
183
  tier,
184
184
  importance: Number.isFinite(Number(record.importance)) ? Number(record.importance) : undefined,
@@ -193,9 +193,8 @@ export async function storeMailboxRecord(record) {
193
193
  * @returns {Promise<string[]>} record id 列表
194
194
  */
195
195
  export async function listMailboxIdsForRecipient(toPubKeyHash) {
196
- const recipient = normalizeHex64(toPubKeyHash)
197
196
  return (await readAll())
198
- .filter(record => record.toPubKeyHash === recipient)
197
+ .filter(record => record.toPubKeyHash === toPubKeyHash)
199
198
  .map(record => record.id)
200
199
  }
201
200
 
@@ -224,8 +223,7 @@ export async function deleteMailboxRecords(ids) {
224
223
  * @returns {Promise<MailboxRecord[]>} 待发记录(不删除)
225
224
  */
226
225
  export async function takeMailboxForRecipient(toPubKeyHash) {
227
- const recipient = normalizeHex64(toPubKeyHash)
228
- return (await readAll()).filter(record => record.toPubKeyHash === recipient)
226
+ return (await readAll()).filter(record => record.toPubKeyHash === toPubKeyHash)
229
227
  }
230
228
 
231
229
  /**
@@ -233,9 +231,8 @@ export async function takeMailboxForRecipient(toPubKeyHash) {
233
231
  * @returns {Promise<number>} 未过期条数
234
232
  */
235
233
  export async function countMailboxPendingForRecipient(toPubKeyHash) {
236
- const recipient = normalizeHex64(toPubKeyHash)
237
234
  const now = Date.now()
238
- return (await readAll()).filter(record => record.toPubKeyHash === recipient && record.expiresAt > now).length
235
+ return (await readAll()).filter(record => record.toPubKeyHash === toPubKeyHash && record.expiresAt > now).length
239
236
  }
240
237
 
241
238
  /**
package/node/denylist.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { compositeKey } from '../core/composite_key.mjs'
2
2
  import { isEntityHash128 } from '../core/entity_id.mjs'
3
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
3
+ import { isHex64 } from '../core/hexIds.mjs'
4
4
  import { withAsyncMutex } from '../utils/async_mutex.mjs'
5
5
 
6
6
  import { readNodeJsonSync, writeNodeJsonSync } from './storage.mjs'
@@ -79,15 +79,15 @@ export function normalizeDenylist(raw) {
79
79
  const groupId = String(entry.groupId || '')
80
80
  if (!scope) continue
81
81
  if (scope === 'entity') {
82
- const value = String(entry?.value || '')
83
- if (isEntityHash128(value))
82
+ const value = isEntityHash128(entry?.value)
83
+ if (value)
84
84
  blocked.push({ scope: 'entity', value })
85
85
  continue
86
86
  }
87
- const value = normalizeHex64(entry?.value)
88
- if (scope === 'node' && isHex64(value))
87
+ const value = isHex64(entry?.value)
88
+ if (scope === 'node' && value)
89
89
  blocked.push({ scope: 'node', value, ...groupId ? { groupId } : {} })
90
- else if (scope === 'subject' && isHex64(value))
90
+ else if (scope === 'subject' && value)
91
91
  blocked.push({ scope: 'subject', value, ...groupId ? { groupId } : {} })
92
92
  }
93
93
  return { blocked }
@@ -116,12 +116,12 @@ export function saveDenylist(list) {
116
116
  * @returns {boolean} 是否命中群级 ban 集合
117
117
  */
118
118
  export function isSubjectBannedByState(state, subject) {
119
- const pk = normalizeHex64(subject?.pubKeyHash)
120
- if (isHex64(pk) && state?.bannedMembers?.has?.(pk)) return true
121
- const entity = String(subject?.entityHash || '')
122
- if (isEntityHash128(entity) && state?.bannedEntities?.has?.(entity)) return true
123
- const node = normalizeHex64(subject?.nodeHash)
124
- if (isHex64(node) && state?.bannedNodes?.has?.(node)) return true
119
+ const pk = isHex64(subject?.pubKeyHash)
120
+ if (pk && state?.bannedMembers?.has?.(pk)) return true
121
+ const entity = isEntityHash128(subject?.entityHash)
122
+ if (entity && state?.bannedEntities?.has?.(entity)) return true
123
+ const node = isHex64(subject?.nodeHash)
124
+ if (node && state?.bannedNodes?.has?.(node)) return true
125
125
  return false
126
126
  }
127
127
 
@@ -132,17 +132,17 @@ export function isSubjectBannedByState(state, subject) {
132
132
  * @returns {boolean} 是否命中
133
133
  */
134
134
  function matchesDenylistIndex(index, subject, groupId = '') {
135
- const pk = normalizeHex64(subject?.pubKeyHash)
135
+ const pk = isHex64(subject?.pubKeyHash)
136
136
  const entity = String(subject?.entityHash || '')
137
- const node = normalizeHex64(subject?.nodeHash)
137
+ const node = isHex64(subject?.nodeHash)
138
138
  const { keys } = index
139
139
 
140
140
  if (entity && keys.has(denyKey('entity', '*', entity))) return true
141
- if (isHex64(node) && keys.has(denyKey('node', '*', node))) return true
142
- if (isHex64(pk) && keys.has(denyKey('subject', '*', pk))) return true
141
+ if (node && keys.has(denyKey('node', '*', node))) return true
142
+ if (pk && keys.has(denyKey('subject', '*', pk))) return true
143
143
  if (!groupId) return false
144
- if (isHex64(pk) && keys.has(denyKey('subject', groupId, pk))) return true
145
- if (isHex64(node) && keys.has(denyKey('node', groupId, node))) return true
144
+ if (pk && keys.has(denyKey('subject', groupId, pk))) return true
145
+ if (node && keys.has(denyKey('node', groupId, node))) return true
146
146
  return false
147
147
  }
148
148
 
@@ -161,8 +161,8 @@ export function isSubjectBlocked(subject, groupId = '') {
161
161
  * @returns {boolean} 是否拉黑
162
162
  */
163
163
  export function isPeerKeyBlocked(groupId, peerKey) {
164
- const key = normalizeHex64(peerKey)
165
- if (!isHex64(key)) return false
164
+ const key = isHex64(peerKey)
165
+ if (!key) return false
166
166
  const index = getDenylistIndex()
167
167
  const { keys } = index
168
168
  if (keys.has(denyKey('subject', '*', key))) return true
@@ -200,9 +200,7 @@ export function addDenylistEntry(entry) {
200
200
  throw new Error('scope and value required')
201
201
  if (scope === 'entity' && entry.groupId)
202
202
  throw new Error('entity scope does not use groupId')
203
- const normValue = scope === 'node' || scope === 'subject'
204
- ? normalizeHex64(entry.value)
205
- : entry.value
203
+ const normValue = entry.value
206
204
  if (!normValue)
207
205
  throw new Error('scope and value required')
208
206
  if (scope === 'subject' && !isHex64(normValue))
@@ -234,8 +232,8 @@ export async function addDenylistFromBanContent(banContent, groupId) {
234
232
  await addDenylistEntry({ scope: 'entity', value: banContent.targetEntityHash })
235
233
  if (scope === 'node' && banContent?.targetNodeHash)
236
234
  await addDenylistEntry({ scope: 'node', value: banContent.targetNodeHash })
237
- const pk = normalizeHex64(banContent?.targetPubKeyHash)
238
- if (isHex64(pk))
235
+ const pk = isHex64(banContent?.targetPubKeyHash)
236
+ if (pk)
239
237
  await addDenylistEntry({ scope: 'subject', value: pk, ...sourceGroupId ? { groupId: sourceGroupId } : {} })
240
238
  }
241
239
 
@@ -259,12 +257,11 @@ export function addGroupBlockedPeer(groupId, scope, value) {
259
257
  * @returns {Promise<void>}
260
258
  */
261
259
  export function removeGroupBlockedPeer(groupId, scope, value) {
262
- const id = scope === 'entity' ? value : normalizeHex64(value)
263
- if (!scope || !id) return Promise.resolve()
260
+ if (!scope || !value) return Promise.resolve()
264
261
  return mutateDenylist(() => {
265
262
  const list = loadDenylist()
266
263
  list.blocked = list.blocked.filter(entry => {
267
- if (entry.scope !== scope || entry.value !== id) return true
264
+ if (entry.scope !== scope || entry.value !== value) return true
268
265
  if (scope === 'entity') return false
269
266
  return entry.groupId !== groupId
270
267
  })
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 通用包级行为开关(布尔 feature map):`{ census: true, ... }`。
3
+ * 新增功能只需加入新布尔 key,无需新增专属设置界面。
4
+ */
5
+
6
+ /** 默认 feature:census 默认开启(人口统计,基于 nostr)。 */
7
+ const DEFAULT_P2P_FEATURES = { census: true }
8
+
9
+ /**
10
+ * @returns {Record<string, boolean>} 默认 feature map 快照
11
+ */
12
+ export function defaultP2PFeatures() {
13
+ return { ...DEFAULT_P2P_FEATURES }
14
+ }
15
+
16
+ /**
17
+ * 归一化 feature map:已知 key 用默认值,patch 中未知 key 原样透传(值必须为 boolean)。
18
+ * @param {Record<string, boolean>} [patch] 部分 feature 覆盖
19
+ * @returns {Record<string, boolean>} 完整 boolean feature map
20
+ */
21
+ export function resolveP2PFeatures(patch = {}) {
22
+ /** @type {Record<string, boolean>} */
23
+ const out = defaultP2PFeatures()
24
+ for (const [key, value] of Object.entries(patch)) {
25
+ if (value !== true && value !== false)
26
+ throw new Error(`p2p: feature "${key}" must be boolean`)
27
+ out[key] = value
28
+ }
29
+ return out
30
+ }
package/node/identity.mjs CHANGED
@@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
4
  import { entityHashFromRecoveryPubKeyHex, parseEntityHash } from '../core/entity_id.mjs'
5
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
5
+ import { isHex64 } from '../core/hexIds.mjs'
6
6
  import { keyPairFromSeed, pubKeyHash } from '../crypto/crypto.mjs'
7
7
  import { normalizeMailboxSettings } from '../mailbox/settings.mjs'
8
8
 
@@ -17,7 +17,7 @@ const NODE_JSON = 'node'
17
17
  * @returns {string} 节点哈希
18
18
  */
19
19
  export function nodeHashFromSeed(seedHex) {
20
- const seed = Buffer.from(normalizeHex64(seedHex), 'hex')
20
+ const seed = Buffer.from(seedHex, 'hex')
21
21
  if (seed.length !== 32) throw new Error('invalid node seed')
22
22
  const { publicKey } = keyPairFromSeed(seed)
23
23
  return pubKeyHash(publicKey)
@@ -46,8 +46,8 @@ function saveNodeFile(patch) {
46
46
  */
47
47
  export function ensureNodeSeed() {
48
48
  const data = loadNodeFile()
49
- const existing = normalizeHex64(data.nodeSeedHex)
50
- if (isHex64(existing)) return existing
49
+ const existing = isHex64(data.nodeSeedHex)
50
+ if (existing) return existing
51
51
  const nodeSeedHex = randomBytes(32).toString('hex')
52
52
  saveNodeFile({ nodeSeedHex })
53
53
  return nodeSeedHex
@@ -105,8 +105,8 @@ export function ensureNodeDefaults() {
105
105
  * @returns {string | null} entityHash(非法 hex 时 null)
106
106
  */
107
107
  export function entityHashFromKeys(nodeHash, recoveryPubKeyHex) {
108
- const pub = normalizeHex64(recoveryPubKeyHex)
109
- if (!isHex64(nodeHash) || !isHex64(pub)) return null
108
+ const pub = isHex64(recoveryPubKeyHex)
109
+ if (!isHex64(nodeHash) || !pub) return null
110
110
  return entityHashFromRecoveryPubKeyHex(nodeHash, pub)
111
111
  }
112
112
 
package/node/instance.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
 
3
3
  import { createFsEntityStore } from './entity_store.mjs'
4
+ import { resolveP2PFeatures } from './feature_config.mjs'
4
5
  import { closeAllFileStreams } from './handles.mjs'
5
6
  import { defaultSignalingRuntimeConfig, resolveSignalingRuntimeConfig } from './signaling_config.mjs'
6
7
 
@@ -14,6 +15,7 @@ import { defaultSignalingRuntimeConfig, resolveSignalingRuntimeConfig } from './
14
15
  * entityStore: import('./entity_store.mjs').EntityStore
15
16
  * logger: NodeLogger | null
16
17
  * signaling: SignalingRuntimeConfig
18
+ * features: Record<string, boolean>
17
19
  * }} NodeRuntime
18
20
  */
19
21
 
@@ -49,6 +51,7 @@ export function initNode(options) {
49
51
  entityStore,
50
52
  logger: console,
51
53
  signaling: resolveSignalingRuntimeConfig(),
54
+ features: resolveP2PFeatures(),
52
55
  }
53
56
  return runtime
54
57
  }
@@ -101,6 +104,24 @@ export function getSignalingRuntimeConfig() {
101
104
  return runtime?.signaling ?? defaultSignalingRuntimeConfig()
102
105
  }
103
106
 
107
+ /**
108
+ * 设置包级行为开关(布尔 feature map);运行时切换,生效于对应 feature 的下一轮。
109
+ * @param {Record<string, boolean>} config - feature 补丁
110
+ * @returns {void}
111
+ */
112
+ export function setP2PFeatures(config) {
113
+ if (!runtime) throw new Error('p2p: setP2PFeatures requires initNode')
114
+ runtime.features = resolveP2PFeatures({ ...runtime.features, ...config })
115
+ emitNodeChange('features-changed', runtime.features)
116
+ }
117
+
118
+ /**
119
+ * @returns {Record<string, boolean>} 当前包级 feature map
120
+ */
121
+ export function getP2PFeatures() {
122
+ return { ...getNode().features }
123
+ }
124
+
104
125
  /**
105
126
  * @returns {string} 节点数据目录绝对路径
106
127
  */
package/node/network.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isEntityHash128 } from '../core/entity_id.mjs'
2
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
2
+ import { isHex64 } from '../core/hexIds.mjs'
3
3
 
4
4
  import { loadDenylist } from './denylist.mjs'
5
5
  import { getNodeDir, isNodeInitialized } from './instance.mjs'
@@ -57,7 +57,7 @@ export function normalizeNetwork(raw) {
57
57
  )]
58
58
  const hints = (file.hints || [])
59
59
  .map(hint => ({
60
- nodeHash: normalizeHex64(hint.nodeHash) || '',
60
+ nodeHash: hint.nodeHash || '',
61
61
  source: String(hint.source || ''),
62
62
  kind: String(hint.kind || ''),
63
63
  weight: Number.isFinite(Number(hint.weight)) ? Number(hint.weight) : 0.1,
@@ -126,8 +126,8 @@ export function saveNetwork(data) {
126
126
  * @returns {void}
127
127
  */
128
128
  export function applyNetworkHint(hint) {
129
- const nodeHash = normalizeHex64(hint?.nodeHash)
130
- if (!isHex64(nodeHash)) return
129
+ const nodeHash = isHex64(hint?.nodeHash)
130
+ if (!nodeHash) return
131
131
  const net = loadNetwork()
132
132
  const now = Date.now()
133
133
  const ttlMs = Number.isFinite(hint.ttlMs) ? hint.ttlMs : DEFAULT_EXPLORE_TTL_MS
@@ -160,8 +160,8 @@ export function widenExploreFromTrustedAnchors() {
160
160
  const net = loadNetwork()
161
161
  const now = Date.now()
162
162
  for (const raw of net.trustedPeers.slice(0, 12)) {
163
- const nodeHash = normalizeHex64(raw)
164
- if (!isHex64(nodeHash)) continue
163
+ const nodeHash = isHex64(raw)
164
+ if (!nodeHash) continue
165
165
  if (!net.explorePeers.includes(nodeHash))
166
166
  net.explorePeers.push(nodeHash)
167
167
  net.hints.push({
@@ -185,12 +185,12 @@ export function widenExploreFromTrustedAnchors() {
185
185
  export function mergeNetworkPeerPools(patch = {}) {
186
186
  const net = loadNetwork()
187
187
  for (const raw of patch.trustedPeers || []) {
188
- const id = normalizeHex64(raw)
189
- if (isHex64(id) && !net.trustedPeers.includes(id)) net.trustedPeers.push(id)
188
+ const id = isHex64(raw)
189
+ if (id && !net.trustedPeers.includes(id)) net.trustedPeers.push(id)
190
190
  }
191
191
  for (const raw of patch.explorePeers || []) {
192
- const id = normalizeHex64(raw)
193
- if (isHex64(id) && !net.explorePeers.includes(id)) net.explorePeers.push(id)
192
+ const id = isHex64(raw)
193
+ if (id && !net.explorePeers.includes(id)) net.explorePeers.push(id)
194
194
  }
195
195
  net.lastRosterAt = Date.now()
196
196
  saveNetwork(net)
@@ -203,8 +203,8 @@ export function mergeNetworkPeerPools(patch = {}) {
203
203
  */
204
204
  export function promoteExplorePeer(nodeHash) {
205
205
  const net = loadNetwork()
206
- const id = normalizeHex64(nodeHash)
207
- if (!isHex64(id)) return
206
+ const id = isHex64(nodeHash)
207
+ if (!id) return
208
208
  net.explorePeers = net.explorePeers.filter(peer => peer !== id)
209
209
  if (!net.trustedPeers.includes(id)) net.trustedPeers.push(id)
210
210
  net.lastRosterAt = Date.now()
@@ -4,7 +4,7 @@
4
4
  * **hide**:纯本地隐藏(personal_hide.json,永不联邦)。
5
5
  */
6
6
  import { parseEntityHash } from '../core/entity_id.mjs'
7
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
7
+ import { isHex64 } from '../core/hexIds.mjs'
8
8
 
9
9
  import { isWritableLocalEntity } from './identity.mjs'
10
10
  import { isNodeInitialized, getEntityStore } from './instance.mjs'
@@ -45,8 +45,8 @@ export function normalizePersonalListEntries(raw) {
45
45
  byKey.set(`entity:${parsed.entityHash}`, { scope: 'entity', value: parsed.entityHash })
46
46
  }
47
47
  else if (scope === 'subject') {
48
- const value = normalizeHex64(entry?.value)
49
- if (isHex64(value))
48
+ const value = isHex64(entry?.value)
49
+ if (value)
50
50
  byKey.set(`subject:${value}`, { scope: 'subject', value })
51
51
  }
52
52
  }
@@ -64,7 +64,7 @@ export function normalizePersonalListEntries(raw) {
64
64
  */
65
65
  export function matchesPersonalListEntries(entries, subject) {
66
66
  const entity = subject?.entityHash || ''
67
- const pk = normalizeHex64(subject?.pubKeyHash || subject?.subjectHash || '')
67
+ const pk = subject?.pubKeyHash || subject?.subjectHash || ''
68
68
  if (entity) {
69
69
  for (const entry of entries)
70
70
  if (entry.scope === 'entity' && entry.value === entity) return true