@reticulum/dacar 1.0.0 → 1.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.
- package/package.json +8 -2
- package/src/cli/dacar.js +574 -0
- package/src/cli/rns_boot.js +131 -0
- package/src/cli/session.js +277 -0
- package/src/cli/smoke.js +59 -0
- package/src/cli/store.js +464 -0
- package/src/crdt.js +9 -3
- package/src/hlc.js +33 -0
- package/src/verifier.js +27 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only RNS boot helpers (work doc #6).
|
|
3
|
+
*
|
|
4
|
+
* Constructs and **connects** a mesh interface, then attaches it to a booted
|
|
5
|
+
* `Reticulum` as the **default** interface — mirroring `@reticulum/node`'s
|
|
6
|
+
* `rfed` CLI `attachInterface`. This is non-optional: without `connect()` the
|
|
7
|
+
* interface's readable/writable streams are never set up and `_packetWriter`
|
|
8
|
+
* stays `null`, so `TransportCore.broadcast()` silently skips it
|
|
9
|
+
* (`if (!iface._packetWriter) continue`) and routed `sendPacket()` throws
|
|
10
|
+
* `No route to host`. No traffic flows in either direction — the
|
|
11
|
+
* `dacar sync --discover` "no rfed.node announce received within 30000ms"
|
|
12
|
+
* timeout symptom, even when an rfed node is announcing on the mesh.
|
|
13
|
+
*
|
|
14
|
+
* Node-only (imports `@reticulum/node` interfaces); kept out of the
|
|
15
|
+
* browser-portable `session.js`/`store.js` (see `test/cli-purity.test.js`).
|
|
16
|
+
* The Node-only CLI bin (`dacar.js`) composes this with the portable helpers.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Reticulum, setLogLevel, toHex } from "@reticulum/core";
|
|
20
|
+
import {
|
|
21
|
+
AutoInterface,
|
|
22
|
+
FileStorageAdapter,
|
|
23
|
+
LocalClientInterface,
|
|
24
|
+
TCPClientInterface,
|
|
25
|
+
} from "@reticulum/node";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Boot a `Reticulum` with a connected, default-attached mesh interface.
|
|
29
|
+
*
|
|
30
|
+
* `shared` (default) prefers a running rnsd via
|
|
31
|
+
* `LocalClientInterface.connectToSharedInstance()` (which internally
|
|
32
|
+
* connects); if no shared instance is reachable it falls back to
|
|
33
|
+
* `AutoInterface` so the node is still on the mesh — the same fallback the
|
|
34
|
+
* rfed CLI uses. `auto` and `tcp` connect their respective interfaces
|
|
35
|
+
* directly.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} configDir Reticulum storage directory (identity/paths).
|
|
38
|
+
* @param {"shared"|"auto"|"tcp"} iface Interface kind to attach.
|
|
39
|
+
* @param {Object} [opts]
|
|
40
|
+
* @param {boolean} [opts.verbose=false] Raise the Reticulum log threshold to
|
|
41
|
+
* `DEBUG` and log interface status + each validated announce the transport
|
|
42
|
+
* sees (dest/name-hash/hops), so a failing `--discover` shows whether any
|
|
43
|
+
* announces arrive at all and for which aspects.
|
|
44
|
+
* @param {() => Promise<import("@reticulum/node").LocalClientInterface | null>} [opts.sharedFactory]
|
|
45
|
+
* Injectable shared-instance connector (tests).
|
|
46
|
+
* @param {() => import("@reticulum/node").AutoInterface} [opts.autoFactory]
|
|
47
|
+
* Injectable AutoInterface factory (tests).
|
|
48
|
+
* @param {(host: string, port: number) => import("@reticulum/node").TCPClientInterface} [opts.tcpFactory]
|
|
49
|
+
* Injectable TCPClientInterface factory (tests).
|
|
50
|
+
* @returns {Promise<import("@reticulum/core").Reticulum>} A booted Reticulum
|
|
51
|
+
* with one connected default interface.
|
|
52
|
+
*/
|
|
53
|
+
export async function bootRns(configDir, iface, opts = {}) {
|
|
54
|
+
if (opts.verbose) setLogLevel("DEBUG");
|
|
55
|
+
const rns = new Reticulum({
|
|
56
|
+
storageAdapter: new FileStorageAdapter(configDir),
|
|
57
|
+
});
|
|
58
|
+
const label = await attachInterface(rns, iface, opts);
|
|
59
|
+
if (opts.verbose) {
|
|
60
|
+
const ifaces = [...rns.transport.interfaces];
|
|
61
|
+
const online = ifaces.filter((i) => i.online).length;
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
` rns: interface=${label} attached=${ifaces.length} online=${online}\n`,
|
|
64
|
+
);
|
|
65
|
+
// Surface every announce the transport validates, so a failed --discover
|
|
66
|
+
// shows whether *any* announces are arriving (and for which aspects).
|
|
67
|
+
rns.transport.addEventListener("announce", (event) => {
|
|
68
|
+
const d = event.detail ?? {};
|
|
69
|
+
const hops = d.packet?.hops ?? "?";
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
` announce: dest=${toHex(d.destinationHash ?? new Uint8Array())} ` +
|
|
72
|
+
`name=${toHex(d.nameHash ?? new Uint8Array())} hops=${hops}\n`,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return rns;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Construct, **connect**, and default-attach the requested interface kind.
|
|
81
|
+
*
|
|
82
|
+
* Connection is awaited before `addInterface(…, true)` so the interface's
|
|
83
|
+
* streams are live by the time the transport binds it (and so a connection
|
|
84
|
+
* failure surfaces immediately as a clear error rather than a 30s hang).
|
|
85
|
+
*
|
|
86
|
+
* @param {import("@reticulum/core").Reticulum} rns A booted Reticulum.
|
|
87
|
+
* @param {"shared"|"auto"|"tcp"} iface Interface kind.
|
|
88
|
+
* @param {Object} [opts] Factory overrides (see {@link bootRns}); `verbose`
|
|
89
|
+
* only affects the shared→auto fallback notice.
|
|
90
|
+
* @returns {Promise<string>} A human-readable label for the attached interface.
|
|
91
|
+
*/
|
|
92
|
+
export async function attachInterface(rns, iface, opts = {}) {
|
|
93
|
+
const makeAuto =
|
|
94
|
+
opts.autoFactory ?? (() => new AutoInterface({ name: "auto" }));
|
|
95
|
+
const makeTcp =
|
|
96
|
+
opts.tcpFactory ??
|
|
97
|
+
((host, port) => new TCPClientInterface({ host, port }));
|
|
98
|
+
const makeShared =
|
|
99
|
+
opts.sharedFactory ?? (() => LocalClientInterface.connectToSharedInstance());
|
|
100
|
+
|
|
101
|
+
if (iface === "auto") {
|
|
102
|
+
const auto = makeAuto();
|
|
103
|
+
await auto.connect();
|
|
104
|
+
rns.addInterface(auto, true);
|
|
105
|
+
return "AutoInterface";
|
|
106
|
+
}
|
|
107
|
+
if (iface === "tcp") {
|
|
108
|
+
const host = process.env.RNS_HOST || "127.0.0.1";
|
|
109
|
+
const port = parseInt(process.env.RNS_PORT || "42424", 10);
|
|
110
|
+
const tcp = makeTcp(host, port);
|
|
111
|
+
await tcp.connect();
|
|
112
|
+
rns.addInterface(tcp, true);
|
|
113
|
+
return `TCP ${host}:${port}`;
|
|
114
|
+
}
|
|
115
|
+
// shared (default): prefer a running rnsd; fall back to AutoInterface so the
|
|
116
|
+
// node is still on the mesh when no daemon is present (mirrors the rfed CLI).
|
|
117
|
+
const shared = await makeShared();
|
|
118
|
+
if (shared) {
|
|
119
|
+
rns.addInterface(shared, true);
|
|
120
|
+
return "shared rnsd instance";
|
|
121
|
+
}
|
|
122
|
+
if (opts.verbose) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
" rns: shared instance unavailable; falling back to AutoInterface\n",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const auto = makeAuto();
|
|
128
|
+
await auto.connect();
|
|
129
|
+
rns.addInterface(auto, true);
|
|
130
|
+
return "AutoInterface (shared instance unavailable)";
|
|
131
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable RNS session + online command helpers (work doc #6, §11.1).
|
|
3
|
+
*
|
|
4
|
+
* Browser- and Node-portable: no filesystem, no argv. The caller constructs a
|
|
5
|
+
* `Reticulum` (with its own `StorageAdapter`) and passes it in — keeping
|
|
6
|
+
* shared-instance discovery out of the portable core (the same decision
|
|
7
|
+
* `@reticulum/core` itself makes). A Node/Deno CLI (`dacar.js`) composes these
|
|
8
|
+
* helpers with `@reticulum/node`'s interfaces and `FileStorageAdapter`.
|
|
9
|
+
*
|
|
10
|
+
* This module is part of the CLI layer but has **no** Node-only dependencies:
|
|
11
|
+
* it imports only `@reticulum/core` (already a core dep) and the dacar pure
|
|
12
|
+
* core. It mirrors Python's `dacar/cli/rns.py` + `run_publish`/`run_sync`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Destination, DestType, Identity, toHex } from "@reticulum/core";
|
|
16
|
+
import { APP_NAME } from "../naming.js";
|
|
17
|
+
import { RfedDeltaSync } from "../transport/rfedSync.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Announce the node's identity on the `dacar.node` destination (§11.2.4).
|
|
21
|
+
*
|
|
22
|
+
* Any announced destination under an identity makes that identity recallable by
|
|
23
|
+
* peers via `Destination.recall(hash, true)` — the announce invariant: without
|
|
24
|
+
* it, receivers drop the node's signed Deltas as "unknown issuer" because the
|
|
25
|
+
* `RnsIdentityResolver` cannot recall the issuer's public key.
|
|
26
|
+
*
|
|
27
|
+
* Returns the announced destination hash. Call before publishing or pulling.
|
|
28
|
+
* @param {import("@reticulum/core").Identity} identity
|
|
29
|
+
* @returns {Promise<Uint8Array>}
|
|
30
|
+
*/
|
|
31
|
+
export async function announceIdentity(identity, rns = null) {
|
|
32
|
+
// `Destination.IN` is a static factory (`Destination.IN(name, type, identity,
|
|
33
|
+
// interfaceLayer)`) — NOT a Direction enum value (unlike Python RNS's
|
|
34
|
+
// `RNS.Destination.IN` constant). The destination must be bound to `rns` as
|
|
35
|
+
// its interface layer, or `announce()` throws "Destination not bound to an
|
|
36
|
+
// RNS instance." Mirrors `@reticulum/core`'s rfed/client.js `listen()`.
|
|
37
|
+
const dest = await Destination.IN(
|
|
38
|
+
`${APP_NAME}.node`,
|
|
39
|
+
DestType.SINGLE,
|
|
40
|
+
identity,
|
|
41
|
+
rns,
|
|
42
|
+
);
|
|
43
|
+
await dest.announce();
|
|
44
|
+
return dest.destinationHash;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How long {@link ensureNodeIdentity} waits for a path-response announce
|
|
49
|
+
* after sending a `path?` request before giving up, in milliseconds.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_NODE_DISCOVERY_TIMEOUT = 15_000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recall a node's identity, proactively requesting its path if unknown.
|
|
55
|
+
*
|
|
56
|
+
* When `--node <hash>` (or `--discover`) resolves to an rfed destination whose
|
|
57
|
+
* announce isn't in the recall store yet, `RFedClient.subscribe` can't open a
|
|
58
|
+
* link and fails with `rfed node identity unknown for <hash>; wait for its
|
|
59
|
+
* announce`. Rather than fail immediately, this sends a `path?` request for
|
|
60
|
+
* the destination and polls `Destination.recall` until the node's
|
|
61
|
+
* path-response announce populates it (or `timeout` elapses), then returns
|
|
62
|
+
* the identity.
|
|
63
|
+
*
|
|
64
|
+
* The rfed node announces every `rfed.*` destination under one shared
|
|
65
|
+
* identity, so a path request for any of them is answered with an announce
|
|
66
|
+
* that makes that identity recallable by destination hash.
|
|
67
|
+
*
|
|
68
|
+
* `onRequest` (if given) is invoked once when the path request is sent, so the
|
|
69
|
+
* CLI can surface "requesting node identity…" progress to the user. Throws
|
|
70
|
+
* the same `rfed node identity unknown for …` error the client raises if
|
|
71
|
+
* still unknown after `timeout` — so callers that skip this helper see no
|
|
72
|
+
* behavior change.
|
|
73
|
+
* @param {import("@reticulum/core").Reticulum} rns A booted Reticulum.
|
|
74
|
+
* @param {Uint8Array} nodeHash An `rfed.*` destination hash of the node.
|
|
75
|
+
* @param {Object} [opts]
|
|
76
|
+
* @param {number} [opts.timeout=15000] Max wait in milliseconds.
|
|
77
|
+
* @param {number} [opts.pollInterval=250] Poll interval in milliseconds.
|
|
78
|
+
* @param {() => void} [opts.onRequest] Invoked once when the path request fires.
|
|
79
|
+
* @returns {Promise<import("@reticulum/core").Identity>}
|
|
80
|
+
*/
|
|
81
|
+
export async function ensureNodeIdentity(
|
|
82
|
+
rns,
|
|
83
|
+
nodeHash,
|
|
84
|
+
{ timeout = DEFAULT_NODE_DISCOVERY_TIMEOUT, pollInterval = 250, onRequest } = {},
|
|
85
|
+
) {
|
|
86
|
+
let identity = await Destination.recall(nodeHash);
|
|
87
|
+
if (identity) return identity;
|
|
88
|
+
// Not yet known — proactively request the destination's path (§7.1). The
|
|
89
|
+
// rfed node answers with a path-response announce (§7.2.4) that populates
|
|
90
|
+
// the recall store; poll until it arrives or the timeout elapses.
|
|
91
|
+
if (onRequest) onRequest();
|
|
92
|
+
await rns.transport.requestPath(nodeHash);
|
|
93
|
+
const deadline = Date.now() + timeout;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
identity = await Destination.recall(nodeHash);
|
|
96
|
+
if (identity) return identity;
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
98
|
+
}
|
|
99
|
+
throw new Error(
|
|
100
|
+
`rfed node identity unknown for ${toHex(nodeHash)}; wait for its announce`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Publish a signed Delta to the rfed channel (§11.1, work doc #6).
|
|
106
|
+
*
|
|
107
|
+
* Testable core: takes an explicit `client` (`RFedClient` or compatible fake)
|
|
108
|
+
* so tests inject doubles without booting RNS. The `cmd_*` wrappers handle RNS
|
|
109
|
+
* boot + announce + real client creation.
|
|
110
|
+
* @param {Object} opts
|
|
111
|
+
* @param {Uint8Array} opts.deltaPayload Signed §5.3 Operation payload.
|
|
112
|
+
* @param {Uint8Array} opts.nodeHash The rfed node's `rfed.*` destination hash.
|
|
113
|
+
* @param {string} [opts.topic] RFed channel name (default `dacar.policy.v1`).
|
|
114
|
+
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
115
|
+
* @returns {Promise<import("@reticulum/core").LXMessage>}
|
|
116
|
+
*/
|
|
117
|
+
export async function runPublish({ deltaPayload, nodeHash, topic, client }) {
|
|
118
|
+
const sync = new RfedDeltaSync({ client, topic });
|
|
119
|
+
await sync.subscribe(nodeHash);
|
|
120
|
+
return sync.publish(deltaPayload, nodeHash);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pull pending Deltas from the rfed channel and apply via verify-on-ingest.
|
|
125
|
+
*
|
|
126
|
+
* Testable core: takes an explicit `client` and `receiver` so tests inject
|
|
127
|
+
* doubles. Routes every blob through `DeltaReceiver.applyPayload()` (§11.2.4)
|
|
128
|
+
* — never through the unauthenticated `StateVector.merge()` path. Returns the
|
|
129
|
+
* count applied (the caller persists the CRDT).
|
|
130
|
+
* @param {Object} opts
|
|
131
|
+
* @param {Uint8Array} opts.nodeHash
|
|
132
|
+
* @param {string} [opts.topic]
|
|
133
|
+
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
134
|
+
* @param {import("../delta.js").DeltaReceiver} opts.receiver
|
|
135
|
+
* @returns {Promise<number>}
|
|
136
|
+
*/
|
|
137
|
+
export async function runSync({ nodeHash, topic, client, receiver }) {
|
|
138
|
+
const sync = new RfedDeltaSync({ receiver, client, topic });
|
|
139
|
+
await sync.subscribe(nodeHash);
|
|
140
|
+
return sync.pull(nodeHash);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register a dacar-scoped announce handler that seeds the durable issuer cache
|
|
145
|
+
* (work doc #5, design decision #3).
|
|
146
|
+
*
|
|
147
|
+
* Listens to the RNS transport `"announce"` event and, on a validated
|
|
148
|
+
* `dacar.node` announce (verified by recomputing the destination hash under
|
|
149
|
+
* the announced identity), registers the issuer's public key into `keyring`.
|
|
150
|
+
* Non-`dacar` announces are ignored (dacar is not a general identity directory).
|
|
151
|
+
* Returns an unsubscribe function.
|
|
152
|
+
* @param {Object} opts
|
|
153
|
+
* @param {import("@reticulum/core").Reticulum} opts.rns A booted Reticulum.
|
|
154
|
+
* @param {import("../verifier.js").Keyring} opts.keyring
|
|
155
|
+
* @param {(keyring: import("../verifier.js").Keyring) => void} [opts.onSave]
|
|
156
|
+
* Called after each seed so the caller can persist the keyring.
|
|
157
|
+
* @returns {Promise<{ unsubscribe: () => void, seeded: number }>}
|
|
158
|
+
*/
|
|
159
|
+
export async function registerAnnounceHandler({ rns, keyring, onSave }) {
|
|
160
|
+
/** @type {() => void} */ let unsubscribe = () => {};
|
|
161
|
+
const state = { seeded: 0 };
|
|
162
|
+
|
|
163
|
+
// The transport emits a CustomEvent("announce", { detail }) on each validated
|
|
164
|
+
// announce. We filter to dacar.node and seed the keyring.
|
|
165
|
+
const handler = async (event) => {
|
|
166
|
+
const detail = event.detail;
|
|
167
|
+
if (!detail || !detail.identity) return;
|
|
168
|
+
const announced = detail.identity;
|
|
169
|
+
const announcedDestHash = detail.destinationHash;
|
|
170
|
+
if (!(announcedDestHash instanceof Uint8Array)) return;
|
|
171
|
+
// Only dacar.node: recompute the destination hash under the dacar app.
|
|
172
|
+
const expected = await _dacarNodeHash(announced);
|
|
173
|
+
const { toHex } = await import("@reticulum/core");
|
|
174
|
+
if (toHex(announcedDestHash) !== toHex(expected)) return; // not dacar.node
|
|
175
|
+
const publicKey = await announced.getPublicKey();
|
|
176
|
+
keyring.registerSingle(announced.identityHash, publicKey);
|
|
177
|
+
state.seeded += 1;
|
|
178
|
+
if (onSave) onSave(keyring);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// core's TransportCore is an EventTarget on `rns.transport`.
|
|
182
|
+
if (rns && rns.transport && typeof rns.transport.addEventListener === "function") {
|
|
183
|
+
rns.transport.addEventListener("announce", handler);
|
|
184
|
+
unsubscribe = () => rns.transport.removeEventListener("announce", handler);
|
|
185
|
+
}
|
|
186
|
+
return { unsubscribe, get seeded() { return state.seeded; } };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Compute the `dacar.node` destination hash for an identity (§11.2.4).
|
|
191
|
+
*
|
|
192
|
+
* `nameHash = SHA-256("dacar.node")[:10]`; `destHash = SHA-256(nameHash ‖
|
|
193
|
+
* identityHash)[:16]` — matching `@reticulum/core`'s `Destination._computeHashes`.
|
|
194
|
+
* @param {import("@reticulum/core").Identity} identity
|
|
195
|
+
* @returns {Promise<Uint8Array>}
|
|
196
|
+
*/
|
|
197
|
+
async function _dacarNodeHash(identity) {
|
|
198
|
+
const encoder = new TextEncoder();
|
|
199
|
+
const nameBytes = encoder.encode(`${APP_NAME}.node`);
|
|
200
|
+
const nameHashBuffer = await crypto.subtle.digest("SHA-256", nameBytes);
|
|
201
|
+
const nameHash = new Uint8Array(nameHashBuffer.slice(0, 10));
|
|
202
|
+
const combined = new Uint8Array(nameHash.length + identity.identityHash.length);
|
|
203
|
+
combined.set(nameHash, 0);
|
|
204
|
+
combined.set(identity.identityHash, nameHash.length);
|
|
205
|
+
const destHashBuffer = await crypto.subtle.digest("SHA-256", combined);
|
|
206
|
+
return new Uint8Array(destHashBuffer.slice(0, 16));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Autodiscover an rfed node from its `rfed.node` announce.
|
|
211
|
+
*
|
|
212
|
+
* Listens for a validated `rfed.node` announce on the live transport and
|
|
213
|
+
* resolves with that announce's `destinationHash` — the rfed node's canonical
|
|
214
|
+
* identifier (the same hash `--node <hash>` accepts and `RFedClient` recalls
|
|
215
|
+
* to open a link).
|
|
216
|
+
*
|
|
217
|
+
* The rfed daemon is an external process (dacar ships only the client); it
|
|
218
|
+
* announces `rfed.node` and the `rfed.channel.*` service destinations under
|
|
219
|
+
* one shared identity. A `dacar.node` announce is a *different* thing — it is
|
|
220
|
+
* a dacar peer advertising its own signing identity (the announce invariant,
|
|
221
|
+
* §11.2.4), not an rfed transport node. Discovery therefore filters for
|
|
222
|
+
* `rfed.node` announces (by `nameHash`), not `dacar.node`, and returns the
|
|
223
|
+
* announced destination hash directly (no derivation — the announce *is* the
|
|
224
|
+
* node hash).
|
|
225
|
+
*
|
|
226
|
+
* @param {Object} opts
|
|
227
|
+
* @param {import("@reticulum/core").Reticulum} opts.rns A booted Reticulum.
|
|
228
|
+
* @param {number} [opts.timeout=30000] Timeout in milliseconds.
|
|
229
|
+
* @returns {Promise<Uint8Array>} The `rfed.node` destination hash of the
|
|
230
|
+
* discovered node.
|
|
231
|
+
* @throws {CliError} If no `rfed.node` announce is received within the timeout.
|
|
232
|
+
*/
|
|
233
|
+
export async function discoverRfedNode({ rns, timeout = 30000 }) {
|
|
234
|
+
if (!rns?.transport?.addEventListener) {
|
|
235
|
+
throw new CliError("RNS transport not available for discovery");
|
|
236
|
+
}
|
|
237
|
+
const encoder = new TextEncoder();
|
|
238
|
+
// nameHash = SHA-256("rfed.node")[:10] — matches `@reticulum/core`'s
|
|
239
|
+
// Destination._computeHashes. The announce event carries this as
|
|
240
|
+
// `detail.nameHash`; filtering on it selects only `rfed.node` announces
|
|
241
|
+
// (the rfed.channel.* services share the identity but have different names).
|
|
242
|
+
const expectedNameHash = new Uint8Array(
|
|
243
|
+
(await crypto.subtle.digest("SHA-256", encoder.encode("rfed.node"))).slice(0, 10),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
let settled = false;
|
|
248
|
+
|
|
249
|
+
const onAnnounce = (event) => {
|
|
250
|
+
const detail = event.detail;
|
|
251
|
+
if (!detail?.nameHash) return;
|
|
252
|
+
// Only resolve on rfed.node announces.
|
|
253
|
+
if (toHex(detail.nameHash) !== toHex(expectedNameHash)) return;
|
|
254
|
+
if (settled) return;
|
|
255
|
+
settled = true;
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
rns.transport.removeEventListener("announce", onAnnounce);
|
|
258
|
+
// The announce's destinationHash *is* the rfed node's canonical hash.
|
|
259
|
+
resolve(detail.destinationHash);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
rns.transport.addEventListener("announce", onAnnounce);
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
if (settled) return;
|
|
265
|
+
settled = true;
|
|
266
|
+
rns.transport.removeEventListener("announce", onAnnounce);
|
|
267
|
+
reject(
|
|
268
|
+
new CliError(
|
|
269
|
+
`no rfed.node announce received within ${timeout}ms ` +
|
|
270
|
+
"(ensure an rfed node is reachable and announcing)",
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
}, timeout);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
class CliError extends Error {}
|
package/src/cli/smoke.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI smoke test for doc #6.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Identity } from "@reticulum/core";
|
|
7
|
+
import { MemoryStorageAdapter } from "@reticulum/core";
|
|
8
|
+
import { DacarStore } from "./store.js";
|
|
9
|
+
import { rmSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
const STORE_DIR = "/tmp/dacar-smoke-test";
|
|
12
|
+
|
|
13
|
+
async function runSmokeTest() {
|
|
14
|
+
console.log("=== Dacar CLI Smoke Test (doc #6) ===\n");
|
|
15
|
+
|
|
16
|
+
// Cleanup
|
|
17
|
+
rmSync(STORE_DIR, { recursive: true, force: true });
|
|
18
|
+
|
|
19
|
+
// Test init
|
|
20
|
+
console.log("1. Running init...");
|
|
21
|
+
const adapter = new MemoryStorageAdapter(STORE_DIR);
|
|
22
|
+
const identity = await Identity.generate();
|
|
23
|
+
const store = await DacarStore.init(adapter, {
|
|
24
|
+
salt: new Uint8Array(32),
|
|
25
|
+
identityBytes: await identity.getPrivateKey(),
|
|
26
|
+
});
|
|
27
|
+
const raw = await store.loadConfig();
|
|
28
|
+
console.log(" PASS: store created");
|
|
29
|
+
console.log(" PASS: self aliases registered");
|
|
30
|
+
console.log(" PASS: config saved");
|
|
31
|
+
|
|
32
|
+
// Test config round-trip
|
|
33
|
+
console.log("\n2. Testing config round-trip...");
|
|
34
|
+
raw.rfedTopic = "test.policy.v1";
|
|
35
|
+
await store.saveConfig(raw);
|
|
36
|
+
|
|
37
|
+
const store2 = new DacarStore(new MemoryStorageAdapter(STORE_DIR));
|
|
38
|
+
const raw2 = await store2.loadConfig();
|
|
39
|
+
console.log(" PASS: rfedTopic round-trips");
|
|
40
|
+
|
|
41
|
+
// Test identities cache
|
|
42
|
+
console.log("\n3. Testing identities cache...");
|
|
43
|
+
const other = await Identity.generate();
|
|
44
|
+
const keyring = await store.loadKeyring();
|
|
45
|
+
keyring.registerSingle(other.identityHash, await other.getPublicKey());
|
|
46
|
+
await store.saveKeyring(keyring);
|
|
47
|
+
|
|
48
|
+
const store3 = new DacarStore(new MemoryStorageAdapter(STORE_DIR));
|
|
49
|
+
const keyring2 = await store3.loadKeyring();
|
|
50
|
+
console.log(" PASS: issuer cached across instances");
|
|
51
|
+
|
|
52
|
+
console.log("\n=== Smoke test passed! ===\n");
|
|
53
|
+
console.log("Run: npm link; dacar --help");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
runSmokeTest().catch((e) => {
|
|
57
|
+
console.error("FAIL:", e.stack || e);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|