@reticulum/dacar 1.1.0 → 1.1.2

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.2",
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
@@ -18,6 +18,7 @@
18
18
  * dacar check <grantee> <relation> <object>
19
19
  * dacar grants
20
20
  * dacar revoke <grantee> <relation> <object> [--publish]
21
+ * dacar publish <file> [<file>...] | --all (work doc #8)
21
22
  * dacar identity remember|forget|list ...
22
23
  *
23
24
  * Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
@@ -28,17 +29,14 @@ import { parseArgs } from "node:util";
28
29
  import { readFile, writeFile, mkdir } from "node:fs/promises";
29
30
  import { homedir } from "node:os";
30
31
  import { join } from "node:path";
32
+ import { pathToFileURL } from "node:url";
31
33
  import process from "node:process";
32
34
 
33
- import { Identity, Reticulum, toHex } from "@reticulum/core";
35
+ import { Identity, toHex } from "@reticulum/core";
34
36
  import { MemoryStorageAdapter } from "@reticulum/core";
35
- import {
36
- AutoInterface,
37
- FileStorageAdapter,
38
- LocalClientInterface,
39
- TCPClientInterface,
40
- } from "@reticulum/node";
37
+ import { FileStorageAdapter } from "@reticulum/node";
41
38
  import { RFedClient } from "@reticulum/core/src/rfed/client.js";
39
+ import { bootRns } from "./rns_boot.js";
42
40
 
43
41
  import { Action, Operation, Tuple, Engine } from "../index.js";
44
42
  import { DeltaReceiver } from "../delta.js";
@@ -48,7 +46,7 @@ import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespac
48
46
  import { Keyring, IssuerKeyset } from "../verifier.js";
49
47
 
50
48
  import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
51
- import { announceIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
49
+ import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
52
50
 
53
51
  const SHORT_HASH = 7;
54
52
 
@@ -131,30 +129,18 @@ async function resolveRnsConfigDir(args) {
131
129
  return dir;
132
130
  }
133
131
 
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
- }
132
+ // `bootRns` lives in `./rns_boot.js` (Node-only): it constructs, **connects**,
133
+ // and default-attaches the chosen mesh interface, and optionally raises the
134
+ // Reticulum log threshold + logs each announce for `--verbose`. See
135
+ // `src/cli/rns_boot.js` for why `connect()` + `isDefault=true` are
136
+ // non-optional (the `--discover` silent-timeout symptom).
152
137
 
153
- async function resolveRfedNode(args, store, aliases) {
138
+ async function resolveRfedNode(args, store, aliases, rns) {
154
139
  if (args.node) return resolveIdentityHash(args.node, aliases);
155
140
  const raw = await store.loadConfig();
156
141
  if (raw.rfedNode) return raw.rfedNode;
157
- throw new CliError("no rfed node configured (use --node <hash> or set [rfed] node in config)");
142
+ if (args.discover && rns) return discoverRfedNode({ rns, timeout: 30000 });
143
+ throw new CliError("no rfed node configured (use --node <hash>, --discover, or set [rfed] node in config)");
158
144
  }
159
145
 
160
146
  async function resolveTopic(args, store) {
@@ -235,28 +221,47 @@ async function _issue(args, action) {
235
221
 
236
222
  // Record plaintext ledger.
237
223
  const ledger = await store.loadLedger();
238
- ledger.set(toHex(tuple.key), { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
224
+ ledger.set(tuple.key, { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
239
225
  await store.saveLedger(ledger);
240
226
 
241
- out(payload.hex());
227
+ out(toHex(payload));
242
228
  err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
243
229
  err(` hlc : 0x${hlc.toString(16)}`);
244
230
  err(` payload : hex on stdout (${payload.length} bytes)`);
245
231
 
246
232
  if (args.publish) {
247
233
  await publishDelta(args, store, identity, payload);
234
+ } else {
235
+ // Outbox (work doc #8): queue locally-issued deltas for `publish --all`.
236
+ // `--publish` sends immediately and never enqueues. (JS `grant` always
237
+ // applies locally — there is no `--no-apply` — so every non-publish grant
238
+ // is a candidate for later batch publish.)
239
+ const outbox = await store.loadOutbox();
240
+ outbox.push(payload);
241
+ await store.saveOutbox(outbox);
242
+ err(" (queued in outbox: `dacar publish --all` to flush)");
248
243
  }
249
244
  return 0;
250
245
  }
251
246
 
252
247
  async function publishDelta(args, store, identity, payload) {
253
248
  const aliases = await store.loadAliases();
254
- const nodeHash = await resolveRfedNode(args, store, aliases);
255
249
  const topic = await resolveTopic(args, store);
256
-
257
250
  const configDir = await resolveRnsConfigDir(args);
258
- const rns = await bootRns(configDir, args.interface || "shared");
259
- await announceIdentity(identity);
251
+ const rns = await bootRns(configDir, args.interface || "shared", {
252
+ verbose: !!args.verbose,
253
+ });
254
+ // RNS must be booted before resolveRfedNode: --discover listens for peer
255
+ // announces on the live transport (mirrors Python's _publish_delta).
256
+ const nodeHash = await resolveRfedNode(args, store, aliases, rns);
257
+ await announceIdentity(identity, rns);
258
+ // Proactively fetch the rfed node's identity: when --node is given (or
259
+ // --discover derived it), the destination's announce may not yet be in
260
+ // the recall store. Send a path? request and wait for the announce rather
261
+ // than failing with "wait for its announce" (work doc #6).
262
+ await ensureNodeIdentity(rns, nodeHash, {
263
+ onRequest: () => err(" requesting rfed node identity…"),
264
+ });
260
265
 
261
266
  // Durable issuer cache (doc #5): seed from observed dacar.node announces.
262
267
  const keyring = await store.loadKeyring();
@@ -276,18 +281,27 @@ async function cmdSync(args) {
276
281
  const identity = await store.loadIdentity();
277
282
  if (!identity) throw new CliError("no signing identity (run `dacar init`)");
278
283
 
279
- const nodeHash = await resolveRfedNode(args, store, aliases);
280
- const topic = await resolveTopic(args, store);
281
-
284
+ // RNS must be booted before discover if we're autodiscovering
282
285
  const configDir = await resolveRnsConfigDir(args);
283
- const rns = await bootRns(configDir, args.interface || "shared");
284
- await announceIdentity(identity);
286
+ const rns = await bootRns(configDir, args.interface || "shared", {
287
+ verbose: !!args.verbose,
288
+ });
289
+ await announceIdentity(identity, rns);
285
290
 
286
291
  // Durable issuer cache (doc #5): load persisted keyring + announce handler.
287
292
  const keyring = await store.loadKeyring();
288
293
  keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
289
294
  await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
290
295
 
296
+ const nodeHash = await resolveRfedNode(args, store, aliases, rns);
297
+ // Proactively fetch the rfed node's identity: when --node is given (or
298
+ // --discover derived it), the destination's announce may not yet be in
299
+ // the recall store. Send a path? request and wait for the announce rather
300
+ // than failing with "wait for its announce" (work doc #6).
301
+ await ensureNodeIdentity(rns, nodeHash, {
302
+ onRequest: () => err(" requesting rfed node identity…"),
303
+ });
304
+ const topic = await resolveTopic(args, store);
291
305
  const state = await store.loadState(config);
292
306
  const resolver = new RnsIdentityResolver(keyring);
293
307
  const rx = new DeltaReceiver(state, resolver);
@@ -333,6 +347,107 @@ async function cmdApply(args) {
333
347
  return 1;
334
348
  }
335
349
 
350
+ /**
351
+ * Read a payload file (or stdin) and auto-detect hex (mirrors Python's
352
+ * `_read_payload_input`): an all-hex, even-length ASCII blob decodes to bytes.
353
+ * `--binary` forces raw bytes.
354
+ * @param {string} path
355
+ * @param {boolean} forceBinary
356
+ * @returns {Promise<Uint8Array>}
357
+ */
358
+ async function readPayloadInput(path, forceBinary) {
359
+ const data = path === "-" ? new Uint8Array(await readStdin()) : await readFile(path);
360
+ return coercePayload(data, forceBinary);
361
+ }
362
+
363
+ /**
364
+ * Auto-detect a hex payload: if *all* bytes are ASCII hex characters (after
365
+ * trimming surrounding whitespace) and the length is even, decode to bytes;
366
+ * otherwise return the raw bytes unchanged.
367
+ * @param {Uint8Array | Buffer} data
368
+ * @param {boolean} forceBinary
369
+ * @returns {Uint8Array}
370
+ */
371
+ function coercePayload(data, forceBinary) {
372
+ const bytes = new Uint8Array(data);
373
+ if (forceBinary || !bytes.length) return bytes;
374
+ let s;
375
+ try {
376
+ s = Buffer.from(bytes).toString("ascii");
377
+ } catch {
378
+ return bytes; // not ASCII -> raw bytes
379
+ }
380
+ const trimmed = s.trim();
381
+ if (!trimmed || trimmed.length % 2 !== 0) return bytes;
382
+ if (!/^[0-9a-fA-F]+$/.test(trimmed)) return bytes;
383
+ return hexToBytes(trimmed);
384
+ }
385
+
386
+ export { coercePayload };
387
+
388
+ /**
389
+ * `dacar publish` — push signed delta(s) to the rfed channel (§11.1, doc #8).
390
+ *
391
+ * Two modes:
392
+ * - `dacar publish <file> [<file>...]` — publish previously-signed delta
393
+ * payload(s) (exact bytes, no re-sign; multiple files → one batch).
394
+ * - `dacar publish --all` — pack + publish the outbox of locally-issued
395
+ * deltas, then clear it. A no-op (exit 0) on an empty outbox.
396
+ *
397
+ * Reuses the `grant --publish` machinery (`publishDelta`: boot RNS, announce,
398
+ * subscribe, publish).
399
+ */
400
+ async function cmdPublish(args) {
401
+ const store = await openStore(args);
402
+ const identity = await store.loadIdentity();
403
+ if (!identity) throw new CliError("no signing identity (run `dacar init`)");
404
+
405
+ const useAll = !!args.all;
406
+ const files = args._positionals ?? [];
407
+
408
+ if (useAll && files.length) {
409
+ throw new CliError("publish: use either <file>... or --all, not both");
410
+ }
411
+ if (!useAll && !files.length) {
412
+ throw new CliError("publish: provide <file>... (one or more) or --all");
413
+ }
414
+
415
+ /** @type {Uint8Array[]} */
416
+ let toPublish;
417
+ if (useAll) {
418
+ toPublish = await store.loadOutbox();
419
+ if (!toPublish.length) {
420
+ err("outbox empty (nothing to publish)");
421
+ return 0;
422
+ }
423
+ } else {
424
+ toPublish = [];
425
+ for (const path of files) {
426
+ const data = await readPayloadInput(path, !!args.binary);
427
+ if (!data.length) throw new CliError(`empty payload: ${path}`);
428
+ toPublish.push(data);
429
+ }
430
+ }
431
+
432
+ // Pack: a single delta as raw bytes, multiple as a batch payload.
433
+ const batch = toPublish.length === 1
434
+ ? toPublish[0]
435
+ : DeltaReceiver.packPayloads(toPublish);
436
+
437
+ const label = useAll ? "outbox" : `${toPublish.length} file(s)`;
438
+ const kind = toPublish.length > 1 ? "a batch" : "a single delta";
439
+ err(` publishing ${toPublish.length} delta(s) (${label}) as ${kind}`);
440
+
441
+ // Reuse the grant --publish machinery (boot RNS, announce, subscribe, publish).
442
+ await publishDelta(args, store, identity, batch);
443
+
444
+ if (useAll) {
445
+ await store.saveOutbox([]);
446
+ err(" outbox cleared");
447
+ }
448
+ return 0;
449
+ }
450
+
336
451
  async function readStdin() {
337
452
  const chunks = [];
338
453
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -358,7 +473,9 @@ async function cmdIdentityRemember(args) {
358
473
  } else {
359
474
  // Boot RNS and try to recall.
360
475
  const configDir = await resolveRnsConfigDir(args);
361
- const rns = await bootRns(configDir, args.interface || "shared");
476
+ const rns = await bootRns(configDir, args.interface || "shared", {
477
+ verbose: !!args.verbose,
478
+ });
362
479
  const { Destination } = await import("@reticulum/core");
363
480
  const recalled = await Destination.recall(issuerHash, true);
364
481
  if (!recalled) {
@@ -442,7 +559,7 @@ async function cmdGrants(args) {
442
559
  err(`${label} (${rows.length})`);
443
560
  for (const { entry, active } of rows) {
444
561
  const t = entry.tuple;
445
- const row = ledger.get(toHex(t.key));
562
+ const row = ledger.get(t.key);
446
563
  const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
447
564
  const obj = row?.object || "[hash]";
448
565
  err(
@@ -466,19 +583,25 @@ const SUBCOMMANDS = {
466
583
  },
467
584
  grant: {
468
585
  run: cmdGrant,
469
- opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
586
+ opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
470
587
  positional: ["grantee", "relation", "object"],
471
588
  online: true,
472
589
  },
473
590
  revoke: {
474
591
  run: cmdRevoke,
475
- opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
592
+ opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
476
593
  positional: ["grantee", "relation", "object"],
477
594
  online: true,
478
595
  },
479
596
  sync: {
480
597
  run: cmdSync,
481
- opts: { node: "string", topic: "string", "rns-dir": "string", interface: "string" },
598
+ opts: { node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
599
+ online: true,
600
+ },
601
+ publish: {
602
+ run: cmdPublish,
603
+ opts: { all: "boolean", binary: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
604
+ // variable file list (0..N) accessed via args._positionals
482
605
  online: true,
483
606
  },
484
607
  apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
@@ -503,17 +626,22 @@ function buildOptions(spec) {
503
626
  for (const [k, t] of Object.entries(spec.opts || {})) {
504
627
  opts[k] = { type: t };
505
628
  }
506
- if (spec.positional && spec.positional.length) {
507
- opts.store = { type: "string" };
508
- opts.identity = { type: "string" };
509
- opts["full-hashes"] = { type: "boolean" };
510
- }
629
+ // --verbose / -v is global: accepted by every (sub)command so it never
630
+ // errors out, and threaded into bootRns to raise the Reticulum log
631
+ // threshold + log interface/announce diagnostics.
632
+ opts.verbose = { type: "boolean", short: "v" };
633
+ // --store / --identity / --full-hashes are global on every (sub)command
634
+ // (they were previously gated on `positional`, which silently dropped them
635
+ // for commands with no positionals — e.g. `sync`, `config show`, `grants`).
636
+ opts.store = { type: "string" };
637
+ opts.identity = { type: "string" };
638
+ opts["full-hashes"] = { type: "boolean" };
511
639
  return opts;
512
640
  }
513
641
 
514
642
  async function main() {
515
643
  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`);
644
+ err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes, -v/--verbose`);
517
645
  return 0;
518
646
  }
519
647
  const argv = process.argv.slice(2);
@@ -559,7 +687,25 @@ async function main() {
559
687
  }
560
688
  }
561
689
 
562
- main().then((code) => process.exit(code ?? 0)).catch((e) => {
563
- err("fatal: " + (e?.stack || e));
564
- process.exit(1);
565
- });
690
+ // Only auto-run when invoked as the entry script (Node/Bun via `pathToFileURL`,
691
+ // Deno via `import.meta.main`), so the module can be imported in tests without
692
+ // triggering the CLI dispatch (mirrors how `./cli/store` + `./cli/session`
693
+ // are unit-tested).
694
+ const isMain = (() => {
695
+ try {
696
+ if (import.meta.main === true) return true; // Deno
697
+ } catch { /* not Deno */ }
698
+ try {
699
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
700
+ return true; // Node / Bun
701
+ }
702
+ } catch { /* pathToFileURL unavailable */ }
703
+ return false;
704
+ })();
705
+
706
+ if (isMain) {
707
+ main().then((code) => process.exit(code ?? 0)).catch((e) => {
708
+ err("fatal: " + (e?.stack || e));
709
+ process.exit(1);
710
+ });
711
+ }
@@ -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
@@ -16,6 +16,9 @@
16
16
  * - `aliases` — msgpack `[{ hash, names[], note? }]`
17
17
  * - `ledger` — msgpack `{ tupleHashHex: { object?, relation?, wildcard?, firstSeen } }`
18
18
  * - `identities` — msgpack `{ hashHex: pubKeyBytes }` (durable issuer cache, doc #5)
19
+ * - `outbox` — msgpack `[payloadBytes, ...]` of locally-issued,
20
+ * not-yet-published signed Deltas (doc #8); flushed + cleared
21
+ * by `dacar publish --all`
19
22
  *
20
23
  * Secret material (the node's own identity private key) uses the adapter's
21
24
  * dedicated `loadKey`/`saveKey` slot, matching `@reticulum/core`.
@@ -221,7 +224,15 @@ export class DacarStore {
221
224
  const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
222
225
  const bytes = await this._adapter.get(NS, "state");
223
226
  if (bytes && bytes.length) {
224
- return StateVector.fromPayload(bytes, { deletionHorizonDays: horizon });
227
+ // `trusted: true` these are this node's own persisted CRDT snapshot
228
+ // (written by `saveState()` → `toPayload()`), never network bytes.
229
+ // Network Operations arrive as signed Deltas through `DeltaReceiver`
230
+ // (the verify-on-ingest path), not here. Asserting `trusted` silences
231
+ // the audible `fromPayload` footgun warning during normal CLI use.
232
+ return StateVector.fromPayload(bytes, {
233
+ deletionHorizonDays: horizon,
234
+ trusted: true,
235
+ });
225
236
  }
226
237
  return new StateVector({ deletionHorizonDays: horizon });
227
238
  }
@@ -317,6 +328,39 @@ export class DacarStore {
317
328
  if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
318
329
  return keyring;
319
330
  }
331
+
332
+ // -- outbox (work doc #8) -----------------------------------------------
333
+
334
+ /**
335
+ * Load the outbox of locally-issued, not-yet-published signed Delta
336
+ * payloads, in the order they were issued. Returns an empty array when no
337
+ * outbox record exists yet.
338
+ * @returns {Promise<Uint8Array[]>}
339
+ */
340
+ async loadOutbox() {
341
+ const bytes = await this._adapter.get(NS, "outbox");
342
+ if (!bytes) return [];
343
+ let obj;
344
+ try {
345
+ obj = MsgPack.decode(bytes);
346
+ } catch {
347
+ return []; // corrupted -> treat as empty (do not crash the CLI)
348
+ }
349
+ if (!Array.isArray(obj)) return [];
350
+ return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
351
+ }
352
+
353
+ /**
354
+ * Persist the outbox as a MessagePack array of signed Delta payloads.
355
+ * @param {Uint8Array[]} payloads
356
+ */
357
+ async saveOutbox(payloads) {
358
+ await this._adapter.set(
359
+ NS,
360
+ "outbox",
361
+ MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
362
+ );
363
+ }
320
364
  }
321
365
 
322
366
  /**
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 " +