@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticulum/dacar",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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,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>...] | --outbox | --sent | --all (docs #8/#11)
21
22
  * dacar identity remember|forget|list ...
22
23
  *
23
24
  * Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
@@ -28,12 +29,13 @@ 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
35
  import { Identity, toHex } from "@reticulum/core";
34
36
  import { MemoryStorageAdapter } from "@reticulum/core";
35
- import { FileStorageAdapter } from "@reticulum/node";
36
- 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";
37
39
  import { bootRns } from "./rns_boot.js";
38
40
 
39
41
  import { Action, Operation, Tuple, Engine } from "../index.js";
@@ -44,7 +46,7 @@ import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespac
44
46
  import { Keyring, IssuerKeyset } from "../verifier.js";
45
47
 
46
48
  import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
47
- import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
49
+ import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublishMany, runSync, registerAnnounceHandler } from "./session.js";
48
50
 
49
51
  const SHORT_HASH = 7;
50
52
 
@@ -77,7 +79,7 @@ function defaultStorePath() {
77
79
 
78
80
  async function openStore(args) {
79
81
  const path = args.store || defaultStorePath();
80
- const adapter = new FileStorageAdapter(path);
82
+ const adapter = new DacarFileAdapter(path);
81
83
  return new DacarStore(adapter, { identityBytes: args.identity ? await readFile(args.identity) : null });
82
84
  }
83
85
 
@@ -101,6 +103,24 @@ function hexToBytes(hex) {
101
103
  return out;
102
104
  }
103
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
+
104
124
  async function resolveRnsConfigDir(args) {
105
125
  const explicit = args.rnsDir ?? process.env.DACAR_RNS_DIR;
106
126
  if (explicit) return explicit;
@@ -154,7 +174,7 @@ async function resolveTopic(args, store) {
154
174
  async function cmdInit(args) {
155
175
  const path = args.store || defaultStorePath();
156
176
  await mkdir(path, { recursive: true });
157
- const adapter = new FileStorageAdapter(path);
177
+ const adapter = new DacarFileAdapter(path);
158
178
  const store = await DacarStore.init(adapter, {
159
179
  salt: args.salt ? hexToBytes(args.salt) : undefined,
160
180
  horizonDays: parseInt(args.horizon || "180", 10),
@@ -219,21 +239,43 @@ async function _issue(args, action) {
219
239
 
220
240
  // Record plaintext ledger.
221
241
  const ledger = await store.loadLedger();
222
- ledger.set(toHex(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) });
223
243
  await store.saveLedger(ledger);
224
244
 
225
- out(payload.hex());
245
+ out(toHex(payload));
226
246
  err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
227
247
  err(` hlc : 0x${hlc.toString(16)}`);
228
248
  err(` payload : hex on stdout (${payload.length} bytes)`);
229
249
 
230
250
  if (args.publish) {
231
- 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
+ }
266
+ } else {
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.)
270
+ const outbox = await store.loadOutbox();
271
+ outbox.push(payload);
272
+ await store.saveOutbox(outbox);
273
+ err(" (queued in outbox: `dacar publish --outbox` to flush)");
232
274
  }
233
275
  return 0;
234
276
  }
235
277
 
236
- async function publishDelta(args, store, identity, payload) {
278
+ async function publishDelta(args, store, identity, payloads) {
237
279
  const aliases = await store.loadAliases();
238
280
  const topic = await resolveTopic(args, store);
239
281
  const configDir = await resolveRnsConfigDir(args);
@@ -258,9 +300,74 @@ async function publishDelta(args, store, identity, payload) {
258
300
  await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
259
301
 
260
302
  const client = new RFedClient({ identity, rns });
261
- 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
+ });
262
310
  await store.saveKeyring(keyring);
263
- 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;
264
371
  }
265
372
 
266
373
  async function cmdSync(args) {
@@ -296,7 +403,7 @@ async function cmdSync(args) {
296
403
  const rx = new DeltaReceiver(state, resolver);
297
404
 
298
405
  const client = new RFedClient({ identity, rns });
299
- const applied = await runSync({ nodeHash, topic, client, receiver: rx });
406
+ const applied = await runSync({ nodeHash, topic, client, receiver: rx, rns });
300
407
  await store.saveState(state);
301
408
  await store.saveKeyring(keyring);
302
409
 
@@ -336,6 +443,150 @@ async function cmdApply(args) {
336
443
  return 1;
337
444
  }
338
445
 
446
+ /**
447
+ * Read a payload file (or stdin) and auto-detect hex (mirrors Python's
448
+ * `_read_payload_input`): an all-hex, even-length ASCII blob decodes to bytes.
449
+ * `--binary` forces raw bytes.
450
+ * @param {string} path
451
+ * @param {boolean} forceBinary
452
+ * @returns {Promise<Uint8Array>}
453
+ */
454
+ async function readPayloadInput(path, forceBinary) {
455
+ const data = path === "-" ? new Uint8Array(await readStdin()) : await readFile(path);
456
+ return coercePayload(data, forceBinary);
457
+ }
458
+
459
+ /**
460
+ * Auto-detect a hex payload: if *all* bytes are ASCII hex characters (after
461
+ * trimming surrounding whitespace) and the length is even, decode to bytes;
462
+ * otherwise return the raw bytes unchanged.
463
+ * @param {Uint8Array | Buffer} data
464
+ * @param {boolean} forceBinary
465
+ * @returns {Uint8Array}
466
+ */
467
+ function coercePayload(data, forceBinary) {
468
+ const bytes = new Uint8Array(data);
469
+ if (forceBinary || !bytes.length) return bytes;
470
+ let s;
471
+ try {
472
+ s = Buffer.from(bytes).toString("ascii");
473
+ } catch {
474
+ return bytes; // not ASCII -> raw bytes
475
+ }
476
+ const trimmed = s.trim();
477
+ if (!trimmed || trimmed.length % 2 !== 0) return bytes;
478
+ if (!/^[0-9a-fA-F]+$/.test(trimmed)) return bytes;
479
+ return hexToBytes(trimmed);
480
+ }
481
+
482
+ export { coercePayload, recordPublish };
483
+
484
+ /**
485
+ * `dacar publish` — push signed delta(s) to the rfed channel (§11.1, docs #8/#11).
486
+ *
487
+ * Two source families (mutually exclusive):
488
+ * - `dacar publish <file> [<file>...]` — publish previously-signed delta
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).
504
+ *
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).
510
+ */
511
+ async function cmdPublish(args) {
512
+ const store = await openStore(args);
513
+ const identity = await store.loadIdentity();
514
+ if (!identity) throw new CliError("no signing identity (run `dacar init`)");
515
+
516
+ const useAll = !!args.all;
517
+ let useOutbox = !!args.outbox || useAll;
518
+ let useSent = !!args.sent || useAll;
519
+ const files = args._positionals ?? [];
520
+ let fromStores = useOutbox || useSent;
521
+
522
+ if (files.length && fromStores) {
523
+ throw new CliError(
524
+ "publish: use either <file>... or a source flag (--outbox/--sent/--all), not both",
525
+ );
526
+ }
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;
532
+ }
533
+
534
+ /** @type {Uint8Array[]} */
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));
563
+ }
564
+ }
565
+
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;
569
+
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(" + ")})`);
575
+
576
+ const accepted = await publishDelta(args, store, identity, deduped);
577
+ const nSent = await recordPublish(store, deduped, accepted, { recordToSent });
578
+
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)");
586
+ }
587
+ return 0;
588
+ }
589
+
339
590
  async function readStdin() {
340
591
  const chunks = [];
341
592
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -354,10 +605,8 @@ async function cmdIdentityRemember(args) {
354
605
  let pubKey;
355
606
  if (args.pubkey) {
356
607
  pubKey = hexToBytes(args.pubkey);
357
- if (pubKey.length !== 64) throw new CliError(`--pubkey must be 64 bytes (128 hex), got ${pubKey.length}`);
358
608
  } else if (args.file) {
359
- pubKey = await readFile(args.file);
360
- if (pubKey.length !== 64) throw new CliError(`pubkey file must contain 64 bytes, got ${pubKey.length}`);
609
+ pubKey = new Uint8Array(await readFile(args.file));
361
610
  } else {
362
611
  // Boot RNS and try to recall.
363
612
  const configDir = await resolveRnsConfigDir(args);
@@ -374,11 +623,12 @@ async function cmdIdentityRemember(args) {
374
623
  pubKey = await recalled.getPublicKey();
375
624
  }
376
625
 
626
+ pubKey = asRnsPubKey(pubKey);
377
627
  const keyring = await store.loadKeyring();
378
628
  keyring.registerSingle(issuerHash, pubKey);
379
629
  await store.saveKeyring(keyring);
380
630
  err(`✔ remembered issuer ${shortHash(issuerHash, args.fullHashes)}`);
381
- err(` pubkey : ${toHex(pubKey).slice(0, SHORT_HASH)}…`);
631
+ err(` pubkey : ${toHex(pubKey.slice(32)).slice(0, SHORT_HASH)}…`);
382
632
  err(` cache : ${keyring.size} entries`);
383
633
  return 0;
384
634
  }
@@ -425,7 +675,11 @@ async function cmdIdentityList(args) {
425
675
  }
426
676
  for (const [hashHex, keyset] of keyring.entries()) {
427
677
  const pub = keyset.memberPublicKeys[0];
428
- 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)}…`);
429
683
  }
430
684
  return 0;
431
685
  }
@@ -447,7 +701,7 @@ async function cmdGrants(args) {
447
701
  err(`${label} (${rows.length})`);
448
702
  for (const { entry, active } of rows) {
449
703
  const t = entry.tuple;
450
- const row = ledger.get(toHex(t.key));
704
+ const row = ledger.get(toHex(await t.hash()));
451
705
  const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
452
706
  const obj = row?.object || "[hash]";
453
707
  err(
@@ -486,6 +740,12 @@ const SUBCOMMANDS = {
486
740
  opts: { node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
487
741
  online: true,
488
742
  },
743
+ publish: {
744
+ run: cmdPublish,
745
+ opts: { all: "boolean", outbox: "boolean", sent: "boolean", binary: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
746
+ // variable file list (0..N) accessed via args._positionals
747
+ online: true,
748
+ },
489
749
  apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
490
750
  check: { run: cmdCheck, opts: {}, positional: ["grantee", "relation", "object"], online: false },
491
751
  grants: { run: cmdGrants, opts: { all: "boolean", revoked: "boolean" }, online: false },
@@ -512,11 +772,12 @@ function buildOptions(spec) {
512
772
  // errors out, and threaded into bootRns to raise the Reticulum log
513
773
  // threshold + log interface/announce diagnostics.
514
774
  opts.verbose = { type: "boolean", short: "v" };
515
- if (spec.positional && spec.positional.length) {
516
- opts.store = { type: "string" };
517
- opts.identity = { type: "string" };
518
- opts["full-hashes"] = { type: "boolean" };
519
- }
775
+ // --store / --identity / --full-hashes are global on every (sub)command
776
+ // (they were previously gated on `positional`, which silently dropped them
777
+ // for commands with no positionals — e.g. `sync`, `config show`, `grants`).
778
+ opts.store = { type: "string" };
779
+ opts.identity = { type: "string" };
780
+ opts["full-hashes"] = { type: "boolean" };
520
781
  return opts;
521
782
  }
522
783
 
@@ -568,7 +829,25 @@ async function main() {
568
829
  }
569
830
  }
570
831
 
571
- main().then((code) => process.exit(code ?? 0)).catch((e) => {
572
- err("fatal: " + (e?.stack || e));
573
- process.exit(1);
574
- });
832
+ // Only auto-run when invoked as the entry script (Node/Bun via `pathToFileURL`,
833
+ // Deno via `import.meta.main`), so the module can be imported in tests without
834
+ // triggering the CLI dispatch (mirrors how `./cli/store` + `./cli/session`
835
+ // are unit-tested).
836
+ const isMain = (() => {
837
+ try {
838
+ if (import.meta.main === true) return true; // Deno
839
+ } catch { /* not Deno */ }
840
+ try {
841
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
842
+ return true; // Node / Bun
843
+ }
844
+ } catch { /* pathToFileURL unavailable */ }
845
+ return false;
846
+ })();
847
+
848
+ if (isMain) {
849
+ main().then((code) => process.exit(code ?? 0)).catch((e) => {
850
+ err("fatal: " + (e?.stack || e));
851
+ process.exit(1);
852
+ });
853
+ }
@@ -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
+ }