@reticulum/dacar 1.1.1 → 1.1.2

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.1.2",
4
4
  "description": "JavaScript implementation of Dacar, a Decentralized Access Control system for Reticulum",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli/dacar.js CHANGED
@@ -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>...] | --all (work doc #8)
21
22
  * dacar identity remember|forget|list ...
22
23
  *
23
24
  * Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
@@ -28,6 +29,7 @@ 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";
@@ -219,16 +221,25 @@ async function _issue(args, action) {
219
221
 
220
222
  // Record plaintext ledger.
221
223
  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) });
224
+ ledger.set(tuple.key, { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
223
225
  await store.saveLedger(ledger);
224
226
 
225
- out(payload.hex());
227
+ out(toHex(payload));
226
228
  err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
227
229
  err(` hlc : 0x${hlc.toString(16)}`);
228
230
  err(` payload : hex on stdout (${payload.length} bytes)`);
229
231
 
230
232
  if (args.publish) {
231
233
  await publishDelta(args, store, identity, payload);
234
+ } 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.)
239
+ const outbox = await store.loadOutbox();
240
+ outbox.push(payload);
241
+ await store.saveOutbox(outbox);
242
+ err(" (queued in outbox: `dacar publish --all` to flush)");
232
243
  }
233
244
  return 0;
234
245
  }
@@ -336,6 +347,107 @@ async function cmdApply(args) {
336
347
  return 1;
337
348
  }
338
349
 
350
+ /**
351
+ * Read a payload file (or stdin) and auto-detect hex (mirrors Python's
352
+ * `_read_payload_input`): an all-hex, even-length ASCII blob decodes to bytes.
353
+ * `--binary` forces raw bytes.
354
+ * @param {string} path
355
+ * @param {boolean} forceBinary
356
+ * @returns {Promise<Uint8Array>}
357
+ */
358
+ async function readPayloadInput(path, forceBinary) {
359
+ const data = path === "-" ? new Uint8Array(await readStdin()) : await readFile(path);
360
+ return coercePayload(data, forceBinary);
361
+ }
362
+
363
+ /**
364
+ * Auto-detect a hex payload: if *all* bytes are ASCII hex characters (after
365
+ * trimming surrounding whitespace) and the length is even, decode to bytes;
366
+ * otherwise return the raw bytes unchanged.
367
+ * @param {Uint8Array | Buffer} data
368
+ * @param {boolean} forceBinary
369
+ * @returns {Uint8Array}
370
+ */
371
+ function coercePayload(data, forceBinary) {
372
+ const bytes = new Uint8Array(data);
373
+ if (forceBinary || !bytes.length) return bytes;
374
+ let s;
375
+ try {
376
+ s = Buffer.from(bytes).toString("ascii");
377
+ } catch {
378
+ return bytes; // not ASCII -> raw bytes
379
+ }
380
+ const trimmed = s.trim();
381
+ if (!trimmed || trimmed.length % 2 !== 0) return bytes;
382
+ if (!/^[0-9a-fA-F]+$/.test(trimmed)) return bytes;
383
+ return hexToBytes(trimmed);
384
+ }
385
+
386
+ export { coercePayload };
387
+
388
+ /**
389
+ * `dacar publish` — push signed delta(s) to the rfed channel (§11.1, doc #8).
390
+ *
391
+ * Two modes:
392
+ * - `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.
396
+ *
397
+ * Reuses the `grant --publish` machinery (`publishDelta`: boot RNS, announce,
398
+ * subscribe, publish).
399
+ */
400
+ async function cmdPublish(args) {
401
+ const store = await openStore(args);
402
+ const identity = await store.loadIdentity();
403
+ if (!identity) throw new CliError("no signing identity (run `dacar init`)");
404
+
405
+ const useAll = !!args.all;
406
+ const files = args._positionals ?? [];
407
+
408
+ if (useAll && files.length) {
409
+ throw new CliError("publish: use either <file>... or --all, not both");
410
+ }
411
+ if (!useAll && !files.length) {
412
+ throw new CliError("publish: provide <file>... (one or more) or --all");
413
+ }
414
+
415
+ /** @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);
429
+ }
430
+ }
431
+
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);
436
+
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}`);
440
+
441
+ // Reuse the grant --publish machinery (boot RNS, announce, subscribe, publish).
442
+ await publishDelta(args, store, identity, batch);
443
+
444
+ if (useAll) {
445
+ await store.saveOutbox([]);
446
+ err(" outbox cleared");
447
+ }
448
+ return 0;
449
+ }
450
+
339
451
  async function readStdin() {
340
452
  const chunks = [];
341
453
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -447,7 +559,7 @@ async function cmdGrants(args) {
447
559
  err(`${label} (${rows.length})`);
448
560
  for (const { entry, active } of rows) {
449
561
  const t = entry.tuple;
450
- const row = ledger.get(toHex(t.key));
562
+ const row = ledger.get(t.key);
451
563
  const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
452
564
  const obj = row?.object || "[hash]";
453
565
  err(
@@ -486,6 +598,12 @@ const SUBCOMMANDS = {
486
598
  opts: { node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
487
599
  online: true,
488
600
  },
601
+ publish: {
602
+ run: cmdPublish,
603
+ opts: { all: "boolean", binary: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
604
+ // variable file list (0..N) accessed via args._positionals
605
+ online: true,
606
+ },
489
607
  apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
490
608
  check: { run: cmdCheck, opts: {}, positional: ["grantee", "relation", "object"], online: false },
491
609
  grants: { run: cmdGrants, opts: { all: "boolean", revoked: "boolean" }, online: false },
@@ -512,11 +630,12 @@ function buildOptions(spec) {
512
630
  // errors out, and threaded into bootRns to raise the Reticulum log
513
631
  // threshold + log interface/announce diagnostics.
514
632
  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
- }
633
+ // --store / --identity / --full-hashes are global on every (sub)command
634
+ // (they were previously gated on `positional`, which silently dropped them
635
+ // for commands with no positionals — e.g. `sync`, `config show`, `grants`).
636
+ opts.store = { type: "string" };
637
+ opts.identity = { type: "string" };
638
+ opts["full-hashes"] = { type: "boolean" };
520
639
  return opts;
521
640
  }
522
641
 
@@ -568,7 +687,25 @@ async function main() {
568
687
  }
569
688
  }
570
689
 
571
- main().then((code) => process.exit(code ?? 0)).catch((e) => {
572
- err("fatal: " + (e?.stack || e));
573
- process.exit(1);
574
- });
690
+ // Only auto-run when invoked as the entry script (Node/Bun via `pathToFileURL`,
691
+ // Deno via `import.meta.main`), so the module can be imported in tests without
692
+ // triggering the CLI dispatch (mirrors how `./cli/store` + `./cli/session`
693
+ // are unit-tested).
694
+ const isMain = (() => {
695
+ try {
696
+ if (import.meta.main === true) return true; // Deno
697
+ } catch { /* not Deno */ }
698
+ try {
699
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
700
+ return true; // Node / Bun
701
+ }
702
+ } catch { /* pathToFileURL unavailable */ }
703
+ return false;
704
+ })();
705
+
706
+ if (isMain) {
707
+ main().then((code) => process.exit(code ?? 0)).catch((e) => {
708
+ err("fatal: " + (e?.stack || e));
709
+ process.exit(1);
710
+ });
711
+ }
package/src/cli/store.js CHANGED
@@ -16,6 +16,9 @@
16
16
  * - `aliases` — msgpack `[{ hash, names[], note? }]`
17
17
  * - `ledger` — msgpack `{ tupleHashHex: { object?, relation?, wildcard?, firstSeen } }`
18
18
  * - `identities` — msgpack `{ hashHex: pubKeyBytes }` (durable issuer cache, doc #5)
19
+ * - `outbox` — msgpack `[payloadBytes, ...]` of locally-issued,
20
+ * not-yet-published signed Deltas (doc #8); flushed + cleared
21
+ * by `dacar publish --all`
19
22
  *
20
23
  * Secret material (the node's own identity private key) uses the adapter's
21
24
  * dedicated `loadKey`/`saveKey` slot, matching `@reticulum/core`.
@@ -325,6 +328,39 @@ export class DacarStore {
325
328
  if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
326
329
  return keyring;
327
330
  }
331
+
332
+ // -- outbox (work doc #8) -----------------------------------------------
333
+
334
+ /**
335
+ * Load the outbox of locally-issued, not-yet-published signed Delta
336
+ * payloads, in the order they were issued. Returns an empty array when no
337
+ * outbox record exists yet.
338
+ * @returns {Promise<Uint8Array[]>}
339
+ */
340
+ async loadOutbox() {
341
+ const bytes = await this._adapter.get(NS, "outbox");
342
+ if (!bytes) return [];
343
+ let obj;
344
+ try {
345
+ obj = MsgPack.decode(bytes);
346
+ } catch {
347
+ return []; // corrupted -> treat as empty (do not crash the CLI)
348
+ }
349
+ if (!Array.isArray(obj)) return [];
350
+ return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
351
+ }
352
+
353
+ /**
354
+ * Persist the outbox as a MessagePack array of signed Delta payloads.
355
+ * @param {Uint8Array[]} payloads
356
+ */
357
+ async saveOutbox(payloads) {
358
+ await this._adapter.set(
359
+ NS,
360
+ "outbox",
361
+ MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
362
+ );
363
+ }
328
364
  }
329
365
 
330
366
  /**