@reticulum/dacar 1.1.1 → 1.2.0

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.
@@ -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
- * Publish a signed Delta to the rfed channel (§11.1, work doc #6).
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
- * Testable core: takes an explicit `client` (`RFedClient` or compatible fake)
108
- * so tests inject doubles without booting RNS. The `cmd_*` wrappers handle RNS
109
- * boot + announce + real client creation.
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.deltaPayload Signed §5.3 Operation payload.
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
- * @returns {Promise<import("@reticulum/core").LXMessage>}
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 runPublish({ deltaPayload, nodeHash, topic, client }) {
209
+ export async function runPublishMany({ deltaPayloads, nodeHash, topic, client, rns = null }) {
118
210
  const sync = new RfedDeltaSync({ client, topic });
119
- await sync.subscribe(nodeHash);
120
- return sync.publish(deltaPayload, nodeHash);
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
- await sync.subscribe(nodeHash);
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 `dacar.node` destination hash for an identity (§11.2.4).
343
+ * Compute the 16-byte SINGLE destination hash for ``name`` under ``identity``.
191
344
  *
192
- * `nameHash = SHA-256("dacar.node")[:10]`; `destHash = SHA-256(nameHash ‖
193
- * identityHash)[:16]` — matching `@reticulum/core`'s `Destination._computeHashes`.
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 _dacarNodeHash(identity) {
354
+ async function _singleDestinationHash(identity, name) {
198
355
  const encoder = new TextEncoder();
199
- const nameBytes = encoder.encode(`${APP_NAME}.node`);
200
- const nameHashBuffer = await crypto.subtle.digest("SHA-256", nameBytes);
201
- const nameHash = new Uint8Array(nameHashBuffer.slice(0, 10));
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
- const destHashBuffer = await crypto.subtle.digest("SHA-256", combined);
206
- return new Uint8Array(destHashBuffer.slice(0, 16));
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
  /**