@reticulum/dacar 1.1.2 → 1.2.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 +4 -3
- package/src/challenge.js +1 -1
- package/src/cli/dacar.js +204 -62
- package/src/cli/fileStore.js +166 -0
- package/src/cli/session.js +187 -20
- package/src/cli/store.js +421 -137
- package/src/crdt.js +2 -2
- package/src/operation.js +2 -2
- package/src/transport/lxmfSync.js +7 -7
- package/src/transport/rfedSync.js +93 -83
- package/src/tuple.js +1 -1
package/src/cli/session.js
CHANGED
|
@@ -16,6 +16,17 @@ 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
|
|
|
19
|
+
/**
|
|
20
|
+
* rfed service destination names (SPEC §2), all sharing the node identity.
|
|
21
|
+
* Mirrors Python's ``dacar.rfed.constants`` and ``@reticulum/core``'s internal
|
|
22
|
+
* ``client.js`` constants. Used to compute per-destination hashes so dacar can
|
|
23
|
+
* request transport paths to the *specific* rfed service a link targets
|
|
24
|
+
* (a path to ``rfed.node`` does not establish a route to ``rfed.channel.*``).
|
|
25
|
+
*/
|
|
26
|
+
const RFED_SUBSCRIBE_DEST = "rfed.channel.subscribe";
|
|
27
|
+
const RFED_PULL_DEST = "rfed.channel.pull";
|
|
28
|
+
const RFED_PUBLISH_DEST = "rfed.channel.publish";
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* Announce the node's identity on the `dacar.node` destination (§11.2.4).
|
|
21
32
|
*
|
|
@@ -102,22 +113,149 @@ export async function ensureNodeIdentity(
|
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
/**
|
|
105
|
-
*
|
|
116
|
+
* How long {@link ensureRfedPath} waits for a path-response announce after
|
|
117
|
+
* sending a `path?` request before giving up, in milliseconds. Mirrors rngit's
|
|
118
|
+
* `PATH_TIMEOUT` (15 s) and Python's `DEFAULT_PATH_TIMEOUT`.
|
|
119
|
+
*/
|
|
120
|
+
export const DEFAULT_PATH_TIMEOUT = 15_000;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Ensure a transport path to a specific rfed service destination before the
|
|
124
|
+
* client links to it.
|
|
125
|
+
*
|
|
126
|
+
* `@reticulum/core`'s `RFedClient` opens a `Link` via `Destination.createLink()`,
|
|
127
|
+
* which sends a `LINKREQUEST` addressed to the *derived* channel destination
|
|
128
|
+
* (e.g. `rfed.channel.subscribe`). The JS `Link` does not proactively request a
|
|
129
|
+
* path before the first attempt — and a `LINKREQUEST` to a destination with no
|
|
130
|
+
* known route is silently dropped by multi-hop peers (Transport "branch 5"),
|
|
131
|
+
* so the link times out with no recourse. This mirrors rngit's
|
|
132
|
+
* `RNS.Transport.await_path` and the LXMF router's `_requestAndAwaitPath`:
|
|
133
|
+
* compute the derived destination hash, send a `path?` request for it, and wait
|
|
134
|
+
* for the node's path-response announce to populate the path table before the
|
|
135
|
+
* client links.
|
|
136
|
+
*
|
|
137
|
+
* A path to `rfed.node` does **not** establish a route to `rfed.channel.*` (RNS
|
|
138
|
+
* path entries are per-destination-hash), so the *specific* service destination
|
|
139
|
+
* a link targets must be requested. The node identity must already be
|
|
140
|
+
* recallable (call {@link ensureNodeIdentity} first). No-op when a path is
|
|
141
|
+
* already known, or when the transport lacks the path API (mock/test
|
|
142
|
+
* transports). Throws if none is found within `timeout`.
|
|
143
|
+
* @param {import("@reticulum/core").Reticulum} rns A booted Reticulum.
|
|
144
|
+
* @param {Uint8Array} nodeHash An `rfed.*` destination hash of the node.
|
|
145
|
+
* @param {string} destName The rfed service name (e.g. `rfed.channel.subscribe`).
|
|
146
|
+
* @param {Object} [opts]
|
|
147
|
+
* @param {number} [opts.timeout=15000] Max wait in milliseconds.
|
|
148
|
+
* @param {number} [opts.pollInterval=100] Poll interval in milliseconds.
|
|
149
|
+
* @param {() => void} [opts.onRequest] Invoked once when the path request fires.
|
|
150
|
+
* @returns {Promise<Uint8Array>} The resolved destination hash.
|
|
151
|
+
*/
|
|
152
|
+
export async function ensureRfedPath(
|
|
153
|
+
rns,
|
|
154
|
+
nodeHash,
|
|
155
|
+
destName,
|
|
156
|
+
{ timeout = DEFAULT_PATH_TIMEOUT, pollInterval = 100, onRequest } = {},
|
|
157
|
+
) {
|
|
158
|
+
const identity = await Destination.recall(nodeHash);
|
|
159
|
+
if (!identity) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`rfed node identity unknown for ${toHex(nodeHash)}; wait for its announce`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const destHash = await _singleDestinationHash(identity, destName);
|
|
165
|
+
const transport = rns?.transport;
|
|
166
|
+
// No-op when a path is already known.
|
|
167
|
+
if (transport?.hasPath?.(destHash)) return destHash;
|
|
168
|
+
// Mock/test transports without the path-discovery API: nothing to wait for.
|
|
169
|
+
if (!transport?.requestPath) return destHash;
|
|
170
|
+
if (onRequest) onRequest();
|
|
171
|
+
await transport.requestPath(destHash).catch(() => {});
|
|
172
|
+
// A fast path-response may already have been ingested.
|
|
173
|
+
if (transport.hasPath?.(destHash)) return destHash;
|
|
174
|
+
const deadline = Date.now() + timeout;
|
|
175
|
+
while (Date.now() < deadline) {
|
|
176
|
+
if (transport.hasPath?.(destHash)) return destHash;
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
178
|
+
}
|
|
179
|
+
throw new Error(
|
|
180
|
+
`no path to ${toHex(destHash)} (${destName}) could be resolved ` +
|
|
181
|
+
`within ${timeout}ms (is the rfed node announcing and reachable?)`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Publish signed Deltas to the rfed channel (§11.1, work doc #4/#10/#11).
|
|
187
|
+
*
|
|
188
|
+
* Testable core: takes an explicit `client` (`RFedClient` or compatible
|
|
189
|
+
* fake) so tests inject doubles without booting RNS. The `cmd_*` wrappers
|
|
190
|
+
* handle RNS boot + announce + real client creation.
|
|
106
191
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
192
|
+
* Subscribes **once** then publishes each Delta as its own compact inner-format
|
|
193
|
+
* message (one §5.3 Operation per envelope, §11.1.1) — RNS is a singleton that
|
|
194
|
+
* cannot be re-booted, and re-subscribing per Delta would be wasteful. Returns
|
|
195
|
+
* a per-Delta list of transport-acceptance flags (fire-and-forget: transport
|
|
196
|
+
* acceptance ≠ node storage). The caller records accepted deltas in the sent
|
|
197
|
+
* box / removes them from the outbox (work doc #11).
|
|
110
198
|
* @param {Object} opts
|
|
111
|
-
* @param {Uint8Array} opts.
|
|
199
|
+
* @param {Uint8Array[]} opts.deltaPayloads Signed §5.3 Operation payloads.
|
|
112
200
|
* @param {Uint8Array} opts.nodeHash The rfed node's `rfed.*` destination hash.
|
|
113
201
|
* @param {string} [opts.topic] RFed channel name (default `dacar.policy.v1`).
|
|
114
202
|
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
115
|
-
* @
|
|
203
|
+
* @param {import("@reticulum/core").Reticulum} [opts.rns] A booted
|
|
204
|
+
* Reticulum. When given, transport paths to the rfed `subscribe` +
|
|
205
|
+
* `publish` destinations are requested before the client links (rngit
|
|
206
|
+
* `await_path` pattern); omitted in tests that inject a fake client.
|
|
207
|
+
* @returns {Promise<boolean[]>}
|
|
116
208
|
*/
|
|
117
|
-
export async function
|
|
209
|
+
export async function runPublishMany({ deltaPayloads, nodeHash, topic, client, rns = null }) {
|
|
118
210
|
const sync = new RfedDeltaSync({ client, topic });
|
|
119
|
-
|
|
120
|
-
|
|
211
|
+
if (rns) {
|
|
212
|
+
await ensureRfedPath(rns, nodeHash, RFED_SUBSCRIBE_DEST);
|
|
213
|
+
await ensureRfedPath(rns, nodeHash, RFED_PUBLISH_DEST);
|
|
214
|
+
}
|
|
215
|
+
const result = await sync.subscribe(nodeHash);
|
|
216
|
+
if (result?.ok === false) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`rfed subscribe to ${toHex(nodeHash)} failed; the node rejected the ` +
|
|
219
|
+
"subscription (signature/channel mismatch) or returned no response — " +
|
|
220
|
+
"the topic will not sync with peers",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
// Per-Delta transport acceptance (fire-and-forget: transport acceptance ≠
|
|
224
|
+
// node storage). The caller records accepted deltas in the sent box /
|
|
225
|
+
// removes them from the outbox (doc #11).
|
|
226
|
+
const accepted = [];
|
|
227
|
+
for (const payload of deltaPayloads) {
|
|
228
|
+
accepted.push(await sync.publish(payload, nodeHash));
|
|
229
|
+
}
|
|
230
|
+
return accepted;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Publish a single signed Delta to the rfed channel (§11.1, work doc #4).
|
|
235
|
+
*
|
|
236
|
+
* Thin convenience wrapper over {@link runPublishMany} for the single-Delta
|
|
237
|
+
* case (`grant --publish` / `revoke --publish`). Returns the per-Delta
|
|
238
|
+
* transport acceptance.
|
|
239
|
+
* @param {Object} opts
|
|
240
|
+
* @param {Uint8Array} opts.deltaPayload Signed §5.3 Operation payload.
|
|
241
|
+
* @param {Uint8Array} opts.nodeHash The rfed node's `rfed.*` destination hash.
|
|
242
|
+
* @param {string} [opts.topic] RFed channel name (default `dacar.policy.v1`).
|
|
243
|
+
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
244
|
+
* @param {import("@reticulum/core").Reticulum} [opts.rns] A booted
|
|
245
|
+
* Reticulum. When given, transport paths to the rfed `subscribe` +
|
|
246
|
+
* `publish` destinations are requested before the client links (rngit
|
|
247
|
+
* `await_path` pattern); omitted in tests that inject a fake client.
|
|
248
|
+
* @returns {Promise<boolean>}
|
|
249
|
+
*/
|
|
250
|
+
export async function runPublish({ deltaPayload, nodeHash, topic, client, rns = null }) {
|
|
251
|
+
const [accepted] = await runPublishMany({
|
|
252
|
+
deltaPayloads: [deltaPayload],
|
|
253
|
+
nodeHash,
|
|
254
|
+
topic,
|
|
255
|
+
client,
|
|
256
|
+
rns,
|
|
257
|
+
});
|
|
258
|
+
return accepted;
|
|
121
259
|
}
|
|
122
260
|
|
|
123
261
|
/**
|
|
@@ -132,11 +270,26 @@ export async function runPublish({ deltaPayload, nodeHash, topic, client }) {
|
|
|
132
270
|
* @param {string} [opts.topic]
|
|
133
271
|
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
134
272
|
* @param {import("../delta.js").DeltaReceiver} opts.receiver
|
|
273
|
+
* @param {import("@reticulum/core").Reticulum} [opts.rns] A booted
|
|
274
|
+
* Reticulum. When given, transport paths to the rfed `subscribe` + `pull`
|
|
275
|
+
* destinations are requested before the client links (rngit `await_path`
|
|
276
|
+
* pattern); omitted in tests that inject a fake client.
|
|
135
277
|
* @returns {Promise<number>}
|
|
136
278
|
*/
|
|
137
|
-
export async function runSync({ nodeHash, topic, client, receiver }) {
|
|
279
|
+
export async function runSync({ nodeHash, topic, client, receiver, rns = null }) {
|
|
138
280
|
const sync = new RfedDeltaSync({ receiver, client, topic });
|
|
139
|
-
|
|
281
|
+
if (rns) {
|
|
282
|
+
await ensureRfedPath(rns, nodeHash, RFED_SUBSCRIBE_DEST);
|
|
283
|
+
await ensureRfedPath(rns, nodeHash, RFED_PULL_DEST);
|
|
284
|
+
}
|
|
285
|
+
const result = await sync.subscribe(nodeHash);
|
|
286
|
+
if (result?.ok === false) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`rfed subscribe to ${toHex(nodeHash)} failed; the node rejected the ` +
|
|
289
|
+
"subscription (signature/channel mismatch) or returned no response — " +
|
|
290
|
+
"the topic will not sync with peers",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
140
293
|
return sync.pull(nodeHash);
|
|
141
294
|
}
|
|
142
295
|
|
|
@@ -187,23 +340,37 @@ export async function registerAnnounceHandler({ rns, keyring, onSave }) {
|
|
|
187
340
|
}
|
|
188
341
|
|
|
189
342
|
/**
|
|
190
|
-
* Compute the
|
|
343
|
+
* Compute the 16-byte SINGLE destination hash for ``name`` under ``identity``.
|
|
191
344
|
*
|
|
192
|
-
*
|
|
193
|
-
* identityHash)[:16]
|
|
345
|
+
* ``nameHash = SHA-256(name)[:10]``; ``destHash = SHA-256(nameHash ‖
|
|
346
|
+
* identityHash)[:16]`` — matching ``@reticulum/core``'s
|
|
347
|
+
* ``Destination._computeHashes``. Generalised from {@link _dacarNodeHash} so any
|
|
348
|
+
* rfed service destination (``rfed.channel.subscribe`` etc.) hash can be derived
|
|
349
|
+
* to request a transport path to it.
|
|
194
350
|
* @param {import("@reticulum/core").Identity} identity
|
|
351
|
+
* @param {string} name The full dotted destination name (e.g. ``rfed.node``).
|
|
195
352
|
* @returns {Promise<Uint8Array>}
|
|
196
353
|
*/
|
|
197
|
-
async function
|
|
354
|
+
async function _singleDestinationHash(identity, name) {
|
|
198
355
|
const encoder = new TextEncoder();
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
356
|
+
const nameHash = new Uint8Array(
|
|
357
|
+
(await crypto.subtle.digest("SHA-256", encoder.encode(name))).slice(0, 10),
|
|
358
|
+
);
|
|
202
359
|
const combined = new Uint8Array(nameHash.length + identity.identityHash.length);
|
|
203
360
|
combined.set(nameHash, 0);
|
|
204
361
|
combined.set(identity.identityHash, nameHash.length);
|
|
205
|
-
|
|
206
|
-
|
|
362
|
+
return new Uint8Array((await crypto.subtle.digest("SHA-256", combined)).slice(0, 16));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Compute the `dacar.node` destination hash for an identity (§11.2.4).
|
|
367
|
+
*
|
|
368
|
+
* Delegates to {@link _singleDestinationHash} with the ``dacar.node`` name.
|
|
369
|
+
* @param {import("@reticulum/core").Identity} identity
|
|
370
|
+
* @returns {Promise<Uint8Array>}
|
|
371
|
+
*/
|
|
372
|
+
async function _dacarNodeHash(identity) {
|
|
373
|
+
return await _singleDestinationHash(identity, `${APP_NAME}.node`);
|
|
207
374
|
}
|
|
208
375
|
|
|
209
376
|
/**
|