@steve02081504/fount-p2p 0.0.11 → 0.0.13
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 +42 -11
- package/core/bytes_codec.mjs +65 -10
- package/core/composite_key.mjs +5 -5
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/crypto/crypto.mjs +3 -3
- package/crypto/key.mjs +12 -12
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/dag/storage.mjs +36 -36
- package/discovery/advert_peer_hints.mjs +1 -1
- package/discovery/adverts.mjs +109 -0
- package/discovery/bt/index.mjs +154 -113
- package/discovery/bt/probe_child.mjs +2 -1
- package/discovery/bt/runtime.mjs +5 -12
- package/discovery/index.mjs +267 -62
- package/discovery/internal/signal_crypto.mjs +109 -0
- package/discovery/lan.mjs +228 -0
- package/discovery/nostr.mjs +430 -141
- package/federation/chunk_fetch_pending.mjs +14 -18
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/manifest_fetch_pending.mjs +1 -3
- package/federation/topo_order_memo.mjs +11 -4
- package/files/assemble.mjs +14 -14
- package/files/assemble_stream.mjs +6 -6
- package/files/chunk_fetch.mjs +2 -2
- package/files/chunk_responder.mjs +29 -20
- package/files/evfs.mjs +31 -30
- package/files/manifest_fetch.mjs +16 -3
- package/files/public_manifest.mjs +11 -11
- package/files/transfer_key_registry.mjs +2 -2
- package/index.mjs +86 -11
- package/infra/cli.mjs +62 -0
- package/infra/debug_log.mjs +56 -0
- package/infra/default_node_dir.mjs +22 -0
- package/infra/priority.mjs +56 -0
- package/infra/service.mjs +140 -0
- package/infra/tunables.json +5 -0
- package/link/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -131
- package/link/handshake.mjs +19 -20
- package/link/pipe.mjs +53 -79
- package/link/providers/ble_gatt.mjs +19 -31
- package/link/providers/lan_tcp.mjs +17 -32
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +42 -51
- package/link/rtc.mjs +24 -12
- package/mailbox/consumer_registry.mjs +2 -2
- package/mailbox/deliver_or_store.mjs +6 -6
- package/mailbox/wire.mjs +28 -24
- package/node/entity_store.mjs +6 -6
- package/node/identity.mjs +4 -4
- package/node/instance.mjs +46 -27
- package/node/local_data_revision.mjs +26 -0
- package/node/log.mjs +56 -0
- package/node/network.mjs +46 -14
- package/node/reputation_store.mjs +4 -4
- package/node/reputation_sync.mjs +318 -0
- package/node/routing_profile.mjs +21 -0
- package/node/signaling_config.mjs +30 -11
- package/overlay/index.mjs +35 -14
- package/package.json +13 -2
- package/rooms/scoped_link.mjs +17 -182
- package/schemas/discovery.mjs +1 -2
- package/schemas/federation_pull.mjs +2 -2
- package/schemas/part_query.mjs +1 -1
- package/schemas/remote_event.mjs +1 -1
- package/transport/advert_ingest.mjs +17 -0
- package/transport/group_link_set.mjs +165 -134
- package/transport/ice_servers.mjs +2 -2
- package/transport/link_registry.mjs +283 -124
- package/transport/mesh_keepalive.mjs +217 -0
- package/transport/node_scope.mjs +289 -0
- package/transport/offer_answer.mjs +48 -47
- package/transport/peer_pool.mjs +169 -78
- package/transport/remote_user_room.mjs +18 -23
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
- package/transport/runtime_bootstrap.mjs +172 -104
- package/transport/tunables.json +11 -0
- package/transport/tunables.mjs +18 -0
- package/transport/user_room.mjs +93 -161
- package/trust_graph/build.mjs +0 -2
- package/trust_graph/cache.mjs +12 -3
- package/trust_graph/registry.mjs +6 -6
- package/trust_graph/send.mjs +1 -4
- package/utils/async_mutex.mjs +4 -4
- package/utils/emit_safe.mjs +11 -0
- package/utils/json_io.mjs +12 -12
- package/utils/map_pool.mjs +5 -5
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +38 -36
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +28 -23
- package/wire/part_query.tunables.json +6 -1
- package/wire/part_query_cache.mjs +1 -1
- package/wire/volatile_signature.mjs +1 -1
- package/discovery/mdns.mjs +0 -191
- package/transport/signal_crypto.mjs +0 -82
package/README.md
CHANGED
|
@@ -18,15 +18,43 @@ Requires **Node.js ≥ 20** (ESM + `import ... with { type: 'json' }`).
|
|
|
18
18
|
## Quick start
|
|
19
19
|
|
|
20
20
|
```javascript
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
startNode,
|
|
23
|
+
ensureUserRoom,
|
|
24
|
+
attachUserRoomDefaultWires,
|
|
25
|
+
createScopedLinkRoom,
|
|
26
|
+
} from '@steve02081504/fount-p2p'
|
|
22
27
|
|
|
23
28
|
await startNode({ nodeDir: '/path/to/p2p/node' })
|
|
24
|
-
await ensureUserRoom()
|
|
29
|
+
await ensureUserRoom() // slot + runtime only
|
|
30
|
+
attachUserRoomDefaultWires({ replicaUsername: 'alice' }) // full business wires
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
Shells talk to the **fount network** (`ensureLinkToNode` / `sendToNodeLink` / rooms). Do not import `link
|
|
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.
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
Public transport subpaths: `link_registry`, `user_room`, `group_link_set`, `node_scope`, `room_scopes`, `remote_user_room`. Other `transport/*` modules are internal.
|
|
36
|
+
|
|
37
|
+
## Infra relay (optional)
|
|
38
|
+
|
|
39
|
+
Public-good overlay + mailbox only — does **not** attach `rep_sync` or full user-room wires:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx @steve02081504/fount-p2p
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Default `nodeDir`: Windows `%LOCALAPPDATA%/fount-p2p/node`; elsewhere `~/.local/share/fount-p2p/node`.
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
import { initNode, startNode, startInfra, stopInfra, setInfraPriority } from '@steve02081504/fount-p2p'
|
|
49
|
+
|
|
50
|
+
initNode({ nodeDir })
|
|
51
|
+
await startNode()
|
|
52
|
+
await startInfra({ maxActive: 64 }) // logger defaults to console; pass null to silence
|
|
53
|
+
setInfraPriority({ useLocalReputation: true }) // optional; reads local reputation.json only
|
|
54
|
+
await stopInfra()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Reputation pull/apply is separate: `pullReputationFromNode` → JSON; `setReputationTable` writes. See [docs/infra.md](./docs/infra.md).
|
|
30
58
|
|
|
31
59
|
## Layout
|
|
32
60
|
|
|
@@ -35,22 +63,22 @@ Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`,
|
|
|
35
63
|
| L0 | `core/` | Pure primitives: `hexIds`, `entity_id*`, `canonical_json` |
|
|
36
64
|
| L1 | `crypto/`, `wire/`, `schemas/` | Cryptography, wire protocol, canonical validation |
|
|
37
65
|
| L2 | `node/` | Node runtime: `identity`, `entity_store`, `denylist`, `reputation_store` |
|
|
38
|
-
| L3 | `discovery/`, `link/`, `transport/`, `rooms/` | Discovery + fount-network registry/rooms (
|
|
66
|
+
| L3 | `discovery/`, `link/`, `transport/`, `rooms/` | Discovery + fount-network registry/rooms (`./link` = provider registration only) |
|
|
39
67
|
| L4 | `trust_graph/`, `mailbox/`, `dag/`, `federation/`, `files/`, `governance/`, `reputation/` | Federation, store-and-forward, DAG, EVFS, tunables |
|
|
68
|
+
| — | `infra/` | Optional public-good relay (`startInfra` / CLI) |
|
|
40
69
|
| — | `registries/` | Pluggable registries (event type, part path, room provider, …) |
|
|
41
70
|
|
|
42
71
|
Facade entry: `index.mjs` (`startNode`, `createGroupLinkSet`, `registerDiscoveryProvider`, …).
|
|
43
72
|
|
|
44
|
-
**
|
|
73
|
+
**Public transport modules:**
|
|
45
74
|
|
|
46
75
|
| Module | Role |
|
|
47
76
|
|---|---|
|
|
48
|
-
| `link_registry.mjs` |
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `signal_crypto.mjs` | Rendezvous topics + AES-GCM signal packets |
|
|
77
|
+
| `link_registry.mjs` | fount-network facade: dial fallback, scope/overlay |
|
|
78
|
+
| `user_room.mjs` / `group_link_set.mjs` / `node_scope.mjs` | rooms + composable node-scope wires |
|
|
79
|
+
| `room_scopes.mjs` / `remote_user_room.mjs` | scope constants / remote user slot |
|
|
52
80
|
|
|
53
|
-
`ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
|
|
81
|
+
`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).
|
|
54
82
|
|
|
55
83
|
Root contains only the facade and package metadata; all modules live in layered subdirectories.
|
|
56
84
|
|
|
@@ -77,5 +105,8 @@ Group chunk remote storage (S3, etc.) is implemented by the shell as `GroupStora
|
|
|
77
105
|
|
|
78
106
|
## Docs
|
|
79
107
|
|
|
108
|
+
- Mesh keep-alive / bootstrap (N/K, mesh-first): [`docs/mesh.md`](./docs/mesh.md)
|
|
80
109
|
- Transports and provider fallback: [`docs/transports.md`](./docs/transports.md)
|
|
81
110
|
- Signaling and WebRTC glare: [`docs/signaling.md`](./docs/signaling.md)
|
|
111
|
+
- Runtime bootstrap / BT probe: [`docs/runtime.md`](./docs/runtime.md)
|
|
112
|
+
- Infra relay / node-scope attaches: [`docs/infra.md`](./docs/infra.md)
|
package/core/bytes_codec.mjs
CHANGED
|
@@ -2,23 +2,78 @@
|
|
|
2
2
|
* Base64 / hex / bytes 互转(无 Node 依赖,浏览器与 Node 共用)。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
const HEX = '0123456789abcdef'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {number} code UTF-16 码元(期望为 0-9 / a-f / A-F)
|
|
9
|
+
* @returns {number} 0–15;非法为 -1
|
|
10
|
+
*/
|
|
11
|
+
function hexNibble(code) {
|
|
12
|
+
if (code >= 48 && code <= 57) return code - 48
|
|
13
|
+
if (code >= 97 && code <= 102) return code - 87
|
|
14
|
+
if (code >= 65 && code <= 70) return code - 55
|
|
15
|
+
return -1
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
|
-
* @param {Uint8Array}
|
|
7
|
-
* @returns {string}
|
|
19
|
+
* @param {Uint8Array} bytes 原始字节
|
|
20
|
+
* @returns {string} 小写 hex
|
|
21
|
+
*/
|
|
22
|
+
export function bytesToHex(bytes) {
|
|
23
|
+
let out = ''
|
|
24
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
25
|
+
const byte = bytes[index]
|
|
26
|
+
out += HEX[byte >> 4] + HEX[byte & 15]
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} hex hex 文本(可含空白;大小写不敏感)
|
|
33
|
+
* @returns {Uint8Array} 解码后的字节
|
|
8
34
|
*/
|
|
9
|
-
export function
|
|
35
|
+
export function hexToBytes(hex) {
|
|
36
|
+
const text = hex.trim().toLowerCase()
|
|
37
|
+
if (text.length % 2) throw new Error('p2p: hex length must be even')
|
|
38
|
+
const out = new Uint8Array(text.length / 2)
|
|
39
|
+
for (let index = 0; index < out.length; index++) {
|
|
40
|
+
const high = hexNibble(text.charCodeAt(index * 2))
|
|
41
|
+
const low = hexNibble(text.charCodeAt(index * 2 + 1))
|
|
42
|
+
if (high < 0 || low < 0) throw new Error('p2p: invalid hex')
|
|
43
|
+
out[index] = (high << 4) | low
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ArrayBuffer / TypedArray / Uint8Array → Uint8Array;`allowString` 时接受文本。
|
|
50
|
+
* @param {unknown} value 待转换值
|
|
51
|
+
* @param {{ allowString?: boolean }} [options] `allowString` 时把非字节输入当文本编码
|
|
52
|
+
* @returns {Uint8Array} 字节视图(可能与输入共享底层 buffer)
|
|
53
|
+
*/
|
|
54
|
+
export function toBytes(value, options = {}) {
|
|
55
|
+
if (value instanceof Uint8Array) return value
|
|
56
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
|
57
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
58
|
+
if (options.allowString)
|
|
59
|
+
return new TextEncoder().encode(typeof value === 'string' ? value : String(value ?? ''))
|
|
60
|
+
throw new Error('p2p: bytes must be Uint8Array-compatible')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Uint8Array} bytes 原始字节
|
|
65
|
+
* @returns {string} 标准 Base64
|
|
66
|
+
*/
|
|
67
|
+
export function bytesToBase64(bytes) {
|
|
10
68
|
let binary = ''
|
|
11
|
-
for (let index = 0; index <
|
|
69
|
+
for (let index = 0; index < bytes.length; index++) binary += String.fromCharCode(bytes[index])
|
|
12
70
|
return btoa(binary)
|
|
13
71
|
}
|
|
14
72
|
|
|
15
73
|
/**
|
|
16
|
-
* @param {string}
|
|
74
|
+
* @param {string} base64 标准 Base64
|
|
17
75
|
* @returns {Uint8Array} 解码后的字节
|
|
18
76
|
*/
|
|
19
|
-
export function
|
|
20
|
-
|
|
21
|
-
const out = new Uint8Array(bin.length)
|
|
22
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
23
|
-
return out
|
|
77
|
+
export function base64ToBytes(base64) {
|
|
78
|
+
return Uint8Array.from(atob(base64), char => char.charCodeAt(0))
|
|
24
79
|
}
|
package/core/composite_key.mjs
CHANGED
|
@@ -77,15 +77,15 @@ export function mapDeleteByPrefix(map, ...prefixParts) {
|
|
|
77
77
|
* 遍历前缀子树;回调收到前缀之后的各段与值。
|
|
78
78
|
* @template V
|
|
79
79
|
* @param {Map<string, V>} map 根表
|
|
80
|
-
* @param {...(string | ((tail: string[], value: V) => void))}
|
|
80
|
+
* @param {...(string | ((tail: string[], value: V) => void))} prefixPartsAndCallback 前缀段 + 末尾回调
|
|
81
81
|
* @returns {void}
|
|
82
82
|
*/
|
|
83
|
-
export function mapForEachUnder(map, ...
|
|
84
|
-
const
|
|
85
|
-
const prefix = compositePrefix(...
|
|
83
|
+
export function mapForEachUnder(map, ...prefixPartsAndCallback) {
|
|
84
|
+
const callback = prefixPartsAndCallback.pop()
|
|
85
|
+
const prefix = compositePrefix(...prefixPartsAndCallback)
|
|
86
86
|
for (const [k, value] of map) {
|
|
87
87
|
if (!k.startsWith(prefix)) continue
|
|
88
88
|
const tail = k.slice(prefix.length).split(SEP).filter(Boolean)
|
|
89
|
-
|
|
89
|
+
callback(tail, value)
|
|
90
90
|
}
|
|
91
91
|
}
|
package/core/tcp_port.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @returns {number | null} 有效端口或 null(未提供 / 非法)
|
|
5
5
|
*/
|
|
6
6
|
export function normalizeTcpPort(port) {
|
|
7
|
-
if (port
|
|
7
|
+
if (!port) return null
|
|
8
8
|
const value = Number(port)
|
|
9
9
|
if (!Number.isInteger(value) || value < 1 || value > 65535) return null
|
|
10
10
|
return value
|
|
@@ -49,9 +49,9 @@ export function isSignedCheckpoint(checkpoint) {
|
|
|
49
49
|
* @returns {Promise<{ valid: boolean, reason?: string }>} 校验结果;`valid` 为 false 时 `reason` 说明原因
|
|
50
50
|
*/
|
|
51
51
|
export async function verifyRemoteCheckpoint(checkpoint) {
|
|
52
|
-
if (!checkpoint
|
|
52
|
+
if (!checkpoint)
|
|
53
53
|
return { valid: false, reason: 'checkpoint missing or not an object' }
|
|
54
|
-
if (!
|
|
54
|
+
if (!checkpoint.eventIdsInEpoch?.length)
|
|
55
55
|
return { valid: false, reason: 'eventIdsInEpoch missing or empty' }
|
|
56
56
|
const root = merkleRoot(checkpoint.eventIdsInEpoch)
|
|
57
57
|
if (checkpoint.epoch_root_hash !== root)
|
package/crypto/crypto.mjs
CHANGED
|
@@ -150,9 +150,9 @@ export function sha256TextHex(text) {
|
|
|
150
150
|
/**
|
|
151
151
|
* Buffer / Uint8Array → 小写 hex 字符串
|
|
152
152
|
*
|
|
153
|
-
* @param {Uint8Array|Buffer}
|
|
153
|
+
* @param {Uint8Array|Buffer} buffer 二进制缓冲
|
|
154
154
|
* @returns {string} 小写十六进制文本
|
|
155
155
|
*/
|
|
156
|
-
function bufferToHexSimple(
|
|
157
|
-
return Buffer.from(
|
|
156
|
+
function bufferToHexSimple(buffer) {
|
|
157
|
+
return Buffer.from(buffer).toString('hex')
|
|
158
158
|
}
|
package/crypto/key.mjs
CHANGED
|
@@ -164,8 +164,8 @@ function edPubToX25519(edPub) {
|
|
|
164
164
|
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(yCopy[i])
|
|
165
165
|
const u = (1n + y) * modInv(1n - y + P25519, P25519) % P25519
|
|
166
166
|
const result = new Uint8Array(32)
|
|
167
|
-
let
|
|
168
|
-
for (let i = 0; i < 32; i++) { result[i] = Number(
|
|
167
|
+
let remaining = u
|
|
168
|
+
for (let i = 0; i < 32; i++) { result[i] = Number(remaining & 0xffn); remaining >>= 8n }
|
|
169
169
|
return result
|
|
170
170
|
}
|
|
171
171
|
|
|
@@ -346,11 +346,11 @@ export function encryptRandomPlaintextWithKey(plaintext, contentKey) {
|
|
|
346
346
|
*/
|
|
347
347
|
export function decryptConvergentCiphertext(raw, contentHashHex) {
|
|
348
348
|
try {
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
const iv =
|
|
352
|
-
const authTag =
|
|
353
|
-
const ciphertext =
|
|
349
|
+
const buffer = Buffer.from(raw)
|
|
350
|
+
if (buffer.length < 28) return null
|
|
351
|
+
const iv = buffer.subarray(0, 12)
|
|
352
|
+
const authTag = buffer.subarray(12, 28)
|
|
353
|
+
const ciphertext = buffer.subarray(28)
|
|
354
354
|
const contentKey = deriveContentKey(contentHashHex)
|
|
355
355
|
const decipher = createDecipheriv('aes-256-gcm', contentKey, iv)
|
|
356
356
|
decipher.setAuthTag(authTag)
|
|
@@ -371,11 +371,11 @@ export function decryptConvergentCiphertext(raw, contentHashHex) {
|
|
|
371
371
|
*/
|
|
372
372
|
export function decryptRandomCiphertext(raw, contentKey, contentHashHex = '') {
|
|
373
373
|
try {
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
376
|
-
const iv =
|
|
377
|
-
const authTag =
|
|
378
|
-
const ciphertext =
|
|
374
|
+
const buffer = Buffer.from(raw)
|
|
375
|
+
if (buffer.length < 28) return null
|
|
376
|
+
const iv = buffer.subarray(0, 12)
|
|
377
|
+
const authTag = buffer.subarray(12, 28)
|
|
378
|
+
const ciphertext = buffer.subarray(28)
|
|
379
379
|
const decipher = createDecipheriv('aes-256-gcm', Buffer.from(contentKey), iv)
|
|
380
380
|
decipher.setAuthTag(authTag)
|
|
381
381
|
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
package/dag/canonicalize_row.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { assertHex64, HEX_ID_64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
|
8
8
|
* @param {string} key 字段名
|
|
9
9
|
*/
|
|
10
10
|
function canonicalizeHexField(obj, key) {
|
|
11
|
-
if (obj[key]
|
|
11
|
+
if (!obj[key]) return
|
|
12
12
|
const normalized = normalizeHex64(obj[key])
|
|
13
13
|
if (!HEX_ID_64.test(normalized))
|
|
14
14
|
throw new Error(`${key} must be 64 hex characters`)
|
|
@@ -27,7 +27,7 @@ export function canonicalizeRowContent(content, hexKeys, entityHashKeys = new Se
|
|
|
27
27
|
for (const key of hexKeys)
|
|
28
28
|
canonicalizeHexField(out, key)
|
|
29
29
|
for (const key of entityHashKeys) {
|
|
30
|
-
if (out[key]
|
|
30
|
+
if (!out[key]) continue
|
|
31
31
|
const entityHash = String(out[key]).toLowerCase()
|
|
32
32
|
if (!isEntityHash128(entityHash))
|
|
33
33
|
throw new Error(`${key} must be 128 hex characters`)
|
|
@@ -54,7 +54,7 @@ export function canonicalizeSignedRow(event, options = {}) {
|
|
|
54
54
|
const out = options.prepare ? options.prepare({ ...event }) : { ...event }
|
|
55
55
|
out.id = assertHex64(out.id, 'id')
|
|
56
56
|
out.sender = assertHex64(out.sender, 'sender')
|
|
57
|
-
if (
|
|
57
|
+
if (out.prev_event_ids)
|
|
58
58
|
out.prev_event_ids = out.prev_event_ids.map((id, index) =>
|
|
59
59
|
assertHex64(id, `prev_event_ids[${index}]`),
|
|
60
60
|
)
|
package/dag/index.mjs
CHANGED
|
@@ -51,8 +51,7 @@ export function computeLocalTipsHash(tipIds) {
|
|
|
51
51
|
* @returns {string[]} 去重并字典序排序后的父事件 id 数组
|
|
52
52
|
*/
|
|
53
53
|
export function sortedPrevEventIds(raw) {
|
|
54
|
-
|
|
55
|
-
return [...new Set(raw.filter(id => EVENT_ID_HEX.test(String(id))))].sort()
|
|
54
|
+
return [...new Set((raw || []).filter(id => EVENT_ID_HEX.test(id)))].sort()
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
/**
|
|
@@ -226,7 +225,7 @@ export function topologicalCanonicalOrder(metas) {
|
|
|
226
225
|
|
|
227
226
|
while (ready.size) {
|
|
228
227
|
const next = ready.pop()
|
|
229
|
-
if (next
|
|
228
|
+
if (!next) break
|
|
230
229
|
ordered.push(next)
|
|
231
230
|
for (const childId of children.get(next) || []) {
|
|
232
231
|
const remaining = (parentCount.get(childId) || 0) - 1
|
package/dag/storage.mjs
CHANGED
|
@@ -20,40 +20,40 @@ const ATOMIC_RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
|
20
20
|
* @param {string} filePath 目标路径
|
|
21
21
|
* @returns {string} 唯一临时文件路径
|
|
22
22
|
*/
|
|
23
|
-
function
|
|
23
|
+
function atomicTemporaryPath(filePath) {
|
|
24
24
|
return `${filePath}.tmp.${process.pid}.${randomUUID()}`
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* @param {string}
|
|
28
|
+
* @param {string} temporaryPath 临时文件路径
|
|
29
29
|
* @returns {Promise<void>}
|
|
30
30
|
*/
|
|
31
|
-
async function
|
|
32
|
-
try { await unlink(
|
|
31
|
+
async function cleanupAtomicTemporary(temporaryPath) {
|
|
32
|
+
try { await unlink(temporaryPath) } catch { /* ok */ }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* 完成原子写的最终 rename;若目标目录已在 cleanup
|
|
37
|
-
* @param {string}
|
|
36
|
+
* 完成原子写的最终 rename;若目标目录已在 cleanup 中消失,则清理残余临时文件后静默返回。
|
|
37
|
+
* @param {string} temporaryPath 临时文件路径
|
|
38
38
|
* @param {string} filePath 最终目标路径
|
|
39
39
|
* @returns {Promise<boolean>} 是否已成功落到目标路径
|
|
40
40
|
*/
|
|
41
|
-
export async function finalizeAtomicRename(
|
|
41
|
+
export async function finalizeAtomicRename(temporaryPath, filePath) {
|
|
42
42
|
/** @type {NodeJS.ErrnoException | undefined} */
|
|
43
43
|
let lastError
|
|
44
44
|
for (const delayMs of ATOMIC_RENAME_RETRY_DELAYS_MS) {
|
|
45
45
|
if (delayMs) await sleep(delayMs)
|
|
46
46
|
try {
|
|
47
|
-
await rename(
|
|
47
|
+
await rename(temporaryPath, filePath)
|
|
48
48
|
return true
|
|
49
49
|
}
|
|
50
|
-
catch (
|
|
51
|
-
lastError =
|
|
52
|
-
if (ATOMIC_RENAME_RETRY_CODES.has(
|
|
50
|
+
catch (error) {
|
|
51
|
+
lastError = error
|
|
52
|
+
if (ATOMIC_RENAME_RETRY_CODES.has(error?.code)) continue
|
|
53
53
|
break
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
await
|
|
56
|
+
await cleanupAtomicTemporary(temporaryPath)
|
|
57
57
|
if (lastError?.code === 'ENOENT') return false
|
|
58
58
|
throw lastError
|
|
59
59
|
}
|
|
@@ -132,7 +132,7 @@ export async function* readJsonlStream(filePath, options = {}) {
|
|
|
132
132
|
export async function rewriteJsonlKeeping(filePath, keep, options = {}) {
|
|
133
133
|
const dir = dirname(filePath)
|
|
134
134
|
await mkdir(dir, { recursive: true })
|
|
135
|
-
const
|
|
135
|
+
const temporaryPath = atomicTemporaryPath(filePath)
|
|
136
136
|
/** @type {object[]} */
|
|
137
137
|
const buffer = []
|
|
138
138
|
let kept = 0
|
|
@@ -143,7 +143,7 @@ export async function rewriteJsonlKeeping(filePath, keep, options = {}) {
|
|
|
143
143
|
let block = ''
|
|
144
144
|
for (const row of buffer)
|
|
145
145
|
block += `${JSON.stringify(row)}\n`
|
|
146
|
-
await appendFile(
|
|
146
|
+
await appendFile(temporaryPath, block, 'utf8')
|
|
147
147
|
buffer.length = 0
|
|
148
148
|
}
|
|
149
149
|
try {
|
|
@@ -159,7 +159,7 @@ export async function rewriteJsonlKeeping(filePath, keep, options = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
catch { /* source missing */ }
|
|
161
161
|
if (kept > 0 || dropped > 0)
|
|
162
|
-
await finalizeAtomicRename(
|
|
162
|
+
await finalizeAtomicRename(temporaryPath, filePath)
|
|
163
163
|
else
|
|
164
164
|
try { await writeFile(filePath, '', 'utf8') }
|
|
165
165
|
catch { /* ok */ }
|
|
@@ -179,9 +179,9 @@ export async function readJsonlTipId(filePath) {
|
|
|
179
179
|
const { size } = await fh.stat()
|
|
180
180
|
if (!size) return null
|
|
181
181
|
const chunk = Math.min(size, 65_536)
|
|
182
|
-
const
|
|
183
|
-
await fh.read(
|
|
184
|
-
const lines =
|
|
182
|
+
const buffer = Buffer.alloc(chunk)
|
|
183
|
+
await fh.read(buffer, 0, chunk, size - chunk)
|
|
184
|
+
const lines = buffer.toString('utf8').split('\n').filter(Boolean)
|
|
185
185
|
const last = lines[lines.length - 1]
|
|
186
186
|
if (!last) return null
|
|
187
187
|
const row = JSON.parse(last)
|
|
@@ -205,14 +205,14 @@ export async function readJsonlTipId(filePath) {
|
|
|
205
205
|
export async function writeJsonl(filePath, records) {
|
|
206
206
|
const dir = dirname(filePath)
|
|
207
207
|
await mkdir(dir, { recursive: true })
|
|
208
|
-
const
|
|
208
|
+
const temporaryPath = atomicTemporaryPath(filePath)
|
|
209
209
|
/** @returns {Generator<string>} JSONL 行 */
|
|
210
210
|
function* lines() {
|
|
211
211
|
for (const rec of records)
|
|
212
212
|
yield `${JSON.stringify(rec)}\n`
|
|
213
213
|
}
|
|
214
|
-
await pipeline(Readable.from(lines()), createWriteStream(
|
|
215
|
-
await finalizeAtomicRename(
|
|
214
|
+
await pipeline(Readable.from(lines()), createWriteStream(temporaryPath, { encoding: 'utf8' }))
|
|
215
|
+
await finalizeAtomicRename(temporaryPath, filePath)
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
@@ -253,18 +253,18 @@ export async function appendJsonlSynced(filePath, record) {
|
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
255
|
* 写入原子临时文件;若父目录已在 cleanup 竞态中消失(ENOENT)则返回 false。
|
|
256
|
-
* @param {string}
|
|
256
|
+
* @param {string} temporaryPath 临时文件路径
|
|
257
257
|
* @param {string} data 文件内容
|
|
258
258
|
* @returns {Promise<boolean>} 是否已写入
|
|
259
259
|
*/
|
|
260
|
-
async function
|
|
260
|
+
async function writeAtomicTemporary(temporaryPath, data) {
|
|
261
261
|
try {
|
|
262
|
-
await writeFile(
|
|
262
|
+
await writeFile(temporaryPath, data, 'utf8')
|
|
263
263
|
return true
|
|
264
264
|
}
|
|
265
|
-
catch (
|
|
266
|
-
if (
|
|
267
|
-
throw
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error?.code === 'ENOENT') return false
|
|
267
|
+
throw error
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
@@ -277,9 +277,9 @@ async function writeAtomicTmp(tmp, data) {
|
|
|
277
277
|
export async function writeJsonAtomic(filePath, obj) {
|
|
278
278
|
const dir = dirname(filePath)
|
|
279
279
|
await mkdir(dir, { recursive: true })
|
|
280
|
-
const
|
|
281
|
-
if (!await
|
|
282
|
-
await finalizeAtomicRename(
|
|
280
|
+
const temporaryPath = atomicTemporaryPath(filePath)
|
|
281
|
+
if (!await writeAtomicTemporary(temporaryPath, JSON.stringify(obj, null, '\t'))) return
|
|
282
|
+
await finalizeAtomicRename(temporaryPath, filePath)
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
/**
|
|
@@ -291,16 +291,16 @@ export async function writeJsonAtomic(filePath, obj) {
|
|
|
291
291
|
export async function writeJsonAtomicSynced(filePath, obj) {
|
|
292
292
|
const dir = dirname(filePath)
|
|
293
293
|
await mkdir(dir, { recursive: true })
|
|
294
|
-
const
|
|
295
|
-
if (!await
|
|
296
|
-
const
|
|
294
|
+
const temporaryPath = atomicTemporaryPath(filePath)
|
|
295
|
+
if (!await writeAtomicTemporary(temporaryPath, JSON.stringify(obj, null, '\t'))) return
|
|
296
|
+
const fileHandle = await open(temporaryPath, 'r+')
|
|
297
297
|
try {
|
|
298
|
-
await
|
|
298
|
+
await fileHandle.sync()
|
|
299
299
|
}
|
|
300
300
|
finally {
|
|
301
|
-
await
|
|
301
|
+
await fileHandle.close()
|
|
302
302
|
}
|
|
303
|
-
if (!await finalizeAtomicRename(
|
|
303
|
+
if (!await finalizeAtomicRename(temporaryPath, filePath)) return
|
|
304
304
|
const outFh = await open(filePath, 'r+')
|
|
305
305
|
try {
|
|
306
306
|
await outFh.sync()
|
|
@@ -5,7 +5,7 @@ import { noteLanPeerHint } from './lan_peer_hints.mjs'
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* 从已验证的 discovery advert + provider meta 写入 LAN/BT peer hints。
|
|
8
|
-
* 任意 discovery 路径(node / group / scoped
|
|
8
|
+
* 任意 discovery 路径(node / group / scoped)收到 advert 时都应调用。
|
|
9
9
|
* @param {string} verifiedNodeHash 验签通过的 nodeHash
|
|
10
10
|
* @param {{ tcpPort?: unknown } | null | undefined} body advert body
|
|
11
11
|
* @param {{ address?: unknown, peripheralId?: unknown } | null | undefined} meta discovery provider meta
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
decryptSignalPacket,
|
|
6
|
+
encryptSignalPacket,
|
|
7
|
+
groupRendezvousKey,
|
|
8
|
+
networkRendezvousKey,
|
|
9
|
+
nodeRendezvousKey,
|
|
10
|
+
} from './internal/signal_crypto.mjs'
|
|
11
|
+
|
|
12
|
+
/** @typedef {'node' | 'network' | { roomSecret: string }} AdvertScope */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 按 scope 派生 rendezvous 键。
|
|
16
|
+
* @param {AdvertScope} scope advert 域
|
|
17
|
+
* @param {string} selfNodeHash 本机 nodeHash
|
|
18
|
+
* @returns {string} rendezvous 键(discovery 内部)
|
|
19
|
+
*/
|
|
20
|
+
export function rendezvousKeyForScope(scope, selfNodeHash) {
|
|
21
|
+
if (scope === 'network') return networkRendezvousKey()
|
|
22
|
+
if (scope === 'node') return nodeRendezvousKey(selfNodeHash)
|
|
23
|
+
if (scope?.roomSecret) return groupRendezvousKey(scope.roomSecret)
|
|
24
|
+
throw new Error('p2p: invalid advert scope')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 为本机身份构建已签名 advert body。
|
|
29
|
+
* @param {AdvertScope} scope advert 域
|
|
30
|
+
* @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} localIdentity 本地身份
|
|
31
|
+
* @param {number | null | undefined} [tcpPort] LAN TCP 端口
|
|
32
|
+
* @returns {Promise<object>} 签名 advert body
|
|
33
|
+
*/
|
|
34
|
+
export async function buildSignedAdvertForScope(scope, localIdentity, tcpPort) {
|
|
35
|
+
const key = rendezvousKeyForScope(scope, localIdentity.nodeHash)
|
|
36
|
+
return await buildSignedAdvert(key, Date.now(), {
|
|
37
|
+
...localIdentity,
|
|
38
|
+
...tcpPort != null ? { tcpPort } : {},
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* AES-GCM 封装已签名 advert 包。
|
|
44
|
+
* @param {string} rendezvousKey rendezvous 键
|
|
45
|
+
* @param {object} advertBody 已签名 advert
|
|
46
|
+
* @returns {Uint8Array} 加密 advert 字节
|
|
47
|
+
*/
|
|
48
|
+
export function encryptAdvertPacket(rendezvousKey, advertBody) {
|
|
49
|
+
return encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 按 scope 加密已签名 advert。
|
|
54
|
+
* @param {AdvertScope} scope advert 域
|
|
55
|
+
* @param {{ nodeHash: string }} localIdentity 本地身份(仅需 nodeHash)
|
|
56
|
+
* @param {object} advertBody 已签名 advert
|
|
57
|
+
* @returns {Uint8Array} 加密 advert 字节
|
|
58
|
+
*/
|
|
59
|
+
export function encryptAdvertForScope(scope, localIdentity, advertBody) {
|
|
60
|
+
return encryptAdvertPacket(rendezvousKeyForScope(scope, localIdentity.nodeHash), advertBody)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Untrusted ingress:解密并验签 advert;失败返回 null,不抛。不写入可见池 / peer hints。
|
|
65
|
+
* @param {string} rendezvousKey rendezvous 键
|
|
66
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
67
|
+
* @param {object} [meta] 元数据
|
|
68
|
+
* @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHash 与 advert body,否则 null
|
|
69
|
+
*/
|
|
70
|
+
export async function ingestEncryptedAdvert(rendezvousKey, bytes, meta) {
|
|
71
|
+
void meta
|
|
72
|
+
const packet = decryptSignalPacket(rendezvousKey, bytes)
|
|
73
|
+
if (packet?.type !== 'advert' || !packet.body) return null
|
|
74
|
+
const verifiedNodeHash = await verifySignedAdvert(rendezvousKey, packet.body)
|
|
75
|
+
if (!verifiedNodeHash) return null
|
|
76
|
+
return { verifiedNodeHash, body: packet.body }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Untrusted ingress:验签 network-scope advert;失败返回 null。不写盘 / 不写 hints。
|
|
81
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
82
|
+
* @param {object} [meta] 元数据
|
|
83
|
+
* @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHash 与 advert body,否则 null
|
|
84
|
+
*/
|
|
85
|
+
export async function ingestNetworkAdvert(bytes, meta) {
|
|
86
|
+
return ingestEncryptedAdvert(networkRendezvousKey(), bytes, meta)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Untrusted ingress:验签 node-scope advert;失败返回 null。不写盘 / 不写 hints。
|
|
91
|
+
* @param {string} nodeHash 目标 nodeHash
|
|
92
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
93
|
+
* @param {object} [meta] 元数据
|
|
94
|
+
* @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHash 与 advert body,否则 null
|
|
95
|
+
*/
|
|
96
|
+
export async function ingestNodeAdvert(nodeHash, bytes, meta) {
|
|
97
|
+
return ingestEncryptedAdvert(nodeRendezvousKey(normalizeHex64(nodeHash)), bytes, meta)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Untrusted ingress:验签 group-scope advert;失败返回 null。不写盘 / 不写 hints。
|
|
102
|
+
* @param {string} roomSecret 房间密钥
|
|
103
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
104
|
+
* @param {object} [meta] 元数据
|
|
105
|
+
* @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHash 与 advert body,否则 null
|
|
106
|
+
*/
|
|
107
|
+
export async function ingestGroupAdvert(roomSecret, bytes, meta) {
|
|
108
|
+
return ingestEncryptedAdvert(groupRendezvousKey(roomSecret), bytes, meta)
|
|
109
|
+
}
|