@steve02081504/fount-p2p 0.0.30 → 0.0.32

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 (64) hide show
  1. package/AGENTS.md +79 -0
  2. package/docs/evfs.md +32 -0
  3. package/docs/infra.md +25 -0
  4. package/docs/mesh.md +67 -0
  5. package/docs/reputation.md +22 -0
  6. package/docs/runtime.md +62 -0
  7. package/docs/signaling.md +23 -0
  8. package/docs/transports.md +92 -0
  9. package/docs/wire.md +21 -0
  10. package/link/providers/webrtc.mjs +5 -2
  11. package/link/rtc/ice_local_hostname.mjs +15 -47
  12. package/link/rtc/polyfill.mjs +3 -15
  13. package/package.json +2 -9
  14. package/sim/AGENTS.md +29 -0
  15. package/sim/admission.mjs +56 -0
  16. package/sim/anomaly.mjs +39 -0
  17. package/sim/apply.mjs +77 -0
  18. package/sim/attack_space.mjs +113 -0
  19. package/sim/attacks.mjs +614 -0
  20. package/sim/behavior.mjs +78 -0
  21. package/sim/cli.mjs +282 -0
  22. package/sim/coevolution.mjs +245 -0
  23. package/sim/cold_start.md +13 -0
  24. package/sim/constants.mjs +44 -0
  25. package/sim/discovery.mjs +259 -0
  26. package/sim/duration.mjs +64 -0
  27. package/sim/graph_adj.mjs +28 -0
  28. package/sim/integrity.mjs +58 -0
  29. package/sim/metrics.mjs +423 -0
  30. package/sim/model.mjs +1176 -0
  31. package/sim/optimizer.mjs +250 -0
  32. package/sim/propagation.mjs +57 -0
  33. package/sim/report.mjs +176 -0
  34. package/sim/reputation_social.tunables.json +6 -0
  35. package/sim/rng.mjs +62 -0
  36. package/sim/scenarios.mjs +345 -0
  37. package/sim/sim_pool.mjs +169 -0
  38. package/sim/sim_worker.mjs +31 -0
  39. package/sim/social_reputation.mjs +30 -0
  40. package/sim/space.mjs +312 -0
  41. package/sim/test/admission.test.mjs +25 -0
  42. package/sim/test/attack_space.test.mjs +39 -0
  43. package/sim/test/behavior_vector.test.mjs +37 -0
  44. package/sim/test/coevolution.test.mjs +32 -0
  45. package/sim/test/cold_start.test.mjs +46 -0
  46. package/sim/test/defense.test.mjs +320 -0
  47. package/sim/test/discovery.test.mjs +68 -0
  48. package/sim/test/duration.test.mjs +20 -0
  49. package/sim/test/fidelity.test.mjs +151 -0
  50. package/sim/test/integrity.test.mjs +56 -0
  51. package/sim/test/optimizer.test.mjs +24 -0
  52. package/sim/test/propagation.test.mjs +29 -0
  53. package/sim/test/sim_model_regressions.test.mjs +110 -0
  54. package/sim/test/slow_drip.test.mjs +28 -0
  55. package/sim/test/smoke.test.mjs +52 -0
  56. package/sim/test/transport.test.mjs +55 -0
  57. package/sim/test/tunables_apply.test.mjs +64 -0
  58. package/sim/test/tunables_param_space.test.mjs +74 -0
  59. package/sim/test/vulnerability.test.mjs +127 -0
  60. package/sim/transport.mjs +135 -0
  61. package/sim/tunables_bundle.mjs +71 -0
  62. package/sim/vulnerability.mjs +532 -0
  63. package/deno.json +0 -7
  64. package/link/rtc/w3c_bridge.mjs +0 -152
package/AGENTS.md ADDED
@@ -0,0 +1,79 @@
1
+ # P2P / Federation / Entity Files Guide
2
+
3
+ ## Package layers (`@steve02081504/fount-p2p`)
4
+
5
+ | Layer | Directory | Role |
6
+ |---|---|---|
7
+ | L0 | `core/` | IDs, logical entity, canonical JSON, bytes |
8
+ | L1 | `crypto/`, `wire/`, `schemas/` | Crypto, wire ingress, canonical validation |
9
+ | L2 | `node/` | `initNode`, identity, entity store, denylist, reputation, storage plugins |
10
+ | L3 | `discovery/`, `link/`, `transport/` | fount network (registry/rooms); `registerLinkProvider` via `./link` or facade |
11
+ | L4 | `trust_graph/`, `mailbox/`, `dag/`, `federation/`, `files/`, `governance/`, `reputation/` | Federation, store-and-forward, DAG, EVFS, tunables |
12
+
13
+ **Outside the package** (shell / frontend; p2p must not import): chat/social semantics, mention rendering, entity identity provisioning, etc. Standalone clients: `import { startNode } from '@steve02081504/fount-p2p'`.
14
+
15
+ **Facade:** `index.mjs`; subpath exports mirror directories.
16
+
17
+ Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signaling](docs/signaling.md) · [runtime](docs/runtime.md) · [infra](docs/infra.md) · [wire](docs/wire.md) · [evfs](docs/evfs.md) · [reputation](docs/reputation.md)
18
+
19
+ ### Runtime: isomorphic vs Node
20
+
21
+ | Surface | Runtime | Notes |
22
+ |---|---|---|
23
+ | `core/*` | Node + browser | No `node:*` builtins |
24
+ | `crypto/crypto.mjs` | Node + browser | `@noble/hashes` + `@noble/curves` only — never `node:crypto` |
25
+ | `crypto/key.mjs` / `crypto/channel.mjs`, disk I/O, LAN/BT, `ws`, CLI / `startNode` | Node (+ Deno bridge) | Do not load the whole package via esm.sh in the browser |
26
+
27
+ Deno / native / BT: [runtime.md](docs/runtime.md).
28
+
29
+ ## Conventions
30
+
31
+ - Prefer shared helpers under `utils/`, `core/`, `wire/subscribe`, `wire/adapter` — do not reimplement LRU/TTL/inflight/atomic-fs/shuffle.
32
+ - **No pure-forward aliases:** do not add `fooText`/`fooAlias` that only `return foo(sameArgs)` when the callee already accepts those types (e.g. never wrap `sha256Hex` as `sha256TextHex`). Domain names must add logic or type narrowing, not just rename.
33
+ - **Heterogeneous backends:** normalize at the load boundary (e.g. `link/rtc/ice_local_hostname.mjs` wraps W3C RTC backends); call sites speak one contract.
34
+ - **File naming:** parent directory is scope — short child names. Tunables default `<dir>/tunables.json` (exception: `schemas/part_query.tunables.json`). Subpath `package.json` exports mirror filenames.
35
+ - **Import boundary:** `test/integration/p2p_shell_import_guard.test.mjs`.
36
+ - **No scattered `trim` / `toLowerCase`:** hex IDs must already be lowercase; `normalizeHex64` strips an optional `0x` only — mixed case / whitespace is rejected. Exceptions: JSONL blank lines, SDP fingerprint, CLI/`scripts` parsing.
37
+ - **No `String(x)` / `x || ''` on typed `string`:** if `@param {string}`, use it directly; `String(...)` / `|| ''` / `?? ''` only at optional / `unknown` / disk / inbound boundaries, or number→string.
38
+ - **Optional methods:** `if (fn) return await fn(...)` / `if (fn) …` — never `typeof x === 'function'`.
39
+
40
+ ## Tests / tools
41
+
42
+ - `npm test` — pure + integration (Node; `--test-force-exit`)
43
+ - `npm run test:live` — live link / LAN smoke
44
+ - `npm run test:fount` — cross-repo Deno bridge (`test/fount/`); see [runtime.md](docs/runtime.md)
45
+ - `npm run test:sim` — tunables co-evolution (dev-only; [sim/AGENTS.md](sim/AGENTS.md))
46
+ - `node scripts/check-imports.mjs` — relative import check
47
+ - `node scripts/find-unused-exports.mjs` — dead-export scan (`--fount <path>` optional)
48
+ - Assertions: `test/helpers/assert.mjs`
49
+ - Fixed-seed identity: `test/helpers/identity.mjs`
50
+ - Mock discovery: `test/helpers/mock_discovery.mjs`
51
+
52
+ ## Hard rules
53
+
54
+ ### Trust / ingress
55
+
56
+ - **Untrusted ingress only:** discovery adverts/signals, link/overlay envelopes, group federation frames, `remoteIngest`, `part_timeline_*` / `part_invoke`, `part_query_*`, public manifest (`fed_manifest_data`). Validate / `canonicalize*` / `verifySignedPublicManifest` **only** here.
57
+ - **Trusted after disk:** from `events.jsonl`, only `stripDagEventLocalExtensions` — no re-canonicalization upstream.
58
+ - **Fanout vs targeted / timed collect:** [wire.md](docs/wire.md).
59
+ - **Channel encryption:** per-channel `K_ch`, scheme `channel-key` (`CHANNEL_KEY_SCHEME`); decrypted payloads are untrusted outside DAG Ed25519 context.
60
+ - **Denylist vs personal lists:** node `denylist.json` vs per-entity `personal_block.json` / `personal_hide.json`.
61
+ - **Manifest ACL / transfer owner:** shells register matchers; core does not hard-code chat/social types.
62
+
63
+ ### Node / network
64
+
65
+ - **Node data:** `initNode({ nodeDir, entityStore? })` — `node.json`, `network.json`, `denylist.json`, `reputation.json`, `mailbox/`, `chunks/`. Default EntityStore: `{nodeDir}/entities/`. No `FOUNT_*` env knobs — subprocess IPC uses argv.
66
+ - **fount network:** shells use `startNode` / `ensureLinkToNode` / `sendToNodeLink` / rooms — never import `link/` internals or pick a transport. Providers: `registerLinkProvider` from `./link` or facade.
67
+ - **Link `level` vs discovery `priority`:** descending `level` picks data transport (`nostr` = −∞ last resort); ascending `priority` orders handshake/presence media only. [transports.md](docs/transports.md)
68
+ - **Mesh first / no versioning:** ≥N links (K acquaintances + N−K explore); discovery API is `listVisibleNodeHashes` + `connectToNode` only; no topic on the fount-network surface; no version/compat fields. [mesh.md](docs/mesh.md)
69
+ - **Room / registry:** `configureLinkRegistry(opts)` before first `getLinkRegistry`. `startNode` does not take registry options. `createGroupLinkSet` is the kernel; `createScopedLinkRoom` is a dial-all preset. Rooms call `registry.ensureRuntime()` before subscribe/advertise — [runtime.md](docs/runtime.md)
70
+ - **Fetch ≠ apply:** `ingestEncryptedAdvert` vs `noteAdvertPeerHints`; reputation pull never writes — [reputation.md](docs/reputation.md). Public-manifest cache/fanout: [evfs.md](docs/evfs.md). Node-scope attaches: [infra.md](docs/infra.md).
71
+
72
+ ## Tunables JSON
73
+
74
+ | File | Directory |
75
+ |---|---|
76
+ | `tunables.json` | `reputation/`, `trust_graph/`, `mailbox/`, `governance/`, `dag/` |
77
+ | `part_query.tunables.json` | `schemas/` |
78
+
79
+ Sim harness: `sim/tunables_bundle.mjs` (dev-only). See [sim/AGENTS.md](sim/AGENTS.md).
package/docs/evfs.md ADDED
@@ -0,0 +1,32 @@
1
+ # EVFS public manifests & fetch
2
+
3
+ Day-to-day package rules: [AGENTS.md](../AGENTS.md). Implementation: `files/` (`evfs`, `evfs_ref`, `chunk/*`, `manifest/*`).
4
+
5
+ ## Storage
6
+
7
+ | What | Path |
8
+ | --- | --- |
9
+ | Ciphertext chunks (CAS) | `{nodeDir}/chunks/` |
10
+ | Manifests | `{EntityStoreRoot}/{entityHash}/files/{path}.manifest.json` |
11
+
12
+ ## Public publish / verify
13
+
14
+ - `publishPublicFile` signs with the recovery key.
15
+ - Remote path: `fed_manifest_get` → `verifySignedPublicManifest` → cache.
16
+ - Signature covers **content fields only**. After verify, drop incoming `meta` except `publicSig`.
17
+ - Profile / avatar meaning belongs in the shell — not in this package.
18
+
19
+ ## `fetchPublicManifest`
20
+
21
+ | Case | Behavior |
22
+ | --- | --- |
23
+ | Default options | No cache write |
24
+ | Local hit with `publicSig` | Return immediately; fanout revalidates in the background. With `cache: true`, write only when remote `publishedAt` is newer |
25
+ | Cold miss | Await fanout |
26
+ | Same key in flight | Deduped via `utils/inflight_table` |
27
+
28
+ Outer caller timeouts must **not** abort the in-flight work — background fill continues after the caller gives up.
29
+
30
+ ## ACL
31
+
32
+ Shells register matchers via the ACL registry. Core does not hard-code chat/social entity types.
package/docs/infra.md ADDED
@@ -0,0 +1,25 @@
1
+ # Infra relay & node-scope attaches
2
+
3
+ Optional public-good overlay + mailbox. Day-to-day rules: [AGENTS.md](../AGENTS.md). Wire part attaches: [wire.md](wire.md).
4
+
5
+ ## `startInfra` / `stopInfra`
6
+
7
+ Call after `initNode`. CLI: `npx @steve02081504/fount-p2p`.
8
+
9
+ - **Connectivity debug:** CLI default on; `--quiet` off. Non-CLI: `setConnectivityDebug(true)`.
10
+ - **Priority:** `setInfraPriority({ useLocalReputation })` reads local `reputation.json` only. `stopInfra` resets priority config so the next start does not inherit a ghost weight.
11
+ - **Reputation pull/export is separate:** `pullReputationFromNode` → JSON; `setReputationTable` to apply. Infra does **not** attach `rep_sync`. Donor must `attachReputationSyncWire()` (+ export allowlist); pull side auto-attaches.
12
+ - **`lockReputationMax` / `unlockReputationMax`:** unlock restores the pre-lock score.
13
+ - **`stopInfra` scope:** releases only its own attach refs, restores `maxActive`, clears rate/debug/priority weight.
14
+
15
+ ## Node-scope attaches
16
+
17
+ Composable attaches under `transport/node_scope/` — import concrete modules, no directory barrel:
18
+
19
+ - `transport/node_scope/wire` — `ensureNodeScope`, `registerNodeScopeWireHook` (shell custom actions when wire is ready), helpers
20
+ - `transport/node_scope/features` — `attachNodeScopeMailbox` / `Part` / `PartQuery` / `Chunks`, `attachNodeScopeDefaultFeatures`, `attachNodeScopeFeature`
21
+
22
+ Each attach returns `dispose` and is **refcount-shared** (infra + default features can both hold mailbox without double handlers).
23
+
24
+ - `ensureUserRoom` is **slot + runtime only** (default `attachDefaultWires: false`).
25
+ - Full preset: `attachNodeScopeDefaultFeatures()` or `ensureUserRoom({ attachDefaultWires: true })`.
package/docs/mesh.md ADDED
@@ -0,0 +1,67 @@
1
+ # Mesh policy
2
+
3
+ A fount node's **first job is to join and stay on the fount network**. Link presence is not business trust: discovery and dialing stay aggressive; trust and fanout are constrained by reputation / TrustGraph / denylist.
4
+
5
+ Do **not** treat "unrelated ⇒ no interconnect" or "no common group ⇒ no dial" as design premises. Nodes with no acquaintances, no shared groups, and no shell intro must still reach the network via discovery media.
6
+
7
+ No versioning — see [transports.md](transports.md).
8
+
9
+ ## Discovery: no topic on the fount-network surface
10
+
11
+ Each discovery / scannable medium exposes only:
12
+
13
+ 1. `listVisibleNodeHashes({ limit? })` → `nodeHash[]` visible on that medium
14
+ 2. `connectToNode(nodeHash)` → dial via registry (`ensureLinkToNode`)
15
+
16
+ Scans with `roomSecret` return **only that group's visible pool**. Media without group semantics (LAN / BT) return `[]` — do not pour network/LAN nodes into group membership.
17
+
18
+ Topic, relay tags, rendezvous keys, and signal crypto live **only** under `discovery/`. Group flows pass `roomSecret` and other business keys; transport only sees `nodeHash` / opaque bytes.
19
+
20
+ Visible pools (Nostr / BT / LAN) only accept **signature-verified** adverts; forged `body.nodeHash` after decrypt alone does not enter the pool. BT scan also records `peripheralId` via `noteAdvertPeerHints` so `connectToNode` / `ble_gatt` can dial.
21
+
22
+ Group presence / advert watch / signaling fan-in **all** providers that implement the methods — do not hard-bind `nostr` id.
23
+
24
+ No discovery-layer mDNS. LAN UDP presence must carry signed+encrypted network `advertBytes`; unsigned `nodeHash` beacons are ignored. WebRTC ICE `.local` host-candidate filtering is `iceLocalHostnamePolicy` (unrelated to discovery).
25
+
26
+ | Medium | Scan | Dial |
27
+ | --- | --- | --- |
28
+ | Nostr | Internal rendezvous; surface is hash list only | Internal signal + trigger link |
29
+ | lan_tcp | Segment presence/beacon (not topic) | TCP dial |
30
+ | BT | Near-field scan | GATT / near-field assist |
31
+
32
+ ## Keep-alive: N links, K + (N−K)
33
+
34
+ After `ensureRuntime`, aim for at least **N** active peer links (subject to `maxActive` / routing profile — "no acquaintances" is never an excuse for N=0):
35
+
36
+ | Slot | Count | Source | Selection |
37
+ | --- | --- | --- | --- |
38
+ | Acquaintance | **K** (`K ≤ N`) | `trustedPeers` / high reputation / recent stable peers | High confidence first |
39
+ | Explore | **N−K** | `listVisibleNodeHashes` + PEX/hints | Continuous try; rotate on failure |
40
+
41
+ Explore keeps the node reachable when acquaintances are offline. Stable explore peers (tunable `meshPromoteStableMs`) promote via `promoteExplorePeer` into `trustedPeers`.
42
+
43
+ `transport/mesh_keepalive.mjs`: explore eviction when full; acquaintance rebalance may kick explore slots; proactive close does not sticky re-dial; inbound non-acquaintance peers are tagged explore.
44
+
45
+ ### K = 0 (new node / empty trust table)
46
+
47
+ **N−K = N**: automatically `listVisibleNodeHashes` + `connectToNode` on each medium — do not idle waiting for the shell to inject friends.
48
+
49
+ ## Trust boundary (linked ≠ trusted)
50
+
51
+ - **Dial / keep-alive / overlay:** connectivity first.
52
+ - **Federation fanout, reputation seeding:** TrustGraph / reputation / denylist; explore slots default low trust.
53
+ - **Inbound payloads:** untrusted ingress — see [AGENTS.md](../AGENTS.md).
54
+
55
+ ## Sparse group linking (orthogonal)
56
+
57
+ Within a **known member set**, `group_link_set` dials sparsely for budget (`selectLinkTargetsFromMembers` within `resolveFederationPoolLimits`: top trusted + explore, denylist/quarantine filtered, **anchors always included**). Member discovery uses `listVisibleNodeHashes({ roomSecret })` + `connectToNode` — **not** `subscribe(groupTopic)`.
58
+
59
+ `start()` dials once; membership changes debounce via `notePeerCandidate` (dial newly selected only; never proactive cut — `trimToBudget` is the backstop). This is a budget choice on the member graph, not a ban on linking outside the group.
60
+
61
+ On a sparse group mesh, first-seen valid events forward to federation targets minus sender. Relaying is not a reputation penalty.
62
+
63
+ ## See also
64
+
65
+ - [transports.md](transports.md)
66
+ - [signaling.md](signaling.md)
67
+ - [runtime.md](runtime.md)
@@ -0,0 +1,22 @@
1
+ # Subjective reputation
2
+
3
+ Day-to-day package rules: [AGENTS.md](../AGENTS.md). Tunables: `reputation/tunables.json`. Sim co-evolution: [sim/AGENTS.md](../sim/AGENTS.md).
4
+
5
+ ## Storage
6
+
7
+ One global score per peer at `{nodeDir}/reputation.json`.
8
+
9
+ ## Slash / anti-Sybil
10
+
11
+ - **Subjective slash:** `subjectiveSlashPenalty` — influence scales with sender trust.
12
+ - **Anti-Sybil:** `applyDecayCollusionAfterSlash` after slash / kick / ban.
13
+ - **Safe penalties:** self-observed attributable signals only.
14
+
15
+ ## Do not add
16
+
17
+ - Penalties for merely relaying invalid events
18
+ - Penalties for RPC timeouts or empty responses
19
+
20
+ ## Fetch ≠ apply
21
+
22
+ `pullReputationFromNode` returns JSON only — never writes. Apply via `setReputationTable` (or equivalent). Infra does not attach `rep_sync` by default — see [infra.md](infra.md).
@@ -0,0 +1,62 @@
1
+ # Runtime bootstrap & lifecycle
2
+
3
+ `ensureRuntime`, startup/shutdown budgets, and Bluetooth hardware probe. Day-to-day shell rules: [AGENTS.md](../AGENTS.md). Providers: [transports.md](transports.md). Mesh N/K: [mesh.md](mesh.md).
4
+
5
+ ## `ensureRuntime` contract
6
+
7
+ Returns after registering lan / nostr / bt discovery providers and scheduling background warm — does **not** await lan_tcp listen, Nostr relays, or BT.
8
+
9
+ | Who waits | For what |
10
+ | --- | --- |
11
+ | Shells (`startNode` / `ensureUserRoom`) | Nothing beyond `ensureRuntime` itself — never read `lanTcpPort` or await public-signaling warm-up |
12
+ | `buildLocalAdvert` / `whenListening` | Local lan_tcp listen only |
13
+ | `ensureLinkToNode` | `whenSignalListening` (Nostr `listenNodeSignals` attached) before dial, so offer/answer does not drop the first signal |
14
+
15
+ Nostr / LAN / BT hooks are progressive. BT discovery / `ble_gatt` always warm in the background.
16
+
17
+ ### Fast-listen & dial path
18
+
19
+ - Fast-listen skips providers with `caps.probe: 'native'` (webrtc/ble) — never call their `isAvailable()` on the startup path.
20
+ - Dial path: `canReach` then `isAvailable` per provider (do not `await listAvailableLinkProviders()` first).
21
+
22
+ ### Regression budgets
23
+
24
+ | Check | Bound | Test |
25
+ | --- | --- | --- |
26
+ | Cold `ensureRuntime` | ≤50ms | `test/pure/startup_budget.test.mjs` |
27
+ | Warm `ensureRuntime` | ≤5ms | same |
28
+ | init → shutdown → natural exit | ≤10s | `test/pure/shutdown_exit.test.mjs` |
29
+ | 10s warm → shutdown → exit | ≤2s | same |
30
+
31
+ Shutdown-exit tests use the production path (default public Nostr + lan). Do not use `relayOverride` / dead-relay crutches there.
32
+
33
+ ## Nostr cleanup
34
+
35
+ Use the `ws` package (not global `WebSocket`) — platform WebSocket lacks force-drop / unref needed for clean shutdown. Subscriptions share one WebSocket per relay URL (signal / network advert / per-node advert / group advert multiplex `REQ`s). Active subscriptions reconnect after drop (`NOSTR_RECONNECT_DELAY_MS`) and re-send `REQ`s. On intentional shutdown: `close()`, then `terminate()` after `NOSTR_CLOSE_GRACE_MS` (1s) if the socket is not yet `CLOSED`. Presence publish still uses short-lived sockets.
36
+
37
+ Self presence echo from relays is filtered (`skipNodeHash`, same idea as LAN) and omitted from `listVisibleNodeHashes`. First-seen peer clues notify the link registry (`noteDiscoveryPeerClue`) so dial cooldown unlocks when a peer reappears.
38
+
39
+ ## Deno vs Node native addons
40
+
41
+ | Surface | Node | Deno |
42
+ | --- | --- | --- |
43
+ | Package tests / production CLI | `node` / `npx @steve02081504/fount-p2p` | Not primary |
44
+ | fount bridge (`npm run test:fount`) | — | `deno.json` keeps `"nodeModulesDir": "none"` |
45
+ | WebRTC | `node-datachannel` when native loads; else pure-JS `node-rtc-connection` (Termux/Android skips native) | same; native needs local `node_modules` + scripts for **only** `node-datachannel` |
46
+ | BLE (`noble` / `bleno`) | optionalDependencies; lazy-loaded | do **not** blanket `--allow-scripts` (optional native builds can abort the whole run) |
47
+
48
+ Recommended Deno one-shot for the published CLI when you want native WebRTC:
49
+
50
+ ```bash
51
+ deno run -A --minimum-dependency-age=0 --node-modules-dir=auto --allow-scripts=npm:node-datachannel npm:@steve02081504/fount-p2p
52
+ ```
53
+
54
+ `deno.json` lists `"allowScripts": ["npm:node-datachannel"]` so project-local `deno install` does not try to compile noble/bleno.
55
+
56
+ On Android/Termux, `node-datachannel` has no Bionic prebuild — loader skips it and uses `node-rtc-connection` (data-channel only; since ≥2.1.0 it exposes native W3C handlers and `bufferedAmount`/`bufferedamountlow` backpressure).
57
+
58
+ ## Bluetooth probe
59
+
60
+ `canUseBluetoothRuntime` probes in-process: sysfs hint (Linux) → `loadNoble` → `waitPoweredOn` → `stop()`. Requires `@stoprocent/noble>=2.5.9` on Windows (safe teardown after probe). On failure, discovery/link fall back to other paths.
61
+
62
+ Actual scan/GATT uses the same `loadNoble` / `loadBleno` path. Win defaults to scan-only. Shutdown does not await BT warm; a generation counter invalidates late side effects.
@@ -0,0 +1,23 @@
1
+ # Signaling
2
+
3
+ Internal WebRTC (`needsOfferAnswer`) glare and handshake. Shells use the fount-network API only — [transports.md](transports.md). Discovery / mesh surface: [mesh.md](mesh.md).
4
+
5
+ ## Glare: connId dual-PC pick-one
6
+
7
+ `node-datachannel` / `node-rtc-connection` cannot safely host two simultaneous glare dials on one PC. Resolution in `transport/offer_answer.mjs`: both sides dial with a random `connId`; on true glare each side builds an independent answer PC, then **keeps the link initiated by the smaller `nodeHash`** (`linkIsPreferred`). Only the canonical link fires `linkUp` / `linkDown`.
8
+
9
+ - Outbound: `ensureDirectLinkToNode` → `dialOfferAnswer`.
10
+ - Inbound offer with unknown `connId`: new answer PC via `accept` — **not** gated by per-`nodeHash` inflights.
11
+ - One-way dial never builds a second PC.
12
+
13
+ ## Handshake: buffer early `auth`
14
+
15
+ Frames: `hello` then `auth`. On simultaneous dial, peer `auth` can arrive before peer `hello` — buffer it (`pendingAuth` in `link/pipe.mjs`); never drop.
16
+
17
+ ## Windows / `trickleIceOff`
18
+
19
+ When set: send final offer/answer after ICE gathering, dedupe remote signals, queue remote ICE until both descriptions are ready.
20
+
21
+ ## Runtime relay override
22
+
23
+ `setSignalingRuntimeConfig({ relayOverride, iceLocalHostnamePolicy, trickleIceOff })` after `initNode` (or pass `signaling` once on first `startNode`). `relayOverride` **replaces** the default public relay list (do not merge defaults back in). Changes emit `signaling-changed` and trigger `reloadDiscoveryRelays` (swap Nostr provider + rebind node presence/signals).
@@ -0,0 +1,92 @@
1
+ # fount network vs internal transports
2
+
3
+ Mesh keep-alive / discovery: [mesh.md](mesh.md). WebRTC glare / handshake: [signaling.md](signaling.md). Runtime lifecycle: [runtime.md](runtime.md).
4
+
5
+ ## No versioning
6
+
7
+ Do **not** introduce version fields, constants, or suffixes (`v`, `version`, `FRAME_VERSION`, `:v1`, …). Changing a shape means changing it; no dual-read / backward-compat paths. Exception: npm `package.json` `version` is for package publish only.
8
+
9
+ ## Public contract (shell / L4)
10
+
11
+ This package exposes a **fount network**: talk to `nodeHash` peers with envelopes.
12
+
13
+ Typical entrypoints:
14
+
15
+ - `startNode` / `getLinkRegistry().ensureRuntime()`
16
+ - `ensureLinkToNode` / `sendToNodeLink` / `subscribeScope`
17
+ - `createGroupLinkSet` / `createScopedLinkRoom` / `ensureUserRoom`
18
+
19
+ `registerScopeAuthorizer` may be called before `initNode` / `getLinkRegistry` — it only buffers policy until the default registry is created.
20
+
21
+ Callers do **not** choose WebRTC, BLE, ICE, or DataChannels. If a path is unavailable on the host, the registry tries the next internal provider; the API surface stays the same.
22
+
23
+ **Public registration only:** `registerLinkProvider` / `registerDiscoveryProvider` from the package facade or `@steve02081504/fount-p2p/link` / `./discovery`. Provider *implementations* under `link/providers/*` remain package-internal — shells must not import them or pick transports.
24
+
25
+ Public `./transport/*` subpaths: `link_registry`, `user_room`, `group_link_set`, `node_scope/wire`, `node_scope/features`, `room_scopes`, `remote_user_room`, `scoped_link`. There is no `transport/node_scope` barrel — import the concrete modules. Modules such as `offer_answer`, `runtime_bootstrap` are internal.
26
+
27
+ Topic / rendezvous / signal crypto live under `discovery/` (`nostr.mjs`, `internal/signal_crypto.mjs`, `adverts.mjs`) — not in `transport/`, not a package export. Do not export `advertiseTopic` / `subscribeTopic` / `sendSignal(topic)` on the fount-network surface. ICE `.local` host-candidate filtering is `iceLocalHostnamePolicy` only.
28
+
29
+ ## Internal layers
30
+
31
+ | Layer | Role |
32
+ | --- | --- |
33
+ | **Discovery** (`discovery/`) | Per medium: `listVisibleNodeHashes` + `connectToNode`. Encrypted adverts/signals via `adverts.mjs` + `index.mjs` helpers. |
34
+ | **Link providers** (`link/providers/`) | Open a duplex pipe; sorted by **`level` (descending)** — not a shell import |
35
+ | **Registry** (`transport/link_registry.mjs`) | fount-network facade: dial fallback, scope/overlay, one canonical link per peer |
36
+ | **Bootstrap** (`transport/runtime_bootstrap.mjs`) | `ensureRuntime` register + progressive listen/discovery/BT warm; `reloadDiscoveryRelays` on `signaling-changed` |
37
+ | **Offer/answer** (`transport/offer_answer.mjs`) | Discovery-signal glare path for `caps.needsOfferAnswer` (**internal**; uses `sendNodeSignalPacket`) |
38
+ | **Mesh keepalive** (`transport/mesh_keepalive.mjs`) | N/K pool, explore eviction, stable promote to `trustedPeers` |
39
+
40
+ LinkHandle for upper layers: `ready` / `nodeHash` / `send` / `onEnvelope` / `onDown` / `close` / `stats`. Transport-specific fields are for in-package scheduling only.
41
+
42
+ Provider optional hooks (package-internal): `ensureListening`, `localEndpoint`, `canReach`, `caps.probe: 'sync' | 'native'` (`native` = skipped on ensureRuntime fast-listen). Discovery `connectToNode` / `sendNodeSignal` may return `false` when the path is unavailable; fan-out treats that as silent skip. Per-provider throw/false in discovery and link dial fallback are silent; only total failure of the abstraction surfaces to the caller.
43
+
44
+ Each registry only calls `ensureListening` on **its own** `lan_tcp` / `ble_gatt` instances (unique registry ids like `lan_tcp:ab12cd34`). Never fan out listening to other registries' sockets.
45
+
46
+ Chain `providerId` on the LinkHandle stays the short name (`lan_tcp` / `ble_gatt` / `webrtc` / `nostr`) for scheduling/stats.
47
+
48
+ ## Level table
49
+
50
+ | id | level |
51
+ | --- | --- |
52
+ | `lan_tcp` | 80 |
53
+ | `webrtc` | 70 |
54
+ | `ble_gatt` | 40 |
55
+ | `nostr` | −∞ |
56
+
57
+ Constants: `link/providers/levels.mjs`. Discovery uses ascending **`priority`** (handshake / presence / signal media order — Nostr stays last at `100`). Link selection uses descending **`level`** (data transport); Nostr is −∞ so it is dialed only after LAN / WebRTC / BLE fail.
58
+
59
+ ## Fallback
60
+
61
+ 1. `canReach` false → skip (no dial)
62
+ 2. `isAvailable()` fails → skip (probed per provider on the dial path; never via `listAvailableLinkProviders()` first)
63
+ 3. dial/handshake fails or soft-fails (`null`) → next lower level
64
+ 4. races: higher `level` wins; same level → smaller `nodeHash` initiates
65
+
66
+ `caps.needsOfferAnswer` providers use the shared discovery-signal glare path (`dial`/`accept` + signal session) — not hard-coded to `id === 'webrtc'`.
67
+
68
+ Dial miss / exhausted peers get exponential cooldown so mesh ticks do not busy-loop on stale acquaintances. First-seen discovery peer clues (and `watchNodeAdvert` ingest) clear that peer's cooldown.
69
+
70
+ ## Providers (internal)
71
+
72
+ ### `lan_tcp` (80)
73
+
74
+ Plain TCP on the LAN. Registry schedules listen in the background after `ensureRuntime`; `buildLocalAdvert` waits for local listen so signed adverts include `tcpPort`. Peers learn `{ host, port }` from discovery meta + advert `tcpPort`. Binding = shared `linkId`; length-prefix framing. No discovery signal / offer-answer. Shells never read `tcpPort`.
75
+
76
+ ### `webrtc` (70)
77
+
78
+ Discovery signal + dual DataChannel; DTLS fingerprint as handshake binding; `needsOfferAnswer` glare path. Soft-fail (`null`) continues to lower-level providers. Backend: `node-datachannel` when the native addon loads, else pure-JS `node-rtc-connection` (Android/Termux skips native). See [runtime.md](runtime.md).
79
+
80
+ ### `ble_gatt` (40)
81
+
82
+ GATT write/notify; binding = shared `linkId`; needs BT peer hint (`peripheralId` in discovery meta); optional noble/bleno. Per-registry instance like `lan_tcp`; `isAvailable` / `canReach` gate dial. On Win32, scan-only stacks cannot accept inbound BLE links. One BLE adapter cannot host two independent peripherals in-process — production is one node per process. Hardware probe: [runtime.md](runtime.md).
83
+
84
+ ### `nostr` (−∞)
85
+
86
+ Last-resort duplex pipe over discovery signal packets (`type: 'link'`), demuxed from WebRTC `type: 'signal'`. Same node rendezvous encryption as signaling; not a second relay subscription. Longer handshake / heartbeat / idle than LAN. Discovery **`priority`** is unchanged (still handshake-last); only link **`level`** is −∞.
87
+
88
+ ### Bluetooth discovery signal
89
+
90
+ `discovery/bt` carries short signal blobs on GATT so WebRTC can negotiate near-field when LAN/nostr are unavailable (package-internal).
91
+
92
+ Discovery peripheral and `ble_gatt` both use bleno + name `fount-bt`. On one adapter they contend — last `setServices`/`startAdvertising` wins. Production: one node per process.
package/docs/wire.md ADDED
@@ -0,0 +1,21 @@
1
+ # Wire part / fanout attaches
2
+
3
+ Day-to-day trust rules: [AGENTS.md](../AGENTS.md). Node-scope presets: [infra.md](infra.md).
4
+
5
+ ## Fanout vs targeted (attach inventory)
6
+
7
+ | Use | API | Notes |
8
+ | --- | --- | --- |
9
+ | Timeline / chunk exploration | `fanoutToTopNodes` | TrustGraph-ranked fanout |
10
+ | Mailbox / targeted packets | `sendToNode` / User Room | Never fanout |
11
+ | part_invoke RPC collect | `wire/part/fanout.collectPartInvokeResponses` | Requires `attachPartWire` already; `timeoutMs` bounds end-to-end |
12
+ | Group-room part | `wire/part/group.attachGroupPartWire` | Group federation frames |
13
+ | TrustGraph / group Trystero chunk | `files/chunk/responder.attachTrustGraphFedChunkResponder` | Chunk responder on trust/group path |
14
+
15
+ Part query runtime lives under `federation/part_query/*`; wire attach only in `wire/part/query.mjs`.
16
+
17
+ ## Timed collect
18
+
19
+ APIs with `timeoutMs` (e.g. `collectPartInvokeResponses`) must register the wait **first** and must **not** `await` fanout/send on the return path — a stuck `discoverRoute` / `link.send` otherwise defeats the timeout.
20
+
21
+ Pattern: `beginFedFanoutFetch` / fire-and-forget fanout + `sent === 0 → finish()`.
@@ -68,8 +68,7 @@ export async function createWebRtcLink(options) {
68
68
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
69
69
  const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
70
70
  const rtc = options.rtc ?? await loadNodeRtcPolyfill()
71
- // JS 后端只做 trickle(SDP 不含 candidate);强制开启 trickle。
72
- const trickleIceOff = !rtc.forcesTrickleIce && getSignalingRuntimeConfig().trickleIceOff === true
71
+ const trickleIceOff = getSignalingRuntimeConfig().trickleIceOff === true
73
72
  const peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
74
73
  const remoteSignalQueue = []
75
74
  const seenRemoteSignals = createLruMap(1024)
@@ -183,6 +182,10 @@ export async function createWebRtcLink(options) {
183
182
  const deadline = Date.now() + handshakeTimeoutMs
184
183
  while (peerConnection.iceGatheringState !== 'complete' && Date.now() < deadline)
185
184
  await new Promise(resolve => setTimeout(resolve, 50))
185
+ if (peerConnection.iceGatheringState !== 'complete') {
186
+ await pipe.close('ice-gathering-timeout')
187
+ throw new Error(`p2p: ice gathering incomplete after ${handshakeTimeoutMs}ms`)
188
+ }
186
189
  }
187
190
 
188
191
  /**
@@ -50,18 +50,12 @@ export function filterIceLocalHostnameCandidate(candidate, RTCIceCandidateCtor,
50
50
  export function wrapRtcPeerConnectionForIceLocalHostname(BaseRTC, RTCIceCandidate, policy = 'drop') {
51
51
  if (policy === 'none') return BaseRTC
52
52
 
53
- const baseRoutesIce = !!BaseRTC.prototype.prepareIceCandidateEvent
54
-
55
53
  return class IceLocalHostnameFilteredRTCPeerConnection extends BaseRTC {
56
54
  /** @type {((event: RTCPeerConnectionIceEvent) => void) | null} */
57
55
  #userIceHandler = null
58
- /** @type {Set<(event: unknown) => void>} */
59
- #iceListeners = new Set()
60
- /** 去重:同一次 native 派发可能既走 attribute 又走 listener */
61
- #lastIceEvent = null
62
56
 
63
57
  /**
64
- * drop:不派发;rewrite:仅派发替换 candidate 后的事件。
58
+ * drop:不派发;rewrite:构造仅携带替换 candidate 的派生事件。
65
59
  * @param {RTCPeerConnectionIceEvent | { candidate?: unknown }} event 原始 ICE 事件
66
60
  * @returns {RTCPeerConnectionIceEvent | { candidate?: unknown } | null} 规范化后的事件;drop 时为 null
67
61
  */
@@ -69,7 +63,10 @@ export function wrapRtcPeerConnectionForIceLocalHostname(BaseRTC, RTCIceCandidat
69
63
  if (!event?.candidate) return event
70
64
  const filtered = filterIceLocalHostnameCandidate(event.candidate, RTCIceCandidate, policy)
71
65
  if (!filtered) return null
72
- return filtered === event.candidate ? event : { candidate: filtered }
66
+ if (filtered === event.candidate) return event
67
+ const rewritten = new event.constructor('icecandidate')
68
+ rewritten.candidate = filtered
69
+ return rewritten
73
70
  }
74
71
 
75
72
  /**
@@ -77,9 +74,6 @@ export function wrapRtcPeerConnectionForIceLocalHostname(BaseRTC, RTCIceCandidat
77
74
  */
78
75
  constructor(config) {
79
76
  super(config)
80
- if (baseRoutesIce) return
81
-
82
- // native EventTarget:在派发前规范化,自管 listener,不依赖 stopImmediatePropagation。
83
77
  Object.defineProperty(this, 'onicecandidate', {
84
78
  configurable: true,
85
79
  enumerable: true,
@@ -93,48 +87,22 @@ export function wrapRtcPeerConnectionForIceLocalHostname(BaseRTC, RTCIceCandidat
93
87
  */
94
88
  set: handler => { this.#userIceHandler = handler },
95
89
  })
96
- super.addEventListener('icecandidate', event => this.#deliverIce(event))
97
90
  }
98
91
 
99
92
  /**
100
- * @param {RTCPeerConnectionIceEvent | { candidate?: unknown }} event 原始 ICE 事件
101
- * @returns {void}
93
+ * 先完成 candidate 转换,再走标准 EventTarget 派发(保留 once/AbortSignal/capture 语义)。
94
+ * addEventListener / removeEventListener 交由基类,故 once / AbortSignal / capture 均保留。
95
+ * drop:不派发;pass-through:派发原事件;rewrite:派发替换 candidate 后的派生事件。
96
+ * @param {Event} event 待派发事件
97
+ * @returns {boolean} 事件是否未被取消
102
98
  */
103
- #deliverIce = event => {
104
- if (this.#lastIceEvent === event) return
105
- this.#lastIceEvent = event
99
+ dispatchEvent(event) {
100
+ if (event?.type !== 'icecandidate') return super.dispatchEvent(event)
106
101
  const normalized = this.prepareIceCandidateEvent(event)
107
- if (normalized == null) return
102
+ if (normalized == null) return true
108
103
  this.#userIceHandler?.(normalized)
109
- for (const listener of this.#iceListeners) listener(normalized)
110
- }
111
-
112
- /**
113
- * @param {string} type 事件名
114
- * @param {(event: unknown) => void} listener 回调
115
- * @param {boolean | AddEventListenerOptions} [options] 监听选项
116
- * @returns {void}
117
- */
118
- addEventListener(type, listener, options) {
119
- if (!baseRoutesIce && type === 'icecandidate') {
120
- this.#iceListeners.add(listener)
121
- return
122
- }
123
- return super.addEventListener(type, listener, options)
124
- }
125
-
126
- /**
127
- * @param {string} type 事件名
128
- * @param {(event: unknown) => void} listener 回调
129
- * @param {boolean | EventListenerOptions} [options] 监听选项
130
- * @returns {void}
131
- */
132
- removeEventListener(type, listener, options) {
133
- if (!baseRoutesIce && type === 'icecandidate') {
134
- this.#iceListeners.delete(listener)
135
- return
136
- }
137
- return super.removeEventListener(type, listener, options)
104
+ if (normalized === event) return super.dispatchEvent(event)
105
+ return super.dispatchEvent(normalized)
138
106
  }
139
107
  }
140
108
  }
@@ -4,7 +4,6 @@ import { getRtcPolyfillCacheEpoch, getSignalingRuntimeConfig } from '../../node/
4
4
  import { nodeDebug } from '../../node/log.mjs'
5
5
 
6
6
  import { wrapRtcPeerConnectionForIceLocalHostname } from './ice_local_hostname.mjs'
7
- import { bridgePeerConnection } from './w3c_bridge.mjs'
8
7
 
9
8
  /** @type {boolean} */
10
9
  let exitCleanupHooked = false
@@ -20,11 +19,9 @@ let cachedDefaultPolyfillEpoch = -1
20
19
  * RTCPeerConnection: typeof RTCPeerConnection,
21
20
  * RTCIceCandidate: typeof RTCIceCandidate,
22
21
  * backend: string,
23
- * forcesTrickleIce: boolean,
24
22
  * }} LoadedRtcPolyfill
25
23
  * @typedef {{
26
24
  * id: string,
27
- * forcesTrickleIce?: boolean,
28
25
  * load: () => Promise<{ RTCPeerConnection: typeof RTCPeerConnection, RTCIceCandidate: typeof RTCIceCandidate }>,
29
26
  * }} RtcBackend
30
27
  */
@@ -57,12 +54,9 @@ async function ensureNodeDatachannelExitCleanup() {
57
54
  * @returns {Promise<{ RTCPeerConnection: typeof RTCPeerConnection, RTCIceCandidate: typeof RTCIceCandidate }>} node-datachannel 构造器
58
55
  */
59
56
  async function loadNodeDatachannelBackend() {
60
- const mod = await import('node-datachannel/polyfill')
57
+ const module = await import('node-datachannel/polyfill')
61
58
  await ensureNodeDatachannelExitCleanup()
62
- return {
63
- RTCPeerConnection: mod.RTCPeerConnection,
64
- RTCIceCandidate: mod.RTCIceCandidate,
65
- }
59
+ return module
66
60
  }
67
61
 
68
62
  /**
@@ -70,17 +64,12 @@ async function loadNodeDatachannelBackend() {
70
64
  * @returns {Promise<{ RTCPeerConnection: typeof RTCPeerConnection, RTCIceCandidate: typeof RTCIceCandidate }>} node-rtc-connection 构造器
71
65
  */
72
66
  async function loadNodeRtcConnectionBackend() {
73
- const module = await import('node-rtc-connection')
74
- return {
75
- RTCPeerConnection: /** @type {typeof RTCPeerConnection} */ bridgePeerConnection(module.RTCPeerConnection),
76
- RTCIceCandidate: module.RTCIceCandidate,
77
- }
67
+ return import('node-rtc-connection')
78
68
  }
79
69
 
80
70
  /** @type {RtcBackend} */
81
71
  const PURE_JS_BACKEND = {
82
72
  id: 'node-rtc-connection',
83
- forcesTrickleIce: true,
84
73
  load: loadNodeRtcConnectionBackend,
85
74
  }
86
75
 
@@ -119,7 +108,6 @@ async function loadNodeRtcPolyfillUncached(options) {
119
108
  ),
120
109
  RTCIceCandidate: mod.RTCIceCandidate,
121
110
  backend: backend.id,
122
- forcesTrickleIce: backend.forcesTrickleIce === true,
123
111
  }
124
112
  }
125
113
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -23,13 +23,6 @@
23
23
  "test:live": "node --test --test-concurrency=1 --test-force-exit test/live/*.test.mjs",
24
24
  "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs"
25
25
  },
26
- "files": [
27
- "**/*.mjs",
28
- "**/*.json",
29
- "!test/**",
30
- "!sim/**",
31
- "!scripts/**"
32
- ],
33
26
  "bin": {
34
27
  "fount-p2p": "infra/cli.mjs"
35
28
  },
@@ -82,7 +75,7 @@
82
75
  "optionalDependencies": {
83
76
  "@stoprocent/bleno": "latest",
84
77
  "@stoprocent/noble": "latest",
85
- "node-datachannel": "latest"
78
+ "node-datachannel": "0.33.0"
86
79
  },
87
80
  "allowScripts": {
88
81
  "node-datachannel": true