@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 +1 -1
- package/src/cli/dacar.js +51 -42
- package/src/cli/rns_boot.js +131 -0
- package/src/cli/session.js +140 -3
- package/src/cli/store.js +9 -1
- package/src/crdt.js +9 -3
package/package.json
CHANGED
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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 " +
|