@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 +4 -3
- package/src/challenge.js +1 -1
- package/src/cli/dacar.js +204 -62
- package/src/cli/fileStore.js +166 -0
- package/src/cli/session.js +187 -20
- package/src/cli/store.js +421 -137
- 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.1
|
|
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
|
|
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,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 (
|
|
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 {
|
|
38
|
-
import { RFedClient } from "@reticulum/core/src/rfed/
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 --
|
|
236
|
-
//
|
|
237
|
-
//
|
|
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 --
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
485
|
+
* `dacar publish` — push signed delta(s) to the rfed channel (§11.1, docs #8/#11).
|
|
390
486
|
*
|
|
391
|
-
* Two
|
|
487
|
+
* Two source families (mutually exclusive):
|
|
392
488
|
* - `dacar publish <file> [<file>...]` — publish previously-signed delta
|
|
393
|
-
* payload(s) (exact bytes, no re-sign
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
-
*
|
|
398
|
-
*
|
|
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 (
|
|
409
|
-
throw new CliError(
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
442
|
-
await
|
|
576
|
+
const accepted = await publishDelta(args, store, identity, deduped);
|
|
577
|
+
const nSent = await recordPublish(store, deduped, accepted, { recordToSent });
|
|
443
578
|
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|