@reticulum/dacar 1.1.2 → 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/src/cli/store.js CHANGED
@@ -2,26 +2,35 @@
2
2
  * DacarStore: persistent node store over a `StorageAdapter` (work doc #6).
3
3
  *
4
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).
5
+ * (`get`/`set`/`delete`/`keys`, namespaced). The on-disk **record bytes are
6
+ * byte-for-byte identical to the canonical Python `Store`** (work doc #9), so a
7
+ * store directory written by one CLI is readable and writable by the other:
10
8
  *
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
- * - `outbox` — msgpack `[payloadBytes, ...]` of locally-issued,
20
- * not-yet-published signed Deltas (doc #8); flushed + cleared
21
- * by `dacar publish --all`
9
+ * - `config` — INI (`configparser` layout: `[salt]`/`[trust]`/
10
+ * `[policy]`/`[rfed]`, `key = value`, `legacy{i}`).
11
+ * - `clock.msgpack` — msgpack `{ last_ms, logical }` (snake_case).
12
+ * - `state.msgpack` — `StateVector.toPayload()` (the CRDT, trusted-local).
13
+ * - `aliases` rnns text `hash name [# note]` (NOT msgpack).
14
+ * - `ledger.msgpack` — msgpack `{ tuple_hash_hex: { object, relation,
15
+ * wildcard, first_seen } }` (snake_case; the tuple
16
+ * hash key is `sha256(preimage).hex()`).
17
+ * - `identities.msgpack`— msgpack `{ hash_hex: 32-byte Ed25519 pub }` (the
18
+ * Ed25519 half of the 64-byte RNS pub key).
19
+ * - `outbox.msgpack` — msgpack `[payload_bytes, ...]` of locally-issued,
20
+ * not-yet-published signed Deltas (doc #8).
21
+ * - `sent.msgpack` — msgpack `[payload_bytes, ...]` durable replay log
22
+ * of published Deltas (doc #11).
22
23
  *
23
- * Secret material (the node's own identity private key) uses the adapter's
24
- * dedicated `loadKey`/`saveKey` slot, matching `@reticulum/core`.
24
+ * Record names are the exact Python filenames (with `.msgpack` where Python
25
+ * uses it); a Node `DacarFileAdapter` (`./fileStore.js`) writes them as loose
26
+ * files in the store root with Python-matching modes. `MemoryStorageAdapter`
27
+ * (tests) just stores the same bytes keyed by name.
28
+ *
29
+ * The node's own signing **identity private key** is the ONE intentional
30
+ * divergence: it stays library-native (Python RNS → 64-byte priv-only
31
+ * `identity`; `@reticulum/core` → 128-byte priv+pub `identity.key` via
32
+ * `adapter.loadKey`/`saveKey`). The two files coexist under different names; a
33
+ * store carries the identity of whichever CLI initialized it.
25
34
  */
26
35
 
27
36
  import { MsgPack, Identity, toHex } from "@reticulum/core";
@@ -35,16 +44,26 @@ import {
35
44
  MAX_LEGACY_SALTS,
36
45
  SALT_SIZE,
37
46
  } from "../namespace.js";
38
- import { Keyring, IssuerKeyset } from "../verifier.js";
47
+ import { Keyring } from "../verifier.js";
39
48
 
40
49
  /** The alias that always names the node's own signing identity. */
41
50
  export const SELF_ALIAS = "self";
42
51
 
43
- /** The 64-byte RNS public key (X25519 ‖ Ed25519). */
44
- const RNS_PUBLIC_KEY_SIZE = 64;
52
+ /** Ed25519 public keys are 32 raw bytes (the verify half of a 64-byte RNS key). */
53
+ const ED25519_PUB_SIZE = 32;
45
54
 
46
55
  const NS = "dacar";
47
56
 
57
+ // Record names mirror the canonical Python `Store` filenames exactly.
58
+ const CONFIG_RECORD = "config";
59
+ const CLOCK_RECORD = "clock.msgpack";
60
+ const STATE_RECORD = "state.msgpack";
61
+ const ALIASES_RECORD = "aliases";
62
+ const LEDGER_RECORD = "ledger.msgpack";
63
+ const IDENTITIES_RECORD = "identities.msgpack";
64
+ const OUTBOX_RECORD = "outbox.msgpack";
65
+ const SENT_RECORD = "sent.msgpack";
66
+
48
67
  /**
49
68
  * @typedef {Object} StoreConfig
50
69
  * @property {Uint8Array} primarySalt
@@ -63,6 +82,14 @@ const NS = "dacar";
63
82
  * @property {string | null} [note]
64
83
  */
65
84
 
85
+ /**
86
+ * @typedef {Object} LedgerRow
87
+ * @property {string | null} [object]
88
+ * @property {string | null} [relation]
89
+ * @property {boolean | null} [wildcard]
90
+ * @property {number} [firstSeen]
91
+ */
92
+
66
93
  /**
67
94
  * A dacar node store backed by a `StorageAdapter`. Each CLI invocation builds a
68
95
  * store, loads what it needs, mutates in memory, and writes back — the
@@ -84,6 +111,11 @@ export class DacarStore {
84
111
 
85
112
  /**
86
113
  * Bootstrap a fresh node store (work doc #6 `init`).
114
+ *
115
+ * Produces the same file set as Python `Store.init`: `config` (INI),
116
+ * `state.msgpack`, `clock.msgpack`, `ledger.msgpack`, `aliases` (with the
117
+ * `self` alias). `identities.msgpack` / `outbox.msgpack` / `sent.msgpack`
118
+ * are NOT pre-written (Python creates them lazily on first save).
87
119
  * @param {import("@reticulum/core").StorageAdapter} adapter
88
120
  * @param {Object} [opts]
89
121
  * @param {Uint8Array} [opts.salt] 32-byte Privacy Salt (default: random).
@@ -93,7 +125,6 @@ export class DacarStore {
93
125
  */
94
126
  static async init(adapter, opts = {}) {
95
127
  const store = new DacarStore(adapter, opts);
96
- // Identity: adopt the override, else generate + persist via saveKey.
97
128
  let identity;
98
129
  if (opts.identityBytes) {
99
130
  identity = await Identity.fromBytes(opts.identityBytes);
@@ -120,34 +151,24 @@ export class DacarStore {
120
151
  const aliases = new AliasRegistry();
121
152
  aliases.add(SELF_ALIAS, identity.identityHash);
122
153
  await store.saveAliases(aliases);
123
- await store.saveKeyring(new Keyring());
124
154
  return store;
125
155
  }
126
156
 
127
157
  /** @returns {Promise<boolean>} */
128
158
  async exists() {
129
- return (await this._adapter.get(NS, "config")) !== null;
159
+ return (await this._adapter.get(NS, CONFIG_RECORD)) !== null;
130
160
  }
131
161
 
132
162
  /** @returns {Promise<StoreConfig>} */
133
163
  async loadConfig() {
134
- const bytes = await this._adapter.get(NS, "config");
164
+ const bytes = await this._adapter.get(NS, CONFIG_RECORD);
135
165
  if (!bytes) throw new Error("store not initialized (run `dacar init`)");
136
- return _decodeConfig(bytes);
166
+ return _decodeConfigIni(bytes);
137
167
  }
138
168
 
139
169
  /** @param {StoreConfig} config */
140
170
  async saveConfig(config) {
141
- const obj = [
142
- config.primarySalt,
143
- config.legacySalts,
144
- config.anchors,
145
- config.authoritative ?? null,
146
- config.horizonDays,
147
- config.rfedTopic,
148
- config.rfedNode ?? null,
149
- ];
150
- await this._adapter.set(NS, "config", MsgPack.encode(obj));
171
+ await this._adapter.set(NS, CONFIG_RECORD, _encodeConfigIni(config));
151
172
  }
152
173
 
153
174
  /**
@@ -196,12 +217,15 @@ export class DacarStore {
196
217
  /** @returns {Promise<Clock>} */
197
218
  async loadClock() {
198
219
  const clock = new Clock();
199
- const bytes = await this._adapter.get(NS, "clock");
220
+ const bytes = await this._adapter.get(NS, CLOCK_RECORD);
200
221
  if (bytes) {
201
222
  const obj = MsgPack.decode(bytes);
202
223
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
203
- if (typeof obj.lastMs === "number" && typeof obj.logical === "number") {
204
- clock.restore(obj);
224
+ // snake_case on disk (Python parity); Clock API is camelCase.
225
+ const lastMs = obj.last_ms ?? obj.lastMs;
226
+ const logical = obj.logical;
227
+ if (typeof lastMs === "number" && typeof logical === "number") {
228
+ clock.restore({ lastMs, logical });
205
229
  }
206
230
  }
207
231
  }
@@ -212,8 +236,8 @@ export class DacarStore {
212
236
  async saveClock(clock) {
213
237
  await this._adapter.set(
214
238
  NS,
215
- "clock",
216
- MsgPack.encode(clock.snapshot()),
239
+ CLOCK_RECORD,
240
+ MsgPack.encode({ last_ms: clock.lastMs, logical: clock.logical }),
217
241
  );
218
242
  }
219
243
 
@@ -222,13 +246,12 @@ export class DacarStore {
222
246
  /** @param {Config} [config] @returns {Promise<StateVector>} */
223
247
  async loadState(config) {
224
248
  const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
225
- const bytes = await this._adapter.get(NS, "state");
249
+ const bytes = await this._adapter.get(NS, STATE_RECORD);
226
250
  if (bytes && bytes.length) {
227
251
  // `trusted: true` — these are this node's own persisted CRDT snapshot
228
252
  // (written by `saveState()` → `toPayload()`), never network bytes.
229
253
  // Network Operations arrive as signed Deltas through `DeltaReceiver`
230
- // (the verify-on-ingest path), not here. Asserting `trusted` silences
231
- // the audible `fromPayload` footgun warning during normal CLI use.
254
+ // (the verify-on-ingest path), not here.
232
255
  return StateVector.fromPayload(bytes, {
233
256
  deletionHorizonDays: horizon,
234
257
  trusted: true,
@@ -239,45 +262,51 @@ export class DacarStore {
239
262
 
240
263
  /** @param {StateVector} state */
241
264
  async saveState(state) {
242
- await this._adapter.set(NS, "state", state.toPayload());
265
+ await this._adapter.set(NS, STATE_RECORD, state.toPayload());
243
266
  }
244
267
 
245
268
  // -- aliases -------------------------------------------------------------
246
269
 
247
270
  /** @returns {Promise<AliasRegistry>} */
248
271
  async loadAliases() {
249
- const bytes = await this._adapter.get(NS, "aliases");
272
+ const bytes = await this._adapter.get(NS, ALIASES_RECORD);
250
273
  if (!bytes) return new AliasRegistry();
251
274
  return AliasRegistry.decode(bytes);
252
275
  }
253
276
 
254
277
  /** @param {AliasRegistry} aliases */
255
278
  async saveAliases(aliases) {
256
- await this._adapter.set(NS, "aliases", aliases.encode());
279
+ await this._adapter.set(NS, ALIASES_RECORD, aliases.encode());
257
280
  }
258
281
 
259
282
  // -- ledger --------------------------------------------------------------
260
283
 
261
284
  /**
262
- * @returns {Promise<Map<string, { object?: string, relation?: string, wildcard?: boolean, firstSeen?: number }>>}
285
+ * Plaintext ledger: `Map<tuple_hash_hex, row>`. The on-disk key is
286
+ * `sha256(preimage).hex()` (Python `Tuple.key`); callers MUST set/lookup with
287
+ * that key (e.g. `toHex(await tuple.hash())`) for cross-CLI parity.
288
+ * @returns {Promise<Map<string, LedgerRow>>}
263
289
  */
264
290
  async loadLedger() {
265
- const bytes = await this._adapter.get(NS, "ledger");
266
- /** @type {Map<string, any>} */
291
+ const bytes = await this._adapter.get(NS, LEDGER_RECORD);
292
+ /** @type {Map<string, LedgerRow>} */
267
293
  const ledger = new Map();
268
294
  if (bytes) {
269
295
  const obj = MsgPack.decode(bytes);
270
296
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
271
- for (const [k, v] of Object.entries(obj)) ledger.set(k, v);
297
+ for (const [k, v] of Object.entries(obj)) {
298
+ ledger.set(k, _decodeLedgerRow(v));
299
+ }
272
300
  }
273
301
  }
274
302
  return ledger;
275
303
  }
276
304
 
277
- /** @param {Map<string, any>} ledger */
305
+ /** @param {Map<string, LedgerRow>} ledger */
278
306
  async saveLedger(ledger) {
279
- const obj = Object.fromEntries(ledger);
280
- await this._adapter.set(NS, "ledger", MsgPack.encode(obj));
307
+ const obj = {};
308
+ for (const [k, row] of ledger) obj[k] = _encodeLedgerRow(row);
309
+ await this._adapter.set(NS, LEDGER_RECORD, MsgPack.encode(obj));
281
310
  }
282
311
 
283
312
  // -- issuer identity cache (work doc #5) ---------------------------------
@@ -285,21 +314,26 @@ export class DacarStore {
285
314
  /**
286
315
  * Load the persisted issuer identity cache. Returns an empty {@link Keyring}
287
316
  * if no cache record exists yet.
317
+ *
318
+ * On disk each value is the 32-byte Ed25519 public key (Python canonical);
319
+ * it is padded back to a 64-byte RNS public key (zeros ‖ Ed25519) for the
320
+ * in-memory `IssuerKeyset`, whose verify path only uses the Ed25519 half.
288
321
  * @returns {Promise<Keyring>}
289
322
  */
290
323
  async loadKeyring() {
291
324
  const keyring = new Keyring();
292
- const bytes = await this._adapter.get(NS, "identities");
325
+ const bytes = await this._adapter.get(NS, IDENTITIES_RECORD);
293
326
  if (bytes) {
294
327
  const obj = MsgPack.decode(bytes);
295
328
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
296
329
  for (const [hashHex, pubKey] of Object.entries(obj)) {
297
- if (pubKey instanceof Uint8Array && pubKey.length === RNS_PUBLIC_KEY_SIZE) {
298
- try {
299
- keyring.registerSingle(_hexToBytes(hashHex), pubKey);
300
- } catch {
301
- // skip malformed hash
302
- }
330
+ if (!(pubKey instanceof Uint8Array) || pubKey.length !== ED25519_PUB_SIZE) {
331
+ continue; // skip malformed (wrong-length) entries
332
+ }
333
+ try {
334
+ keyring.registerSingle(_hexToBytes(hashHex), _padToRnsPub(pubKey));
335
+ } catch {
336
+ // skip malformed hash
303
337
  }
304
338
  }
305
339
  }
@@ -312,10 +346,11 @@ export class DacarStore {
312
346
  const obj = {};
313
347
  for (const [hashHex, keyset] of keyring.entries()) {
314
348
  if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
315
- obj[hashHex] = keyset.memberPublicKeys[0];
349
+ // Store the 32-byte Ed25519 half (last 32 bytes of the 64-byte RNS key).
350
+ obj[hashHex] = keyset.memberPublicKeys[0].slice(32, 64);
316
351
  }
317
352
  }
318
- await this._adapter.set(NS, "identities", MsgPack.encode(obj));
353
+ await this._adapter.set(NS, IDENTITIES_RECORD, MsgPack.encode(obj));
319
354
  }
320
355
 
321
356
  /**
@@ -338,7 +373,7 @@ export class DacarStore {
338
373
  * @returns {Promise<Uint8Array[]>}
339
374
  */
340
375
  async loadOutbox() {
341
- const bytes = await this._adapter.get(NS, "outbox");
376
+ const bytes = await this._adapter.get(NS, OUTBOX_RECORD);
342
377
  if (!bytes) return [];
343
378
  let obj;
344
379
  try {
@@ -357,7 +392,41 @@ export class DacarStore {
357
392
  async saveOutbox(payloads) {
358
393
  await this._adapter.set(
359
394
  NS,
360
- "outbox",
395
+ OUTBOX_RECORD,
396
+ MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
397
+ );
398
+ }
399
+
400
+ // -- sent box (work doc #11) -------------------------------------------
401
+
402
+ /**
403
+ * Load the sent box: the durable replay log of published Deltas (own
404
+ * issuance), as exact signed bytes, in publication order. Re-publishing them
405
+ * (`publish --sent`) is a no-op on peers (CRDT merge is idempotent). Returns
406
+ * an empty array when no sent record exists yet.
407
+ * @returns {Promise<Uint8Array[]>}
408
+ */
409
+ async loadSent() {
410
+ const bytes = await this._adapter.get(NS, SENT_RECORD);
411
+ if (!bytes) return [];
412
+ let obj;
413
+ try {
414
+ obj = MsgPack.decode(bytes);
415
+ } catch {
416
+ return []; // corrupted -> treat as empty (do not crash the CLI)
417
+ }
418
+ if (!Array.isArray(obj)) return [];
419
+ return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
420
+ }
421
+
422
+ /**
423
+ * Persist the sent box as a MessagePack array of signed Delta payloads.
424
+ * @param {Uint8Array[]} payloads
425
+ */
426
+ async saveSent(payloads) {
427
+ await this._adapter.set(
428
+ NS,
429
+ SENT_RECORD,
361
430
  MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
362
431
  );
363
432
  }
@@ -365,7 +434,8 @@ export class DacarStore {
365
434
 
366
435
  /**
367
436
  * In-memory alias registry: `hash → names[]` with an optional note. Mirrors
368
- * Python's `AliasRegistry` (rnns `hash name [# note]`).
437
+ * Python's `AliasRegistry` (rnns `hash name [# note]`); `encode()`/`decode()`
438
+ * produce the exact rnns text bytes Python does.
369
439
  */
370
440
  export class AliasRegistry {
371
441
  /** @param {AliasEntry[]} [entries] */
@@ -373,118 +443,329 @@ export class AliasRegistry {
373
443
  /** @type {AliasEntry[]} */ this.entries = entries;
374
444
  }
375
445
 
376
- /** @param {Uint8Array} bytes @returns {AliasRegistry} */
446
+ /**
447
+ * Parse rnns `hash name [# note]` lines (mirrors Python `AliasRegistry.parse`).
448
+ * Blank lines and lines whose first token is not a 32-hex hash are skipped.
449
+ * @param {Uint8Array} bytes
450
+ * @returns {AliasRegistry}
451
+ */
377
452
  static decode(bytes) {
378
- const obj = MsgPack.decode(bytes);
379
- if (!Array.isArray(obj)) return new AliasRegistry();
380
- /** @type {AliasEntry[]} */
381
- const entries = [];
382
- for (const row of obj) {
383
- if (!Array.isArray(row)) continue;
384
- const [hash, names, note] = row;
385
- if (!(hash instanceof Uint8Array) || !Array.isArray(names)) continue;
386
- entries.push({ hash, names, note: note ?? null });
453
+ const registry = new AliasRegistry();
454
+ const text = new TextDecoder().decode(bytes);
455
+ for (const raw of text.split(/\r?\n/)) {
456
+ const line = raw.trim();
457
+ if (!line) continue;
458
+ let head = line;
459
+ let note = null;
460
+ const hashIdx = line.indexOf("#");
461
+ if (hashIdx !== -1) {
462
+ head = line.slice(0, hashIdx);
463
+ note = line.slice(hashIdx + 1).trim() || null;
464
+ }
465
+ const tokens = head.trim().split(/\s+/).filter(Boolean);
466
+ if (tokens.length === 0) continue;
467
+ const hashHex = tokens[0];
468
+ const names = tokens.slice(1);
469
+ if (hashHex.length !== HASH_SIZE * 2) continue;
470
+ let hashBytes;
471
+ try {
472
+ hashBytes = _hexToBytes(hashHex);
473
+ } catch {
474
+ continue;
475
+ }
476
+ if (hashBytes.length !== HASH_SIZE) continue;
477
+ if (names.length === 0) continue;
478
+ const existing = registry._entryFor(hashBytes);
479
+ if (existing) {
480
+ for (const n of names) {
481
+ if (!existing.names.includes(n)) existing.names.push(n);
482
+ }
483
+ if (note !== null) existing.note = note;
484
+ } else {
485
+ registry.entries.push({ hash: hashBytes, names: [...names], note });
486
+ }
387
487
  }
388
- return new AliasRegistry(entries);
488
+ return registry;
389
489
  }
390
490
 
391
- /** @returns {Uint8Array} */
491
+ /** @returns {Uint8Array} rnns text bytes (`hash name [# note]` per line). */
392
492
  encode() {
393
- return MsgPack.encode(this.entries.map((e) => [e.hash, e.names, e.note ?? null]));
394
- }
395
-
396
- /** @param {string} name @returns {Uint8Array | null} */
397
- resolve(name) {
493
+ if (this.entries.length === 0) return new Uint8Array(0);
494
+ const lines = [];
398
495
  for (const e of this.entries) {
399
- if (e.names.includes(name)) return e.hash;
496
+ const hashHex = toHex(e.hash);
497
+ let field = e.names.length ? `${hashHex} ${e.names.join(" ")}` : hashHex;
498
+ if (e.note) field += ` # ${e.note}`;
499
+ lines.push(field);
400
500
  }
401
- return null;
501
+ return new TextEncoder().encode(lines.join("\n") + "\n");
402
502
  }
403
503
 
404
- /** @param {Uint8Array} hash @returns {string[]} */
405
- namesFor(hash) {
406
- for (const e of this.entries) {
407
- if (_bytesEqual(e.hash, hash)) return [...e.names];
408
- }
409
- return [];
504
+ /** @param {Uint8Array} hash @returns {AliasEntry | undefined} */
505
+ _entryFor(hash) {
506
+ return this.entries.find((e) => _bytesEqual(e.hash, hash));
410
507
  }
411
508
 
412
- /** @param {Uint8Array} hash @returns {string | null} */
413
- primaryName(hash) {
414
- const names = this.namesFor(hash);
415
- return names[0] ?? null;
416
- }
417
-
418
- /** @param {string} name @param {Uint8Array} hash @param {string | null} [note] */
509
+ /**
510
+ * @param {string} name
511
+ * @param {Uint8Array} hash
512
+ * @param {string} [note]
513
+ */
419
514
  add(name, hash, note) {
420
- for (const e of this.entries) {
421
- if (_bytesEqual(e.hash, hash)) {
422
- if (!e.names.includes(name)) e.names.push(name);
423
- if (note !== undefined) e.note = note;
424
- return;
425
- }
515
+ const existing = this._entryFor(hash);
516
+ if (existing) {
517
+ if (!existing.names.includes(name)) existing.names.push(name);
518
+ if (note != null) existing.note = note;
519
+ } else {
520
+ this.entries.push({ hash, names: [name], note: note ?? null });
426
521
  }
427
- this.entries.push({ hash, names: [name], note: note ?? null });
428
522
  }
429
523
 
430
- /** @param {Uint8Array} hash */
524
+ /**
525
+ * Point the `self` alias at `hash` (replacing any prior), mirroring Python
526
+ * `set_self`: strip `SELF_ALIAS` from every entry, prune now-empty entries,
527
+ * then add `SELF_ALIAS` to the new hash.
528
+ * @param {Uint8Array} hash
529
+ */
431
530
  setSelf(hash) {
432
531
  for (const e of this.entries) {
433
- const i = e.names.indexOf(SELF_ALIAS);
434
- if (i !== -1) e.names.splice(i, 1);
532
+ e.names = e.names.filter((n) => n !== SELF_ALIAS);
435
533
  }
436
534
  this.entries = this.entries.filter((e) => e.names.length > 0);
437
535
  this.add(SELF_ALIAS, hash);
438
536
  }
537
+
538
+ /**
539
+ * Remove `name` from its entry (mirrors Python `remove`). Returns `true` if
540
+ * the name existed; the entry is dropped when it has no names left.
541
+ * @param {string} name
542
+ * @returns {boolean}
543
+ */
544
+ remove(name) {
545
+ for (const e of this.entries) {
546
+ const idx = e.names.indexOf(name);
547
+ if (idx !== -1) {
548
+ e.names.splice(idx, 1);
549
+ if (e.names.length === 0) {
550
+ this.entries = this.entries.filter((en) => en !== e);
551
+ }
552
+ return true;
553
+ }
554
+ }
555
+ return false;
556
+ }
557
+
558
+ /** @param {string} name @returns {Uint8Array | undefined} */
559
+ resolve(name) {
560
+ const e = this.entries.find((en) => en.names.includes(name));
561
+ return e?.hash;
562
+ }
563
+
564
+ /** @param {Uint8Array} hash @returns {string[]} */
565
+ namesFor(hash) {
566
+ return this._entryFor(hash)?.names ?? [];
567
+ }
568
+
569
+ /** @param {Uint8Array} hash @returns {string | null} */
570
+ primaryName(hash) {
571
+ const names = this.namesFor(hash);
572
+ return names.length > 0 ? names[0] : null;
573
+ }
439
574
  }
440
575
 
441
- // -- decode helpers ---------------------------------------------------------
576
+ // =========================================================================
577
+ // Helpers — record encode/decode (Python-canonical bytes) + small utilities
578
+ // =========================================================================
442
579
 
443
580
  /**
581
+ * Encode a {@link StoreConfig} as the INI text `configparser` would write
582
+ * (sections `[salt]`/`[trust]`/`[policy]`/`[rfed]`, `key = value`, a blank
583
+ * line after each section, trailing blank line). Byte-identical to Python's
584
+ * `Store.save_config` output.
585
+ * @param {StoreConfig} config
586
+ * @returns {Uint8Array}
587
+ */
588
+ function _encodeConfigIni(config) {
589
+ let out = "";
590
+ out += "[salt]\n";
591
+ out += `primary = ${toHex(config.primarySalt)}\n`;
592
+ for (let i = 0; i < config.legacySalts.length && i < MAX_LEGACY_SALTS; i++) {
593
+ out += `legacy${i} = ${toHex(config.legacySalts[i])}\n`;
594
+ }
595
+ out += "\n";
596
+ out += "[trust]\n";
597
+ out += `anchors = ${config.anchors.map(toHex).join(", ")}\n`;
598
+ if (config.authoritative) out += `authoritative = ${toHex(config.authoritative)}\n`;
599
+ out += "\n";
600
+ out += "[policy]\n";
601
+ out += `deletion_horizon_days = ${config.horizonDays}\n`;
602
+ out += "\n";
603
+ out += "[rfed]\n";
604
+ out += `topic = ${config.rfedTopic}\n`;
605
+ if (config.rfedNode) out += `node = ${toHex(config.rfedNode)}\n`;
606
+ out += "\n";
607
+ return new TextEncoder().encode(out);
608
+ }
609
+
610
+ /**
611
+ * Decode an INI `config` blob (as written by Python `configparser`) into a
612
+ * {@link StoreConfig}. Mirrors Python `Store.load_config_raw`: option keys are
613
+ * case-insensitive (configparser lowercases them); missing fields fall back to
614
+ * defaults; optional sections (`authoritative`, `node`, `legacy{i}`) are
615
+ * omitted when absent.
444
616
  * @param {Uint8Array} bytes
445
617
  * @returns {StoreConfig}
446
618
  */
447
- function _decodeConfig(bytes) {
448
- const arr = MsgPack.decode(bytes);
449
- if (!Array.isArray(arr) || arr.length !== 7) {
450
- throw new Error("config record must be a 7-element MessagePack array");
619
+ function _decodeConfigIni(bytes) {
620
+ const sections = _parseIni(bytes);
621
+ const salt = sections.get("salt") ?? new Map();
622
+ const primaryHex = salt.get("primary") ?? toHex(DEFAULT_SALT);
623
+ const primarySalt = _expectHex("primary", primaryHex, SALT_SIZE);
624
+ const legacySalts = [];
625
+ for (let i = 0; i < MAX_LEGACY_SALTS; i++) {
626
+ const v = salt.get(`legacy${i}`);
627
+ if (v) legacySalts.push(_expectHex(`legacy${i}`, v, SALT_SIZE));
628
+ }
629
+ const trust = sections.get("trust") ?? new Map();
630
+ const anchorsRaw = trust.get("anchors") ?? "";
631
+ const anchors = anchorsRaw
632
+ .split(",")
633
+ .map((s) => s.trim())
634
+ .filter(Boolean)
635
+ .map((h, i) => _expectHex(`anchors[${i}]`, h, HASH_SIZE));
636
+ const authoritative = trust.has("authoritative")
637
+ ? _expectHex("authoritative", trust.get("authoritative"), HASH_SIZE)
638
+ : null;
639
+ const policy = sections.get("policy") ?? new Map();
640
+ const horizonDays = parseInt(policy.get("deletion_horizon_days") ?? String(DEFAULT_DELETION_HORIZON_DAYS), 10);
641
+ const rfed = sections.get("rfed") ?? new Map();
642
+ const rfedTopic = rfed.get("topic") ?? RFED_TOPIC;
643
+ const rfedNode = rfed.has("node") ? _expectHex("node", rfed.get("node"), HASH_SIZE) : null;
644
+ return { primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode };
645
+ }
646
+
647
+ /**
648
+ * Minimal INI parser compatible with Python `configparser` output: sections in
649
+ * `[brackets]`, `key = value` (or `key: value`) lines, `#`/`;` comments and
650
+ * blank lines ignored. Option keys are lowercased (configparser `optionxform`).
651
+ * @param {Uint8Array} bytes
652
+ * @returns {Map<string, Map<string, string>>}
653
+ */
654
+ function _parseIni(bytes) {
655
+ const text = new TextDecoder().decode(bytes);
656
+ /** @type {Map<string, Map<string, string>>} */
657
+ const sections = new Map();
658
+ let cur = null;
659
+ for (const rawLine of text.split(/\r?\n/)) {
660
+ const line = rawLine.trim();
661
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
662
+ const sec = line.match(/^\[(.+)\]$/);
663
+ if (sec) {
664
+ cur = sec[1];
665
+ sections.set(cur, new Map());
666
+ continue;
667
+ }
668
+ if (!cur) continue;
669
+ const idx = line.search(/[=:]/);
670
+ if (idx < 0) continue;
671
+ const key = line.slice(0, idx).trim().toLowerCase();
672
+ const val = line.slice(idx + 1).trim();
673
+ sections.get(cur).set(key, val);
674
+ }
675
+ return sections;
676
+ }
677
+
678
+ /**
679
+ * @param {string} name
680
+ * @param {string} hex
681
+ * @param {number} len
682
+ * @returns {Uint8Array}
683
+ */
684
+ function _expectHex(name, hex, len) {
685
+ const bytes = _hexToBytes(hex);
686
+ if (bytes.length !== len) {
687
+ throw new Error(`${name} must be ${len} bytes (${len * 2} hex), got ${bytes.length}`);
688
+ }
689
+ return bytes;
690
+ }
691
+
692
+ /**
693
+ * @param {unknown} v
694
+ * @returns {LedgerRow}
695
+ */
696
+ function _decodeLedgerRow(v) {
697
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
698
+ return { object: null, relation: null, wildcard: null, firstSeen: 0 };
451
699
  }
452
- const [primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode] = arr;
700
+ const o = /** @type {Record<string, unknown>} */ (v);
453
701
  return {
454
- primarySalt: _expectBytes(primarySalt, SALT_SIZE, "primary_salt"),
455
- legacySalts: legacySalts.map((s) => _expectBytes(s, SALT_SIZE, "legacy_salt")),
456
- anchors: anchors.map((a) => _expectBytes(a, HASH_SIZE, "anchor")),
457
- authoritative: authoritative instanceof Uint8Array ? authoritative : null,
458
- horizonDays: Number(horizonDays),
459
- rfedTopic: String(rfedTopic),
460
- rfedNode: rfedNode instanceof Uint8Array ? rfedNode : null,
702
+ object: _optStr(o.object),
703
+ relation: _optStr(o.relation),
704
+ wildcard: _optBool(o.wildcard),
705
+ firstSeen: typeof o.first_seen === "number" ? o.first_seen : 0,
461
706
  };
462
707
  }
463
708
 
464
709
  /**
465
- * @param {unknown} value
466
- * @param {number} len
467
- * @param {string} name
710
+ * @param {LedgerRow} row
711
+ * @returns {Record<string, unknown>}
712
+ */
713
+ function _encodeLedgerRow(row) {
714
+ return {
715
+ object: row.object ?? null,
716
+ relation: row.relation ?? null,
717
+ wildcard: row.wildcard ?? null,
718
+ first_seen: row.firstSeen ?? 0,
719
+ };
720
+ }
721
+
722
+ /** @param {unknown} v @returns {string | null} */
723
+ function _optStr(v) {
724
+ return typeof v === "string" ? v : null;
725
+ }
726
+
727
+ /** @param {unknown} v @returns {boolean | null} */
728
+ function _optBool(v) {
729
+ return typeof v === "boolean" ? v : null;
730
+ }
731
+
732
+ /**
733
+ * Pad a 32-byte Ed25519 public key to a 64-byte RNS public key
734
+ * (`X25519(pub=zeros) ‖ Ed25519(pub)`). The verifier's `IssuerKeyset` holds
735
+ * 64-byte RNS keys; the X25519 half is unused for signature verification.
736
+ * @param {Uint8Array} ed25519Pub
468
737
  * @returns {Uint8Array}
469
738
  */
470
- function _expectBytes(value, len, name) {
471
- if (!(value instanceof Uint8Array) || value.length !== len) {
472
- throw new Error(`${name} must be a ${len}-byte Uint8Array`);
473
- }
474
- return value;
739
+ function _padToRnsPub(ed25519Pub) {
740
+ const padded = new Uint8Array(64);
741
+ padded.set(ed25519Pub, 32);
742
+ return padded;
475
743
  }
476
744
 
477
- /** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
745
+ /**
746
+ * @param {Uint8Array} a
747
+ * @param {Uint8Array} b
748
+ * @returns {boolean}
749
+ */
478
750
  function _bytesEqual(a, b) {
479
751
  if (a.length !== b.length) return false;
480
- let diff = 0;
481
- for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
482
- return diff === 0;
752
+ for (let i = 0; i < a.length; i++) {
753
+ if (a[i] !== b[i]) return false;
754
+ }
755
+ return true;
483
756
  }
484
757
 
485
- /** @param {string} hex @returns {Uint8Array} */
758
+ /**
759
+ * Parse a hex string (with or without `0x`) into bytes. Lower/upper-case
760
+ * tolerant; matches Python's `bytes.fromhex`.
761
+ * @param {string} hex
762
+ * @returns {Uint8Array}
763
+ */
486
764
  function _hexToBytes(hex) {
487
765
  const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
766
+ if (clean.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(clean)) {
767
+ throw new Error(`invalid hex string (length ${clean.length})`);
768
+ }
488
769
  const out = new Uint8Array(clean.length / 2);
489
770
  for (let i = 0; i < out.length; i++) {
490
771
  out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
@@ -492,9 +773,12 @@ function _hexToBytes(hex) {
492
773
  return out;
493
774
  }
494
775
 
495
- /** @param {number} n @returns {Uint8Array} */
496
- function _randomBytes(n) {
497
- const out = new Uint8Array(n);
776
+ /**
777
+ * @param {number} len
778
+ * @returns {Uint8Array}
779
+ */
780
+ function _randomBytes(len) {
781
+ const out = new Uint8Array(len);
498
782
  crypto.getRandomValues(out);
499
783
  return out;
500
784
  }