@reticulum/dacar 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/store.js CHANGED
@@ -2,23 +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)
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).
19
23
  *
20
- * Secret material (the node's own identity private key) uses the adapter's
21
- * 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.
22
34
  */
23
35
 
24
36
  import { MsgPack, Identity, toHex } from "@reticulum/core";
@@ -32,16 +44,26 @@ import {
32
44
  MAX_LEGACY_SALTS,
33
45
  SALT_SIZE,
34
46
  } from "../namespace.js";
35
- import { Keyring, IssuerKeyset } from "../verifier.js";
47
+ import { Keyring } from "../verifier.js";
36
48
 
37
49
  /** The alias that always names the node's own signing identity. */
38
50
  export const SELF_ALIAS = "self";
39
51
 
40
- /** The 64-byte RNS public key (X25519 ‖ Ed25519). */
41
- 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;
42
54
 
43
55
  const NS = "dacar";
44
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
+
45
67
  /**
46
68
  * @typedef {Object} StoreConfig
47
69
  * @property {Uint8Array} primarySalt
@@ -60,6 +82,14 @@ const NS = "dacar";
60
82
  * @property {string | null} [note]
61
83
  */
62
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
+
63
93
  /**
64
94
  * A dacar node store backed by a `StorageAdapter`. Each CLI invocation builds a
65
95
  * store, loads what it needs, mutates in memory, and writes back — the
@@ -81,6 +111,11 @@ export class DacarStore {
81
111
 
82
112
  /**
83
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).
84
119
  * @param {import("@reticulum/core").StorageAdapter} adapter
85
120
  * @param {Object} [opts]
86
121
  * @param {Uint8Array} [opts.salt] 32-byte Privacy Salt (default: random).
@@ -90,7 +125,6 @@ export class DacarStore {
90
125
  */
91
126
  static async init(adapter, opts = {}) {
92
127
  const store = new DacarStore(adapter, opts);
93
- // Identity: adopt the override, else generate + persist via saveKey.
94
128
  let identity;
95
129
  if (opts.identityBytes) {
96
130
  identity = await Identity.fromBytes(opts.identityBytes);
@@ -117,34 +151,24 @@ export class DacarStore {
117
151
  const aliases = new AliasRegistry();
118
152
  aliases.add(SELF_ALIAS, identity.identityHash);
119
153
  await store.saveAliases(aliases);
120
- await store.saveKeyring(new Keyring());
121
154
  return store;
122
155
  }
123
156
 
124
157
  /** @returns {Promise<boolean>} */
125
158
  async exists() {
126
- return (await this._adapter.get(NS, "config")) !== null;
159
+ return (await this._adapter.get(NS, CONFIG_RECORD)) !== null;
127
160
  }
128
161
 
129
162
  /** @returns {Promise<StoreConfig>} */
130
163
  async loadConfig() {
131
- const bytes = await this._adapter.get(NS, "config");
164
+ const bytes = await this._adapter.get(NS, CONFIG_RECORD);
132
165
  if (!bytes) throw new Error("store not initialized (run `dacar init`)");
133
- return _decodeConfig(bytes);
166
+ return _decodeConfigIni(bytes);
134
167
  }
135
168
 
136
169
  /** @param {StoreConfig} config */
137
170
  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));
171
+ await this._adapter.set(NS, CONFIG_RECORD, _encodeConfigIni(config));
148
172
  }
149
173
 
150
174
  /**
@@ -193,12 +217,15 @@ export class DacarStore {
193
217
  /** @returns {Promise<Clock>} */
194
218
  async loadClock() {
195
219
  const clock = new Clock();
196
- const bytes = await this._adapter.get(NS, "clock");
220
+ const bytes = await this._adapter.get(NS, CLOCK_RECORD);
197
221
  if (bytes) {
198
222
  const obj = MsgPack.decode(bytes);
199
223
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
200
- if (typeof obj.lastMs === "number" && typeof obj.logical === "number") {
201
- 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 });
202
229
  }
203
230
  }
204
231
  }
@@ -209,8 +236,8 @@ export class DacarStore {
209
236
  async saveClock(clock) {
210
237
  await this._adapter.set(
211
238
  NS,
212
- "clock",
213
- MsgPack.encode(clock.snapshot()),
239
+ CLOCK_RECORD,
240
+ MsgPack.encode({ last_ms: clock.lastMs, logical: clock.logical }),
214
241
  );
215
242
  }
216
243
 
@@ -219,13 +246,12 @@ export class DacarStore {
219
246
  /** @param {Config} [config] @returns {Promise<StateVector>} */
220
247
  async loadState(config) {
221
248
  const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
222
- const bytes = await this._adapter.get(NS, "state");
249
+ const bytes = await this._adapter.get(NS, STATE_RECORD);
223
250
  if (bytes && bytes.length) {
224
251
  // `trusted: true` — these are this node's own persisted CRDT snapshot
225
252
  // (written by `saveState()` → `toPayload()`), never network bytes.
226
253
  // Network Operations arrive as signed Deltas through `DeltaReceiver`
227
- // (the verify-on-ingest path), not here. Asserting `trusted` silences
228
- // the audible `fromPayload` footgun warning during normal CLI use.
254
+ // (the verify-on-ingest path), not here.
229
255
  return StateVector.fromPayload(bytes, {
230
256
  deletionHorizonDays: horizon,
231
257
  trusted: true,
@@ -236,45 +262,51 @@ export class DacarStore {
236
262
 
237
263
  /** @param {StateVector} state */
238
264
  async saveState(state) {
239
- await this._adapter.set(NS, "state", state.toPayload());
265
+ await this._adapter.set(NS, STATE_RECORD, state.toPayload());
240
266
  }
241
267
 
242
268
  // -- aliases -------------------------------------------------------------
243
269
 
244
270
  /** @returns {Promise<AliasRegistry>} */
245
271
  async loadAliases() {
246
- const bytes = await this._adapter.get(NS, "aliases");
272
+ const bytes = await this._adapter.get(NS, ALIASES_RECORD);
247
273
  if (!bytes) return new AliasRegistry();
248
274
  return AliasRegistry.decode(bytes);
249
275
  }
250
276
 
251
277
  /** @param {AliasRegistry} aliases */
252
278
  async saveAliases(aliases) {
253
- await this._adapter.set(NS, "aliases", aliases.encode());
279
+ await this._adapter.set(NS, ALIASES_RECORD, aliases.encode());
254
280
  }
255
281
 
256
282
  // -- ledger --------------------------------------------------------------
257
283
 
258
284
  /**
259
- * @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>>}
260
289
  */
261
290
  async loadLedger() {
262
- const bytes = await this._adapter.get(NS, "ledger");
263
- /** @type {Map<string, any>} */
291
+ const bytes = await this._adapter.get(NS, LEDGER_RECORD);
292
+ /** @type {Map<string, LedgerRow>} */
264
293
  const ledger = new Map();
265
294
  if (bytes) {
266
295
  const obj = MsgPack.decode(bytes);
267
296
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
268
- 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
+ }
269
300
  }
270
301
  }
271
302
  return ledger;
272
303
  }
273
304
 
274
- /** @param {Map<string, any>} ledger */
305
+ /** @param {Map<string, LedgerRow>} ledger */
275
306
  async saveLedger(ledger) {
276
- const obj = Object.fromEntries(ledger);
277
- 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));
278
310
  }
279
311
 
280
312
  // -- issuer identity cache (work doc #5) ---------------------------------
@@ -282,21 +314,26 @@ export class DacarStore {
282
314
  /**
283
315
  * Load the persisted issuer identity cache. Returns an empty {@link Keyring}
284
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.
285
321
  * @returns {Promise<Keyring>}
286
322
  */
287
323
  async loadKeyring() {
288
324
  const keyring = new Keyring();
289
- const bytes = await this._adapter.get(NS, "identities");
325
+ const bytes = await this._adapter.get(NS, IDENTITIES_RECORD);
290
326
  if (bytes) {
291
327
  const obj = MsgPack.decode(bytes);
292
328
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
293
329
  for (const [hashHex, pubKey] of Object.entries(obj)) {
294
- if (pubKey instanceof Uint8Array && pubKey.length === RNS_PUBLIC_KEY_SIZE) {
295
- try {
296
- keyring.registerSingle(_hexToBytes(hashHex), pubKey);
297
- } catch {
298
- // skip malformed hash
299
- }
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
300
337
  }
301
338
  }
302
339
  }
@@ -309,10 +346,11 @@ export class DacarStore {
309
346
  const obj = {};
310
347
  for (const [hashHex, keyset] of keyring.entries()) {
311
348
  if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
312
- 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);
313
351
  }
314
352
  }
315
- await this._adapter.set(NS, "identities", MsgPack.encode(obj));
353
+ await this._adapter.set(NS, IDENTITIES_RECORD, MsgPack.encode(obj));
316
354
  }
317
355
 
318
356
  /**
@@ -325,11 +363,79 @@ export class DacarStore {
325
363
  if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
326
364
  return keyring;
327
365
  }
366
+
367
+ // -- outbox (work doc #8) -----------------------------------------------
368
+
369
+ /**
370
+ * Load the outbox of locally-issued, not-yet-published signed Delta
371
+ * payloads, in the order they were issued. Returns an empty array when no
372
+ * outbox record exists yet.
373
+ * @returns {Promise<Uint8Array[]>}
374
+ */
375
+ async loadOutbox() {
376
+ const bytes = await this._adapter.get(NS, OUTBOX_RECORD);
377
+ if (!bytes) return [];
378
+ let obj;
379
+ try {
380
+ obj = MsgPack.decode(bytes);
381
+ } catch {
382
+ return []; // corrupted -> treat as empty (do not crash the CLI)
383
+ }
384
+ if (!Array.isArray(obj)) return [];
385
+ return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
386
+ }
387
+
388
+ /**
389
+ * Persist the outbox as a MessagePack array of signed Delta payloads.
390
+ * @param {Uint8Array[]} payloads
391
+ */
392
+ async saveOutbox(payloads) {
393
+ await this._adapter.set(
394
+ NS,
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,
430
+ MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
431
+ );
432
+ }
328
433
  }
329
434
 
330
435
  /**
331
436
  * In-memory alias registry: `hash → names[]` with an optional note. Mirrors
332
- * 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.
333
439
  */
334
440
  export class AliasRegistry {
335
441
  /** @param {AliasEntry[]} [entries] */
@@ -337,118 +443,329 @@ export class AliasRegistry {
337
443
  /** @type {AliasEntry[]} */ this.entries = entries;
338
444
  }
339
445
 
340
- /** @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
+ */
341
452
  static decode(bytes) {
342
- const obj = MsgPack.decode(bytes);
343
- if (!Array.isArray(obj)) return new AliasRegistry();
344
- /** @type {AliasEntry[]} */
345
- const entries = [];
346
- for (const row of obj) {
347
- if (!Array.isArray(row)) continue;
348
- const [hash, names, note] = row;
349
- if (!(hash instanceof Uint8Array) || !Array.isArray(names)) continue;
350
- 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
+ }
351
487
  }
352
- return new AliasRegistry(entries);
488
+ return registry;
353
489
  }
354
490
 
355
- /** @returns {Uint8Array} */
491
+ /** @returns {Uint8Array} rnns text bytes (`hash name [# note]` per line). */
356
492
  encode() {
357
- return MsgPack.encode(this.entries.map((e) => [e.hash, e.names, e.note ?? null]));
493
+ if (this.entries.length === 0) return new Uint8Array(0);
494
+ const lines = [];
495
+ for (const e of this.entries) {
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);
500
+ }
501
+ return new TextEncoder().encode(lines.join("\n") + "\n");
358
502
  }
359
503
 
360
- /** @param {string} name @returns {Uint8Array | null} */
361
- resolve(name) {
504
+ /** @param {Uint8Array} hash @returns {AliasEntry | undefined} */
505
+ _entryFor(hash) {
506
+ return this.entries.find((e) => _bytesEqual(e.hash, hash));
507
+ }
508
+
509
+ /**
510
+ * @param {string} name
511
+ * @param {Uint8Array} hash
512
+ * @param {string} [note]
513
+ */
514
+ add(name, hash, note) {
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 });
521
+ }
522
+ }
523
+
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
+ */
530
+ setSelf(hash) {
362
531
  for (const e of this.entries) {
363
- if (e.names.includes(name)) return e.hash;
532
+ e.names = e.names.filter((n) => n !== SELF_ALIAS);
364
533
  }
365
- return null;
534
+ this.entries = this.entries.filter((e) => e.names.length > 0);
535
+ this.add(SELF_ALIAS, hash);
366
536
  }
367
537
 
368
- /** @param {Uint8Array} hash @returns {string[]} */
369
- namesFor(hash) {
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) {
370
545
  for (const e of this.entries) {
371
- if (_bytesEqual(e.hash, hash)) return [...e.names];
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
+ }
372
554
  }
373
- return [];
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 ?? [];
374
567
  }
375
568
 
376
569
  /** @param {Uint8Array} hash @returns {string | null} */
377
570
  primaryName(hash) {
378
571
  const names = this.namesFor(hash);
379
- return names[0] ?? null;
572
+ return names.length > 0 ? names[0] : null;
380
573
  }
574
+ }
381
575
 
382
- /** @param {string} name @param {Uint8Array} hash @param {string | null} [note] */
383
- add(name, hash, note) {
384
- for (const e of this.entries) {
385
- if (_bytesEqual(e.hash, hash)) {
386
- if (!e.names.includes(name)) e.names.push(name);
387
- if (note !== undefined) e.note = note;
388
- return;
389
- }
390
- }
391
- this.entries.push({ hash, names: [name], note: note ?? null });
576
+ // =========================================================================
577
+ // Helpers — record encode/decode (Python-canonical bytes) + small utilities
578
+ // =========================================================================
579
+
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.
616
+ * @param {Uint8Array} bytes
617
+ * @returns {StoreConfig}
618
+ */
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));
392
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
+ }
393
646
 
394
- /** @param {Uint8Array} hash */
395
- setSelf(hash) {
396
- for (const e of this.entries) {
397
- const i = e.names.indexOf(SELF_ALIAS);
398
- if (i !== -1) e.names.splice(i, 1);
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;
399
667
  }
400
- this.entries = this.entries.filter((e) => e.names.length > 0);
401
- this.add(SELF_ALIAS, hash);
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);
402
674
  }
675
+ return sections;
403
676
  }
404
677
 
405
- // -- decode helpers ---------------------------------------------------------
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
+ }
406
691
 
407
692
  /**
408
- * @param {Uint8Array} bytes
409
- * @returns {StoreConfig}
693
+ * @param {unknown} v
694
+ * @returns {LedgerRow}
410
695
  */
411
- function _decodeConfig(bytes) {
412
- const arr = MsgPack.decode(bytes);
413
- if (!Array.isArray(arr) || arr.length !== 7) {
414
- throw new Error("config record must be a 7-element MessagePack array");
696
+ function _decodeLedgerRow(v) {
697
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
698
+ return { object: null, relation: null, wildcard: null, firstSeen: 0 };
415
699
  }
416
- const [primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode] = arr;
700
+ const o = /** @type {Record<string, unknown>} */ (v);
417
701
  return {
418
- primarySalt: _expectBytes(primarySalt, SALT_SIZE, "primary_salt"),
419
- legacySalts: legacySalts.map((s) => _expectBytes(s, SALT_SIZE, "legacy_salt")),
420
- anchors: anchors.map((a) => _expectBytes(a, HASH_SIZE, "anchor")),
421
- authoritative: authoritative instanceof Uint8Array ? authoritative : null,
422
- horizonDays: Number(horizonDays),
423
- rfedTopic: String(rfedTopic),
424
- 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,
425
706
  };
426
707
  }
427
708
 
428
709
  /**
429
- * @param {unknown} value
430
- * @param {number} len
431
- * @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
432
737
  * @returns {Uint8Array}
433
738
  */
434
- function _expectBytes(value, len, name) {
435
- if (!(value instanceof Uint8Array) || value.length !== len) {
436
- throw new Error(`${name} must be a ${len}-byte Uint8Array`);
437
- }
438
- return value;
739
+ function _padToRnsPub(ed25519Pub) {
740
+ const padded = new Uint8Array(64);
741
+ padded.set(ed25519Pub, 32);
742
+ return padded;
439
743
  }
440
744
 
441
- /** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
745
+ /**
746
+ * @param {Uint8Array} a
747
+ * @param {Uint8Array} b
748
+ * @returns {boolean}
749
+ */
442
750
  function _bytesEqual(a, b) {
443
751
  if (a.length !== b.length) return false;
444
- let diff = 0;
445
- for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
446
- return diff === 0;
752
+ for (let i = 0; i < a.length; i++) {
753
+ if (a[i] !== b[i]) return false;
754
+ }
755
+ return true;
447
756
  }
448
757
 
449
- /** @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
+ */
450
764
  function _hexToBytes(hex) {
451
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
+ }
452
769
  const out = new Uint8Array(clean.length / 2);
453
770
  for (let i = 0; i < out.length; i++) {
454
771
  out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
@@ -456,9 +773,12 @@ function _hexToBytes(hex) {
456
773
  return out;
457
774
  }
458
775
 
459
- /** @param {number} n @returns {Uint8Array} */
460
- function _randomBytes(n) {
461
- const out = new Uint8Array(n);
776
+ /**
777
+ * @param {number} len
778
+ * @returns {Uint8Array}
779
+ */
780
+ function _randomBytes(len) {
781
+ const out = new Uint8Array(len);
462
782
  crypto.getRandomValues(out);
463
783
  return out;
464
784
  }