@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticulum/dacar",
3
- "version": "1.1.2",
3
+ "version": "1.2.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",
@@ -44,8 +44,9 @@
44
44
  "node": ">=20"
45
45
  },
46
46
  "dependencies": {
47
- "@reticulum/core": "^0.5.3",
48
- "@reticulum/node": "^0.6.1"
47
+ "@reticulum/core": "^0.6.5",
48
+ "@reticulum/node": "^0.6.5",
49
+ "@types/node": "^26.2.0"
49
50
  },
50
51
  "publishConfig": {
51
52
  "access": "public"
package/src/challenge.js CHANGED
@@ -61,7 +61,7 @@ async function asIdentity(value) {
61
61
  return value instanceof Identity ? value : await Identity.fromPublicKey(value);
62
62
  }
63
63
 
64
- /** @param {unknown} value @param {number} len @param {string} name @returns {Uint8Array} */
64
+ /** @param {Uint8Array} value @param {number} len @param {string} name @returns {Uint8Array} */
65
65
  function expectBytes(value, len, name) {
66
66
  if (!(value instanceof Uint8Array) || value.length !== len) {
67
67
  throw new Error(`${name} must be a ${len}-byte Uint8Array`);
package/src/cli/dacar.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Node/Deno-only. Declared in `package.json` `bin` and **excluded from the
6
6
  * browser `exports` map** so it never bloats a browser bundle. Composes the
7
7
  * portable {@link module:cli/session} + {@link module:cli/store} helpers with
8
- * `@reticulum/node`'s interfaces and `FileStorageAdapter`.
8
+ * `@reticulum/node`'s interfaces and `DacarFileAdapter` (Python-parity layout).
9
9
  *
10
10
  * Mirrors Python's `dacar/cli/__init__.py` + `commands.py`. Offline commands
11
11
  * never start RNS; online commands (`grant --publish`, `sync`) boot RNS, announce
@@ -18,7 +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
+ * dacar publish <file> [<file>...] | --outbox | --sent | --all (docs #8/#11)
22
22
  * dacar identity remember|forget|list ...
23
23
  *
24
24
  * Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
@@ -34,8 +34,8 @@ import process from "node:process";
34
34
 
35
35
  import { Identity, toHex } from "@reticulum/core";
36
36
  import { MemoryStorageAdapter } from "@reticulum/core";
37
- import { FileStorageAdapter } from "@reticulum/node";
38
- import { RFedClient } from "@reticulum/core/src/rfed/client.js";
37
+ import { DacarFileAdapter } from "./fileStore.js";
38
+ import { RFedClient } from "@reticulum/core/src/rfed/index.js";
39
39
  import { bootRns } from "./rns_boot.js";
40
40
 
41
41
  import { Action, Operation, Tuple, Engine } from "../index.js";
@@ -46,7 +46,7 @@ import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespac
46
46
  import { Keyring, IssuerKeyset } from "../verifier.js";
47
47
 
48
48
  import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
49
- import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
49
+ import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublishMany, runSync, registerAnnounceHandler } from "./session.js";
50
50
 
51
51
  const SHORT_HASH = 7;
52
52
 
@@ -79,7 +79,7 @@ function defaultStorePath() {
79
79
 
80
80
  async function openStore(args) {
81
81
  const path = args.store || defaultStorePath();
82
- const adapter = new FileStorageAdapter(path);
82
+ const adapter = new DacarFileAdapter(path);
83
83
  return new DacarStore(adapter, { identityBytes: args.identity ? await readFile(args.identity) : null });
84
84
  }
85
85
 
@@ -103,6 +103,24 @@ function hexToBytes(hex) {
103
103
  return out;
104
104
  }
105
105
 
106
+ /**
107
+ * Normalize an issuer public key to the 64-byte RNS form (X25519 ‖ Ed25519)
108
+ * used by `IssuerKeyset`. Accepts the canonical 32-byte Ed25519 public key
109
+ * (Python parity) — padding the unused X25519 half with zeros — or a full
110
+ * 64-byte RNS public key as-is.
111
+ * @param {Uint8Array} pubKey 32-byte Ed25519 or 64-byte RNS public key.
112
+ * @returns {Uint8Array}
113
+ */
114
+ function asRnsPubKey(pubKey) {
115
+ if (pubKey.length === 32) {
116
+ const padded = new Uint8Array(64);
117
+ padded.set(pubKey, 32);
118
+ return padded;
119
+ }
120
+ if (pubKey.length === 64) return pubKey;
121
+ throw new CliError(`pubkey must be 32 bytes (64 hex, Ed25519) or 64 bytes (128 hex, RNS), got ${pubKey.length}`);
122
+ }
123
+
106
124
  async function resolveRnsConfigDir(args) {
107
125
  const explicit = args.rnsDir ?? process.env.DACAR_RNS_DIR;
108
126
  if (explicit) return explicit;
@@ -156,7 +174,7 @@ async function resolveTopic(args, store) {
156
174
  async function cmdInit(args) {
157
175
  const path = args.store || defaultStorePath();
158
176
  await mkdir(path, { recursive: true });
159
- const adapter = new FileStorageAdapter(path);
177
+ const adapter = new DacarFileAdapter(path);
160
178
  const store = await DacarStore.init(adapter, {
161
179
  salt: args.salt ? hexToBytes(args.salt) : undefined,
162
180
  horizonDays: parseInt(args.horizon || "180", 10),
@@ -221,7 +239,7 @@ async function _issue(args, action) {
221
239
 
222
240
  // Record plaintext ledger.
223
241
  const ledger = await store.loadLedger();
224
- ledger.set(tuple.key, { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
242
+ ledger.set(toHex(await tuple.hash()), { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
225
243
  await store.saveLedger(ledger);
226
244
 
227
245
  out(toHex(payload));
@@ -230,21 +248,34 @@ async function _issue(args, action) {
230
248
  err(` payload : hex on stdout (${payload.length} bytes)`);
231
249
 
232
250
  if (args.publish) {
233
- await publishDelta(args, store, identity, payload);
251
+ // Durability (work doc #11): enqueue the signed payload to the outbox
252
+ // *before* the risky network send, so it survives a crash or failed
253
+ // transport and can be retried via `publish --outbox`. On send it moves
254
+ // outbox → sent box (the durable replay log), so every issued Delta lands
255
+ // in the durable log and can be re-sent to new peers.
256
+ const ob = await store.loadOutbox();
257
+ ob.push(payload);
258
+ await store.saveOutbox(ob);
259
+ const accepted = await publishDelta(args, store, identity, [payload]);
260
+ await recordPublish(store, [payload], accepted, { recordToSent: true });
261
+ if (accepted[0]) {
262
+ err(" (published + logged to sent box)");
263
+ } else {
264
+ err(" (send failed; retained in outbox for retry)");
265
+ }
234
266
  } 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.)
267
+ // Outbox (work doc #8): queue locally-issued deltas for `publish --outbox`.
268
+ // (JS `grant` always applies locally there is no `--no-apply` — so every
269
+ // non-publish grant is a candidate for later batch publish.)
239
270
  const outbox = await store.loadOutbox();
240
271
  outbox.push(payload);
241
272
  await store.saveOutbox(outbox);
242
- err(" (queued in outbox: `dacar publish --all` to flush)");
273
+ err(" (queued in outbox: `dacar publish --outbox` to flush)");
243
274
  }
244
275
  return 0;
245
276
  }
246
277
 
247
- async function publishDelta(args, store, identity, payload) {
278
+ async function publishDelta(args, store, identity, payloads) {
248
279
  const aliases = await store.loadAliases();
249
280
  const topic = await resolveTopic(args, store);
250
281
  const configDir = await resolveRnsConfigDir(args);
@@ -269,9 +300,74 @@ async function publishDelta(args, store, identity, payload) {
269
300
  await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
270
301
 
271
302
  const client = new RFedClient({ identity, rns });
272
- await runPublish({ deltaPayload: payload, nodeHash, topic, client });
303
+ // Publish each Delta as its own compact inner-format message (§11.1.1)
304
+ // one §5.3 Operation per envelope, never a multi-delta batch (the publish
305
+ // destination is fire-and-forget and capped by the ~500-byte path MTU).
306
+ // RNS is a singleton, so boot + subscribe happen once for the whole batch.
307
+ const accepted = await runPublishMany({
308
+ deltaPayloads: payloads, nodeHash, topic, client, rns,
309
+ });
273
310
  await store.saveKeyring(keyring);
274
- err(` published to rfed channel ${JSON.stringify(topic)} via ${shortHash(nodeHash, args.fullHashes)}`);
311
+ const total = payloads.length;
312
+ const sent = accepted.filter((ok) => ok).length;
313
+ if (sent < total) {
314
+ err(` ⚠ only ${sent}/${total} delta(s) accepted by the transport ` +
315
+ "(fire-and-forget: node storage is not confirmed)");
316
+ }
317
+ err(` sent ${sent}/${total} delta(s) to rfed channel ${JSON.stringify(topic)} via ${shortHash(nodeHash, args.fullHashes)}`);
318
+ return accepted;
319
+ }
320
+
321
+ /**
322
+ * Update the outbox + sent box after a publish attempt (work doc #11).
323
+ *
324
+ * - Remove every transport-accepted payload from the **outbox** (it has been
325
+ * sent, so it leaves the unsent queue).
326
+ * - If `recordToSent`, append transport-accepted payloads to the **sent box**
327
+ * (the durable replay log), deduplicating by exact bytes. Re-sends from the
328
+ * sent box (`publish --sent`) are already present, so this is a no-op for them.
329
+ *
330
+ * Returns the number of accepted deltas. Pure store logic (no RNS) so it runs
331
+ * even when `publishDelta` is patched in tests.
332
+ * @param {import("./store.js").DacarStore} store
333
+ * @param {Uint8Array[]} payloads
334
+ * @param {boolean[]} accepted Per-delta transport acceptance.
335
+ * @param {Object} opts
336
+ * @param {boolean} opts.recordToSent
337
+ * @returns {Promise<number>}
338
+ */
339
+ async function recordPublish(store, payloads, accepted, { recordToSent }) {
340
+ /** @type {Uint8Array[]} */
341
+ const acceptedBytes = [];
342
+ for (let i = 0; i < payloads.length; i++) {
343
+ if (accepted[i]) acceptedBytes.push(new Uint8Array(payloads[i]));
344
+ }
345
+ if (!acceptedBytes.length) return 0;
346
+ // Drain accepted deltas from the outbox (they've been sent).
347
+ const outbox = await store.loadOutbox();
348
+ if (outbox.length) {
349
+ const accSet = new Set(acceptedBytes.map((p) => toHex(p)));
350
+ const newOutbox = outbox.filter((p) => !accSet.has(toHex(p)));
351
+ if (newOutbox.length !== outbox.length) {
352
+ await store.saveOutbox(newOutbox);
353
+ }
354
+ }
355
+ // Append to the sent box (dedup by exact bytes, preserve order).
356
+ if (recordToSent) {
357
+ const sent = await store.loadSent();
358
+ const existing = new Set(sent.map((p) => toHex(p)));
359
+ let changed = false;
360
+ for (const p of acceptedBytes) {
361
+ const h = toHex(p);
362
+ if (!existing.has(h)) {
363
+ sent.push(p);
364
+ existing.add(h);
365
+ changed = true;
366
+ }
367
+ }
368
+ if (changed) await store.saveSent(sent);
369
+ }
370
+ return acceptedBytes.length;
275
371
  }
276
372
 
277
373
  async function cmdSync(args) {
@@ -307,7 +403,7 @@ async function cmdSync(args) {
307
403
  const rx = new DeltaReceiver(state, resolver);
308
404
 
309
405
  const client = new RFedClient({ identity, rns });
310
- const applied = await runSync({ nodeHash, topic, client, receiver: rx });
406
+ const applied = await runSync({ nodeHash, topic, client, receiver: rx, rns });
311
407
  await store.saveState(state);
312
408
  await store.saveKeyring(keyring);
313
409
 
@@ -383,19 +479,34 @@ function coercePayload(data, forceBinary) {
383
479
  return hexToBytes(trimmed);
384
480
  }
385
481
 
386
- export { coercePayload };
482
+ export { coercePayload, recordPublish };
387
483
 
388
484
  /**
389
- * `dacar publish` — push signed delta(s) to the rfed channel (§11.1, doc #8).
485
+ * `dacar publish` — push signed delta(s) to the rfed channel (§11.1, docs #8/#11).
390
486
  *
391
- * Two modes:
487
+ * Two source families (mutually exclusive):
392
488
  * - `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.
489
+ * payload(s) (exact bytes, no re-sign). The **exact signed bytes** are
490
+ * published — no re-signing, no new HLC, no local state change so the
491
+ * receiver's verify-on-ingest authenticates the *original* issuer. These
492
+ * are external payloads and are **not** added to the sent box (they are
493
+ * not this node's issuance).
494
+ * - `dacar publish [--outbox] [--sent] [--all]` — publish this node's own
495
+ * issuance from its durable stores (work doc #11):
496
+ * - `--outbox` flushes the unsent queue; each Delta **moves** to the sent
497
+ * box (the durable replay log) once the transport accepts it.
498
+ * - `--sent` re-sends every Delta in the sent box (idempotent: CRDT merge
499
+ * is a no-op for already-delivered deltas). The sent box is not modified.
500
+ * - `--all` is `--outbox` + `--sent` (everything this node has issued).
501
+ *
502
+ * With no source flag and no files, `--outbox` is implied (the common "flush
503
+ * what I've issued" case). Bare `publish` on an empty outbox is a no-op (0).
396
504
  *
397
- * Reuses the `grant --publish` machinery (`publishDelta`: boot RNS, announce,
398
- * subscribe, publish).
505
+ * Each Delta is published as its **own** rfed message (one §5.3 Operation per
506
+ * compact inner-format envelope, §11.1.1) — exactly like `grant --publish`.
507
+ * All sources reuse the `grant --publish` machinery (`publishDelta`: boot RNS,
508
+ * announce, subscribe, publish), then record accepted deltas via
509
+ * `recordPublish` (sent box append + outbox drain).
399
510
  */
400
511
  async function cmdPublish(args) {
401
512
  const store = await openStore(args);
@@ -403,47 +514,75 @@ async function cmdPublish(args) {
403
514
  if (!identity) throw new CliError("no signing identity (run `dacar init`)");
404
515
 
405
516
  const useAll = !!args.all;
517
+ let useOutbox = !!args.outbox || useAll;
518
+ let useSent = !!args.sent || useAll;
406
519
  const files = args._positionals ?? [];
520
+ let fromStores = useOutbox || useSent;
407
521
 
408
- if (useAll && files.length) {
409
- throw new CliError("publish: use either <file>... or --all, not both");
522
+ if (files.length && fromStores) {
523
+ throw new CliError(
524
+ "publish: use either <file>... or a source flag (--outbox/--sent/--all), not both",
525
+ );
410
526
  }
411
- if (!useAll && !files.length) {
412
- throw new CliError("publish: provide <file>... (one or more) or --all");
527
+ // With no files and no source flag, `--outbox` is implied (doc #11): the
528
+ // common case is "flush what I've issued".
529
+ if (!files.length && !fromStores) {
530
+ useOutbox = true;
531
+ fromStores = true;
413
532
  }
414
533
 
415
534
  /** @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);
535
+ const toPublish = [];
536
+ if (useOutbox) toPublish.push(...(await store.loadOutbox()));
537
+ if (useSent) toPublish.push(...(await store.loadSent()));
538
+ for (const path of files) {
539
+ const data = await readPayloadInput(path, !!args.binary);
540
+ if (!data.length) throw new CliError(`empty payload: ${path}`);
541
+ toPublish.push(data);
542
+ }
543
+
544
+ if (!toPublish.length) {
545
+ const which = [
546
+ ["outbox", useOutbox],
547
+ ["sent", useSent],
548
+ ].filter(([, on]) => on).map(([n]) => n).join(" + ") || "outbox";
549
+ err(`nothing to publish (${which} empty)`);
550
+ return 0;
551
+ }
552
+
553
+ // Dedup the send list by exact bytes, preserving first-seen order (a delta
554
+ // could appear in both the outbox and the sent box after a partial-failure
555
+ // recovery; sending it once is sufficient — CRDT merge is idempotent).
556
+ const seen = new Set();
557
+ const deduped = [];
558
+ for (const payload of toPublish) {
559
+ const h = toHex(payload);
560
+ if (!seen.has(h)) {
561
+ seen.add(h);
562
+ deduped.push(new Uint8Array(payload));
429
563
  }
430
564
  }
431
565
 
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);
566
+ // External file payloads are not this node's issuance -> not logged to the
567
+ // sent box. Anything sourced from a store (outbox/sent/--all) is recorded.
568
+ const recordToSent = files.length === 0;
436
569
 
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}`);
570
+ const labelParts = [];
571
+ if (useOutbox) labelParts.push("outbox");
572
+ if (useSent) labelParts.push("sent");
573
+ if (files.length) labelParts.push(`${files.length} file(s)`);
574
+ err(` publishing ${deduped.length} delta(s) (${labelParts.join(" + ")})`);
440
575
 
441
- // Reuse the grant --publish machinery (boot RNS, announce, subscribe, publish).
442
- await publishDelta(args, store, identity, batch);
576
+ const accepted = await publishDelta(args, store, identity, deduped);
577
+ const nSent = await recordPublish(store, deduped, accepted, { recordToSent });
443
578
 
444
- if (useAll) {
445
- await store.saveOutbox([]);
446
- err(" outbox cleared");
579
+ if (useOutbox && !files.length) {
580
+ err(
581
+ ` (${nSent} moved outbox → sent box; ` +
582
+ "`dacar publish --sent` to re-send)",
583
+ );
584
+ } else if (useSent && !useOutbox && !files.length) {
585
+ err(" (sent box re-sent; not modified — idempotent)");
447
586
  }
448
587
  return 0;
449
588
  }
@@ -466,10 +605,8 @@ async function cmdIdentityRemember(args) {
466
605
  let pubKey;
467
606
  if (args.pubkey) {
468
607
  pubKey = hexToBytes(args.pubkey);
469
- if (pubKey.length !== 64) throw new CliError(`--pubkey must be 64 bytes (128 hex), got ${pubKey.length}`);
470
608
  } else if (args.file) {
471
- pubKey = await readFile(args.file);
472
- if (pubKey.length !== 64) throw new CliError(`pubkey file must contain 64 bytes, got ${pubKey.length}`);
609
+ pubKey = new Uint8Array(await readFile(args.file));
473
610
  } else {
474
611
  // Boot RNS and try to recall.
475
612
  const configDir = await resolveRnsConfigDir(args);
@@ -486,11 +623,12 @@ async function cmdIdentityRemember(args) {
486
623
  pubKey = await recalled.getPublicKey();
487
624
  }
488
625
 
626
+ pubKey = asRnsPubKey(pubKey);
489
627
  const keyring = await store.loadKeyring();
490
628
  keyring.registerSingle(issuerHash, pubKey);
491
629
  await store.saveKeyring(keyring);
492
630
  err(`✔ remembered issuer ${shortHash(issuerHash, args.fullHashes)}`);
493
- err(` pubkey : ${toHex(pubKey).slice(0, SHORT_HASH)}…`);
631
+ err(` pubkey : ${toHex(pubKey.slice(32)).slice(0, SHORT_HASH)}…`);
494
632
  err(` cache : ${keyring.size} entries`);
495
633
  return 0;
496
634
  }
@@ -537,7 +675,11 @@ async function cmdIdentityList(args) {
537
675
  }
538
676
  for (const [hashHex, keyset] of keyring.entries()) {
539
677
  const pub = keyset.memberPublicKeys[0];
540
- err(` ${shortHash(hexToBytes(hashHex), args.fullHashes)} pubkey=${toHex(pub).slice(0, SHORT_HASH)}…`);
678
+ // On disk only the 32-byte Ed25519 half is stored (Python parity); in
679
+ // memory it's padded to a 64-byte RNS key (zeros ‖ Ed25519). Show the
680
+ // meaningful Ed25519 half.
681
+ const ed25519 = pub.length === 64 ? pub.slice(32) : pub;
682
+ err(` ${shortHash(hexToBytes(hashHex), args.fullHashes)} pubkey=${toHex(ed25519).slice(0, SHORT_HASH)}…`);
541
683
  }
542
684
  return 0;
543
685
  }
@@ -559,7 +701,7 @@ async function cmdGrants(args) {
559
701
  err(`${label} (${rows.length})`);
560
702
  for (const { entry, active } of rows) {
561
703
  const t = entry.tuple;
562
- const row = ledger.get(t.key);
704
+ const row = ledger.get(toHex(await t.hash()));
563
705
  const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
564
706
  const obj = row?.object || "[hash]";
565
707
  err(
@@ -600,7 +742,7 @@ const SUBCOMMANDS = {
600
742
  },
601
743
  publish: {
602
744
  run: cmdPublish,
603
- opts: { all: "boolean", binary: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
745
+ opts: { all: "boolean", outbox: "boolean", sent: "boolean", binary: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
604
746
  // variable file list (0..N) accessed via args._positionals
605
747
  online: true,
606
748
  },
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `DacarFileAdapter` — a `StorageAdapter` that writes the **canonical Python
3
+ * `Store` loose-file layout** for the `dacar` namespace (work doc #9), while
4
+ * delegating everything else (the node signing identity, RNS ratchets, and any
5
+ * non-dacar namespace) to `@reticulum/node`'s `FileStorageAdapter`.
6
+ *
7
+ * Why a separate adapter? `@reticulum/core`'s `FileStorageAdapter` lays records
8
+ * out as `<dir>/<namespace>/<key>.bin` (the KV contract the browser/test
9
+ * `MemoryStorageAdapter` also models). Python's canonical `Store` instead
10
+ * writes **loose files** at the store root — `config`, `clock.msgpack`,
11
+ * `state.msgpack`, `aliases`, `ledger.msgpack`, `identities.msgpack`,
12
+ * `outbox.msgpack` — with per-file modes (0600 secret / 0644 public). For the
13
+ * JS and Python CLIs to share one store directory byte-for-byte, JS must write
14
+ * the same filenames at the same paths with the same modes.
15
+ *
16
+ * The record *names* (the `key` passed to `get`/`set`) are the exact Python
17
+ * filenames (set in `src/cli/store.js`); this adapter simply writes them at
18
+ * `<dir>/<key>` with the matching mode, and lists them back via `keys`.
19
+ *
20
+ * The node's own signing **identity private key** stays library-native:
21
+ * `FileStorageAdapter` writes/reads `<dir>/identity.key` (128-byte priv+pub),
22
+ * which coexists with — and is distinct from — Python's 64-byte `<dir>/identity`.
23
+ * A store therefore carries the identity of whichever CLI initialized it.
24
+ *
25
+ * Node-only (imports `node:fs` + `@reticulum/node`); the portable record
26
+ * encode/decode lives in `src/cli/store.js` (browser-safe, no `fs`).
27
+ */
28
+
29
+ import { existsSync } from "node:fs";
30
+ import { chmod, mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
31
+ import { join } from "node:path";
32
+
33
+ import { FileStorageAdapter } from "@reticulum/node";
34
+
35
+ /** Namespace the `DacarStore` writes its records under. */
36
+ const DACAR_NS = "dacar";
37
+
38
+ /** Directory mode (Python `Store._DIR_MODE`). */
39
+ const DIR_MODE = 0o700;
40
+
41
+ /**
42
+ * Per-record file modes, mirroring the canonical Python `Store`:
43
+ * - secret records (config, state, ledger, identities, outbox): 0600
44
+ * - public records (clock, aliases): 0644
45
+ * Unknown dacar records default to 0600 (fail-closed).
46
+ */
47
+ const FILE_MODES = {
48
+ config: 0o600,
49
+ "clock.msgpack": 0o644,
50
+ "state.msgpack": 0o600,
51
+ aliases: 0o644,
52
+ "ledger.msgpack": 0o600,
53
+ "identities.msgpack": 0o600,
54
+ "outbox.msgpack": 0o600,
55
+ "sent.msgpack": 0o600,
56
+ };
57
+
58
+ export class DacarFileAdapter {
59
+ /**
60
+ * @param {string} directory Absolute store directory (e.g. `~/.dacar`).
61
+ */
62
+ constructor(directory) {
63
+ this.directory = directory;
64
+ /** @type {import("@reticulum/node").FileStorageAdapter} */
65
+ this._fallback = new FileStorageAdapter(directory);
66
+ this._dirReady = false;
67
+ }
68
+
69
+ /**
70
+ * Ensure the store directory exists with mode 0700 (Python `Store.init`
71
+ * `os.chmod(path, DIR_MODE)`; umask-independent via explicit chmod).
72
+ */
73
+ async _ensureDir() {
74
+ if (this._dirReady) return;
75
+ await mkdir(this.directory, { recursive: true });
76
+ await chmod(this.directory, DIR_MODE);
77
+ this._dirReady = true;
78
+ }
79
+
80
+ /**
81
+ * @param {string} namespace
82
+ * @param {string} key
83
+ * @returns {Promise<Uint8Array | null>}
84
+ */
85
+ async get(namespace, key) {
86
+ if (namespace === DACAR_NS) {
87
+ try {
88
+ const buf = await readFile(join(this.directory, key));
89
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
90
+ } catch (e) {
91
+ if (e.code === "ENOENT") return null;
92
+ throw e;
93
+ }
94
+ }
95
+ return this._fallback.get(namespace, key);
96
+ }
97
+
98
+ /**
99
+ * @param {string} namespace
100
+ * @param {string} key
101
+ * @param {Uint8Array} value
102
+ */
103
+ async set(namespace, key, value) {
104
+ if (namespace === DACAR_NS) {
105
+ await this._ensureDir();
106
+ const path = join(this.directory, key);
107
+ await writeFile(path, value, { mode: 0o600 });
108
+ // Force the exact mode (umask-independent), matching Python's os.chmod.
109
+ await chmod(path, FILE_MODES[key] ?? 0o600);
110
+ return;
111
+ }
112
+ return this._fallback.set(namespace, key, value);
113
+ }
114
+
115
+ /**
116
+ * @param {string} namespace
117
+ * @param {string} key
118
+ */
119
+ async delete(namespace, key) {
120
+ if (namespace === DACAR_NS) {
121
+ try {
122
+ await unlink(join(this.directory, key));
123
+ } catch (e) {
124
+ if (e.code === "ENOENT") return;
125
+ throw e;
126
+ }
127
+ return;
128
+ }
129
+ return this._fallback.delete(namespace, key);
130
+ }
131
+
132
+ /**
133
+ * @param {string} namespace
134
+ * @returns {Promise<string[]>}
135
+ */
136
+ async keys(namespace) {
137
+ if (namespace === DACAR_NS) {
138
+ if (!existsSync(this.directory)) return [];
139
+ const entries = await readdir(this.directory);
140
+ return entries.filter((f) => Object.prototype.hasOwnProperty.call(FILE_MODES, f));
141
+ }
142
+ return this._fallback.keys(namespace);
143
+ }
144
+
145
+ // -- identity + ratchets: delegate to FileStorageAdapter (library-native) -
146
+
147
+ /** @param {Uint8Array} bytes */
148
+ async saveKey(bytes) {
149
+ return this._fallback.saveKey(bytes);
150
+ }
151
+
152
+ /** @returns {Promise<Uint8Array | null>} */
153
+ async loadKey() {
154
+ return this._fallback.loadKey();
155
+ }
156
+
157
+ /** @param {Uint8Array} hash @param {Uint8Array} bytes */
158
+ async saveOwnedRatchets(hash, bytes) {
159
+ return this._fallback.saveOwnedRatchets(hash, bytes);
160
+ }
161
+
162
+ /** @param {Uint8Array} hash @returns {Promise<Uint8Array | null>} */
163
+ async loadOwnedRatchets(hash) {
164
+ return this._fallback.loadOwnedRatchets(hash);
165
+ }
166
+ }