@dcasegr/skein 0.0.0-stage → 0.1.1

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 (176) hide show
  1. package/README.md +522 -2
  2. package/bin/skein-host.js +22 -0
  3. package/bin/skein.js +8 -0
  4. package/dist/client/admin-cli.d.ts +10 -0
  5. package/dist/client/admin-cli.js +370 -0
  6. package/dist/client/admin.d.ts +139 -0
  7. package/dist/client/admin.js +256 -0
  8. package/dist/client/bundle.d.ts +9 -0
  9. package/dist/client/bundle.js +34 -0
  10. package/dist/client/cli.d.ts +44 -0
  11. package/dist/client/cli.js +306 -0
  12. package/dist/client/client.d.ts +161 -0
  13. package/dist/client/client.js +237 -0
  14. package/dist/client/config.d.ts +18 -0
  15. package/dist/client/config.js +31 -0
  16. package/dist/client/conversation.d.ts +62 -0
  17. package/dist/client/conversation.js +113 -0
  18. package/dist/client/raw.d.ts +94 -0
  19. package/dist/client/raw.js +154 -0
  20. package/dist/client/target.d.ts +34 -0
  21. package/dist/client/target.js +158 -0
  22. package/dist/dev/cli.d.ts +8 -0
  23. package/dist/dev/cli.js +248 -0
  24. package/dist/dev/scan.d.ts +15 -0
  25. package/dist/dev/scan.js +173 -0
  26. package/dist/envelope-cbor.d.ts +110 -0
  27. package/dist/envelope-cbor.js +160 -0
  28. package/dist/envelope.d.ts +38 -0
  29. package/dist/envelope.js +101 -0
  30. package/dist/host/arc.d.ts +131 -0
  31. package/dist/host/arc.js +425 -0
  32. package/dist/host/billing.d.ts +102 -0
  33. package/dist/host/billing.js +167 -0
  34. package/dist/host/boot.d.ts +246 -0
  35. package/dist/host/boot.js +643 -0
  36. package/dist/host/cli.d.ts +57 -0
  37. package/dist/host/cli.js +1253 -0
  38. package/dist/host/clock.d.ts +3 -0
  39. package/dist/host/clock.js +8 -0
  40. package/dist/host/control.d.ts +45 -0
  41. package/dist/host/control.js +170 -0
  42. package/dist/host/cron.d.ts +99 -0
  43. package/dist/host/cron.js +196 -0
  44. package/dist/host/deliver.d.ts +53 -0
  45. package/dist/host/deliver.js +251 -0
  46. package/dist/host/deploy.d.ts +29 -0
  47. package/dist/host/deploy.js +55 -0
  48. package/dist/host/door-answer.d.ts +24 -0
  49. package/dist/host/door-answer.js +21 -0
  50. package/dist/host/events.d.ts +89 -0
  51. package/dist/host/events.js +222 -0
  52. package/dist/host/fake-arcade.d.ts +40 -0
  53. package/dist/host/fake-arcade.js +149 -0
  54. package/dist/host/fake-discovery.d.ts +4 -0
  55. package/dist/host/fake-discovery.js +58 -0
  56. package/dist/host/feeds.d.ts +127 -0
  57. package/dist/host/feeds.js +422 -0
  58. package/dist/host/frontdoor.d.ts +92 -0
  59. package/dist/host/frontdoor.js +172 -0
  60. package/dist/host/genesis.d.ts +259 -0
  61. package/dist/host/genesis.js +353 -0
  62. package/dist/host/handles.d.ts +40 -0
  63. package/dist/host/handles.js +88 -0
  64. package/dist/host/hostenv.d.ts +30 -0
  65. package/dist/host/hostenv.js +83 -0
  66. package/dist/host/image-chain.d.ts +127 -0
  67. package/dist/host/image-chain.js +440 -0
  68. package/dist/host/install.d.ts +22 -0
  69. package/dist/host/install.js +139 -0
  70. package/dist/host/instances.d.ts +227 -0
  71. package/dist/host/instances.js +430 -0
  72. package/dist/host/kernel.d.ts +246 -0
  73. package/dist/host/kernel.js +603 -0
  74. package/dist/host/liveness.d.ts +65 -0
  75. package/dist/host/liveness.js +155 -0
  76. package/dist/host/manifest.d.ts +160 -0
  77. package/dist/host/manifest.js +597 -0
  78. package/dist/host/p2p.d.ts +337 -0
  79. package/dist/host/p2p.js +1002 -0
  80. package/dist/host/packet.d.ts +121 -0
  81. package/dist/host/packet.js +562 -0
  82. package/dist/host/plan.d.ts +154 -0
  83. package/dist/host/plan.js +418 -0
  84. package/dist/host/providers.d.ts +238 -0
  85. package/dist/host/providers.js +565 -0
  86. package/dist/host/roster.d.ts +72 -0
  87. package/dist/host/roster.js +172 -0
  88. package/dist/host/router.d.ts +773 -0
  89. package/dist/host/router.js +2389 -0
  90. package/dist/host/signer.d.ts +49 -0
  91. package/dist/host/signer.js +167 -0
  92. package/dist/host/store-sqlite.d.ts +39 -0
  93. package/dist/host/store-sqlite.js +170 -0
  94. package/dist/host/stores.d.ts +43 -0
  95. package/dist/host/stores.js +342 -0
  96. package/dist/host/sync.d.ts +121 -0
  97. package/dist/host/sync.js +467 -0
  98. package/dist/host/testbeef.d.ts +21 -0
  99. package/dist/host/testbeef.js +37 -0
  100. package/dist/host/testhost.d.ts +149 -0
  101. package/dist/host/testhost.js +211 -0
  102. package/dist/host/vcdiff.d.ts +9 -0
  103. package/dist/host/vcdiff.js +210 -0
  104. package/dist/host/wasm-kernel.d.ts +87 -0
  105. package/dist/host/wasm-kernel.js +296 -0
  106. package/dist/host/wasm-worker.d.ts +1 -0
  107. package/dist/host/wasm-worker.js +186 -0
  108. package/dist/peers/cron.d.ts +33 -0
  109. package/dist/peers/cron.js +75 -0
  110. package/dist/peers/infer-main.d.ts +2 -0
  111. package/dist/peers/infer-main.js +74 -0
  112. package/dist/peers/infer.d.ts +163 -0
  113. package/dist/peers/infer.js +367 -0
  114. package/dist/runtime/beef.d.ts +28 -0
  115. package/dist/runtime/beef.js +107 -0
  116. package/dist/runtime/bitcoin.d.ts +36 -0
  117. package/dist/runtime/bitcoin.js +141 -0
  118. package/dist/runtime/cid.d.ts +14 -0
  119. package/dist/runtime/cid.js +51 -0
  120. package/dist/runtime/dispatch.d.ts +92 -0
  121. package/dist/runtime/dispatch.js +142 -0
  122. package/dist/runtime/envelope.d.ts +60 -0
  123. package/dist/runtime/envelope.js +101 -0
  124. package/dist/runtime/heads.d.ts +25 -0
  125. package/dist/runtime/heads.js +37 -0
  126. package/dist/runtime/identity.d.ts +28 -0
  127. package/dist/runtime/identity.js +67 -0
  128. package/dist/runtime/index-store.d.ts +152 -0
  129. package/dist/runtime/index-store.js +715 -0
  130. package/dist/runtime/log.d.ts +62 -0
  131. package/dist/runtime/log.js +104 -0
  132. package/dist/runtime/programs.d.ts +30 -0
  133. package/dist/runtime/programs.js +61 -0
  134. package/dist/runtime/records.d.ts +135 -0
  135. package/dist/runtime/records.js +149 -0
  136. package/dist/runtime/sqlite.d.ts +33 -0
  137. package/dist/runtime/sqlite.js +604 -0
  138. package/dist/runtime/store.d.ts +141 -0
  139. package/dist/runtime/store.js +24 -0
  140. package/dist/runtime/syscalls.d.ts +9 -0
  141. package/dist/runtime/syscalls.js +41 -0
  142. package/dist/runtime/tree.d.ts +60 -0
  143. package/dist/runtime/tree.js +196 -0
  144. package/dist/runtime/types.d.ts +100 -0
  145. package/dist/runtime/types.js +7 -0
  146. package/dist/testapps.d.ts +101 -0
  147. package/dist/testapps.js +275 -0
  148. package/dist/testkit.d.ts +25 -0
  149. package/dist/testkit.js +48 -0
  150. package/dist/wallet.d.ts +35 -0
  151. package/dist/wallet.js +87 -0
  152. package/images/README.md +68 -0
  153. package/images/default/README.md +38 -0
  154. package/images/default/bin/frontdoor.cid +1 -0
  155. package/images/default/bin/frontdoor.json +5 -0
  156. package/images/default/bin/messagebox.cid +1 -0
  157. package/images/default/bin/messagebox.json +8 -0
  158. package/images/default/bin/wallet.cid +1 -0
  159. package/images/default/bin/wallet.json +14 -0
  160. package/images/default/etc/config.json +1 -0
  161. package/images/default/etc/dispatch.json +82 -0
  162. package/images/host/etc/apps.json +8 -0
  163. package/kernel-zig/README.md +1247 -0
  164. package/kernel-zig/web/client.js +65 -0
  165. package/kernel-zig/web/kernel.d.ts +59 -0
  166. package/kernel-zig/web/kernel.js +329 -0
  167. package/kernel-zig/web/store.js +67 -0
  168. package/kernel-zig/web/worker.js +79 -0
  169. package/kernel-zig/zig-out/web/skein-kernel.wasm +0 -0
  170. package/package.json +77 -4
  171. package/wasm/README.md +35 -0
  172. package/wasm/frontdoor.wasm +0 -0
  173. package/wasm/messagebox.wasm +0 -0
  174. package/wasm/resolve.wasm +0 -0
  175. package/wasm/wallet.wasm +0 -0
  176. package/wasm/wire-probe.wasm +0 -0
@@ -0,0 +1,1002 @@
1
+ // The router's libp2p host (#43, #51): a router component, like the HTTP
2
+ // proxy and the feeds. The runtime has no network; this is where the sockets
3
+ // are. One js-libp2p node per peer KEY (#160) for the instances that declare
4
+ // libp2p — in their config (the genesis's `libp2p`, from etc/config.json), or
5
+ // by a `libp2p` row in the kernel's dispatch table (#72, #77: a topic, or a
6
+ // `/<protocol>`) — all inside the router process: a master-key host derives a
7
+ // key per instance, so one node per instance; a one-key host's instances share
8
+ // the one peer key, so one node carries them all ("the host" below):
9
+ //
10
+ // peer key a secp256k1 key derived from the INSTANCE's root key with BRC-42/43
11
+ // (#129: [2, "skein instance"], key ID `libp2p:<handle>`, self — signer.ts
12
+ // peerKey): the key the instance's signer answers for getPublicKey with
13
+ // those arguments, so a program computes its own peer ID; a child, not
14
+ // the root. The peer ID is the identity
15
+ // multihash of the key's protobuf (type secp256k1, the 33-byte
16
+ // compressed key), so the key reads straight out of the peer ID.
17
+ // node GossipSub (StrictSign: every message is signed by its publisher's
18
+ // peer key), noise + yamux, TCP and WebSocket listeners (WSS with a
19
+ // certificate), Kademlia DHT off | client | server, mDNS on/off,
20
+ // bootstrap peers kept connected, circuit relays.
21
+ // topics the instance's `libp2p.topics`, its dispatch table's libp2p
22
+ // rows' topics (libp2pConfig) and the topics its apps subscribed
23
+ // (#119: `subscribe {topic, program, fn}` / `unsubscribe {topic}`,
24
+ // emitted by an app's installed program; the subscriptions folded
25
+ // from the log, subscriptionsOf, then followed live): subscribed; each message is judged
26
+ // by the async topic validator, which makes one front-door call
27
+ // (`inbound`) and returns its verdict to GossipSub (accept: admit +
28
+ // forward; reject: drop + penalise the delivering peer; ignore: drop).
29
+ // A redelivered message already admitted (its `p2p` event record is
30
+ // in the kernel's `unique` map) is refused at admit, nothing written:
31
+ // ignore.
32
+ // A topic only an app's liveness takes (#138, liveness.ts) is
33
+ // subscribed too, but its messages are never admitted: each is
34
+ // judged by the liveness tool alone (a beat whose instance
35
+ // signature verifies is kept and accepted, a bad one rejected).
36
+ // With the DHT on, each topic name is a rendezvous: provide the
37
+ // CID v1 raw sha2-256 of the name and dial the providers found
38
+ // (go-libp2p's RoutingDiscovery, as go-p2p-message-bus uses it).
39
+ // protocols the instance's `libp2p.protocols` and its dispatch table's
40
+ // libp2p rows' protocols (`/…`): for each inbound stream the
41
+ // router reads length-prefixed frames (unsigned varint), makes one
42
+ // front-door call per frame and writes the answer's body back.
43
+ // Every node also serves /skein/message/1.0.0 (#70): a frame there is
44
+ // a signed message to the instance, its package {message, body}; the
45
+ // front door checks and routes it, no route needed.
46
+ // changes `declare` again with the instance's config as it now stands (the
47
+ // router does when the dispatch table changed: an install or an
48
+ // uninstall added or removed libp2p rows, #72/#77): new topics are
49
+ // subscribed and new protocols handled; gone ones unsubscribed and
50
+ // unhandled; no topic or protocol left: the node stops. No restart.
51
+ // outbound the `libp2p` provider's work (#70, providers.ts): publish, dial,
52
+ // send, close for the messages an instance emits to it, and each frame
53
+ // read from a dialled stream handed back (`frames`) to become a
54
+ // message to the instance — the next frame arrives as an entry.
55
+ //
56
+ // Host-wide settings (hostP2PConfig): SKEIN_LIBP2P_LISTEN, _BOOTSTRAP, _RELAYS
57
+ // (comma-separated multiaddrs), _DHT (off | client | server), _MDNS (on | off),
58
+ // _TLS_CERT / _TLS_KEY (PEM files: the WebSocket listener serves TLS, so
59
+ // browsers can dial /tls/ws); from the environment, else $SKEIN_HOME/host.env.
60
+ import { existsSync, readFileSync } from "node:fs";
61
+ import { join } from "node:path";
62
+ import { noise } from "@chainsafe/libp2p-noise";
63
+ import { yamux } from "@chainsafe/libp2p-yamux";
64
+ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
65
+ import { privateKeyFromRaw } from "@libp2p/crypto/keys";
66
+ import { gossipsub, TopicValidatorResult } from "@libp2p/gossipsub";
67
+ import { identify } from "@libp2p/identify";
68
+ import { kadDHT, passthroughMapper, removePrivateAddressesMapper } from "@libp2p/kad-dht";
69
+ import { mdns } from "@libp2p/mdns";
70
+ import { peerIdFromPrivateKey, peerIdFromString } from "@libp2p/peer-id";
71
+ import { ping } from "@libp2p/ping";
72
+ import { tcp } from "@libp2p/tcp";
73
+ import { isPrivate, lpStream } from "@libp2p/utils";
74
+ import { webSockets } from "@libp2p/websockets";
75
+ import { multiaddr } from "@multiformats/multiaddr";
76
+ import * as dagCbor from "@ipld/dag-cbor";
77
+ import { createLibp2p } from "libp2p";
78
+ import { CID } from "multiformats/cid";
79
+ import { sha256 } from "multiformats/hashes/sha2";
80
+ import { MESSAGE_KEY_ID, MESSAGE_PROTOCOL as SIGN_PROTOCOL } from "./providers.js";
81
+ import { verifyAnyone } from "../runtime/identity.js";
82
+ import { Liveness } from "./liveness.js";
83
+ /** The default listeners: loopback TCP and WebSocket, ports chosen by the OS (plain WS on loopback: dev only). */
84
+ export const DEFAULT_LISTEN = ["/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/tcp/0/ws"];
85
+ /** The stream protocol a signed message travels on to an instance (#70). */
86
+ export const MESSAGE_PROTOCOL = "/skein/message/1.0.0";
87
+ /**
88
+ * The overlay's receive row (skein-overlay etc/app.json; the settled sync design, David 2026-10-30):
89
+ * the host's OWN entry for the BEEFs it fetched (sync.ts `deliver` → Router.p2pInbound), never served
90
+ * to peers ("not a good door to have": a node takes BEEFs only from paths it chose). Whatever rows an
91
+ * instance declares, no node handles it inbound.
92
+ */
93
+ export const RECEIVE_PROTOCOL = "/skein/overlay/beef/1.0.0";
94
+ /** Whether a node handles `protocol` inbound for its handles' rows: not the receive row, not the host's own. */
95
+ const inboundRow = (protocol, host) => protocol !== MESSAGE_PROTOCOL && protocol !== RECEIVE_PROTOCOL && !(protocol in host);
96
+ // ---------------------------------------------------------------- keys and IDs
97
+ /** A bsv secp256k1 key as a libp2p private key. */
98
+ export function libp2pKey(k) {
99
+ return privateKeyFromRaw(Uint8Array.from(k.toArray("be", 32)));
100
+ }
101
+ /** The peer ID of a key: the identity multihash of its protobuf (secp256k1, compressed). */
102
+ export function peerIdOf(k) {
103
+ return peerIdFromPrivateKey(libp2pKey(k));
104
+ }
105
+ /** The compressed secp256k1 key a peer ID embeds (hex), or undefined for another kind. */
106
+ export function keyOfPeerId(id) {
107
+ const b = typeof id === "string" ? peerIdFromString(id).toMultihash().bytes : id;
108
+ // identity multihash (0x00, 0x25) over protobuf {1: KeyType = 2 (secp256k1), 2: 33 bytes}
109
+ if (b.length !== 39 || b[0] !== 0x00 || b[1] !== 0x25 || b[2] !== 0x08 || b[3] !== 0x02 || b[4] !== 0x12 || b[5] !== 0x21)
110
+ return undefined;
111
+ return Buffer.from(b.subarray(6)).toString("hex");
112
+ }
113
+ /** A topic name's rendezvous CID: v1 raw over sha2-256 of the name (go-libp2p's nsToCid). */
114
+ export async function topicCid(topic) {
115
+ return CID.createV1(0x55, await sha256.digest(new TextEncoder().encode(topic)));
116
+ }
117
+ // ---------------------------------------------------------------- config
118
+ const list = (s) => (s ?? "").split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
119
+ /** The host-wide settings: the environment's SKEIN_LIBP2P_*, else $SKEIN_HOME/host.env's. */
120
+ export function hostP2PConfig(vars, home) {
121
+ const file = {};
122
+ const envFile = home ? join(home, "host.env") : undefined;
123
+ if (envFile && existsSync(envFile)) {
124
+ for (const line of readFileSync(envFile, "utf8").split("\n")) {
125
+ const m = /^\s*(?:export\s+)?(SKEIN_LIBP2P_[A-Z_]+)=(.*)$/.exec(line);
126
+ if (m)
127
+ file[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
128
+ }
129
+ }
130
+ const get = (k) => vars[k] ?? file[k];
131
+ const dht = (get("SKEIN_LIBP2P_DHT") || "off").toLowerCase();
132
+ if (dht !== "off" && dht !== "client" && dht !== "server")
133
+ throw new Error(`SKEIN_LIBP2P_DHT: off | client | server, not ${dht}`);
134
+ const cert = get("SKEIN_LIBP2P_TLS_CERT"), key = get("SKEIN_LIBP2P_TLS_KEY");
135
+ const listen = list(get("SKEIN_LIBP2P_LISTEN"));
136
+ return {
137
+ listen: listen.length ? listen : DEFAULT_LISTEN,
138
+ bootstrap: list(get("SKEIN_LIBP2P_BOOTSTRAP")),
139
+ dht,
140
+ relays: list(get("SKEIN_LIBP2P_RELAYS")),
141
+ mdns: /^(on|1|true|yes)$/i.test(get("SKEIN_LIBP2P_MDNS") ?? ""),
142
+ ...(cert && key ? { tls: { cert: readFileSync(cert, "utf8"), key: readFileSync(key, "utf8") } } : {}),
143
+ };
144
+ }
145
+ /** The libp2p config a genesis carries (`libp2p: {topics?, protocols?, listen?}`), or undefined: no node. */
146
+ export function libp2pOf(g) {
147
+ const c = g?.libp2p;
148
+ if (!c || typeof c !== "object")
149
+ return undefined;
150
+ const strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length > 0) : [];
151
+ const listen = strs(c.listen);
152
+ return { topics: strs(c.topics), protocols: strs(c.protocols), ...(listen.length ? { listen } : {}) };
153
+ }
154
+ /**
155
+ * An instance's libp2p config as it stands (#72, #77): the genesis's
156
+ * (libp2pOf: a tree names what its node subscribes in `config.libp2p`),
157
+ * plus the topics and protocols named by the dispatch table's `libp2p`
158
+ * rows added since the genesis (an app's install; address: a topic) — a
159
+ * genesis topic row alone subscribes nothing, as a route alone did not — and
160
+ * every stream protocol a `libp2p` row names (`/<protocol>`), whatever
161
+ * installed it, the genesis included (#155: an image-born instance serves its
162
+ * sync row; the overlay's receive row stays unserved, P2PHost), plus the
163
+ * topics the apps subscribed (#119, `subscribed`).
164
+ * `beacons` (#126): an app's beacon (or its liveness, #138: P2PHost.liveness
165
+ * subscribes those topics itself) needs the node, and adds no topic.
166
+ * Undefined: no node — the genesis declares none and nothing needs one.
167
+ */
168
+ export function libp2pConfig(g, rows, subscribed = [], beacons = false) {
169
+ const base = libp2pOf(g);
170
+ const seeded = new Set((g?.dispatch ?? []).filter((r) => r.transport === "libp2p").map((r) => String(r.address)));
171
+ // #155: a stream protocol is declared whatever installed its row; a topic row from the genesis alone subscribes nothing.
172
+ const names = rows.filter((r) => r.transport === "libp2p" && (String(r.address).startsWith("/") || !seeded.has(String(r.address)))).map((r) => r.address).filter((p) => typeof p === "string" && p.length > 0);
173
+ // #126: a beacon needs the node (to publish), not a subscription of its topic.
174
+ if (!base && !names.length && !subscribed.length && !beacons)
175
+ return undefined;
176
+ const add = (xs, ys) => [...new Set([...xs, ...ys])];
177
+ return {
178
+ topics: add(add(base?.topics ?? [], names.filter((n) => !n.startsWith("/"))), subscribed),
179
+ protocols: add(base?.protocols ?? [], names.filter((n) => n.startsWith("/"))),
180
+ ...(base?.listen ? { listen: base.listen } : {}),
181
+ };
182
+ }
183
+ export const isTopic = (t) => typeof t === "string" && t.length > 0 && !/[\s\0]/.test(t) && !t.startsWith("/");
184
+ /** A `subscribe` / `unsubscribe` event record, read (subscriptions.zig eventOf); or why it is not one this host follows. */
185
+ export function subscriptionEvent(rec) {
186
+ const event = rec.event;
187
+ if (rec.kind !== "event" || (event !== "subscribe" && event !== "unsubscribe"))
188
+ return { refused: "not a subscribe or unsubscribe event" };
189
+ if (typeof rec.app !== "string" || !rec.app)
190
+ return { refused: "it names no app (its program record is not installed): ignored" };
191
+ if (!isTopic(rec.topic))
192
+ return { refused: `${JSON.stringify(rec.topic)} is not a topic (text, no space; a /protocol is not subscribed)` };
193
+ if (event === "unsubscribe")
194
+ return { event, app: rec.app, topic: rec.topic };
195
+ if (typeof rec.program !== "string" || !rec.program || typeof rec.fn !== "string" || !rec.fn)
196
+ return { refused: "a subscribe names its program and fn" };
197
+ return { event, sub: { app: rec.app, topic: rec.topic, program: rec.program, fn: rec.fn, ...(typeof rec.filter === "string" ? { filter: rec.filter } : {}) } };
198
+ }
199
+ /** Fold subscription events in order (subscriptions.zig apply). */
200
+ export function foldSubscriptions(events, into = []) {
201
+ for (const e of events) {
202
+ const [app, topic] = e.event === "subscribe" ? [e.sub.app, e.sub.topic] : [e.app, e.topic];
203
+ const i = into.findIndex((s) => s.app === app && s.topic === topic);
204
+ if (e.event === "subscribe") {
205
+ if (i >= 0)
206
+ into[i] = e.sub;
207
+ else
208
+ into.push(e.sub);
209
+ }
210
+ else if (i >= 0)
211
+ into.splice(i, 1);
212
+ }
213
+ return into;
214
+ }
215
+ /** The topics the subscriptions take (each once): what the node subscribes for the apps. */
216
+ export const subscribedTopics = (subs) => [...new Set(subs.map((s) => s.topic))];
217
+ /**
218
+ * The subscriptions, folded from the store's log (#119, the host's recovery
219
+ * at hydrate; subscriptions.zig fold): the `subscribe` / `unsubscribe`
220
+ * records each step's update lists in `emitted`, in log order — by the entry
221
+ * the step processed (`input`'s `n`), the step's time, its thread's start,
222
+ * the thread's CID, its place in the thread and in `emitted`. Read only.
223
+ */
224
+ export async function subscriptionsOf(store) {
225
+ return foldSubscriptions(await emittedEvents(store, (rec) => { const e = subscriptionEvent(rec); return "refused" in e ? undefined : e; }));
226
+ }
227
+ /**
228
+ * The beacons standing (#126), folded from the store's log as the
229
+ * subscriptions are: the `beacon` / `unbeacon` records the steps' updates
230
+ * list, in log order, keyed by (app, topic). Read only.
231
+ */
232
+ export async function beaconsOf(store) {
233
+ return foldBeacons(await emittedEvents(store, (rec) => { const e = beaconEvent(rec); return "refused" in e ? undefined : e; }));
234
+ }
235
+ /** The event records the steps' updates list in `emitted` that `read` takes, in log order (the kernel's fold order, subscriptions.zig); `read` gets each one's place too. */
236
+ export async function emittedEvents(store, read) {
237
+ const ns = new Map();
238
+ const nOf = async (input) => {
239
+ if (!isCid(input))
240
+ return -1;
241
+ const k = input.toString();
242
+ let n = ns.get(k);
243
+ if (n === undefined) {
244
+ const e = await store.get(input).catch(() => undefined);
245
+ n = typeof e?.n === "number" ? e.n : -1;
246
+ ns.set(k, n);
247
+ }
248
+ return n;
249
+ };
250
+ const found = [];
251
+ for await (const origin of store.edges.query({ kind: "thread" })) {
252
+ const o = await store.get(origin);
253
+ const thread = origin, threadBytes = origin.bytes;
254
+ let seq = 0;
255
+ for await (const c of store.chains.history(origin)) {
256
+ if (c.equals(origin))
257
+ continue;
258
+ seq++;
259
+ const u = await store.get(c);
260
+ if (!Array.isArray(u.emitted) || !u.emitted.length)
261
+ continue;
262
+ for (const [i, x] of u.emitted.entries()) {
263
+ if (!isCid(x))
264
+ continue;
265
+ const rec = await store.get(x).catch(() => undefined);
266
+ if (rec?.kind !== "event")
267
+ continue;
268
+ const n = await nOf(u.input), at = Number(u.at ?? 0);
269
+ const e = read(rec, { n, at, thread, seq, i, record: x });
270
+ if (e === undefined)
271
+ continue;
272
+ found.push({ key: [n, at, Number(o.at ?? 0), seq, i], thread: threadBytes, e });
273
+ }
274
+ }
275
+ }
276
+ const cmp = (x, y) => {
277
+ for (let i = 0; i < 3; i++)
278
+ if (x.key[i] !== y.key[i])
279
+ return x.key[i] - y.key[i];
280
+ const t = Buffer.compare(x.thread, y.thread);
281
+ if (t)
282
+ return t;
283
+ for (let i = 3; i < x.key.length; i++)
284
+ if (x.key[i] !== y.key[i])
285
+ return x.key[i] - y.key[i];
286
+ return 0;
287
+ };
288
+ found.sort(cmp);
289
+ return found.map((f) => f.e);
290
+ }
291
+ /** What a beat's signature covers. */
292
+ export function beaconPreimage(topic, b) {
293
+ return dagCbor.encode({ kind: "beacon", topic, body: b.body, at: b.at, sender: b.sender });
294
+ }
295
+ /** A beat's frame for `topic`, signed by `sign` (the instance's signer). */
296
+ export async function beaconFrame(topic, body, at, sender, sign) {
297
+ const signature = await sign(beaconPreimage(topic, { body, at, sender }));
298
+ return dagCbor.encode({ body, at, sender, signature });
299
+ }
300
+ /** A beat read from a frame on `topic`, its signature checked against its sender; or why it is not one. */
301
+ export function beaconBeat(topic, frame) {
302
+ let v;
303
+ try {
304
+ v = dagCbor.decode(frame);
305
+ }
306
+ catch {
307
+ return { problem: "not dag-cbor" };
308
+ }
309
+ if (!v || typeof v !== "object")
310
+ return { problem: "not a map" };
311
+ const { body, at, sender, signature } = v;
312
+ if (!(body instanceof Uint8Array) || typeof at !== "number" || !(sender instanceof Uint8Array) || sender.length !== 33 || !(signature instanceof Uint8Array))
313
+ return { problem: "want {body: bytes, at: int, sender: bytes(33), signature: bytes}" };
314
+ const b = { body, at, sender, signature };
315
+ if (!verifyAnyone(Buffer.from(sender).toString("hex"), SIGN_PROTOCOL, MESSAGE_KEY_ID, beaconPreimage(topic, b), signature))
316
+ return { problem: "the signature is not the sender's" };
317
+ return b;
318
+ }
319
+ /** A `beacon` / `unbeacon` event record, read; or why it is not one this host follows. */
320
+ export function beaconEvent(rec) {
321
+ const event = rec.event;
322
+ if (rec.kind !== "event" || (event !== "beacon" && event !== "unbeacon"))
323
+ return { refused: "not a beacon or unbeacon event" };
324
+ if (typeof rec.app !== "string" || !rec.app)
325
+ return { refused: "it names no app (its program record is not installed): ignored" };
326
+ if (!isTopic(rec.topic))
327
+ return { refused: `${JSON.stringify(rec.topic)} is not a topic` };
328
+ if (event === "unbeacon")
329
+ return { event, app: rec.app, topic: rec.topic };
330
+ const every = typeof rec.every === "number" ? rec.every : typeof rec.every === "bigint" ? Number(rec.every) : NaN;
331
+ if (!Number.isFinite(every) || every < 1000)
332
+ return { refused: "a beacon beats every second or slower (`every`, ms)" };
333
+ if (!(rec.body instanceof Uint8Array))
334
+ return { refused: "a beacon has a body (bytes)" };
335
+ return { event, beacon: { app: rec.app, topic: rec.topic, every, body: rec.body } };
336
+ }
337
+ /** Fold beacon events in order: a beacon replaces the app's own on the topic, an unbeacon removes it. */
338
+ export function foldBeacons(events, into = []) {
339
+ for (const e of events) {
340
+ const [app, topic] = e.event === "beacon" ? [e.beacon.app, e.beacon.topic] : [e.app, e.topic];
341
+ const i = into.findIndex((b) => b.app === app && b.topic === topic);
342
+ if (e.event === "beacon") {
343
+ if (i >= 0)
344
+ into[i] = e.beacon;
345
+ else
346
+ into.push(e.beacon);
347
+ }
348
+ else if (i >= 0)
349
+ into.splice(i, 1);
350
+ }
351
+ return into;
352
+ }
353
+ const isCid = (x) => CID.asCID(x) !== null;
354
+ /** Stream IDs unique across restarts (a thread's recorded stream never names a later stream). */
355
+ let nextStream = Date.now() * 1000;
356
+ const u64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, n, false); return b; };
357
+ /** The answer to a stream whose first frame names no topic where several instances declare its protocol (#160). */
358
+ export const TOPIC_REQUIRED = "topic required";
359
+ export class P2PHost {
360
+ o;
361
+ /** The liveness tool's state (#138): the apps' liveness and the beats kept, in memory. */
362
+ live = new Liveness();
363
+ /** The nodes, by peer ID (#160: one per key). */
364
+ nodes = new Map();
365
+ /** Each handle's node. */
366
+ byHandle = new Map();
367
+ /** Each handle's peer ID, as derived (keyOf). */
368
+ ids = new Map();
369
+ /** Each key's declares, in order (one at a time). */
370
+ starting = new Map();
371
+ stopped = false;
372
+ constructor(o) {
373
+ this.o = o;
374
+ if (o.beat)
375
+ this.live.kept = (h, topic, b) => { if (b.from !== this.peerId(h))
376
+ o.beat(h, topic, b); };
377
+ }
378
+ say(source, line) { this.o.log?.(source, line); }
379
+ /** A node's name in a log line: its one handle (as before #160), else its first. */
380
+ nameOf(n) { return n.handles.keys().next().value ?? n.id; }
381
+ /** The node of an instance, if it has one. */
382
+ node(handle) { return this.byHandle.get(handle)?.node; }
383
+ /** An instance's peer ID (derived: whether or not its node runs). */
384
+ peerId(handle) {
385
+ let id = this.ids.get(handle);
386
+ if (!id)
387
+ this.ids.set(handle, (id = peerIdOf(this.o.keyOf(handle)).toString()));
388
+ return id;
389
+ }
390
+ /** The handles on `handle`'s node (#160: one in a master-key host; every one sharing its key in a one-key host). */
391
+ handlesOn(handle) { return [...(this.byHandle.get(handle)?.handles.keys() ?? [])]; }
392
+ /** How many nodes run (#160: one per key). */
393
+ nodeCount() { return this.nodes.size; }
394
+ /** Where an instance's node listens (with /p2p/<id>). */
395
+ addrs(handle) { return this.node(handle)?.getMultiaddrs().map((m) => m.toString()) ?? []; }
396
+ /**
397
+ * `handle`'s libp2p config is this: its key's node started (once) with its peer key, the handle's
398
+ * topics subscribed with the validator, its protocols handled. Declared again, the handle is brought
399
+ * to the new config. No config: the handle leaves its node; the node stops with its last handle.
400
+ */
401
+ async declare(handle, config) {
402
+ if (this.stopped)
403
+ return;
404
+ const key = this.byHandle.get(handle)?.id ?? this.peerId(handle);
405
+ const prev = this.starting.get(key) ?? Promise.resolve();
406
+ // The previous declare's failure went to its own caller (it awaited it); this one runs regardless.
407
+ const next = prev.catch(() => { }).then(async () => {
408
+ if (this.stopped)
409
+ return;
410
+ if (!config)
411
+ return this.drop(handle);
412
+ const n = this.byHandle.get(handle) ?? this.nodes.get(key);
413
+ if (n)
414
+ return this.reconfigure(n, handle, config);
415
+ await this.start(key, handle, config);
416
+ });
417
+ this.starting.set(key, next);
418
+ // Only clears the slot: the error reaches this call's caller through `await next` below.
419
+ void next.catch(() => { }).finally(() => { if (this.starting.get(key) === next)
420
+ this.starting.delete(key); });
421
+ await next;
422
+ }
423
+ /** The topics and protocols an instance's node serves for it now. */
424
+ served(handle) {
425
+ const h = this.byHandle.get(handle)?.handles.get(handle);
426
+ return h && { topics: [...h.config.topics], protocols: [...h.config.protocols] };
427
+ }
428
+ /** Bring `handle` on a running node to `config` (joining it if it is new there): subscribe and handle what is new, unsubscribe and unhandle what no handle takes any more. */
429
+ async reconfigure(n, handle, config) {
430
+ let h = n.handles.get(handle);
431
+ const joined = !h;
432
+ if (!h) {
433
+ n.handles.set(handle, (h = { config: { topics: [], protocols: [] }, streams: new Map(), beats: new Map() }));
434
+ this.byHandle.set(handle, n);
435
+ if (config.listen?.length)
436
+ this.say(handle, `libp2p: its listen ${config.listen.join(" ")} not honoured: its key's node (${n.id}) carries ${n.handles.size} instances and listens on ${n.node.getMultiaddrs().map(String).join(" ") || "(nothing)"} (#160)`);
437
+ }
438
+ const was = h.config;
439
+ const added = config.topics.filter((t) => !was.topics.includes(t));
440
+ const removed = was.topics.filter((t) => !config.topics.includes(t));
441
+ const addedP = config.protocols.filter((p) => !was.protocols.includes(p) && !this.hostServed(p));
442
+ const removedP = was.protocols.filter((p) => !config.protocols.includes(p) && !this.hostServed(p));
443
+ h.config = { ...config, listen: joined ? undefined : was.listen };
444
+ if (!joined && !added.length && !removed.length && !addedP.length && !removedP.length)
445
+ return;
446
+ const sub = this.resubscribe(n);
447
+ await this.rehandle(n);
448
+ const list = (xs) => xs.join(", ") || "none";
449
+ if (joined)
450
+ this.say(handle, `libp2p: on the node of its key ${n.id} (${n.handles.size} instances); topics ${list(config.topics)}; protocols ${list(config.protocols)}`);
451
+ else
452
+ this.say(handle, `libp2p: topics ${list(config.topics)} (+${list(added)} −${list(removed)}); protocols ${list(config.protocols)} (+${list(addedP)} −${list(removedP)})`);
453
+ if (added.length || sub.added.length)
454
+ setTimeout(() => void this.discover(n).catch((e) => this.say(this.nameOf(n), `libp2p: discovery: ${e.message}`)), 200);
455
+ }
456
+ subscribe(n, topic) {
457
+ const ps = this.pubsub(n);
458
+ ps.topicValidators.set(topic, (from, msg) => this.validate(n, topic, from, msg));
459
+ ps.subscribe(topic);
460
+ n.subscribed.add(topic);
461
+ }
462
+ /** The topics `handle` takes: its config's and its apps' liveness topics (#138). */
463
+ takes(handle, h) { return [...h.config.topics, ...this.live.topics(handle)]; }
464
+ /** Subscribe what the handles' configs and liveness tools (#138) take, unsubscribe the rest: the topics added and removed. */
465
+ resubscribe(n) {
466
+ const want = new Set([...n.handles].flatMap(([handle, h]) => this.takes(handle, h)));
467
+ const ps = this.pubsub(n);
468
+ const removed = [...n.subscribed].filter((t) => !want.has(t));
469
+ const added = [...want].filter((t) => !n.subscribed.has(t));
470
+ for (const t of removed) {
471
+ ps.unsubscribe(t);
472
+ ps.topicValidators.delete(t);
473
+ n.subscribed.delete(t);
474
+ }
475
+ for (const t of added)
476
+ this.subscribe(n, t);
477
+ return { added, removed };
478
+ }
479
+ /** Handle the protocols the handles declare, unhandle those none declares any more (the host's own aside). */
480
+ async rehandle(n) {
481
+ const host = this.o.hostProtocols ?? {};
482
+ const want = new Set([...n.handles.values()].flatMap((h) => h.config.protocols).filter((p) => inboundRow(p, host)));
483
+ for (const p of [...n.handled])
484
+ if (!want.has(p)) {
485
+ n.handled.delete(p);
486
+ await n.node.unhandle(p);
487
+ }
488
+ for (const p of want)
489
+ if (!n.handled.has(p)) {
490
+ n.handled.add(p);
491
+ await this.handleProtocol(n, p);
492
+ }
493
+ }
494
+ /** The topics `handle`'s node subscribes for it now: its config's and its apps' liveness topics (#138). */
495
+ listening(handle) {
496
+ const n = this.byHandle.get(handle);
497
+ const h = n?.handles.get(handle);
498
+ return n && h ? [...new Set(this.takes(handle, h))].filter((t) => n.subscribed.has(t)) : [];
499
+ }
500
+ /**
501
+ * The liveness `handle`'s apps keep (#138, liveness.ts): the tool keeps it, and the node subscribes
502
+ * each topic (not admitting its messages) and leaves those no liveness nor config takes. One line
503
+ * when a topic is taken or left. No node: kept for when it starts.
504
+ */
505
+ liveness(handle, lives) {
506
+ this.live.set(handle, lives);
507
+ const n = this.byHandle.get(handle);
508
+ if (!n)
509
+ return;
510
+ const { added, removed } = this.resubscribe(n);
511
+ for (const t of added)
512
+ this.say(handle, `libp2p: liveness ${t} (app ${lives.filter((l) => l.topic === t).map((l) => l.app).join(", ")})`);
513
+ for (const t of removed)
514
+ this.say(handle, `libp2p: ${t} left`);
515
+ if (added.length)
516
+ setTimeout(() => void this.discover(n).catch((e) => this.say(this.nameOf(n), `libp2p: discovery: ${e.message}`)), 200);
517
+ }
518
+ /** Whether the host serves `protocol` on every node itself (/skein/message/1.0.0, `hostProtocols`). */
519
+ hostServed(protocol) { return protocol === MESSAGE_PROTOCOL || protocol in (this.o.hostProtocols ?? {}); }
520
+ async handleHostProtocol(n, protocol, f) {
521
+ await n.node.handle(protocol, (stream, connection) => {
522
+ void (async () => {
523
+ const lp = lpStream(stream);
524
+ try {
525
+ for (;;) {
526
+ let frame;
527
+ try {
528
+ frame = (await lp.read()).subarray();
529
+ }
530
+ catch {
531
+ break;
532
+ }
533
+ // The router names no host protocol (#154); a node's first handle answers for it.
534
+ await lp.write(await f(this.nameOf(n), connection.remotePeer, frame));
535
+ }
536
+ await stream.close().catch(() => { });
537
+ }
538
+ catch (e) {
539
+ this.say(this.nameOf(n), `libp2p: ${protocol} from ${connection.remotePeer}: ${e.message}`);
540
+ stream.abort(e);
541
+ }
542
+ })();
543
+ }, { runOnLimitedConnection: true });
544
+ }
545
+ async handleProtocol(n, protocol) {
546
+ await n.node.handle(protocol, (stream, connection) => { void this.serve(n, protocol, stream, connection.remotePeer); }, { runOnLimitedConnection: true });
547
+ }
548
+ async start(key, handle, config) {
549
+ const h = this.o.host;
550
+ const n = { id: key, node: undefined, handles: new Map([[handle, { config, streams: new Map(), beats: new Map() }]]), seqnos: new Map(), subscribed: new Set(), handled: new Set() };
551
+ const listen = [...(config.listen ?? h.listen), ...h.relays.map((r) => `${r.replace(/\/+$/, "")}/p2p-circuit`)];
552
+ // An address that does not parse counts as private here; start() reports it when it is dialled or listened on.
553
+ const privateDev = [...h.bootstrap, ...listen].every((a) => { try {
554
+ return isPrivateAddr(multiaddr(a));
555
+ }
556
+ catch {
557
+ return true;
558
+ } });
559
+ const services = {
560
+ identify: identify(),
561
+ ping: ping(),
562
+ pubsub: gossipsub({
563
+ globalSignaturePolicy: "StrictSign",
564
+ allowPublishToZeroTopicPeers: true,
565
+ emitSelf: false,
566
+ // The default id (the publisher's key ‖ seqno), and publish's seqno caught by the data object.
567
+ msgIdFn: async (msg) => {
568
+ if (msg.type !== "signed")
569
+ throw new Error("unsigned message");
570
+ if (n.seqnos.has(msg.data))
571
+ n.seqnos.set(msg.data, msg.sequenceNumber);
572
+ const key = (msg.from.publicKey ?? msg.key);
573
+ const { publicKeyToProtobuf } = await import("@libp2p/crypto/keys");
574
+ const k = publicKeyToProtobuf(key);
575
+ const out = new Uint8Array(k.length + 8);
576
+ out.set(k, 0);
577
+ out.set(u64(msg.sequenceNumber), k.length);
578
+ return out;
579
+ },
580
+ }),
581
+ };
582
+ if (h.dht !== "off")
583
+ services.dht = kadDHT({ clientMode: h.dht === "client", peerInfoMapper: privateDev ? passthroughMapper : removePrivateAddressesMapper });
584
+ const node = await createLibp2p({
585
+ privateKey: libp2pKey(this.o.keyOf(handle)),
586
+ addresses: { listen },
587
+ transports: [tcp(), webSockets(h.tls ? { https: { cert: h.tls.cert, key: h.tls.key } } : {}), ...(h.relays.length ? [circuitRelayTransport()] : [])],
588
+ connectionEncrypters: [noise()],
589
+ streamMuxers: [yamux()],
590
+ peerDiscovery: h.mdns ? [mdns()] : [],
591
+ services: services,
592
+ });
593
+ n.node = node;
594
+ if (this.stopped) {
595
+ await node.stop();
596
+ return undefined;
597
+ }
598
+ this.nodes.set(key, n);
599
+ this.byHandle.set(handle, n);
600
+ if (h.mdns)
601
+ node.addEventListener("peer:discovery", (e) => { void node.dial(e.detail.id).catch((err) => this.say(this.nameOf(n), `libp2p: mDNS peer ${e.detail.id}: ${err.message}`)); });
602
+ this.resubscribe(n);
603
+ const host = this.o.hostProtocols ?? {};
604
+ for (const protocol of config.protocols.filter((p) => inboundRow(p, host))) {
605
+ n.handled.add(protocol);
606
+ await this.handleProtocol(n, protocol);
607
+ }
608
+ await this.handleProtocol(n, MESSAGE_PROTOCOL);
609
+ for (const [protocol, f] of Object.entries(host))
610
+ await this.handleHostProtocol(n, protocol, f);
611
+ this.say(handle, `libp2p: peer ${node.peerId} listening on ${node.getMultiaddrs().map(String).join(" ") || "(nothing)"}; topics ${config.topics.join(", ") || "none"}; protocols ${config.protocols.join(", ") || "none"}`);
612
+ const discover = () => void this.discover(n).catch((e) => this.say(this.nameOf(n), `libp2p: discovery: ${e.message}`));
613
+ setTimeout(discover, 200);
614
+ n.timer = setInterval(discover, this.o.discoveryMs ?? 30_000);
615
+ return n;
616
+ }
617
+ // ---------------------------------------------------------------- discovery
618
+ /** Keep the bootstrap peers connected; with the DHT on, advertise each topic and dial its providers. */
619
+ async discover(n) {
620
+ const node = n.node;
621
+ const name = this.nameOf(n);
622
+ const connected = new Set(node.getConnections().map((c) => c.remotePeer.toString()));
623
+ for (const a of this.o.host.bootstrap) {
624
+ let ma;
625
+ try {
626
+ ma = multiaddr(a);
627
+ }
628
+ catch {
629
+ this.say(name, `libp2p: bad bootstrap address ${a}`);
630
+ continue;
631
+ }
632
+ const id = ma.getComponents().find((c) => c.name === "p2p")?.value;
633
+ if (id === node.peerId.toString() || (id && connected.has(id)))
634
+ continue;
635
+ await node.dial(ma).catch((e) => this.say(name, `libp2p: bootstrap ${a}: ${e.message}`));
636
+ }
637
+ if (this.o.host.dht === "off" || !node.services.dht)
638
+ return;
639
+ for (const topic of [...n.subscribed]) {
640
+ const cid = await topicCid(topic);
641
+ await node.contentRouting.provide(cid, { signal: AbortSignal.timeout(20_000) }).catch((e) => { if (!timedOut(e))
642
+ this.say(name, `libp2p: provide ${topic}: ${e.message}`); });
643
+ try {
644
+ for await (const p of node.contentRouting.findProviders(cid, { signal: AbortSignal.timeout(20_000) })) {
645
+ if (p.id.equals(node.peerId) || node.getConnections(p.id).length)
646
+ continue;
647
+ await node.dial(p.multiaddrs.length ? p.multiaddrs : p.id).catch((e) => this.say(name, `libp2p: ${topic} provider ${p.id}: ${e.message}`));
648
+ }
649
+ }
650
+ catch (e) {
651
+ if (!timedOut(e))
652
+ this.say(name, `libp2p: find providers ${topic}: ${e.message}`);
653
+ } // timed out: next round
654
+ }
655
+ }
656
+ // ---------------------------------------------------------------- inbound
657
+ pubsub(n) {
658
+ return n.node.services.pubsub;
659
+ }
660
+ /**
661
+ * The topic validator: for every handle on the node that takes the topic, one front-door call (or,
662
+ * for a topic only its apps' liveness takes, #138, the liveness tool's verdict alone: nothing
663
+ * admitted); GossipSub's verdict (async: forwarding waits on it) is Accept if any handle accepts,
664
+ * Reject only if every one rejects, else Ignore (#160).
665
+ */
666
+ async validate(n, topic, _source, msg) {
667
+ if (msg.type !== "signed")
668
+ return TopicValidatorResult.Reject;
669
+ const takers = [...n.handles].filter(([handle, h]) => h.config.topics.includes(topic) || this.live.takes(handle, topic));
670
+ if (!takers.length)
671
+ return TopicValidatorResult.Ignore;
672
+ const verdicts = await Promise.all(takers.map(([handle, h]) => this.validateFor(handle, h, topic, msg)));
673
+ if (verdicts.includes(TopicValidatorResult.Accept))
674
+ return TopicValidatorResult.Accept;
675
+ if (verdicts.every((v) => v === TopicValidatorResult.Reject))
676
+ return TopicValidatorResult.Reject;
677
+ return TopicValidatorResult.Ignore;
678
+ }
679
+ /** One handle's verdict on a topic message (the validator as it was per node before #160). */
680
+ async validateFor(handle, h, topic, msg) {
681
+ const live = this.live.takes(handle, topic) ? this.live.observe(handle, topic, msg.data, msg.from.toString()) : undefined;
682
+ if (!h.config.topics.includes(topic))
683
+ return live === "accept" ? TopicValidatorResult.Accept : live === "reject" ? TopicValidatorResult.Reject : TopicValidatorResult.Ignore;
684
+ try {
685
+ const a = await this.o.inbound(handle, {
686
+ transport: "libp2p", topic, from: msg.from.toMultihash().bytes, seqno: u64(msg.sequenceNumber), signature: msg.signature, body: msg.data,
687
+ });
688
+ return a.verdict === "accept" ? TopicValidatorResult.Accept : a.verdict === "reject" ? TopicValidatorResult.Reject : TopicValidatorResult.Ignore;
689
+ }
690
+ catch (e) {
691
+ // The host failing is not the message's fault: no penalty.
692
+ this.say(handle, `libp2p: ${topic}: validation failed: ${e.message}`);
693
+ return TopicValidatorResult.Ignore;
694
+ }
695
+ }
696
+ /**
697
+ * Which handle an inbound stream on `protocol` is for (#160), by its first frame: the handles that
698
+ * declare the protocol (every handle declares /skein/message/1.0.0); the frame's `topic` (dag-cbor
699
+ * {topic, …}) picks the one among them that takes it; a frame naming none, or a topic none of them
700
+ * takes, goes to the one handle that declares the protocol; with several, a problem.
701
+ */
702
+ streamHandle(n, protocol, first) {
703
+ const declaring = [...n.handles].filter(([, h]) => protocol === MESSAGE_PROTOCOL || h.config.protocols.includes(protocol)).map(([handle]) => handle);
704
+ if (declaring.length === 1)
705
+ return { handle: declaring[0] };
706
+ if (!declaring.length)
707
+ return { problem: `no instance here declares ${protocol}` };
708
+ let topic;
709
+ try {
710
+ const v = dagCbor.decode(first);
711
+ topic = v && typeof v === "object" ? v.topic : undefined;
712
+ }
713
+ catch { /* not dag-cbor: it names no topic */ }
714
+ if (typeof topic !== "string" || !topic)
715
+ return { problem: TOPIC_REQUIRED };
716
+ // A topic several declaring instances take (a discovery instance beside a token instance, #137): the first declared of them.
717
+ const taker = declaring.find((handle) => n.handles.get(handle).config.topics.includes(topic));
718
+ return taker ? { handle: taker } : { problem: `no instance here takes ${topic}` };
719
+ }
720
+ /** An inbound stream on a handled protocol: its first frame picks the handle (#160); each frame one front-door call, its answer's body written back. */
721
+ async serve(n, protocol, stream, remote) {
722
+ const lp = lpStream(stream);
723
+ let handle = this.nameOf(n);
724
+ try {
725
+ let first;
726
+ try {
727
+ first = (await lp.read()).subarray();
728
+ }
729
+ catch { /* the remote closed before a frame: nothing owed */ }
730
+ if (first) {
731
+ const to = this.streamHandle(n, protocol, first);
732
+ if ("problem" in to) {
733
+ this.say(handle, `libp2p: ${protocol} from ${remote}: ${to.problem}: answered and closed`);
734
+ await lp.write(dagCbor.encode({ kind: "error", message: to.problem }));
735
+ await stream.close().catch(() => { });
736
+ return;
737
+ }
738
+ handle = to.handle;
739
+ for (let frame = first;;) {
740
+ const a = await this.o.inbound(handle, { transport: "libp2p", protocol, from: remote.toMultihash().bytes, body: frame });
741
+ if (a.body)
742
+ await lp.write(a.body);
743
+ if (a.close || a.verdict === "reject")
744
+ break;
745
+ try {
746
+ frame = (await lp.read()).subarray();
747
+ }
748
+ catch {
749
+ break;
750
+ } // the remote closed or reset its side: the stream is over either way, and nothing here is owed an answer
751
+ }
752
+ }
753
+ await stream.close().catch(() => { }); // a stream the remote already closed or reset cannot be closed again; every frame was answered
754
+ }
755
+ catch (e) {
756
+ this.say(handle, `libp2p: ${protocol} from ${remote}: ${e.message}`);
757
+ stream.abort(e);
758
+ }
759
+ }
760
+ // ---------------------------------------------------------------- outbound (the libp2p provider's, #70)
761
+ nodeOf(handle) {
762
+ const n = this.byHandle.get(handle);
763
+ if (!n)
764
+ throw new Error("this instance has no libp2p node (its config names no libp2p)");
765
+ return n;
766
+ }
767
+ /**
768
+ * The beacons `handle`'s node beats (#126): each a new frame (beaconFrame:
769
+ * the body, the beat's time, the instance's signature) published on its topic
770
+ * every `every` ms — the topic not subscribed; nothing logged per beat (a
771
+ * failed beat is dropped: the next one is the retry). The FIRST beacon on a
772
+ * topic publishes at once. A beacon REPLACING one (the same app and topic, a
773
+ * new body and/or `every`; the settled sync design, David 2026-10-30) swaps
774
+ * what the next beat carries and leaves the timer alone — no publish now, no
775
+ * reset — unless `every` changed, when the timer is reset to the new
776
+ * interval (still no publish now). One line when a beacon starts or stops.
777
+ * No node: nothing beats.
778
+ */
779
+ beacons(handle, beacons) {
780
+ const n = this.byHandle.get(handle);
781
+ const hs = n?.handles.get(handle);
782
+ if (!n || !hs)
783
+ return;
784
+ const want = new Map(beacons.map((b) => [`${b.app} ${b.topic}`, b]));
785
+ // Each beat a new frame: the entry's body as it stands then, now, signed by the instance's signer (beaconFrame).
786
+ const beatOf = (k) => () => {
787
+ const x = hs.beats.get(k);
788
+ if (!x || this.byHandle.get(handle) !== n || n.handles.get(handle) !== hs)
789
+ return;
790
+ const b = x.beacon;
791
+ const { sign, identity } = this.o;
792
+ if (!sign || !identity)
793
+ return;
794
+ void (async () => {
795
+ const sender = await identity(handle);
796
+ if (!sender)
797
+ return;
798
+ const frame = await beaconFrame(b.topic, b.body, Date.now(), sender, (d) => sign(handle, d));
799
+ await this.publish(handle, b.topic, frame);
800
+ // #138: the node's own beat into its liveness set (gossip does not echo it).
801
+ if (this.live.takes(handle, b.topic))
802
+ this.live.observe(handle, b.topic, frame, n.node.peerId.toString());
803
+ })().catch(() => { });
804
+ };
805
+ for (const [k, x] of hs.beats) {
806
+ const b = want.get(k);
807
+ if (b) {
808
+ if (b.every !== x.beacon.every) {
809
+ clearInterval(x.timer);
810
+ x.timer = setInterval(beatOf(k), b.every);
811
+ }
812
+ x.beacon = b; // the next beat carries it
813
+ continue;
814
+ }
815
+ clearInterval(x.timer);
816
+ hs.beats.delete(k);
817
+ this.say(handle, `libp2p: beacon ${x.beacon.topic} (app ${x.beacon.app}) stopped`);
818
+ }
819
+ for (const [k, b] of want) {
820
+ if (hs.beats.has(k))
821
+ continue;
822
+ hs.beats.set(k, { beacon: b, timer: setInterval(beatOf(k), b.every) });
823
+ beatOf(k)();
824
+ this.say(handle, `libp2p: beacon ${b.topic} (app ${b.app}) every ${b.every} ms, ${b.body.length} bytes`);
825
+ }
826
+ }
827
+ /** Publish `body` on `topic` from `handle`'s node (signed with its peer key): the message's seqno, and how many peers took it. */
828
+ async publish(handle, topic, body) {
829
+ const n = this.nodeOf(handle);
830
+ const data = Uint8Array.from(body); // a fresh object: the key msgIdFn catches the seqno by
831
+ n.seqnos.set(data, -1n);
832
+ try {
833
+ const r = await this.pubsub(n).publish(topic, data);
834
+ const seq = n.seqnos.get(data);
835
+ if (seq < 0n)
836
+ throw new Error("publish: no seqno");
837
+ return { seqno: u64(seq), recipients: r.recipients.length };
838
+ }
839
+ finally {
840
+ n.seqnos.delete(data);
841
+ }
842
+ }
843
+ /** Open a stream from `handle`'s node to a peer (a peer ID, or a multiaddr with /p2p/<id>): its id; each frame read from it, and its end, to `frames`. */
844
+ async dial(handle, peer, protocol, frames) {
845
+ const n = this.nodeOf(handle);
846
+ const hs = n.handles.get(handle);
847
+ const target = peer.startsWith("/") ? multiaddr(peer) : peerIdFromString(peer);
848
+ const stream = await n.node.dialProtocol(target, protocol, { signal: AbortSignal.timeout(15_000) });
849
+ const id = ++nextStream;
850
+ const s = { stream, lp: lpStream(stream) };
851
+ hs.streams.set(id, s);
852
+ void (async () => {
853
+ for (;;) {
854
+ try {
855
+ frames({ body: (await s.lp.read()).subarray() });
856
+ }
857
+ catch (e) {
858
+ const name = e.name;
859
+ const clean = name === "UnexpectedEOFError" || name === "StreamClosedError" || name === "StreamResetError";
860
+ if (hs.streams.get(id) === s)
861
+ frames({ closed: true, ...(clean ? {} : { error: e.message }) });
862
+ return;
863
+ }
864
+ }
865
+ })();
866
+ return id;
867
+ }
868
+ /**
869
+ * One request to a peer on `protocol` (a host protocol, shruggr/skein#150): a stream opened, one
870
+ * frame written, one frame read back, the stream closed. → the answer's bytes.
871
+ */
872
+ async request(handle, peer, protocol, body, timeoutMs = 15_000) {
873
+ const n = this.nodeOf(handle);
874
+ const signal = AbortSignal.timeout(timeoutMs);
875
+ const stream = await n.node.dialProtocol(peerIdFromString(peer), protocol, { signal });
876
+ try {
877
+ const lp = lpStream(stream);
878
+ await lp.write(body, { signal });
879
+ const answer = (await lp.read({ signal })).subarray();
880
+ await stream.close().catch(() => { });
881
+ return answer;
882
+ }
883
+ catch (e) {
884
+ stream.abort(e);
885
+ throw e;
886
+ }
887
+ }
888
+ /** The protocols `handle`'s node handles inbound now (the libp2p node's own registry: its rows', the host's, /skein/message/1.0.0). */
889
+ handledProtocols(handle) { return this.byHandle.get(handle)?.node.getProtocols() ?? []; }
890
+ /** The addresses `handle`'s node knows for `peer` (its peer store), as multiaddr strings. */
891
+ async peerAddrs(handle, peer) {
892
+ const n = this.byHandle.get(handle);
893
+ if (!n)
894
+ return [];
895
+ try {
896
+ return (await n.node.peerStore.get(peerIdFromString(peer))).addresses.map((a) => a.multiaddr.toString());
897
+ }
898
+ catch {
899
+ return [];
900
+ }
901
+ }
902
+ /** Dial `peer` from `handle`'s node, at `addrs` when given (a start's persisted peers), else as the peer store knows it. */
903
+ async connect(handle, peer, addrs = []) {
904
+ const n = this.nodeOf(handle);
905
+ const id = peerIdFromString(peer);
906
+ if (n.node.getConnections(id).length)
907
+ return;
908
+ const mas = addrs.flatMap((a) => { try {
909
+ const m = multiaddr(a);
910
+ return [m.getComponents().some((c) => c.name === "p2p") ? m : m.encapsulate(`/p2p/${peer}`)];
911
+ }
912
+ catch {
913
+ return [];
914
+ } });
915
+ await n.node.dial(mas.length ? mas : id, { signal: AbortSignal.timeout(15_000) });
916
+ }
917
+ /** The peers `handle`'s node knows subscribe `topic` (GossipSub's view), as peer ID strings. */
918
+ subscribers(handle, topic) {
919
+ const n = this.byHandle.get(handle);
920
+ return n ? this.pubsub(n).getSubscribers(topic).map((p) => p.toString()) : [];
921
+ }
922
+ /** Write one length-prefixed frame on a dialled stream. */
923
+ async send(handle, id, body) {
924
+ const s = this.nodeOf(handle).handles.get(handle).streams.get(id);
925
+ if (!s)
926
+ throw new Error(`send: no stream ${id}`);
927
+ await s.lp.write(body);
928
+ }
929
+ /** Close a dialled stream (our side), and forget it. */
930
+ async close(handle, id) {
931
+ const hs = this.nodeOf(handle).handles.get(handle);
932
+ const s = hs.streams.get(id);
933
+ if (!s)
934
+ throw new Error(`close: no stream ${id}`);
935
+ hs.streams.delete(id);
936
+ await s.stream.close().catch(() => s.stream.abort(new Error("closed")));
937
+ }
938
+ // ---------------------------------------------------------------- lifecycle
939
+ /** `handle` leaves its node (its beats stopped, its liveness forgotten); the node stops with its last handle, else follows the handles left. */
940
+ async drop(handle) {
941
+ const n = this.byHandle.get(handle);
942
+ if (!n)
943
+ return;
944
+ const hs = n.handles.get(handle);
945
+ n.handles.delete(handle);
946
+ this.byHandle.delete(handle);
947
+ for (const b of hs?.beats.values() ?? [])
948
+ clearInterval(b.timer);
949
+ hs?.beats.clear();
950
+ this.live.drop(handle);
951
+ if (n.handles.size) {
952
+ this.resubscribe(n);
953
+ await this.rehandle(n).catch((e) => this.say(handle, `libp2p: leaving its key's node: ${e.message}`));
954
+ this.say(handle, `libp2p: left the node of its key ${n.id} (${n.handles.size} instances on it)`);
955
+ return;
956
+ }
957
+ this.nodes.delete(n.id);
958
+ clearInterval(n.timer);
959
+ try {
960
+ await n.node.stop();
961
+ }
962
+ catch (e) {
963
+ this.say(handle, `libp2p: stop: ${e.message}`);
964
+ } // dropped either way
965
+ }
966
+ async stop() {
967
+ this.stopped = true;
968
+ await Promise.all([...this.starting.values()].map((p) => p.catch(() => { }))); // each declare's failure went to its caller: this waits for them to settle
969
+ // Every node stopped at once, its handles with it (nothing to follow on a node that stops).
970
+ await Promise.all([...this.nodes.values()].map(async (n) => {
971
+ for (const [handle, hs] of n.handles) {
972
+ for (const b of hs.beats.values())
973
+ clearInterval(b.timer);
974
+ this.byHandle.delete(handle);
975
+ this.live.drop(handle);
976
+ }
977
+ n.handles.clear();
978
+ this.nodes.delete(n.id);
979
+ clearInterval(n.timer);
980
+ try {
981
+ await n.node.stop();
982
+ }
983
+ catch (e) {
984
+ this.say(n.id, `libp2p: stop: ${e.message}`);
985
+ } // dropped either way
986
+ }));
987
+ }
988
+ }
989
+ /** A multiaddr on a loopback or private network (dev: the DHT keeps such addresses). */
990
+ function isPrivateAddr(ma) {
991
+ const c = ma.getComponents().find((x) => x.name.startsWith("dns"));
992
+ if (c?.value)
993
+ return c.value === "localhost" || c.value.endsWith(".localhost");
994
+ try {
995
+ return isPrivate(ma);
996
+ }
997
+ catch {
998
+ return true;
999
+ } // an address isPrivate cannot classify (no IP component) counts as private, as a dev address
1000
+ }
1001
+ /** An AbortSignal.timeout's abort: the DHT's 20-second rounds end this way, and the next round tries again. */
1002
+ const timedOut = (e) => e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");