@reticulum/dacar 1.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticulum/dacar",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "JavaScript implementation of Dacar, a Decentralized Access Control system for Reticulum",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli/dacar.js CHANGED
@@ -30,15 +30,11 @@ import { homedir } from "node:os";
30
30
  import { join } from "node:path";
31
31
  import process from "node:process";
32
32
 
33
- import { Identity, Reticulum, toHex } from "@reticulum/core";
33
+ import { Identity, toHex } from "@reticulum/core";
34
34
  import { MemoryStorageAdapter } from "@reticulum/core";
35
- import {
36
- AutoInterface,
37
- FileStorageAdapter,
38
- LocalClientInterface,
39
- TCPClientInterface,
40
- } from "@reticulum/node";
35
+ import { FileStorageAdapter } from "@reticulum/node";
41
36
  import { RFedClient } from "@reticulum/core/src/rfed/client.js";
37
+ import { bootRns } from "./rns_boot.js";
42
38
 
43
39
  import { Action, Operation, Tuple, Engine } from "../index.js";
44
40
  import { DeltaReceiver } from "../delta.js";
@@ -48,7 +44,7 @@ import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespac
48
44
  import { Keyring, IssuerKeyset } from "../verifier.js";
49
45
 
50
46
  import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
51
- import { announceIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
47
+ import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
52
48
 
53
49
  const SHORT_HASH = 7;
54
50
 
@@ -131,30 +127,18 @@ async function resolveRnsConfigDir(args) {
131
127
  return dir;
132
128
  }
133
129
 
134
- /**
135
- * Boot RNS with an interface. Mirrors `@reticulum/node`'s `rfed` CLI:
136
- * `--interface shared|auto|tcp` (default `shared`).
137
- */
138
- async function bootRns(configDir, iface) {
139
- const rns = new Reticulum({ storageAdapter: new FileStorageAdapter(configDir) });
140
- if (iface === "auto") {
141
- rns.addInterface(new AutoInterface({}));
142
- } else if (iface === "tcp") {
143
- const host = process.env.RNS_HOST || "127.0.0.1";
144
- const port = parseInt(process.env.RNS_PORT || "42424", 10);
145
- rns.addInterface(new TCPClientInterface({ host, port }));
146
- } else {
147
- // shared (default): attach to a running rnsd, else no-op (standalone).
148
- rns.addInterface(new LocalClientInterface({}));
149
- }
150
- return rns;
151
- }
130
+ // `bootRns` lives in `./rns_boot.js` (Node-only): it constructs, **connects**,
131
+ // and default-attaches the chosen mesh interface, and optionally raises the
132
+ // Reticulum log threshold + logs each announce for `--verbose`. See
133
+ // `src/cli/rns_boot.js` for why `connect()` + `isDefault=true` are
134
+ // non-optional (the `--discover` silent-timeout symptom).
152
135
 
153
- async function resolveRfedNode(args, store, aliases) {
136
+ async function resolveRfedNode(args, store, aliases, rns) {
154
137
  if (args.node) return resolveIdentityHash(args.node, aliases);
155
138
  const raw = await store.loadConfig();
156
139
  if (raw.rfedNode) return raw.rfedNode;
157
- throw new CliError("no rfed node configured (use --node <hash> or set [rfed] node in config)");
140
+ if (args.discover && rns) return discoverRfedNode({ rns, timeout: 30000 });
141
+ throw new CliError("no rfed node configured (use --node <hash>, --discover, or set [rfed] node in config)");
158
142
  }
159
143
 
160
144
  async function resolveTopic(args, store) {
@@ -251,12 +235,22 @@ async function _issue(args, action) {
251
235
 
252
236
  async function publishDelta(args, store, identity, payload) {
253
237
  const aliases = await store.loadAliases();
254
- const nodeHash = await resolveRfedNode(args, store, aliases);
255
238
  const topic = await resolveTopic(args, store);
256
-
257
239
  const configDir = await resolveRnsConfigDir(args);
258
- const rns = await bootRns(configDir, args.interface || "shared");
259
- await announceIdentity(identity);
240
+ const rns = await bootRns(configDir, args.interface || "shared", {
241
+ verbose: !!args.verbose,
242
+ });
243
+ // RNS must be booted before resolveRfedNode: --discover listens for peer
244
+ // announces on the live transport (mirrors Python's _publish_delta).
245
+ const nodeHash = await resolveRfedNode(args, store, aliases, rns);
246
+ await announceIdentity(identity, rns);
247
+ // Proactively fetch the rfed node's identity: when --node is given (or
248
+ // --discover derived it), the destination's announce may not yet be in
249
+ // the recall store. Send a path? request and wait for the announce rather
250
+ // than failing with "wait for its announce" (work doc #6).
251
+ await ensureNodeIdentity(rns, nodeHash, {
252
+ onRequest: () => err(" requesting rfed node identity…"),
253
+ });
260
254
 
261
255
  // Durable issuer cache (doc #5): seed from observed dacar.node announces.
262
256
  const keyring = await store.loadKeyring();
@@ -276,18 +270,27 @@ async function cmdSync(args) {
276
270
  const identity = await store.loadIdentity();
277
271
  if (!identity) throw new CliError("no signing identity (run `dacar init`)");
278
272
 
279
- const nodeHash = await resolveRfedNode(args, store, aliases);
280
- const topic = await resolveTopic(args, store);
281
-
273
+ // RNS must be booted before discover if we're autodiscovering
282
274
  const configDir = await resolveRnsConfigDir(args);
283
- const rns = await bootRns(configDir, args.interface || "shared");
284
- await announceIdentity(identity);
275
+ const rns = await bootRns(configDir, args.interface || "shared", {
276
+ verbose: !!args.verbose,
277
+ });
278
+ await announceIdentity(identity, rns);
285
279
 
286
280
  // Durable issuer cache (doc #5): load persisted keyring + announce handler.
287
281
  const keyring = await store.loadKeyring();
288
282
  keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
289
283
  await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
290
284
 
285
+ const nodeHash = await resolveRfedNode(args, store, aliases, rns);
286
+ // Proactively fetch the rfed node's identity: when --node is given (or
287
+ // --discover derived it), the destination's announce may not yet be in
288
+ // the recall store. Send a path? request and wait for the announce rather
289
+ // than failing with "wait for its announce" (work doc #6).
290
+ await ensureNodeIdentity(rns, nodeHash, {
291
+ onRequest: () => err(" requesting rfed node identity…"),
292
+ });
293
+ const topic = await resolveTopic(args, store);
291
294
  const state = await store.loadState(config);
292
295
  const resolver = new RnsIdentityResolver(keyring);
293
296
  const rx = new DeltaReceiver(state, resolver);
@@ -358,7 +361,9 @@ async function cmdIdentityRemember(args) {
358
361
  } else {
359
362
  // Boot RNS and try to recall.
360
363
  const configDir = await resolveRnsConfigDir(args);
361
- const rns = await bootRns(configDir, args.interface || "shared");
364
+ const rns = await bootRns(configDir, args.interface || "shared", {
365
+ verbose: !!args.verbose,
366
+ });
362
367
  const { Destination } = await import("@reticulum/core");
363
368
  const recalled = await Destination.recall(issuerHash, true);
364
369
  if (!recalled) {
@@ -466,19 +471,19 @@ const SUBCOMMANDS = {
466
471
  },
467
472
  grant: {
468
473
  run: cmdGrant,
469
- opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
474
+ opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
470
475
  positional: ["grantee", "relation", "object"],
471
476
  online: true,
472
477
  },
473
478
  revoke: {
474
479
  run: cmdRevoke,
475
- opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
480
+ opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
476
481
  positional: ["grantee", "relation", "object"],
477
482
  online: true,
478
483
  },
479
484
  sync: {
480
485
  run: cmdSync,
481
- opts: { node: "string", topic: "string", "rns-dir": "string", interface: "string" },
486
+ opts: { node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
482
487
  online: true,
483
488
  },
484
489
  apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
@@ -503,6 +508,10 @@ function buildOptions(spec) {
503
508
  for (const [k, t] of Object.entries(spec.opts || {})) {
504
509
  opts[k] = { type: t };
505
510
  }
511
+ // --verbose / -v is global: accepted by every (sub)command so it never
512
+ // errors out, and threaded into bootRns to raise the Reticulum log
513
+ // threshold + log interface/announce diagnostics.
514
+ opts.verbose = { type: "boolean", short: "v" };
506
515
  if (spec.positional && spec.positional.length) {
507
516
  opts.store = { type: "string" };
508
517
  opts.identity = { type: "string" };
@@ -513,7 +522,7 @@ function buildOptions(spec) {
513
522
 
514
523
  async function main() {
515
524
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
516
- err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes`);
525
+ err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes, -v/--verbose`);
517
526
  return 0;
518
527
  }
519
528
  const argv = process.argv.slice(2);
@@ -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
+ }
@@ -12,7 +12,7 @@
12
12
  * core. It mirrors Python's `dacar/cli/rns.py` + `run_publish`/`run_sync`.
13
13
  */
14
14
 
15
- import { Destination, Identity } from "@reticulum/core";
15
+ import { Destination, DestType, Identity, toHex } from "@reticulum/core";
16
16
  import { APP_NAME } from "../naming.js";
17
17
  import { RfedDeltaSync } from "../transport/rfedSync.js";
18
18
 
@@ -28,12 +28,79 @@ import { RfedDeltaSync } from "../transport/rfedSync.js";
28
28
  * @param {import("@reticulum/core").Identity} identity
29
29
  * @returns {Promise<Uint8Array>}
30
30
  */
31
- export async function announceIdentity(identity) {
32
- const dest = await Destination.IN(`${APP_NAME}.node`, "single", identity, null);
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
+ );
33
43
  await dest.announce();
34
44
  return dest.destinationHash;
35
45
  }
36
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
+
37
104
  /**
38
105
  * Publish a signed Delta to the rfed channel (§11.1, work doc #6).
39
106
  *
@@ -138,3 +205,73 @@ async function _dacarNodeHash(identity) {
138
205
  const destHashBuffer = await crypto.subtle.digest("SHA-256", combined);
139
206
  return new Uint8Array(destHashBuffer.slice(0, 16));
140
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/store.js CHANGED
@@ -221,7 +221,15 @@ export class DacarStore {
221
221
  const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
222
222
  const bytes = await this._adapter.get(NS, "state");
223
223
  if (bytes && bytes.length) {
224
- return StateVector.fromPayload(bytes, { deletionHorizonDays: horizon });
224
+ // `trusted: true` these are this node's own persisted CRDT snapshot
225
+ // (written by `saveState()` → `toPayload()`), never network bytes.
226
+ // Network Operations arrive as signed Deltas through `DeltaReceiver`
227
+ // (the verify-on-ingest path), not here. Asserting `trusted` silences
228
+ // the audible `fromPayload` footgun warning during normal CLI use.
229
+ return StateVector.fromPayload(bytes, {
230
+ deletionHorizonDays: horizon,
231
+ trusted: true,
232
+ });
225
233
  }
226
234
  return new StateVector({ deletionHorizonDays: horizon });
227
235
  }
package/src/crdt.js CHANGED
@@ -256,14 +256,20 @@ export class StateVector {
256
256
  * > `DeltaReceiver.applyPayloads()` (a batch of signed §5.3 Operations)
257
257
  * > instead.
258
258
  * >
259
- * > A one-time `console.warn` is emitted to make this contract audible.
259
+ * > A one-time `console.warn` is emitted to make this contract audible
260
+ * > unless `opts.trusted` is set, which a caller that has already asserted
261
+ * > it is loading its own persisted snapshot (e.g. `DacarStore.loadState`)
262
+ * > passes to keep normal CLI output free of developer-footgun noise.
260
263
  * @param {Uint8Array} data
261
264
  * @param {Object} [opts]
262
265
  * @param {number} [opts.deletionHorizonDays]
266
+ * @param {boolean} [opts.trusted=false] Suppress the audible warning when the
267
+ * caller has asserted the bytes are a trusted-local snapshot (its own
268
+ * store). The JSDoc contract above still applies regardless.
263
269
  * @returns {StateVector}
264
270
  */
265
- static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
266
- if (!__trustedLocalWarned) {
271
+ static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS, trusted = false } = {}) {
272
+ if (!trusted && !__trustedLocalWarned) {
267
273
  __trustedLocalWarned = true;
268
274
  console.warn(
269
275
  "StateVector.fromPayload() is trusted-local-only: it performs no " +