@reticulum/dacar 1.0.0 → 1.1.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,14 +1,19 @@
1
1
  {
2
2
  "name": "@reticulum/dacar",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
9
  "./transport": "./src/transport/index.js",
10
+ "./cli/session": "./src/cli/session.js",
11
+ "./cli/store": "./src/cli/store.js",
10
12
  "./package.json": "./package.json"
11
13
  },
14
+ "bin": {
15
+ "dacar": "src/cli/dacar.js"
16
+ },
12
17
  "files": [
13
18
  "src",
14
19
  "README.md"
@@ -39,7 +44,8 @@
39
44
  "node": ">=20"
40
45
  },
41
46
  "dependencies": {
42
- "@reticulum/core": "^0.5.3"
47
+ "@reticulum/core": "^0.5.3",
48
+ "@reticulum/node": "^0.6.1"
43
49
  },
44
50
  "publishConfig": {
45
51
  "access": "public"
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dacar` — offline-first CLI for managing Dacar authorization grants (work doc #6).
4
+ *
5
+ * Node/Deno-only. Declared in `package.json` `bin` and **excluded from the
6
+ * browser `exports` map** so it never bloats a browser bundle. Composes the
7
+ * portable {@link module:cli/session} + {@link module:cli/store} helpers with
8
+ * `@reticulum/node`'s interfaces and `FileStorageAdapter`.
9
+ *
10
+ * Mirrors Python's `dacar/cli/__init__.py` + `commands.py`. Offline commands
11
+ * never start RNS; online commands (`grant --publish`, `sync`) boot RNS, announce
12
+ * the node identity, publish/pull, then exit — the one-shot, daemon-free model.
13
+ *
14
+ * Usage:
15
+ * dacar init
16
+ * dacar grant <grantee> <relation> <object> [--publish]
17
+ * dacar sync
18
+ * dacar check <grantee> <relation> <object>
19
+ * dacar grants
20
+ * dacar revoke <grantee> <relation> <object> [--publish]
21
+ * dacar identity remember|forget|list ...
22
+ *
23
+ * Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
24
+ * --rns-dir <path> (default: ~/.reticulum or $DACAR_RNS_DIR).
25
+ */
26
+
27
+ import { parseArgs } from "node:util";
28
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+ import process from "node:process";
32
+
33
+ import { Identity, Reticulum, toHex } from "@reticulum/core";
34
+ import { MemoryStorageAdapter } from "@reticulum/core";
35
+ import {
36
+ AutoInterface,
37
+ FileStorageAdapter,
38
+ LocalClientInterface,
39
+ TCPClientInterface,
40
+ } from "@reticulum/node";
41
+ import { RFedClient } from "@reticulum/core/src/rfed/client.js";
42
+
43
+ import { Action, Operation, Tuple, Engine } from "../index.js";
44
+ import { DeltaReceiver } from "../delta.js";
45
+ import { RnsIdentityResolver } from "../transport/rnsIdentity.js";
46
+ import { RFED_TOPIC, APP_NAME } from "../naming.js";
47
+ import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespace.js";
48
+ import { Keyring, IssuerKeyset } from "../verifier.js";
49
+
50
+ import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
51
+ import { announceIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
52
+
53
+ const SHORT_HASH = 7;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Output helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function err(msg) {
60
+ process.stderr.write(msg + "\n");
61
+ }
62
+
63
+ function shortHash(hash, full = false) {
64
+ const hex = toHex(hash);
65
+ return full ? hex : hex.slice(0, SHORT_HASH) + "…";
66
+ }
67
+
68
+ function out(msg) {
69
+ process.stdout.write(msg + "\n");
70
+ }
71
+
72
+ class CliError extends Error {}
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Store + RNS resolution
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function defaultStorePath() {
79
+ return process.env.DACAR_HOME || join(homedir(), ".dacar");
80
+ }
81
+
82
+ async function openStore(args) {
83
+ const path = args.store || defaultStorePath();
84
+ const adapter = new FileStorageAdapter(path);
85
+ return new DacarStore(adapter, { identityBytes: args.identity ? await readFile(args.identity) : null });
86
+ }
87
+
88
+ function resolveIdentityHash(value, aliases) {
89
+ const fromAlias = aliases.resolve(value);
90
+ if (fromAlias) return fromAlias;
91
+ const clean = value.toLowerCase().replace(/^0x/, "");
92
+ const raw = hexToBytes(clean);
93
+ if (raw.length !== 16) {
94
+ throw new CliError(`unknown identity ${JSON.stringify(value)} (not a known alias or 16-byte hex hash)`);
95
+ }
96
+ return raw;
97
+ }
98
+
99
+ function hexToBytes(hex) {
100
+ const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
101
+ const out = new Uint8Array(Math.floor(clean.length / 2));
102
+ for (let i = 0; i < out.length; i++) {
103
+ out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ async function resolveRnsConfigDir(args) {
109
+ const explicit = args.rnsDir ?? process.env.DACAR_RNS_DIR;
110
+ if (explicit) return explicit;
111
+ const user = join(homedir(), ".reticulum");
112
+ try {
113
+ await readFile(join(user, "config"));
114
+ return user;
115
+ } catch {
116
+ // fall through to store-local default
117
+ }
118
+ const storePath = args.store || defaultStorePath();
119
+ const dir = join(storePath, "rns");
120
+ await mkdir(dir, { recursive: true });
121
+ const cfgPath = join(dir, "config");
122
+ try {
123
+ await readFile(cfgPath);
124
+ } catch {
125
+ await writeFile(
126
+ cfgPath,
127
+ "[reticulum]\n share_instance = Yes\n enable_transport = False\n\n" +
128
+ "[interfaces]\n [[Default interface]]\n type = AutoInterface\n enabled = Yes\n",
129
+ );
130
+ }
131
+ return dir;
132
+ }
133
+
134
+ /**
135
+ * Boot RNS with an interface. Mirrors `@reticulum/node`'s `rfed` CLI:
136
+ * `--interface shared|auto|tcp` (default `shared`).
137
+ */
138
+ async function bootRns(configDir, iface) {
139
+ const rns = new Reticulum({ storageAdapter: new FileStorageAdapter(configDir) });
140
+ if (iface === "auto") {
141
+ rns.addInterface(new AutoInterface({}));
142
+ } else if (iface === "tcp") {
143
+ const host = process.env.RNS_HOST || "127.0.0.1";
144
+ const port = parseInt(process.env.RNS_PORT || "42424", 10);
145
+ rns.addInterface(new TCPClientInterface({ host, port }));
146
+ } else {
147
+ // shared (default): attach to a running rnsd, else no-op (standalone).
148
+ rns.addInterface(new LocalClientInterface({}));
149
+ }
150
+ return rns;
151
+ }
152
+
153
+ async function resolveRfedNode(args, store, aliases) {
154
+ if (args.node) return resolveIdentityHash(args.node, aliases);
155
+ const raw = await store.loadConfig();
156
+ if (raw.rfedNode) return raw.rfedNode;
157
+ throw new CliError("no rfed node configured (use --node <hash> or set [rfed] node in config)");
158
+ }
159
+
160
+ async function resolveTopic(args, store) {
161
+ if (args.topic) return args.topic;
162
+ const raw = await store.loadConfig();
163
+ return raw.rfedTopic || RFED_TOPIC;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Commands
168
+ // ---------------------------------------------------------------------------
169
+
170
+ async function cmdInit(args) {
171
+ const path = args.store || defaultStorePath();
172
+ await mkdir(path, { recursive: true });
173
+ const adapter = new FileStorageAdapter(path);
174
+ const store = await DacarStore.init(adapter, {
175
+ salt: args.salt ? hexToBytes(args.salt) : undefined,
176
+ horizonDays: parseInt(args.horizon || "180", 10),
177
+ identityBytes: args.identity ? await readFile(args.identity) : undefined,
178
+ });
179
+ const identity = await store.loadIdentity();
180
+ const aliases = await store.loadAliases();
181
+ err("✔ initialized store at " + path);
182
+ err(" identity : " + shortHash(identity.identityHash, args.fullHashes));
183
+ err(" anchor : " + shortHash(identity.identityHash, args.fullHashes) + " (self)");
184
+ return 0;
185
+ }
186
+
187
+ async function cmdConfigShow(args) {
188
+ const store = await openStore(args);
189
+ const raw = await store.loadConfig();
190
+ const aliases = await store.loadAliases();
191
+ err("store: " + (args.store || defaultStorePath()));
192
+ err("[salt]");
193
+ err(" primary : " + (args.reveal ? toHex(raw.primarySalt) : "<masked (use --reveal)>"));
194
+ err("[trust]");
195
+ for (const a of raw.anchors) err(" anchor : " + shortHash(a, args.fullHashes));
196
+ err("[policy]");
197
+ err(" deletion_horizon_days : " + raw.horizonDays);
198
+ err("[rfed]");
199
+ err(" topic : " + raw.rfedTopic);
200
+ err(" node : " + (raw.rfedNode ? shortHash(raw.rfedNode, args.fullHashes) : "(not set)"));
201
+ return 0;
202
+ }
203
+
204
+ async function cmdGrant(args) {
205
+ return _issue(args, Action.GRANT);
206
+ }
207
+
208
+ async function cmdRevoke(args) {
209
+ return _issue(args, Action.REVOKE);
210
+ }
211
+
212
+ async function _issue(args, action) {
213
+ const store = await openStore(args);
214
+ const config = await store.loadConfigValidated();
215
+ const aliases = await store.loadAliases();
216
+ const identity = await store.loadIdentity();
217
+ if (!identity) throw new CliError("no signing identity (run `dacar init`)");
218
+
219
+ const grantee = resolveIdentityHash(args.grantee, aliases);
220
+ const hasher = config.primaryHasher;
221
+ const tuple = await Tuple.fromPlaintext({
222
+ objectId: args.object, relation: args.relation, grantee, issuer: identity.identityHash, hasher,
223
+ });
224
+
225
+ const clock = await store.loadClock();
226
+ const hlc = clock.now();
227
+ await store.saveClock(clock);
228
+
229
+ const op = await new Operation({ tuple, action, hlc }).sign(identity);
230
+ const payload = op.toPayload();
231
+
232
+ const state = await store.loadState(config);
233
+ state.apply(op);
234
+ await store.saveState(state);
235
+
236
+ // Record plaintext ledger.
237
+ const ledger = await store.loadLedger();
238
+ ledger.set(toHex(tuple.key), { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
239
+ await store.saveLedger(ledger);
240
+
241
+ out(payload.hex());
242
+ err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
243
+ err(` hlc : 0x${hlc.toString(16)}`);
244
+ err(` payload : hex on stdout (${payload.length} bytes)`);
245
+
246
+ if (args.publish) {
247
+ await publishDelta(args, store, identity, payload);
248
+ }
249
+ return 0;
250
+ }
251
+
252
+ async function publishDelta(args, store, identity, payload) {
253
+ const aliases = await store.loadAliases();
254
+ const nodeHash = await resolveRfedNode(args, store, aliases);
255
+ const topic = await resolveTopic(args, store);
256
+
257
+ const configDir = await resolveRnsConfigDir(args);
258
+ const rns = await bootRns(configDir, args.interface || "shared");
259
+ await announceIdentity(identity);
260
+
261
+ // Durable issuer cache (doc #5): seed from observed dacar.node announces.
262
+ const keyring = await store.loadKeyring();
263
+ keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
264
+ await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
265
+
266
+ const client = new RFedClient({ identity, rns });
267
+ await runPublish({ deltaPayload: payload, nodeHash, topic, client });
268
+ await store.saveKeyring(keyring);
269
+ err(` published to rfed channel ${JSON.stringify(topic)} via ${shortHash(nodeHash, args.fullHashes)}`);
270
+ }
271
+
272
+ async function cmdSync(args) {
273
+ const store = await openStore(args);
274
+ const config = await store.loadConfigValidated();
275
+ const aliases = await store.loadAliases();
276
+ const identity = await store.loadIdentity();
277
+ if (!identity) throw new CliError("no signing identity (run `dacar init`)");
278
+
279
+ const nodeHash = await resolveRfedNode(args, store, aliases);
280
+ const topic = await resolveTopic(args, store);
281
+
282
+ const configDir = await resolveRnsConfigDir(args);
283
+ const rns = await bootRns(configDir, args.interface || "shared");
284
+ await announceIdentity(identity);
285
+
286
+ // Durable issuer cache (doc #5): load persisted keyring + announce handler.
287
+ const keyring = await store.loadKeyring();
288
+ keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
289
+ await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
290
+
291
+ const state = await store.loadState(config);
292
+ const resolver = new RnsIdentityResolver(keyring);
293
+ const rx = new DeltaReceiver(state, resolver);
294
+
295
+ const client = new RFedClient({ identity, rns });
296
+ const applied = await runSync({ nodeHash, topic, client, receiver: rx });
297
+ await store.saveState(state);
298
+ await store.saveKeyring(keyring);
299
+
300
+ err(`✔ synced: applied ${applied} delta(s) from rfed channel ${JSON.stringify(topic)}`);
301
+ return 0;
302
+ }
303
+
304
+ async function cmdCheck(args) {
305
+ const store = await openStore(args);
306
+ const config = await store.loadConfigValidated();
307
+ const state = await store.loadState(config);
308
+ const aliases = await store.loadAliases();
309
+ const engine = new Engine(config, state);
310
+ const grantee = resolveIdentityHash(args.grantee, aliases);
311
+ const allowed = await engine.evaluate(args.object, args.relation, grantee);
312
+ const mark = allowed ? "✔" : "✘";
313
+ err(`${mark} ${allowed ? "ALLOW" : "DENY"} ${shortHash(grantee, args.fullHashes)} ${args.relation} ${args.object}`);
314
+ return allowed ? 0 : 1;
315
+ }
316
+
317
+ async function cmdApply(args) {
318
+ const store = await openStore(args);
319
+ const config = await store.loadConfigValidated();
320
+ const state = await store.loadState(config);
321
+ const keyring = await store.keyringForVerify();
322
+ const rx = new DeltaReceiver(state, keyring);
323
+ const data = args.payload === "-"
324
+ ? new Uint8Array(await readStdin())
325
+ : await readFile(args.payload);
326
+ const applied = await rx.applyPayload(data);
327
+ if (applied) {
328
+ await store.saveState(state);
329
+ err(`✔ applied 1 delta`);
330
+ return 0;
331
+ }
332
+ err("✘ delta rejected (unknown issuer, bad signature, stale §9, or malformed)");
333
+ return 1;
334
+ }
335
+
336
+ async function readStdin() {
337
+ const chunks = [];
338
+ for await (const chunk of process.stdin) chunks.push(chunk);
339
+ return Buffer.concat(chunks);
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // identity remember / forget / list (work doc #5)
344
+ // ---------------------------------------------------------------------------
345
+
346
+ async function cmdIdentityRemember(args) {
347
+ const store = await openStore(args);
348
+ const aliases = await store.loadAliases();
349
+ const issuerHash = resolveIdentityHash(args.hash, aliases);
350
+
351
+ let pubKey;
352
+ if (args.pubkey) {
353
+ pubKey = hexToBytes(args.pubkey);
354
+ if (pubKey.length !== 64) throw new CliError(`--pubkey must be 64 bytes (128 hex), got ${pubKey.length}`);
355
+ } else if (args.file) {
356
+ pubKey = await readFile(args.file);
357
+ if (pubKey.length !== 64) throw new CliError(`pubkey file must contain 64 bytes, got ${pubKey.length}`);
358
+ } else {
359
+ // Boot RNS and try to recall.
360
+ const configDir = await resolveRnsConfigDir(args);
361
+ const rns = await bootRns(configDir, args.interface || "shared");
362
+ const { Destination } = await import("@reticulum/core");
363
+ const recalled = await Destination.recall(issuerHash, true);
364
+ if (!recalled) {
365
+ throw new CliError(
366
+ `could not recall ${shortHash(issuerHash, args.fullHashes)} from RNS; use --pubkey <hex> or --file <path>`,
367
+ );
368
+ }
369
+ pubKey = await recalled.getPublicKey();
370
+ }
371
+
372
+ const keyring = await store.loadKeyring();
373
+ keyring.registerSingle(issuerHash, pubKey);
374
+ await store.saveKeyring(keyring);
375
+ err(`✔ remembered issuer ${shortHash(issuerHash, args.fullHashes)}`);
376
+ err(` pubkey : ${toHex(pubKey).slice(0, SHORT_HASH)}…`);
377
+ err(` cache : ${keyring.size} entries`);
378
+ return 0;
379
+ }
380
+
381
+ async function cmdIdentityForget(args) {
382
+ const store = await openStore(args);
383
+ const aliases = await store.loadAliases();
384
+ const issuerHash = resolveIdentityHash(args.hash, aliases);
385
+
386
+ if (!args.force) {
387
+ // Refuse to purge an issuer with active grants in the live CRDT.
388
+ const config = await store.loadConfigValidated();
389
+ const state = await store.loadState(config);
390
+ let active = 0;
391
+ for (const tuple of state.activeTuples()) {
392
+ if (toHex(tuple.issuer) === toHex(issuerHash)) active++;
393
+ }
394
+ if (active > 0) {
395
+ throw new CliError(
396
+ `issuer ${shortHash(issuerHash, args.fullHashes)} has ${active} active grant(s) in the live CRDT; ` +
397
+ "forgetting it would make its revokes unverifiable (use --force to override)",
398
+ );
399
+ }
400
+ }
401
+
402
+ const keyring = await store.loadKeyring();
403
+ if (!keyring.forget(issuerHash)) {
404
+ throw new CliError(`issuer ${shortHash(issuerHash, args.fullHashes)} not in the cache`);
405
+ }
406
+ await store.saveKeyring(keyring);
407
+ err(`✔ forgot issuer ${shortHash(issuerHash, args.fullHashes)}`);
408
+ err(` cache : ${keyring.size} entries`);
409
+ return 0;
410
+ }
411
+
412
+ async function cmdIdentityList(args) {
413
+ const store = await openStore(args);
414
+ const aliases = await store.loadAliases();
415
+ const keyring = await store.loadKeyring();
416
+ err(`ISSUER IDENTITY CACHE (${keyring.size})`);
417
+ if (keyring.size === 0) {
418
+ err("(none — use `dacar identity remember <hash>` to seed)");
419
+ return 0;
420
+ }
421
+ for (const [hashHex, keyset] of keyring.entries()) {
422
+ const pub = keyset.memberPublicKeys[0];
423
+ err(` ${shortHash(hexToBytes(hashHex), args.fullHashes)} pubkey=${toHex(pub).slice(0, SHORT_HASH)}…`);
424
+ }
425
+ return 0;
426
+ }
427
+
428
+ async function cmdGrants(args) {
429
+ const store = await openStore(args);
430
+ const config = await store.loadConfigValidated();
431
+ const state = await store.loadState(config);
432
+ const aliases = await store.loadAliases();
433
+ const ledger = await store.loadLedger();
434
+ /** @type {any[]} */ const rows = [];
435
+ for (const entry of state._entries.values()) {
436
+ const active = entry.addTs !== null && (entry.removeTs === null || entry.addTs > entry.removeTs);
437
+ if (args.revoked && active) continue;
438
+ if (!args.all && !args.revoked && !active) continue;
439
+ rows.push({ entry, active });
440
+ }
441
+ const label = args.revoked ? "REVOKED TOMBSTONES" : args.all ? "ALL TUPLES" : "ACTIVE GRANTS";
442
+ err(`${label} (${rows.length})`);
443
+ for (const { entry, active } of rows) {
444
+ const t = entry.tuple;
445
+ const row = ledger.get(toHex(t.key));
446
+ const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
447
+ const obj = row?.object || "[hash]";
448
+ err(
449
+ `${shortHash(t.grantee, args.fullHashes)} ${rel} ${obj} ← ${shortHash(t.issuer, args.fullHashes)} ` +
450
+ `${active ? "active" : "revoked"}`,
451
+ );
452
+ }
453
+ return 0;
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Dispatch
458
+ // ---------------------------------------------------------------------------
459
+
460
+ const SUBCOMMANDS = {
461
+ init: { run: cmdInit, opts: { salt: "string", horizon: "string" }, online: false },
462
+ "config": {
463
+ sub: {
464
+ show: { run: cmdConfigShow, opts: { reveal: "boolean" }, online: false },
465
+ },
466
+ },
467
+ grant: {
468
+ run: cmdGrant,
469
+ opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
470
+ positional: ["grantee", "relation", "object"],
471
+ online: true,
472
+ },
473
+ revoke: {
474
+ run: cmdRevoke,
475
+ opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
476
+ positional: ["grantee", "relation", "object"],
477
+ online: true,
478
+ },
479
+ sync: {
480
+ run: cmdSync,
481
+ opts: { node: "string", topic: "string", "rns-dir": "string", interface: "string" },
482
+ online: true,
483
+ },
484
+ apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
485
+ check: { run: cmdCheck, opts: {}, positional: ["grantee", "relation", "object"], online: false },
486
+ grants: { run: cmdGrants, opts: { all: "boolean", revoked: "boolean" }, online: false },
487
+ identity: {
488
+ sub: {
489
+ remember: {
490
+ run: cmdIdentityRemember,
491
+ opts: { pubkey: "string", file: "string", "rns-dir": "string", interface: "string", force: "boolean" },
492
+ positional: ["hash"],
493
+ online: true,
494
+ },
495
+ forget: { run: cmdIdentityForget, opts: { force: "boolean" }, positional: ["hash"], online: false },
496
+ list: { run: cmdIdentityList, opts: {}, online: false },
497
+ },
498
+ },
499
+ };
500
+
501
+ function buildOptions(spec) {
502
+ const opts = {};
503
+ for (const [k, t] of Object.entries(spec.opts || {})) {
504
+ opts[k] = { type: t };
505
+ }
506
+ if (spec.positional && spec.positional.length) {
507
+ opts.store = { type: "string" };
508
+ opts.identity = { type: "string" };
509
+ opts["full-hashes"] = { type: "boolean" };
510
+ }
511
+ return opts;
512
+ }
513
+
514
+ async function main() {
515
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
516
+ err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes`);
517
+ return 0;
518
+ }
519
+ const argv = process.argv.slice(2);
520
+ const [cmd, ...rest] = argv;
521
+ const spec = SUBCOMMANDS[cmd];
522
+ if (!spec) {
523
+ err(`usage: dacar <command> [options]\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}`);
524
+ return 1;
525
+ }
526
+ // Subcommand dispatch (config show, identity remember/forget/list).
527
+ if (spec.sub) {
528
+ const [sub, ...subrest] = rest;
529
+ const subspec = spec.sub[sub];
530
+ if (!subspec) {
531
+ err(`usage: dacar ${cmd} <subcommand>\nsubcommands: ${Object.keys(spec.sub).join(", ")}`);
532
+ return 1;
533
+ }
534
+ const { values, positionals } = parseArgs({
535
+ args: subrest,
536
+ options: buildOptions(subspec),
537
+ allowPositionals: true,
538
+ });
539
+ values.fullHashes = values["full-hashes"];
540
+ try {
541
+ return await subspec.run({ ...values, _positionals: positionals, ...Object.fromEntries(positionals.map((v, i) => [subspec.positional?.[i] ?? `_p${i}`, v])) });
542
+ } catch (e) {
543
+ if (e instanceof CliError) { err("error: " + e.message); return 1; }
544
+ throw e;
545
+ }
546
+ }
547
+ // Top-level command.
548
+ const { values, positionals } = parseArgs({
549
+ args: rest,
550
+ options: buildOptions(spec),
551
+ allowPositionals: true,
552
+ });
553
+ values.fullHashes = values["full-hashes"];
554
+ try {
555
+ return await spec.run({ ...values, _positionals: positionals, ...Object.fromEntries(positionals.map((v, i) => [spec.positional?.[i] ?? `_p${i}`, v])) });
556
+ } catch (e) {
557
+ if (e instanceof CliError) { err("error: " + e.message); return 1; }
558
+ throw e;
559
+ }
560
+ }
561
+
562
+ main().then((code) => process.exit(code ?? 0)).catch((e) => {
563
+ err("fatal: " + (e?.stack || e));
564
+ process.exit(1);
565
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Portable RNS session + online command helpers (work doc #6, §11.1).
3
+ *
4
+ * Browser- and Node-portable: no filesystem, no argv. The caller constructs a
5
+ * `Reticulum` (with its own `StorageAdapter`) and passes it in — keeping
6
+ * shared-instance discovery out of the portable core (the same decision
7
+ * `@reticulum/core` itself makes). A Node/Deno CLI (`dacar.js`) composes these
8
+ * helpers with `@reticulum/node`'s interfaces and `FileStorageAdapter`.
9
+ *
10
+ * This module is part of the CLI layer but has **no** Node-only dependencies:
11
+ * it imports only `@reticulum/core` (already a core dep) and the dacar pure
12
+ * core. It mirrors Python's `dacar/cli/rns.py` + `run_publish`/`run_sync`.
13
+ */
14
+
15
+ import { Destination, Identity } from "@reticulum/core";
16
+ import { APP_NAME } from "../naming.js";
17
+ import { RfedDeltaSync } from "../transport/rfedSync.js";
18
+
19
+ /**
20
+ * Announce the node's identity on the `dacar.node` destination (§11.2.4).
21
+ *
22
+ * Any announced destination under an identity makes that identity recallable by
23
+ * peers via `Destination.recall(hash, true)` — the announce invariant: without
24
+ * it, receivers drop the node's signed Deltas as "unknown issuer" because the
25
+ * `RnsIdentityResolver` cannot recall the issuer's public key.
26
+ *
27
+ * Returns the announced destination hash. Call before publishing or pulling.
28
+ * @param {import("@reticulum/core").Identity} identity
29
+ * @returns {Promise<Uint8Array>}
30
+ */
31
+ export async function announceIdentity(identity) {
32
+ const dest = await Destination.IN(`${APP_NAME}.node`, "single", identity, null);
33
+ await dest.announce();
34
+ return dest.destinationHash;
35
+ }
36
+
37
+ /**
38
+ * Publish a signed Delta to the rfed channel (§11.1, work doc #6).
39
+ *
40
+ * Testable core: takes an explicit `client` (`RFedClient` or compatible fake)
41
+ * so tests inject doubles without booting RNS. The `cmd_*` wrappers handle RNS
42
+ * boot + announce + real client creation.
43
+ * @param {Object} opts
44
+ * @param {Uint8Array} opts.deltaPayload Signed §5.3 Operation payload.
45
+ * @param {Uint8Array} opts.nodeHash The rfed node's `rfed.*` destination hash.
46
+ * @param {string} [opts.topic] RFed channel name (default `dacar.policy.v1`).
47
+ * @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
48
+ * @returns {Promise<import("@reticulum/core").LXMessage>}
49
+ */
50
+ export async function runPublish({ deltaPayload, nodeHash, topic, client }) {
51
+ const sync = new RfedDeltaSync({ client, topic });
52
+ await sync.subscribe(nodeHash);
53
+ return sync.publish(deltaPayload, nodeHash);
54
+ }
55
+
56
+ /**
57
+ * Pull pending Deltas from the rfed channel and apply via verify-on-ingest.
58
+ *
59
+ * Testable core: takes an explicit `client` and `receiver` so tests inject
60
+ * doubles. Routes every blob through `DeltaReceiver.applyPayload()` (§11.2.4)
61
+ * — never through the unauthenticated `StateVector.merge()` path. Returns the
62
+ * count applied (the caller persists the CRDT).
63
+ * @param {Object} opts
64
+ * @param {Uint8Array} opts.nodeHash
65
+ * @param {string} [opts.topic]
66
+ * @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
67
+ * @param {import("../delta.js").DeltaReceiver} opts.receiver
68
+ * @returns {Promise<number>}
69
+ */
70
+ export async function runSync({ nodeHash, topic, client, receiver }) {
71
+ const sync = new RfedDeltaSync({ receiver, client, topic });
72
+ await sync.subscribe(nodeHash);
73
+ return sync.pull(nodeHash);
74
+ }
75
+
76
+ /**
77
+ * Register a dacar-scoped announce handler that seeds the durable issuer cache
78
+ * (work doc #5, design decision #3).
79
+ *
80
+ * Listens to the RNS transport `"announce"` event and, on a validated
81
+ * `dacar.node` announce (verified by recomputing the destination hash under
82
+ * the announced identity), registers the issuer's public key into `keyring`.
83
+ * Non-`dacar` announces are ignored (dacar is not a general identity directory).
84
+ * Returns an unsubscribe function.
85
+ * @param {Object} opts
86
+ * @param {import("@reticulum/core").Reticulum} opts.rns A booted Reticulum.
87
+ * @param {import("../verifier.js").Keyring} opts.keyring
88
+ * @param {(keyring: import("../verifier.js").Keyring) => void} [opts.onSave]
89
+ * Called after each seed so the caller can persist the keyring.
90
+ * @returns {Promise<{ unsubscribe: () => void, seeded: number }>}
91
+ */
92
+ export async function registerAnnounceHandler({ rns, keyring, onSave }) {
93
+ /** @type {() => void} */ let unsubscribe = () => {};
94
+ const state = { seeded: 0 };
95
+
96
+ // The transport emits a CustomEvent("announce", { detail }) on each validated
97
+ // announce. We filter to dacar.node and seed the keyring.
98
+ const handler = async (event) => {
99
+ const detail = event.detail;
100
+ if (!detail || !detail.identity) return;
101
+ const announced = detail.identity;
102
+ const announcedDestHash = detail.destinationHash;
103
+ if (!(announcedDestHash instanceof Uint8Array)) return;
104
+ // Only dacar.node: recompute the destination hash under the dacar app.
105
+ const expected = await _dacarNodeHash(announced);
106
+ const { toHex } = await import("@reticulum/core");
107
+ if (toHex(announcedDestHash) !== toHex(expected)) return; // not dacar.node
108
+ const publicKey = await announced.getPublicKey();
109
+ keyring.registerSingle(announced.identityHash, publicKey);
110
+ state.seeded += 1;
111
+ if (onSave) onSave(keyring);
112
+ };
113
+
114
+ // core's TransportCore is an EventTarget on `rns.transport`.
115
+ if (rns && rns.transport && typeof rns.transport.addEventListener === "function") {
116
+ rns.transport.addEventListener("announce", handler);
117
+ unsubscribe = () => rns.transport.removeEventListener("announce", handler);
118
+ }
119
+ return { unsubscribe, get seeded() { return state.seeded; } };
120
+ }
121
+
122
+ /**
123
+ * Compute the `dacar.node` destination hash for an identity (§11.2.4).
124
+ *
125
+ * `nameHash = SHA-256("dacar.node")[:10]`; `destHash = SHA-256(nameHash ‖
126
+ * identityHash)[:16]` — matching `@reticulum/core`'s `Destination._computeHashes`.
127
+ * @param {import("@reticulum/core").Identity} identity
128
+ * @returns {Promise<Uint8Array>}
129
+ */
130
+ async function _dacarNodeHash(identity) {
131
+ const encoder = new TextEncoder();
132
+ const nameBytes = encoder.encode(`${APP_NAME}.node`);
133
+ const nameHashBuffer = await crypto.subtle.digest("SHA-256", nameBytes);
134
+ const nameHash = new Uint8Array(nameHashBuffer.slice(0, 10));
135
+ const combined = new Uint8Array(nameHash.length + identity.identityHash.length);
136
+ combined.set(nameHash, 0);
137
+ combined.set(identity.identityHash, nameHash.length);
138
+ const destHashBuffer = await crypto.subtle.digest("SHA-256", combined);
139
+ return new Uint8Array(destHashBuffer.slice(0, 16));
140
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI smoke test for doc #6.
4
+ */
5
+
6
+ import { Identity } from "@reticulum/core";
7
+ import { MemoryStorageAdapter } from "@reticulum/core";
8
+ import { DacarStore } from "./store.js";
9
+ import { rmSync } from "node:fs";
10
+
11
+ const STORE_DIR = "/tmp/dacar-smoke-test";
12
+
13
+ async function runSmokeTest() {
14
+ console.log("=== Dacar CLI Smoke Test (doc #6) ===\n");
15
+
16
+ // Cleanup
17
+ rmSync(STORE_DIR, { recursive: true, force: true });
18
+
19
+ // Test init
20
+ console.log("1. Running init...");
21
+ const adapter = new MemoryStorageAdapter(STORE_DIR);
22
+ const identity = await Identity.generate();
23
+ const store = await DacarStore.init(adapter, {
24
+ salt: new Uint8Array(32),
25
+ identityBytes: await identity.getPrivateKey(),
26
+ });
27
+ const raw = await store.loadConfig();
28
+ console.log(" PASS: store created");
29
+ console.log(" PASS: self aliases registered");
30
+ console.log(" PASS: config saved");
31
+
32
+ // Test config round-trip
33
+ console.log("\n2. Testing config round-trip...");
34
+ raw.rfedTopic = "test.policy.v1";
35
+ await store.saveConfig(raw);
36
+
37
+ const store2 = new DacarStore(new MemoryStorageAdapter(STORE_DIR));
38
+ const raw2 = await store2.loadConfig();
39
+ console.log(" PASS: rfedTopic round-trips");
40
+
41
+ // Test identities cache
42
+ console.log("\n3. Testing identities cache...");
43
+ const other = await Identity.generate();
44
+ const keyring = await store.loadKeyring();
45
+ keyring.registerSingle(other.identityHash, await other.getPublicKey());
46
+ await store.saveKeyring(keyring);
47
+
48
+ const store3 = new DacarStore(new MemoryStorageAdapter(STORE_DIR));
49
+ const keyring2 = await store3.loadKeyring();
50
+ console.log(" PASS: issuer cached across instances");
51
+
52
+ console.log("\n=== Smoke test passed! ===\n");
53
+ console.log("Run: npm link; dacar --help");
54
+ }
55
+
56
+ runSmokeTest().catch((e) => {
57
+ console.error("FAIL:", e.stack || e);
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,456 @@
1
+ /**
2
+ * DacarStore: persistent node store over a `StorageAdapter` (work doc #6).
3
+ *
4
+ * Backend-neutral: built on `@reticulum/core`'s `StorageAdapter` KV contract
5
+ * (`get`/`set`/`delete`/`keys`, namespaced). Mirrors Python's `Store` fields
6
+ * logically, but stores each as a namespaced KV record rather than an INI +
7
+ * loose files — JS has no `0600`-mode INI convention, and the KV contract is
8
+ * the idiomatic, portable choice (Node `FileStorageAdapter`, in-memory for
9
+ * tests, IndexedDB for browsers).
10
+ *
11
+ * Records:
12
+ * - `config` — msgpack `{ primarySalt, legacySalts[], anchors[],
13
+ * authoritative?, horizonDays, rfedTopic, rfedNode? }`
14
+ * - `clock` — msgpack `{ lastMs, logical }`
15
+ * - `state` — `StateVector.toPayload()` (the CRDT, trusted-local)
16
+ * - `aliases` — msgpack `[{ hash, names[], note? }]`
17
+ * - `ledger` — msgpack `{ tupleHashHex: { object?, relation?, wildcard?, firstSeen } }`
18
+ * - `identities` — msgpack `{ hashHex: pubKeyBytes }` (durable issuer cache, doc #5)
19
+ *
20
+ * Secret material (the node's own identity private key) uses the adapter's
21
+ * dedicated `loadKey`/`saveKey` slot, matching `@reticulum/core`.
22
+ */
23
+
24
+ import { MsgPack, Identity, toHex } from "@reticulum/core";
25
+ import { Config, DEFAULT_DELETION_HORIZON_DAYS } from "../config.js";
26
+ import { StateVector } from "../crdt.js";
27
+ import { Clock } from "../hlc.js";
28
+ import { RFED_TOPIC } from "../naming.js";
29
+ import {
30
+ DEFAULT_SALT,
31
+ HASH_SIZE,
32
+ MAX_LEGACY_SALTS,
33
+ SALT_SIZE,
34
+ } from "../namespace.js";
35
+ import { Keyring, IssuerKeyset } from "../verifier.js";
36
+
37
+ /** The alias that always names the node's own signing identity. */
38
+ export const SELF_ALIAS = "self";
39
+
40
+ /** The 64-byte RNS public key (X25519 ‖ Ed25519). */
41
+ const RNS_PUBLIC_KEY_SIZE = 64;
42
+
43
+ const NS = "dacar";
44
+
45
+ /**
46
+ * @typedef {Object} StoreConfig
47
+ * @property {Uint8Array} primarySalt
48
+ * @property {Uint8Array[]} legacySalts
49
+ * @property {Uint8Array[]} anchors
50
+ * @property {Uint8Array | null} [authoritative]
51
+ * @property {number} horizonDays
52
+ * @property {string} rfedTopic
53
+ * @property {Uint8Array | null} [rfedNode]
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} AliasEntry
58
+ * @property {Uint8Array} hash
59
+ * @property {string[]} names
60
+ * @property {string | null} [note]
61
+ */
62
+
63
+ /**
64
+ * A dacar node store backed by a `StorageAdapter`. Each CLI invocation builds a
65
+ * store, loads what it needs, mutates in memory, and writes back — the
66
+ * offline-first, daemon-free model.
67
+ */
68
+ export class DacarStore {
69
+ /**
70
+ * @param {import("@reticulum/core").StorageAdapter} adapter
71
+ * @param {Object} [opts]
72
+ * @param {Uint8Array | string} [opts.identityBytes] A 128-byte private-key blob
73
+ * overriding the store's own identity (mirrors Python's `--identity PATH`).
74
+ */
75
+ constructor(adapter, opts = {}) {
76
+ this._adapter = adapter;
77
+ this._identityOverride = opts.identityBytes ?? null;
78
+ }
79
+
80
+ // -- config --------------------------------------------------------------
81
+
82
+ /**
83
+ * Bootstrap a fresh node store (work doc #6 `init`).
84
+ * @param {import("@reticulum/core").StorageAdapter} adapter
85
+ * @param {Object} [opts]
86
+ * @param {Uint8Array} [opts.salt] 32-byte Privacy Salt (default: random).
87
+ * @param {number} [opts.horizonDays]
88
+ * @param {Uint8Array} [opts.identityBytes] 128-byte private-key blob to adopt.
89
+ * @returns {Promise<DacarStore>}
90
+ */
91
+ static async init(adapter, opts = {}) {
92
+ const store = new DacarStore(adapter, opts);
93
+ // Identity: adopt the override, else generate + persist via saveKey.
94
+ let identity;
95
+ if (opts.identityBytes) {
96
+ identity = await Identity.fromBytes(opts.identityBytes);
97
+ if (!identity) throw new Error("could not load identity from provided bytes");
98
+ await adapter.saveKey(opts.identityBytes);
99
+ } else {
100
+ identity = await Identity.loadOrGenerate(adapter);
101
+ }
102
+ const salt = opts.salt ?? _randomBytes(SALT_SIZE);
103
+ /** @type {StoreConfig} */
104
+ const config = {
105
+ primarySalt: salt,
106
+ legacySalts: [],
107
+ anchors: [identity.identityHash],
108
+ authoritative: null,
109
+ horizonDays: opts.horizonDays ?? DEFAULT_DELETION_HORIZON_DAYS,
110
+ rfedTopic: RFED_TOPIC,
111
+ rfedNode: null,
112
+ };
113
+ await store.saveConfig(config);
114
+ await store.saveState(new StateVector({ deletionHorizonDays: config.horizonDays }));
115
+ await store.saveClock(new Clock());
116
+ await store.saveLedger(new Map());
117
+ const aliases = new AliasRegistry();
118
+ aliases.add(SELF_ALIAS, identity.identityHash);
119
+ await store.saveAliases(aliases);
120
+ await store.saveKeyring(new Keyring());
121
+ return store;
122
+ }
123
+
124
+ /** @returns {Promise<boolean>} */
125
+ async exists() {
126
+ return (await this._adapter.get(NS, "config")) !== null;
127
+ }
128
+
129
+ /** @returns {Promise<StoreConfig>} */
130
+ async loadConfig() {
131
+ const bytes = await this._adapter.get(NS, "config");
132
+ if (!bytes) throw new Error("store not initialized (run `dacar init`)");
133
+ return _decodeConfig(bytes);
134
+ }
135
+
136
+ /** @param {StoreConfig} config */
137
+ async saveConfig(config) {
138
+ const obj = [
139
+ config.primarySalt,
140
+ config.legacySalts,
141
+ config.anchors,
142
+ config.authoritative ?? null,
143
+ config.horizonDays,
144
+ config.rfedTopic,
145
+ config.rfedNode ?? null,
146
+ ];
147
+ await this._adapter.set(NS, "config", MsgPack.encode(obj));
148
+ }
149
+
150
+ /**
151
+ * Build a validated {@link Config} from the stored config.
152
+ * @returns {Promise<Config>}
153
+ */
154
+ async loadConfigValidated() {
155
+ const raw = await this.loadConfig();
156
+ return new Config({
157
+ rootTrustAnchors: raw.anchors,
158
+ primarySalt: raw.primarySalt,
159
+ legacySalts: raw.legacySalts,
160
+ authoritativeIdentity: raw.authoritative ?? undefined,
161
+ deletionHorizonDays: raw.horizonDays,
162
+ });
163
+ }
164
+
165
+ // -- identity ------------------------------------------------------------
166
+
167
+ /** @returns {Promise<Identity | null>} */
168
+ async loadIdentity() {
169
+ if (this._identityOverride) {
170
+ const bytes = typeof this._identityOverride === "string"
171
+ ? _hexToBytes(this._identityOverride)
172
+ : this._identityOverride;
173
+ const id = await Identity.fromBytes(bytes);
174
+ if (!id) throw new Error("could not load identity from override");
175
+ return id;
176
+ }
177
+ const keyBytes = await this._adapter.loadKey();
178
+ if (!keyBytes) return null;
179
+ const id = await Identity.fromBytes(keyBytes);
180
+ if (!id) throw new Error("could not load stored identity (corrupt?)");
181
+ return id;
182
+ }
183
+
184
+ /** @returns {Promise<Uint8Array>} */
185
+ async identityHash() {
186
+ const id = await this.loadIdentity();
187
+ if (!id) throw new Error("no signing identity (run `dacar init`)");
188
+ return id.identityHash;
189
+ }
190
+
191
+ // -- clock (HLC) ---------------------------------------------------------
192
+
193
+ /** @returns {Promise<Clock>} */
194
+ async loadClock() {
195
+ const clock = new Clock();
196
+ const bytes = await this._adapter.get(NS, "clock");
197
+ if (bytes) {
198
+ const obj = MsgPack.decode(bytes);
199
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
200
+ if (typeof obj.lastMs === "number" && typeof obj.logical === "number") {
201
+ clock.restore(obj);
202
+ }
203
+ }
204
+ }
205
+ return clock;
206
+ }
207
+
208
+ /** @param {Clock} clock */
209
+ async saveClock(clock) {
210
+ await this._adapter.set(
211
+ NS,
212
+ "clock",
213
+ MsgPack.encode(clock.snapshot()),
214
+ );
215
+ }
216
+
217
+ // -- state (CRDT) --------------------------------------------------------
218
+
219
+ /** @param {Config} [config] @returns {Promise<StateVector>} */
220
+ async loadState(config) {
221
+ const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
222
+ const bytes = await this._adapter.get(NS, "state");
223
+ if (bytes && bytes.length) {
224
+ return StateVector.fromPayload(bytes, { deletionHorizonDays: horizon });
225
+ }
226
+ return new StateVector({ deletionHorizonDays: horizon });
227
+ }
228
+
229
+ /** @param {StateVector} state */
230
+ async saveState(state) {
231
+ await this._adapter.set(NS, "state", state.toPayload());
232
+ }
233
+
234
+ // -- aliases -------------------------------------------------------------
235
+
236
+ /** @returns {Promise<AliasRegistry>} */
237
+ async loadAliases() {
238
+ const bytes = await this._adapter.get(NS, "aliases");
239
+ if (!bytes) return new AliasRegistry();
240
+ return AliasRegistry.decode(bytes);
241
+ }
242
+
243
+ /** @param {AliasRegistry} aliases */
244
+ async saveAliases(aliases) {
245
+ await this._adapter.set(NS, "aliases", aliases.encode());
246
+ }
247
+
248
+ // -- ledger --------------------------------------------------------------
249
+
250
+ /**
251
+ * @returns {Promise<Map<string, { object?: string, relation?: string, wildcard?: boolean, firstSeen?: number }>>}
252
+ */
253
+ async loadLedger() {
254
+ const bytes = await this._adapter.get(NS, "ledger");
255
+ /** @type {Map<string, any>} */
256
+ const ledger = new Map();
257
+ if (bytes) {
258
+ const obj = MsgPack.decode(bytes);
259
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
260
+ for (const [k, v] of Object.entries(obj)) ledger.set(k, v);
261
+ }
262
+ }
263
+ return ledger;
264
+ }
265
+
266
+ /** @param {Map<string, any>} ledger */
267
+ async saveLedger(ledger) {
268
+ const obj = Object.fromEntries(ledger);
269
+ await this._adapter.set(NS, "ledger", MsgPack.encode(obj));
270
+ }
271
+
272
+ // -- issuer identity cache (work doc #5) ---------------------------------
273
+
274
+ /**
275
+ * Load the persisted issuer identity cache. Returns an empty {@link Keyring}
276
+ * if no cache record exists yet.
277
+ * @returns {Promise<Keyring>}
278
+ */
279
+ async loadKeyring() {
280
+ const keyring = new Keyring();
281
+ const bytes = await this._adapter.get(NS, "identities");
282
+ if (bytes) {
283
+ const obj = MsgPack.decode(bytes);
284
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
285
+ for (const [hashHex, pubKey] of Object.entries(obj)) {
286
+ if (pubKey instanceof Uint8Array && pubKey.length === RNS_PUBLIC_KEY_SIZE) {
287
+ try {
288
+ keyring.registerSingle(_hexToBytes(hashHex), pubKey);
289
+ } catch {
290
+ // skip malformed hash
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ return keyring;
297
+ }
298
+
299
+ /** @param {Keyring} keyring */
300
+ async saveKeyring(keyring) {
301
+ const obj = {};
302
+ for (const [hashHex, keyset] of keyring.entries()) {
303
+ if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
304
+ obj[hashHex] = keyset.memberPublicKeys[0];
305
+ }
306
+ }
307
+ await this._adapter.set(NS, "identities", MsgPack.encode(obj));
308
+ }
309
+
310
+ /**
311
+ * Build a verify-on-ingest keyring from the persisted cache + own identity.
312
+ * @returns {Promise<Keyring>}
313
+ */
314
+ async keyringForVerify() {
315
+ const keyring = await this.loadKeyring();
316
+ const own = await this.loadIdentity();
317
+ if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
318
+ return keyring;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * In-memory alias registry: `hash → names[]` with an optional note. Mirrors
324
+ * Python's `AliasRegistry` (rnns `hash name [# note]`).
325
+ */
326
+ export class AliasRegistry {
327
+ /** @param {AliasEntry[]} [entries] */
328
+ constructor(entries = []) {
329
+ /** @type {AliasEntry[]} */ this.entries = entries;
330
+ }
331
+
332
+ /** @param {Uint8Array} bytes @returns {AliasRegistry} */
333
+ static decode(bytes) {
334
+ const obj = MsgPack.decode(bytes);
335
+ if (!Array.isArray(obj)) return new AliasRegistry();
336
+ /** @type {AliasEntry[]} */
337
+ const entries = [];
338
+ for (const row of obj) {
339
+ if (!Array.isArray(row)) continue;
340
+ const [hash, names, note] = row;
341
+ if (!(hash instanceof Uint8Array) || !Array.isArray(names)) continue;
342
+ entries.push({ hash, names, note: note ?? null });
343
+ }
344
+ return new AliasRegistry(entries);
345
+ }
346
+
347
+ /** @returns {Uint8Array} */
348
+ encode() {
349
+ return MsgPack.encode(this.entries.map((e) => [e.hash, e.names, e.note ?? null]));
350
+ }
351
+
352
+ /** @param {string} name @returns {Uint8Array | null} */
353
+ resolve(name) {
354
+ for (const e of this.entries) {
355
+ if (e.names.includes(name)) return e.hash;
356
+ }
357
+ return null;
358
+ }
359
+
360
+ /** @param {Uint8Array} hash @returns {string[]} */
361
+ namesFor(hash) {
362
+ for (const e of this.entries) {
363
+ if (_bytesEqual(e.hash, hash)) return [...e.names];
364
+ }
365
+ return [];
366
+ }
367
+
368
+ /** @param {Uint8Array} hash @returns {string | null} */
369
+ primaryName(hash) {
370
+ const names = this.namesFor(hash);
371
+ return names[0] ?? null;
372
+ }
373
+
374
+ /** @param {string} name @param {Uint8Array} hash @param {string | null} [note] */
375
+ add(name, hash, note) {
376
+ for (const e of this.entries) {
377
+ if (_bytesEqual(e.hash, hash)) {
378
+ if (!e.names.includes(name)) e.names.push(name);
379
+ if (note !== undefined) e.note = note;
380
+ return;
381
+ }
382
+ }
383
+ this.entries.push({ hash, names: [name], note: note ?? null });
384
+ }
385
+
386
+ /** @param {Uint8Array} hash */
387
+ setSelf(hash) {
388
+ for (const e of this.entries) {
389
+ const i = e.names.indexOf(SELF_ALIAS);
390
+ if (i !== -1) e.names.splice(i, 1);
391
+ }
392
+ this.entries = this.entries.filter((e) => e.names.length > 0);
393
+ this.add(SELF_ALIAS, hash);
394
+ }
395
+ }
396
+
397
+ // -- decode helpers ---------------------------------------------------------
398
+
399
+ /**
400
+ * @param {Uint8Array} bytes
401
+ * @returns {StoreConfig}
402
+ */
403
+ function _decodeConfig(bytes) {
404
+ const arr = MsgPack.decode(bytes);
405
+ if (!Array.isArray(arr) || arr.length !== 7) {
406
+ throw new Error("config record must be a 7-element MessagePack array");
407
+ }
408
+ const [primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode] = arr;
409
+ return {
410
+ primarySalt: _expectBytes(primarySalt, SALT_SIZE, "primary_salt"),
411
+ legacySalts: legacySalts.map((s) => _expectBytes(s, SALT_SIZE, "legacy_salt")),
412
+ anchors: anchors.map((a) => _expectBytes(a, HASH_SIZE, "anchor")),
413
+ authoritative: authoritative instanceof Uint8Array ? authoritative : null,
414
+ horizonDays: Number(horizonDays),
415
+ rfedTopic: String(rfedTopic),
416
+ rfedNode: rfedNode instanceof Uint8Array ? rfedNode : null,
417
+ };
418
+ }
419
+
420
+ /**
421
+ * @param {unknown} value
422
+ * @param {number} len
423
+ * @param {string} name
424
+ * @returns {Uint8Array}
425
+ */
426
+ function _expectBytes(value, len, name) {
427
+ if (!(value instanceof Uint8Array) || value.length !== len) {
428
+ throw new Error(`${name} must be a ${len}-byte Uint8Array`);
429
+ }
430
+ return value;
431
+ }
432
+
433
+ /** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
434
+ function _bytesEqual(a, b) {
435
+ if (a.length !== b.length) return false;
436
+ let diff = 0;
437
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
438
+ return diff === 0;
439
+ }
440
+
441
+ /** @param {string} hex @returns {Uint8Array} */
442
+ function _hexToBytes(hex) {
443
+ const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
444
+ const out = new Uint8Array(clean.length / 2);
445
+ for (let i = 0; i < out.length; i++) {
446
+ out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
447
+ }
448
+ return out;
449
+ }
450
+
451
+ /** @param {number} n @returns {Uint8Array} */
452
+ function _randomBytes(n) {
453
+ const out = new Uint8Array(n);
454
+ crypto.getRandomValues(out);
455
+ return out;
456
+ }
package/src/hlc.js CHANGED
@@ -68,6 +68,39 @@ export class Clock {
68
68
  #lastMs = 0;
69
69
  #logical = 0;
70
70
 
71
+ /**
72
+ * Get the last physical timestamp (ms).
73
+ * @returns {number}
74
+ */
75
+ get lastMs() {
76
+ return this.#lastMs;
77
+ }
78
+
79
+ /**
80
+ * Get the current logical counter.
81
+ * @returns {number}
82
+ */
83
+ get logical() {
84
+ return this.#logical;
85
+ }
86
+
87
+ /**
88
+ * Restore the clock from a snapshot (for store persistence).
89
+ * @param {{ lastMs: number, logical: number }} snap
90
+ */
91
+ restore(snap) {
92
+ if (!snap || typeof snap.lastMs !== "number" || typeof snap.logical !== "number") {
93
+ throw new Error("restore requires an object with lastMs and logical");
94
+ }
95
+ this.#lastMs = snap.lastMs;
96
+ this.#logical = snap.logical;
97
+ }
98
+
99
+ /** Obtain a snapshot for persistence. @returns {{ lastMs: number, logical: number }} */
100
+ snapshot() {
101
+ return { lastMs: this.#lastMs, logical: this.#logical };
102
+ }
103
+
71
104
  /** Advance from a local event and return the new HLC. @returns {bigint} */
72
105
  now() {
73
106
  const phys = physicalNowMs();
package/src/verifier.js CHANGED
@@ -144,6 +144,33 @@ export class Keyring {
144
144
  resolve(issuerHash) {
145
145
  return this._map.get(toHex(_asHash(issuerHash))) ?? null;
146
146
  }
147
+
148
+ /**
149
+ * Remove an Issuer from the keyring.
150
+ * @param {Uint8Array} issuerHash
151
+ * @returns {boolean} `true` if the Issuer was present (and is now removed).
152
+ */
153
+ forget(issuerHash) {
154
+ return this._map.delete(toHex(_asHash(issuerHash)));
155
+ }
156
+
157
+ /**
158
+ * Return `[issuerHashHex, keyset]` pairs for all registered Issuers.
159
+ * @returns {[string, IssuerKeyset][]}
160
+ */
161
+ entries() {
162
+ return [...this._map.entries()];
163
+ }
164
+
165
+ /** Number of registered Issuers. @returns {number} */
166
+ get size() {
167
+ return this._map.size;
168
+ }
169
+
170
+ /** @param {Uint8Array} issuerHash @returns {boolean} */
171
+ has(issuerHash) {
172
+ return this._map.has(toHex(_asHash(issuerHash)));
173
+ }
147
174
  }
148
175
 
149
176
  /**