@steve02081504/fount-p2p 0.0.15 → 0.0.16

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/README.md CHANGED
@@ -30,7 +30,9 @@ await ensureUserRoom() // slot + runtime only
30
30
  attachUserRoomDefaultWires({ replicaUsername: 'alice' }) // full business wires
31
31
  ```
32
32
 
33
- Shells talk to the **fount network** (`ensureLinkToNode` / `sendToNodeLink` / rooms). Do not import `link/providers/*` or choose WebRTC / BLE / LAN yourself. Provider registration: `registerLinkProvider` from `@steve02081504/fount-p2p/link` or the facade.
33
+ Shells talk to the **fount network** (`ensureLinkToNode` / `sendToNodeLink` / rooms). Do not import `link/providers/*` or choose WebRTC / BLE / LAN / Nostr yourself. Provider registration: `registerLinkProvider` from `@steve02081504/fount-p2p/link` or the facade.
34
+
35
+ Dial order is descending link **`level`**: `lan_tcp` → `webrtc` → `ble_gatt` → `nostr` (−∞ last resort). Discovery **`priority`** only orders handshake / presence media. Details: [docs/transports.md](./docs/transports.md).
34
36
 
35
37
  Public transport subpaths: `link_registry`, `user_room`, `group_link_set`, `node_scope`, `room_scopes`, `remote_user_room`. Other `transport/*` modules are internal.
36
38
 
@@ -86,7 +88,7 @@ Facade entry: `index.mjs` (`startNode`, `createGroupLinkSet`, `registerDiscovery
86
88
  | `user_room.mjs` / `group_link_set.mjs` / `node_scope.mjs` | rooms + composable node-scope wires |
87
89
  | `room_scopes.mjs` / `remote_user_room.mjs` | scope constants / remote user slot |
88
90
 
89
- `runtime_bootstrap`, `offer_answer`, `advert_ingest` are **internal** (transport). Signal crypto / rendezvous live under `discovery/internal/signal_crypto.mjs` (used by `nostr.mjs` / `adverts.mjs`; not a package export). `ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. `setSignalingRuntimeConfig` → `reloadDiscoveryRelays`. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
91
+ `runtime_bootstrap`, `offer_answer`, `advert_ingest` are **internal** (transport). Signal crypto / rendezvous live under `discovery/internal/signal_crypto.mjs` (used by discovery `nostr.mjs` / `adverts.mjs`; not a package export). The Nostr **link** provider (`link/providers/nostr.mjs`) reuses the same discovery signal path (`type: 'link'`) as a last-resort duplex pipe. `ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. `setSignalingRuntimeConfig` → `reloadDiscoveryRelays`. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
90
92
 
91
93
  Root contains only the facade and package metadata; all modules live in layered subdirectories.
92
94
 
@@ -99,7 +101,7 @@ npm run test:live # link / LAN / glare smoke
99
101
  npm run test:sim # tunables co-evolution sim (dev only, not published; --social-tunables to write back)
100
102
  ```
101
103
 
102
- During development: `node scripts/check-imports.mjs` validates relative imports. After a layout migration, `node scripts/cleanup-root-duplicates.mjs` removes stale root-level stubs.
104
+ During development: `node scripts/check-imports.mjs` validates relative imports; `node scripts/find-unused-exports.mjs` scans dead exports (`--fount <path>` optional).
103
105
 
104
106
  Maintainer notes for agents / contributors: [AGENTS.md](./AGENTS.md). Sim harness fidelity: [sim/AGENTS.md](./sim/AGENTS.md).
105
107
 
@@ -107,7 +109,7 @@ Maintainer notes for agents / contributors: [AGENTS.md](./AGENTS.md). Sim harnes
107
109
 
108
110
  - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is subprocess-only; see [docs/runtime.md](./docs/runtime.md).
109
111
  - `node-datachannel` — WebRTC DataChannels (dependency).
110
- - `ws` — Nostr discovery/signaling WebSockets (dependency).
112
+ - `ws` — Nostr discovery / signaling / last-resort link WebSockets (dependency).
111
113
 
112
114
  Group chunk remote storage (S3, etc.) is implemented by the shell as `GroupStoragePlugin` and injected; see `node/storage_plugins.mjs` for the local reference implementation.
113
115
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Domain-key 信封:X25519 ECIES 包装与 AES-GCM 消息载荷(wire scheme 由消费方定义,如 ckg)。
2
+ * Domain-key 信封:X25519 ECIES 包装与 AES-GCM 消息载荷(wire scheme:channel-key)。
3
3
  * 解密 payload 不可脱离外层 DAG Ed25519 签名上下文单独传递或信任。
4
4
  */
5
5
  import { Buffer } from 'node:buffer'
@@ -7,8 +7,8 @@ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:cr
7
7
 
8
8
  import { unwrapKeyEcies, wrapKeyEcies } from './key.mjs'
9
9
 
10
- /** @type {'ckg'} 频道消息 content 加密 scheme */
11
- export const CKG_SCHEME = 'ckg'
10
+ /** @type {'channel-key'} 频道消息 content 加密 scheme */
11
+ export const CHANNEL_KEY_SCHEME = 'channel-key'
12
12
 
13
13
  /** @typedef {{ ephemPub: string, iv: string, ciphertext: string, authTag: string }} EciesWrapBlob */
14
14
 
@@ -49,7 +49,7 @@ function messageAesKey(channelKeyHex, channelId, generation) {
49
49
  return Buffer.from(hkdfSync(
50
50
  'sha256',
51
51
  Buffer.from(channelKeyHex, 'hex'),
52
- `ckg:${String(channelId)}:${String(generation)}`,
52
+ `${CHANNEL_KEY_SCHEME}:${String(channelId)}:${String(generation)}`,
53
53
  '',
54
54
  32,
55
55
  ))
@@ -60,7 +60,7 @@ function messageAesKey(channelKeyHex, channelId, generation) {
60
60
  * @param {string} channelKeyHex K_ch
61
61
  * @param {string} channelId 频道 ID
62
62
  * @param {number} generation 密钥代际
63
- * @returns {{ scheme: typeof CKG_SCHEME, channelId: string, generation: number, payload: string }} 频道密钥信封
63
+ * @returns {{ scheme: typeof CHANNEL_KEY_SCHEME, channelId: string, generation: number, payload: string }} 频道密钥信封
64
64
  */
65
65
  export function encryptWithChannelKey(plaintext, channelKeyHex, channelId, generation) {
66
66
  const key = messageAesKey(channelKeyHex, channelId, generation)
@@ -70,7 +70,7 @@ export function encryptWithChannelKey(plaintext, channelKeyHex, channelId, gener
70
70
  const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()])
71
71
  const authTag = cipher.getAuthTag()
72
72
  return {
73
- scheme: CKG_SCHEME,
73
+ scheme: CHANNEL_KEY_SCHEME,
74
74
  channelId: String(channelId),
75
75
  generation: Number(generation) || 0,
76
76
  payload: `${iv.toString('base64')}.${ciphertext.toString('base64')}.${authTag.toString('base64')}`,
@@ -84,7 +84,7 @@ export function encryptWithChannelKey(plaintext, channelKeyHex, channelId, gener
84
84
  * @returns {string | null} 明文 UTF-8
85
85
  */
86
86
  export function decryptWithChannelKey(envelope, channelKeyHex, channelId) {
87
- if (envelope?.scheme !== CKG_SCHEME || !envelope.payload) return null
87
+ if (envelope?.scheme !== CHANNEL_KEY_SCHEME || !envelope.payload) return null
88
88
  try {
89
89
  const parts = envelope.payload.split('.')
90
90
  if (parts.length !== 3) return null
@@ -85,4 +85,5 @@ export {
85
85
  LINK_LEVEL_LAN_TCP,
86
86
  LINK_LEVEL_WEBRTC,
87
87
  LINK_LEVEL_BLE_GATT,
88
+ LINK_LEVEL_NOSTR,
88
89
  } from './levels.mjs'
@@ -7,3 +7,5 @@ export const LINK_LEVEL_LAN_TCP = 80
7
7
  export const LINK_LEVEL_WEBRTC = 70
8
8
  /** BLE GATT 链路 level。 */
9
9
  export const LINK_LEVEL_BLE_GATT = 40
10
+ /** Nostr relay 末位数据链(仅其它传输失败后)。 */
11
+ export const LINK_LEVEL_NOSTR = Number.NEGATIVE_INFINITY
@@ -0,0 +1,280 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { randomBytes } from 'node:crypto'
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 { mergeSignalingRelayUrls } from '../../discovery/nostr.mjs'
8
+ import { getNodeTransportSettings } from '../../node/identity.mjs'
9
+ import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
10
+ import { ms } from '../../utils/duration.mjs'
11
+ import { createLruMap } from '../../utils/lru.mjs'
12
+ import { asLinkHandle } from '../pipe.mjs'
13
+
14
+ import { LINK_LEVEL_NOSTR } from './levels.mjs'
15
+ import { createLinkIdBoundPipe } from './link_id_pipe.mjs'
16
+
17
+ /** 单包 payload(UTF-8 / base64)上限,避免撞 relay content 限制。 */
18
+ const MAX_LINK_PAYLOAD_CHARS = 12 * 1024
19
+ /** open 到达前为同一 linkId 暂存的 c/b 包上限。 */
20
+ const PENDING_PACKETS_MAX = 32
21
+ /** Nostr 链握手超时(relay RTT 更慢)。 */
22
+ const NOSTR_HANDSHAKE_TIMEOUT_MS = ms('30s')
23
+ /** Nostr 链心跳间隔。 */
24
+ const NOSTR_HEARTBEAT_MS = ms('60s')
25
+ /** Nostr 链空闲超时。 */
26
+ const NOSTR_IDLE_TIMEOUT_MS = ms('3m')
27
+
28
+ /**
29
+ * @returns {string[]} 当前可用中继 URL
30
+ */
31
+ function resolveDefaultRelayUrls() {
32
+ return getSignalingRuntimeConfig().relayOverride
33
+ ?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
34
+ }
35
+
36
+ /**
37
+ * @param {string} remoteNodeHash 对端
38
+ * @param {object} packet link 包
39
+ * @returns {Promise<void>}
40
+ */
41
+ async function publishLinkPacket(remoteNodeHash, packet) {
42
+ await sendNodeSignalPacket(remoteNodeHash, packet)
43
+ }
44
+
45
+ /**
46
+ * 创建 Nostr 末位数据链路 provider(level = -∞)。
47
+ * @param {{ getRelayUrls?: () => string[] }} [options] 中继解析(测试可注入)
48
+ * @returns {import('./index.mjs').LinkProvider & { deliverPacket: (packet: object) => void }} provider
49
+ */
50
+ export function createNostrLinkProvider(options = {}) {
51
+ const resolveRelayUrls = typeof options.getRelayUrls === 'function'
52
+ ? options.getRelayUrls
53
+ : resolveDefaultRelayUrls
54
+
55
+ /** @type {((link: import('./index.mjs').LinkHandle) => void) | null} */
56
+ let onInbound = null
57
+ /** @type {object | null} */
58
+ let localIdentity = null
59
+ /**
60
+ * @typedef {{
61
+ * pipe: ReturnType<typeof createLinkIdBoundPipe>,
62
+ * remoteNodeHash: string,
63
+ * initiator: boolean,
64
+ * }} NostrLinkSession
65
+ */
66
+ /** @type {Map<string, NostrLinkSession>} */
67
+ const sessions = new Map()
68
+ /** @type {Map<string, object[]> & { touch: (key: string, value: object[]) => void }} */
69
+ const pendingByLinkId = createLruMap(64)
70
+
71
+ /**
72
+ * @returns {boolean} discovery nostr 已注册且有中继
73
+ */
74
+ function isAvailable() {
75
+ if (!getDiscoveryProvider('nostr')) return false
76
+ return resolveRelayUrls().length > 0
77
+ }
78
+
79
+ /**
80
+ * @param {NostrLinkSession} session 会话
81
+ * @param {object} packet link 包
82
+ * @returns {void}
83
+ */
84
+ function applySessionPacket(session, packet) {
85
+ const op = String(packet.op || '')
86
+ if (op === 'close') {
87
+ void session.pipe.close('remote-close')
88
+ return
89
+ }
90
+ if (op === 'c') {
91
+ if (typeof packet.payload !== 'string') return
92
+ session.pipe.handleInbound(packet.payload)
93
+ return
94
+ }
95
+ if (op === 'b') {
96
+ if (typeof packet.payload !== 'string') return
97
+ try {
98
+ session.pipe.handleInbound(Buffer.from(base64ToBytes(packet.payload)))
99
+ }
100
+ catch { /* drop malformed */ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {string} linkId 链路 id
106
+ * @param {object} packet 暂存包
107
+ * @returns {void}
108
+ */
109
+ function bufferPending(linkId, packet) {
110
+ let list = pendingByLinkId.get(linkId)
111
+ if (!list) {
112
+ list = []
113
+ pendingByLinkId.touch(linkId, list)
114
+ }
115
+ if (list.length >= PENDING_PACKETS_MAX) list.shift()
116
+ list.push(packet)
117
+ }
118
+
119
+ /**
120
+ * @param {string} remoteNodeHash 对端
121
+ * @param {string} linkId 链路 id
122
+ * @param {string} op open|c|b|close
123
+ * @param {string} [payload] 可选载荷
124
+ * @returns {Promise<void>}
125
+ */
126
+ async function sendOp(remoteNodeHash, linkId, op, payload) {
127
+ if (payload != null && payload.length > MAX_LINK_PAYLOAD_CHARS)
128
+ throw new Error('p2p: nostr link payload too large')
129
+ const packet = {
130
+ type: 'link',
131
+ op,
132
+ from: localIdentity?.nodeHash || '',
133
+ linkId,
134
+ }
135
+ if (payload != null) packet.payload = payload
136
+ await publishLinkPacket(remoteNodeHash, packet)
137
+ }
138
+
139
+ /**
140
+ * @param {object} opts 会话选项
141
+ * @returns {ReturnType<typeof createLinkIdBoundPipe>} pipe
142
+ */
143
+ function openPipe(opts) {
144
+ const { linkId, remoteNodeHash, initiator } = opts
145
+ const pipe = createLinkIdBoundPipe({
146
+ providerId: 'nostr',
147
+ level: LINK_LEVEL_NOSTR,
148
+ initiator: !!initiator,
149
+ linkId,
150
+ nodeHash: remoteNodeHash,
151
+ localIdentity: opts.localIdentity || localIdentity,
152
+ handshakeTimeoutMs: NOSTR_HANDSHAKE_TIMEOUT_MS,
153
+ heartbeatMs: NOSTR_HEARTBEAT_MS,
154
+ idleTimeoutMs: NOSTR_IDLE_TIMEOUT_MS,
155
+ /**
156
+ * @param {string} text control JSON
157
+ * @returns {Promise<void>}
158
+ */
159
+ async sendControlText(text) {
160
+ await sendOp(remoteNodeHash, linkId, 'c', text)
161
+ },
162
+ /**
163
+ * @param {string} _action action
164
+ * @param {Uint8Array} frame 帧
165
+ * @returns {Promise<void>}
166
+ */
167
+ async sendFrame(_action, frame) {
168
+ await sendOp(remoteNodeHash, linkId, 'b', bytesToBase64(frame))
169
+ },
170
+ /**
171
+ * @returns {Promise<void>}
172
+ */
173
+ async closeTransport() {
174
+ sessions.delete(linkId)
175
+ try {
176
+ await sendOp(remoteNodeHash, linkId, 'close')
177
+ }
178
+ catch { /* ignore */ }
179
+ },
180
+ })
181
+ sessions.set(linkId, { pipe, remoteNodeHash, initiator: !!initiator })
182
+ pipe.onDown(() => { sessions.delete(linkId) })
183
+ return pipe
184
+ }
185
+
186
+ /**
187
+ * 入站已解密的 link 包(由信令 demux 调用)。
188
+ * @param {object} packet link 包
189
+ * @returns {void}
190
+ */
191
+ function deliverPacket(packet) {
192
+ if (packet?.type !== 'link') return
193
+ const linkId = normalizeHex64(packet.linkId)
194
+ const from = normalizeHex64(packet.from)
195
+ if (!linkId || !from) return
196
+ if (localIdentity?.nodeHash && from === localIdentity.nodeHash) return
197
+
198
+ const op = String(packet.op || '')
199
+ if (op === 'open') {
200
+ if (sessions.has(linkId)) return
201
+ if (!onInbound || !localIdentity) return
202
+ const pipe = openPipe({
203
+ linkId,
204
+ remoteNodeHash: from,
205
+ initiator: false,
206
+ localIdentity,
207
+ })
208
+ onInbound(asLinkHandle(pipe))
209
+ void pipe.startHandshake().catch(() => {
210
+ sessions.delete(linkId)
211
+ void pipe.close('accept-failed')
212
+ })
213
+ const pending = pendingByLinkId.get(linkId) || []
214
+ pendingByLinkId.delete(linkId)
215
+ const session = sessions.get(linkId)
216
+ if (session)
217
+ for (const queued of pending)
218
+ applySessionPacket(session, queued)
219
+ return
220
+ }
221
+
222
+ const session = sessions.get(linkId)
223
+ if (!session) {
224
+ if (op === 'c' || op === 'b' || op === 'close')
225
+ bufferPending(linkId, packet)
226
+ return
227
+ }
228
+ applySessionPacket(session, packet)
229
+ }
230
+
231
+ return {
232
+ id: 'nostr',
233
+ level: LINK_LEVEL_NOSTR,
234
+ caps: { needsOfferAnswer: false, needsDiscoverySignal: false, probe: 'sync' },
235
+ isAvailable,
236
+ /**
237
+ * @returns {boolean} 有中继即可
238
+ */
239
+ canReach() {
240
+ return isAvailable()
241
+ },
242
+ deliverPacket,
243
+ /**
244
+ * @param {object} dialOptions dial 选项
245
+ * @returns {Promise<import('./index.mjs').LinkHandle | null>} link
246
+ */
247
+ async dial(dialOptions) {
248
+ if (!isAvailable()) return null
249
+ const remoteNodeHash = normalizeHex64(dialOptions.nodeHash)
250
+ if (!remoteNodeHash) return null
251
+ localIdentity = dialOptions.localIdentity || localIdentity
252
+ if (!localIdentity?.nodeHash) throw new Error('p2p: nostr dial requires localIdentity')
253
+ const linkId = randomBytes(32).toString('hex')
254
+ const pipe = openPipe({
255
+ linkId,
256
+ remoteNodeHash,
257
+ initiator: true,
258
+ localIdentity,
259
+ })
260
+ await sendOp(remoteNodeHash, linkId, 'open')
261
+ await pipe.startHandshake()
262
+ return asLinkHandle(pipe)
263
+ },
264
+ /**
265
+ * @param {{ onInbound: (link: import('./index.mjs').LinkHandle) => void, localIdentity: object }} handlers 回调
266
+ * @returns {() => void} 停止 listening
267
+ */
268
+ ensureListening(handlers) {
269
+ onInbound = handlers.onInbound
270
+ localIdentity = handlers.localIdentity
271
+ return () => {
272
+ onInbound = null
273
+ for (const session of sessions.values())
274
+ void session.pipe.close('listen-stop')
275
+ sessions.clear()
276
+ pendingByLinkId.clear()
277
+ }
278
+ },
279
+ }
280
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -150,11 +150,10 @@ export function createOfferAnswerDial(deps) {
150
150
  }
151
151
 
152
152
  /**
153
- * @param {Uint8Array} bytes 加密信令
153
+ * @param {object} packet 已解密的 signal 包
154
154
  * @returns {Promise<void>}
155
155
  */
156
- async function handleIncomingSignal(bytes) {
157
- const packet = decryptNodeSignalPacket(localIdentity.nodeHash, bytes)
156
+ async function handleSignalPacket(packet) {
158
157
  if (packet?.type !== 'signal') return
159
158
  const remoteNodeHash = normalizeHex64(packet.from)
160
159
  const connId = String(packet.connId || '')
@@ -175,6 +174,21 @@ export function createOfferAnswerDial(deps) {
175
174
  session.deliver(packet.body)
176
175
  }
177
176
 
177
+ /**
178
+ * @param {Uint8Array} bytes 加密信令
179
+ * @returns {Promise<void>}
180
+ */
181
+ async function handleIncomingSignal(bytes) {
182
+ const packet = decryptNodeSignalPacket(localIdentity.nodeHash, bytes)
183
+ if (!packet) return
184
+ if (packet.type === 'link') {
185
+ const provider = listLinkProviders().find(entry => entry.id === 'nostr')
186
+ provider?.deliverPacket?.(packet)
187
+ return
188
+ }
189
+ await handleSignalPacket(packet)
190
+ }
191
+
178
192
  /**
179
193
  * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
180
194
  * @param {string} remoteNodeHash 远端 nodeHash
@@ -187,5 +201,5 @@ export function createOfferAnswerDial(deps) {
187
201
  return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
188
202
  }
189
203
 
190
- return { handleIncomingSignal, dialOfferAnswer }
204
+ return { handleIncomingSignal, handleSignalPacket, dialOfferAnswer }
191
205
  }
@@ -18,6 +18,7 @@ import {
18
18
  unregisterLinkProvider,
19
19
  } from '../link/providers/index.mjs'
20
20
  import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
21
+ import { createNostrLinkProvider } from '../link/providers/nostr.mjs'
21
22
  import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
22
23
  import { getNodeTransportSettings } from '../node/identity.mjs'
23
24
  import { getSignalingRuntimeConfig, onNodeChange } from '../node/instance.mjs'
@@ -157,6 +158,8 @@ export function createRuntimeBootstrap(deps) {
157
158
  const ids = new Set(listLinkProviders().map(provider => provider.id))
158
159
  if (!ids.has('webrtc'))
159
160
  registerLinkProvider(createWebRtcLinkProvider())
161
+ if (!ids.has('nostr'))
162
+ registerLinkProvider(createNostrLinkProvider({ getRelayUrls: resolveNostrRelayUrls }))
160
163
  }
161
164
 
162
165
  /**