@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 +4 -3
- package/src/challenge.js +1 -1
- package/src/cli/dacar.js +307 -28
- package/src/cli/fileStore.js +166 -0
- package/src/cli/session.js +187 -20
- package/src/cli/store.js +451 -131
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticulum/dacar",
|
|
3
|
-
"version": "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
|
|
48
|
-
"@reticulum/node": "^0.6.
|
|
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 {
|
|
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 `
|
|
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 {
|
|
36
|
-
import { RFedClient } from "@reticulum/core/src/rfed/
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
+
}
|