@noy-db/hub 0.1.0-pre.7 → 0.1.0-pre.9

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.
Files changed (89) hide show
  1. package/dist/blobs/index.cjs.map +1 -1
  2. package/dist/blobs/index.d.cts +2 -2
  3. package/dist/blobs/index.d.ts +2 -2
  4. package/dist/blobs/index.js +2 -2
  5. package/dist/bundle/index.d.cts +2 -2
  6. package/dist/bundle/index.d.ts +2 -2
  7. package/dist/bundle/index.js +3 -3
  8. package/dist/{chunk-KPF2HHPI.js → chunk-2CSJGFCB.js} +2 -2
  9. package/dist/{chunk-INSJBB5W.js → chunk-4PWAI7Q4.js} +3 -3
  10. package/dist/{chunk-CL37QSND.js → chunk-AVVPZ4BC.js} +2 -2
  11. package/dist/{chunk-FAAWLVTF.js → chunk-EXHNQEV4.js} +2 -2
  12. package/dist/{chunk-NZ4XCIKS.js → chunk-MDDTIZUO.js} +3 -3
  13. package/dist/{chunk-GILMPJXB.js → chunk-PTVMYYON.js} +2 -2
  14. package/dist/{chunk-N2LMZKLR.js → chunk-QAVUREFT.js} +2 -2
  15. package/dist/{chunk-3WCRU7TI.js → chunk-QGZRWRSL.js} +2 -2
  16. package/dist/{chunk-B6HF6NTZ.js → chunk-RKJ6OL7K.js} +1 -1
  17. package/dist/chunk-RKJ6OL7K.js.map +1 -0
  18. package/dist/{chunk-XCL3WP6J.js → chunk-SCZXXXU4.js} +2 -1
  19. package/dist/{chunk-XCL3WP6J.js.map → chunk-SCZXXXU4.js.map} +1 -1
  20. package/dist/{chunk-UFL4DUEV.js → chunk-VQBTTTUN.js} +1 -1
  21. package/dist/chunk-VQBTTTUN.js.map +1 -0
  22. package/dist/{chunk-6IJQ27XN.js → chunk-WDM5XGGS.js} +51 -4
  23. package/dist/chunk-WDM5XGGS.js.map +1 -0
  24. package/dist/consent/index.d.cts +2 -2
  25. package/dist/consent/index.d.ts +2 -2
  26. package/dist/{delegation-XDJCBTI2.js → delegation-2DBS2EOH.js} +2 -2
  27. package/dist/{dev-unlock-CcJ1qIi7.d.ts → dev-unlock-BdPp68qn.d.ts} +1 -1
  28. package/dist/{dev-unlock-Dk14V6lX.d.cts → dev-unlock-Da1B0TIK.d.cts} +1 -1
  29. package/dist/{hash-h_2U3TFb.d.cts → hash-BEfzPKwo.d.cts} +1 -1
  30. package/dist/{hash-1Xsqx1jl.d.ts → hash-lsoL3eEW.d.ts} +1 -1
  31. package/dist/history/index.cjs.map +1 -1
  32. package/dist/history/index.d.cts +3 -3
  33. package/dist/history/index.d.ts +3 -3
  34. package/dist/history/index.js +2 -2
  35. package/dist/i18n/index.cjs +11 -0
  36. package/dist/i18n/index.cjs.map +1 -1
  37. package/dist/i18n/index.d.cts +2 -2
  38. package/dist/i18n/index.d.ts +2 -2
  39. package/dist/i18n/index.js +3 -3
  40. package/dist/{index-DZn6Yick.d.ts → index-8QDuznDr.d.ts} +1 -1
  41. package/dist/{index-Cvb0efA_.d.cts → index-CywCC1qZ.d.cts} +1 -1
  42. package/dist/index.cjs +590 -59
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +5 -5
  45. package/dist/index.d.ts +5 -5
  46. package/dist/index.js +548 -74
  47. package/dist/index.js.map +1 -1
  48. package/dist/{ledger-5V67MAIL.js → ledger-QZTTHQAQ.js} +3 -3
  49. package/dist/periods/index.cjs.map +1 -1
  50. package/dist/periods/index.d.cts +2 -2
  51. package/dist/periods/index.d.ts +2 -2
  52. package/dist/periods/index.js +3 -3
  53. package/dist/{public-envelope-DFJZHXVH.js → public-envelope-6JTACYJV.js} +3 -3
  54. package/dist/session/index.cjs.map +1 -1
  55. package/dist/session/index.d.cts +3 -3
  56. package/dist/session/index.d.ts +3 -3
  57. package/dist/session/index.js +1 -1
  58. package/dist/shadow/index.d.cts +2 -2
  59. package/dist/shadow/index.d.ts +2 -2
  60. package/dist/store/index.d.cts +2 -2
  61. package/dist/store/index.d.ts +2 -2
  62. package/dist/sync/index.cjs.map +1 -1
  63. package/dist/sync/index.d.cts +1 -1
  64. package/dist/sync/index.d.ts +1 -1
  65. package/dist/sync/index.js +2 -2
  66. package/dist/team/index.cjs +11 -0
  67. package/dist/team/index.cjs.map +1 -1
  68. package/dist/team/index.d.cts +2 -2
  69. package/dist/team/index.d.ts +2 -2
  70. package/dist/team/index.js +4 -4
  71. package/dist/tx/index.d.cts +2 -2
  72. package/dist/tx/index.d.ts +2 -2
  73. package/dist/{types-D3QLmhlk.d.cts → types-Bnb82f5R.d.cts} +818 -74
  74. package/dist/{types-D-6bmD2c.d.ts → types-Bo7NSXJr.d.ts} +818 -74
  75. package/package.json +1 -1
  76. package/dist/chunk-6IJQ27XN.js.map +0 -1
  77. package/dist/chunk-B6HF6NTZ.js.map +0 -1
  78. package/dist/chunk-UFL4DUEV.js.map +0 -1
  79. /package/dist/{chunk-KPF2HHPI.js.map → chunk-2CSJGFCB.js.map} +0 -0
  80. /package/dist/{chunk-INSJBB5W.js.map → chunk-4PWAI7Q4.js.map} +0 -0
  81. /package/dist/{chunk-CL37QSND.js.map → chunk-AVVPZ4BC.js.map} +0 -0
  82. /package/dist/{chunk-FAAWLVTF.js.map → chunk-EXHNQEV4.js.map} +0 -0
  83. /package/dist/{chunk-NZ4XCIKS.js.map → chunk-MDDTIZUO.js.map} +0 -0
  84. /package/dist/{chunk-GILMPJXB.js.map → chunk-PTVMYYON.js.map} +0 -0
  85. /package/dist/{chunk-N2LMZKLR.js.map → chunk-QAVUREFT.js.map} +0 -0
  86. /package/dist/{chunk-3WCRU7TI.js.map → chunk-QGZRWRSL.js.map} +0 -0
  87. /package/dist/{delegation-XDJCBTI2.js.map → delegation-2DBS2EOH.js.map} +0 -0
  88. /package/dist/{ledger-5V67MAIL.js.map → ledger-QZTTHQAQ.js.map} +0 -0
  89. /package/dist/{public-envelope-DFJZHXVH.js.map → public-envelope-6JTACYJV.js.map} +0 -0
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  issueDelegation,
12
12
  loadActiveDelegations,
13
13
  revokeDelegation
14
- } from "./chunk-XCL3WP6J.js";
14
+ } from "./chunk-SCZXXXU4.js";
15
15
  import {
16
16
  LazyQuery,
17
17
  decodeIdxId,
@@ -31,7 +31,7 @@ import {
31
31
  readNoydbBundlePublicEnvelope,
32
32
  resetBrotliSupportCache,
33
33
  writeNoydbBundle
34
- } from "./chunk-FAAWLVTF.js";
34
+ } from "./chunk-EXHNQEV4.js";
35
35
  import {
36
36
  PUBLIC_ENVELOPE_RECORD_ID,
37
37
  isPublicEnvelope,
@@ -39,13 +39,13 @@ import {
39
39
  readPublicEnvelope,
40
40
  savePublicEnvelope,
41
41
  validatePublicEnvelopeInput
42
- } from "./chunk-GILMPJXB.js";
42
+ } from "./chunk-PTVMYYON.js";
43
43
  import {
44
44
  CONSENT_AUDIT_COLLECTION
45
45
  } from "./chunk-M62XNWRA.js";
46
46
  import {
47
47
  PERIODS_COLLECTION
48
- } from "./chunk-3WCRU7TI.js";
48
+ } from "./chunk-QGZRWRSL.js";
49
49
  import "./chunk-UF3BUNQZ.js";
50
50
  import {
51
51
  CollectionFrame,
@@ -69,7 +69,7 @@ import {
69
69
  isI18nTextDescriptor,
70
70
  resolveI18nText,
71
71
  validateI18nTextValue
72
- } from "./chunk-NZ4XCIKS.js";
72
+ } from "./chunk-MDDTIZUO.js";
73
73
  import {
74
74
  createBundleStore,
75
75
  routeStore,
@@ -89,12 +89,12 @@ import {
89
89
  getCredential,
90
90
  listCredentials,
91
91
  putCredential
92
- } from "./chunk-INSJBB5W.js";
92
+ } from "./chunk-4PWAI7Q4.js";
93
93
  import {
94
94
  PresenceHandle,
95
95
  SyncEngine,
96
96
  SyncTransaction
97
- } from "./chunk-CL37QSND.js";
97
+ } from "./chunk-AVVPZ4BC.js";
98
98
  import {
99
99
  USER_ENVELOPE_COLLECTION,
100
100
  USER_ENVELOPE_MAX_BYTES,
@@ -123,8 +123,9 @@ import {
123
123
  revoke,
124
124
  rotateKeys,
125
125
  saveUserEnvelope,
126
+ updateKeyringIdentity,
126
127
  validatePassphrase
127
- } from "./chunk-6IJQ27XN.js";
128
+ } from "./chunk-WDM5XGGS.js";
128
129
  import {
129
130
  BUNDLE_STORE_POLICY,
130
131
  INDEXED_STORE_POLICY,
@@ -144,7 +145,7 @@ import {
144
145
  revokeAllSessions,
145
146
  revokeSession,
146
147
  validateSessionPolicy
147
- } from "./chunk-UFL4DUEV.js";
148
+ } from "./chunk-VQBTTTUN.js";
148
149
  import {
149
150
  generateULID,
150
151
  isULID
@@ -161,7 +162,7 @@ import {
161
162
  LedgerStore,
162
163
  applyPatch,
163
164
  computePatch
164
- } from "./chunk-N2LMZKLR.js";
165
+ } from "./chunk-QAVUREFT.js";
165
166
  import {
166
167
  canonicalJson,
167
168
  envelopePayloadHash,
@@ -216,14 +217,14 @@ import {
216
217
  detectMimeType,
217
218
  isPreCompressed,
218
219
  runCompaction
219
- } from "./chunk-KPF2HHPI.js";
220
+ } from "./chunk-2CSJGFCB.js";
220
221
  import {
221
222
  NOYDB_BACKUP_VERSION,
222
223
  NOYDB_FORMAT_VERSION,
223
224
  NOYDB_KEYRING_VERSION,
224
225
  NOYDB_SYNC_VERSION,
225
226
  createStore
226
- } from "./chunk-B6HF6NTZ.js";
227
+ } from "./chunk-RKJ6OL7K.js";
227
228
  import {
228
229
  base64ToBuffer,
229
230
  bufferToBase64,
@@ -434,18 +435,58 @@ async function enrollAuthenticator(store, vault, keyring, options) {
434
435
  `enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
435
436
  );
436
437
  }
437
- const slot = {
438
+ const base = {
438
439
  id: options.id,
439
440
  method: options.method,
440
441
  enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
441
442
  enrolled_via_tier: options.enrolled_via_tier ?? 1,
442
- wrapped_kek: options.wrapped_kek,
443
443
  meta: options.meta
444
444
  };
445
+ const slot = options.wrapKind === "deks" ? {
446
+ ...base,
447
+ wrapKind: "deks",
448
+ wrapped_deks: options.wrapped_deks,
449
+ iv: options.iv
450
+ } : {
451
+ ...base,
452
+ wrapped_kek: options.wrapped_kek
453
+ };
445
454
  const next = appendSlot(keyring, slot);
446
455
  await persistKeyring(store, vault, next);
447
456
  return next;
448
457
  }
458
+ async function updateAuthenticator(store, vault, keyring, slotId, options) {
459
+ if (options.meta === void 0) {
460
+ throw new ValidationError(
461
+ `updateAuthenticator: at least one of meta must be provided (slotId: "${slotId}").`
462
+ );
463
+ }
464
+ const idx = keyring.authenticators.findIndex((a) => a.id === slotId);
465
+ if (idx === -1) {
466
+ throw new NoAccessError(
467
+ `updateAuthenticator: slot "${slotId}" not found in vault "${vault}".`
468
+ );
469
+ }
470
+ const existing = keyring.authenticators[idx];
471
+ const mergedMeta = { ...existing.meta };
472
+ for (const [k, v] of Object.entries(options.meta)) {
473
+ if (v === void 0) continue;
474
+ if (v === null) {
475
+ delete mergedMeta[k];
476
+ continue;
477
+ }
478
+ mergedMeta[k] = v;
479
+ }
480
+ const next = { ...existing, meta: mergedMeta };
481
+ const nextSlots = [...keyring.authenticators];
482
+ nextSlots[idx] = next;
483
+ const nextKeyring = {
484
+ ...keyring,
485
+ authenticators: nextSlots
486
+ };
487
+ await persistKeyring(store, vault, nextKeyring);
488
+ return nextKeyring;
489
+ }
449
490
  async function removeAuthenticator(store, vault, keyring, slotId) {
450
491
  const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
451
492
  if (filtered.length === keyring.authenticators.length) {
@@ -545,51 +586,38 @@ var RecoveryProfileNotImplementedError = class extends NoydbError {
545
586
  }
546
587
  };
547
588
 
548
- // src/team/recovery.ts
549
- var PAPER_DOC_ID = "recovery-paper";
550
- async function loadPaperRecoveryEntries(store, vault) {
551
- const env = await store.get(vault, "_meta", PAPER_DOC_ID);
552
- if (!env) return [];
553
- try {
554
- const doc = JSON.parse(env._data);
555
- if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
556
- return doc.entries;
557
- } catch {
558
- return [];
589
+ // src/team/wrapped-deks.ts
590
+ var PBKDF2_ITERATIONS = 6e5;
591
+ var SALT_BYTES = 32;
592
+ var IV_BYTES = 12;
593
+ var subtle = globalThis.crypto.subtle;
594
+ async function mintWrappedDeksBlob(deks, credential) {
595
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
596
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
597
+ const wrappingKey = await deriveWrappingKey(credential, salt);
598
+ const exported = {};
599
+ for (const [coll, dek] of deks) {
600
+ const raw = await subtle.exportKey("raw", dek);
601
+ exported[coll] = bytesToBase64(new Uint8Array(raw));
559
602
  }
560
- }
561
- async function savePaperRecoveryEntries(store, vault, entries) {
562
- const doc = {
563
- _noydb_recovery: 1,
564
- profile: "paper",
565
- entries
566
- };
567
- const envelope = {
568
- _noydb: NOYDB_FORMAT_VERSION,
569
- _v: 1,
570
- _ts: (/* @__PURE__ */ new Date()).toISOString(),
571
- _iv: "",
572
- _data: JSON.stringify(doc)
603
+ const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
604
+ const ciphertext = await subtle.encrypt(
605
+ { name: "AES-GCM", iv },
606
+ wrappingKey,
607
+ plaintext
608
+ );
609
+ return {
610
+ salt: bytesToBase64(salt),
611
+ iv: bytesToBase64(iv),
612
+ wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
573
613
  };
574
- await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
575
- }
576
- async function burnPaperRecoveryEntry(store, vault, codeId) {
577
- const entries = await loadPaperRecoveryEntries(store, vault);
578
- const remaining = entries.filter((e) => e.codeId !== codeId);
579
- await savePaperRecoveryEntries(store, vault, remaining);
580
- }
581
- async function hasRecoveryEnrolled(store, vault) {
582
- const paper = await loadPaperRecoveryEntries(store, vault);
583
- return paper.length > 0;
584
614
  }
585
- var subtle = globalThis.crypto.subtle;
586
- var RECOVERY_PBKDF2_ITERATIONS = 6e5;
587
- async function unwrapDeksFromPaperEntry(entry, code) {
588
- const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
615
+ async function unwrapDeksFromBlob(blob, credential) {
616
+ const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
589
617
  const plaintext = await subtle.decrypt(
590
- { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
618
+ { name: "AES-GCM", iv: base64ToBytes(blob.iv) },
591
619
  wrappingKey,
592
- base64ToBytes(entry.wrappedDeks)
620
+ base64ToBytes(blob.wrappedDeks)
593
621
  );
594
622
  const parsed = JSON.parse(new TextDecoder().decode(plaintext));
595
623
  const deks = /* @__PURE__ */ new Map();
@@ -606,10 +634,10 @@ async function unwrapDeksFromPaperEntry(entry, code) {
606
634
  }
607
635
  return deks;
608
636
  }
609
- async function deriveRecoveryWrappingKey(code, salt) {
637
+ async function deriveWrappingKey(credential, salt) {
610
638
  const ikm = await subtle.importKey(
611
639
  "raw",
612
- new TextEncoder().encode(code),
640
+ new TextEncoder().encode(credential),
613
641
  "PBKDF2",
614
642
  false,
615
643
  ["deriveKey"]
@@ -618,7 +646,7 @@ async function deriveRecoveryWrappingKey(code, salt) {
618
646
  {
619
647
  name: "PBKDF2",
620
648
  salt,
621
- iterations: RECOVERY_PBKDF2_ITERATIONS,
649
+ iterations: PBKDF2_ITERATIONS,
622
650
  hash: "SHA-256"
623
651
  },
624
652
  ikm,
@@ -627,6 +655,11 @@ async function deriveRecoveryWrappingKey(code, salt) {
627
655
  ["encrypt", "decrypt"]
628
656
  );
629
657
  }
658
+ function bytesToBase64(b) {
659
+ let s = "";
660
+ for (const x of b) s += String.fromCharCode(x);
661
+ return btoa(s);
662
+ }
630
663
  function base64ToBytes(b64) {
631
664
  const s = atob(b64);
632
665
  const out = new Uint8Array(s.length);
@@ -634,6 +667,55 @@ function base64ToBytes(b64) {
634
667
  return out;
635
668
  }
636
669
 
670
+ // src/team/recovery.ts
671
+ var PAPER_DOC_ID = "recovery-paper";
672
+ async function loadPaperRecoveryEntries(store, vault) {
673
+ const env = await store.get(vault, "_meta", PAPER_DOC_ID);
674
+ if (!env) return [];
675
+ try {
676
+ const doc = JSON.parse(env._data);
677
+ if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
678
+ return doc.entries;
679
+ } catch {
680
+ return [];
681
+ }
682
+ }
683
+ async function savePaperRecoveryEntries(store, vault, entries) {
684
+ const doc = {
685
+ _noydb_recovery: 1,
686
+ profile: "paper",
687
+ entries
688
+ };
689
+ const envelope = {
690
+ _noydb: NOYDB_FORMAT_VERSION,
691
+ _v: 1,
692
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
693
+ _iv: "",
694
+ _data: JSON.stringify(doc)
695
+ };
696
+ await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
697
+ }
698
+ async function burnPaperRecoveryEntry(store, vault, codeId) {
699
+ const entries = await loadPaperRecoveryEntries(store, vault);
700
+ const remaining = entries.filter((e) => e.codeId !== codeId);
701
+ await savePaperRecoveryEntries(store, vault, remaining);
702
+ }
703
+ async function hasRecoveryEnrolled(store, vault) {
704
+ const paper = await loadPaperRecoveryEntries(store, vault);
705
+ return paper.length > 0;
706
+ }
707
+ async function mintPaperRecoveryEntry(deks, code, codeId) {
708
+ const blob = await mintWrappedDeksBlob(deks, code);
709
+ return {
710
+ ...blob,
711
+ codeId,
712
+ enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
713
+ };
714
+ }
715
+ async function unwrapDeksFromPaperEntry(entry, code) {
716
+ return unwrapDeksFromBlob(entry, code);
717
+ }
718
+
637
719
  // src/team/rotate-recover.ts
638
720
  async function rotatePassphrase(store, vault, userId, input) {
639
721
  if (!input.allowWeakPassphrase) {
@@ -656,14 +738,53 @@ async function rotatePassphrase(store, vault, userId, input) {
656
738
  for (const [coll, dek] of deks) {
657
739
  wrappedDeks[coll] = await wrapKey(dek, newKek);
658
740
  }
741
+ const oldSlots = file.authenticators ?? [];
742
+ const newSlots = [];
743
+ if (input.slotCeremonies && oldSlots.length > 0) {
744
+ for (const oldSlot of oldSlots) {
745
+ const ceremony = input.slotCeremonies[oldSlot.id];
746
+ if (!ceremony) continue;
747
+ const result = await ceremony({ newKek, newDeks: deks, oldSlot });
748
+ if (result.id !== oldSlot.id) {
749
+ throw new ValidationError(
750
+ `slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
751
+ );
752
+ }
753
+ if (result.method !== oldSlot.method) {
754
+ throw new ValidationError(
755
+ `slotCeremonies['${oldSlot.id}'] returned method="${result.method}", expected "${oldSlot.method}". The method must match the rotated slot \u2014 a ceremony cannot change the auth method (e.g. webauthn \u2192 password) under cover of rotation.`
756
+ );
757
+ }
758
+ const baseFields = {
759
+ id: result.id,
760
+ method: result.method,
761
+ // Preserve original enrolled_at — rotation is rewrapping, not
762
+ // re-enrollment. The slot's enrolment timestamp tracks when
763
+ // the user originally added the slot, not when it was last
764
+ // rewrapped. Forensics consumers reading enrolled_at are
765
+ // tracking the slot's ORIGIN, not its CURRENT wrapping.
766
+ enrolled_at: oldSlot.enrolled_at,
767
+ enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
768
+ meta: result.meta
769
+ };
770
+ const newSlot = result.wrapKind === "deks" ? {
771
+ ...baseFields,
772
+ wrapKind: "deks",
773
+ wrapped_deks: result.wrapped_deks,
774
+ iv: result.iv
775
+ } : {
776
+ ...baseFields,
777
+ wrapped_kek: result.wrapped_kek
778
+ };
779
+ newSlots.push(newSlot);
780
+ }
781
+ }
659
782
  const next = {
660
783
  ...file,
661
784
  _noydb_keyring: NOYDB_KEYRING_VERSION,
662
785
  deks: wrappedDeks,
663
786
  salt: bufferToBase64(newSalt),
664
- // Tier-2 slots reference the old KEK — drop them. User
665
- // re-enrols afterwards via `db.enrollAuthenticator`.
666
- authenticators: []
787
+ authenticators: newSlots
667
788
  };
668
789
  await writeKeyringFile(store, vault, userId, next);
669
790
  return {
@@ -674,7 +795,7 @@ async function rotatePassphrase(store, vault, userId, input) {
674
795
  deks,
675
796
  kek: newKek,
676
797
  salt: newSalt,
677
- authenticators: [],
798
+ authenticators: newSlots,
678
799
  ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
679
800
  ...file.import_capability !== void 0 && { importCapability: file.import_capability }
680
801
  };
@@ -807,6 +928,17 @@ var UserApi = class {
807
928
  * the envelope on first call. Optimistic-concurrency safe — a stale
808
929
  * `_v` (parallel writer on another device) throws `ConflictError`.
809
930
  *
931
+ * Patch semantics (#57):
932
+ * - `undefined` (or omitted key) — skip; existing value preserved
933
+ * - `null` — delete the field from the merged result
934
+ * - any other value — overwrite (deep-merge for plain objects,
935
+ * replace for primitives / arrays)
936
+ *
937
+ * To clear a field, pass `null` rather than `undefined`. Callers
938
+ * with shape `T = string | null` where `null` is a meaningful value
939
+ * should use `setMe` for that specific field instead — `null` here
940
+ * always means delete.
941
+ *
810
942
  * Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
811
943
  * Pass `presented` to satisfy tightened policies that require a
812
944
  * factor proof (e.g. STRICT_POLICY's TOTP requirement).
@@ -978,9 +1110,17 @@ function deepMerge(source, patch) {
978
1110
  }
979
1111
  const out = { ...source };
980
1112
  for (const [key, patchVal] of Object.entries(patch)) {
1113
+ if (patchVal === void 0) {
1114
+ continue;
1115
+ }
1116
+ if (patchVal === null) {
1117
+ delete out[key];
1118
+ continue;
1119
+ }
981
1120
  const sourceVal = source[key];
982
- if (isPlainObject(sourceVal) && isPlainObject(patchVal)) {
983
- out[key] = deepMerge(sourceVal, patchVal);
1121
+ if (isPlainObject(patchVal)) {
1122
+ const recurseSource = isPlainObject(sourceVal) ? sourceVal : {};
1123
+ out[key] = deepMerge(recurseSource, patchVal);
984
1124
  } else {
985
1125
  out[key] = patchVal;
986
1126
  }
@@ -1148,6 +1288,70 @@ function sanitizeId(s) {
1148
1288
  return s.replace(/[^a-zA-Z0-9]/g, "_");
1149
1289
  }
1150
1290
 
1291
+ // src/team/peer-recover.ts
1292
+ var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
1293
+ function canRecover(callerRole, targetRole) {
1294
+ if (callerRole === "owner") return true;
1295
+ if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
1296
+ return false;
1297
+ }
1298
+ async function recoverUser(store, vault, callerKeyring, options) {
1299
+ const env = await store.get(vault, "_keyring", options.userId);
1300
+ if (!env) {
1301
+ throw new NoAccessError(
1302
+ `recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
1303
+ );
1304
+ }
1305
+ const target = JSON.parse(env._data);
1306
+ const targetRole = options.role ?? target.role;
1307
+ if (!canRecover(callerKeyring.role, targetRole)) {
1308
+ throw new PermissionDeniedError(
1309
+ `Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
1310
+ );
1311
+ }
1312
+ if (!canRecover(callerKeyring.role, target.role)) {
1313
+ throw new PermissionDeniedError(
1314
+ `Role "${callerKeyring.role}" cannot recover role "${target.role}"`
1315
+ );
1316
+ }
1317
+ for (const coll of Object.keys(target.deks)) {
1318
+ if (!callerKeyring.deks.has(coll)) {
1319
+ throw new PrivilegeEscalationError(coll);
1320
+ }
1321
+ }
1322
+ if (options.validatePassphrase && !options.allowWeakPassphrase) {
1323
+ assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
1324
+ }
1325
+ const newSalt = generateSalt();
1326
+ const newKek = await deriveKey(options.passphrase, newSalt);
1327
+ const wrappedDeks = {};
1328
+ for (const coll of Object.keys(target.deks)) {
1329
+ const callerDek = callerKeyring.deks.get(coll);
1330
+ if (!callerDek) {
1331
+ throw new PrivilegeEscalationError(coll);
1332
+ }
1333
+ wrappedDeks[coll] = await wrapKey(callerDek, newKek);
1334
+ }
1335
+ const next = {
1336
+ ...target,
1337
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
1338
+ role: targetRole,
1339
+ display_name: options.displayName ?? target.display_name,
1340
+ deks: wrappedDeks,
1341
+ salt: bufferToBase64(newSalt),
1342
+ granted_by: callerKeyring.userId,
1343
+ authenticators: []
1344
+ };
1345
+ const envelope = {
1346
+ _noydb: 1,
1347
+ _v: 1,
1348
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
1349
+ _iv: "",
1350
+ _data: JSON.stringify(next)
1351
+ };
1352
+ await store.put(vault, "_keyring", options.userId, envelope);
1353
+ }
1354
+
1151
1355
  // src/crdt/strategy.ts
1152
1356
  var NOT_ENABLED = new Error(
1153
1357
  'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
@@ -4804,7 +5008,12 @@ var Vault = class {
4804
5008
  * collection.
4805
5009
  */
4806
5010
  async delegate(opts) {
4807
- const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-XDJCBTI2.js");
5011
+ const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
5012
+ if (!this.keyring.kek) {
5013
+ throw new ValidationError(
5014
+ "issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
5015
+ );
5016
+ }
4808
5017
  const targetKek = this.keyring.kek;
4809
5018
  const delegationsDek = await this.getDEK(DELEGATIONS_COLLECTION2);
4810
5019
  return issueDelegation2(
@@ -4821,7 +5030,7 @@ var Vault = class {
4821
5030
  * if the id does not exist.
4822
5031
  */
4823
5032
  async revokeDelegation(id) {
4824
- const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-XDJCBTI2.js");
5033
+ const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
4825
5034
  await revokeDelegation2(this.adapter, this.name, id);
4826
5035
  void DELEGATIONS_COLLECTION2;
4827
5036
  }
@@ -5221,7 +5430,7 @@ var Vault = class {
5221
5430
  * @see docs/subsystems/public-envelope.md
5222
5431
  */
5223
5432
  async getPublicEnvelope(opts = {}) {
5224
- const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-DFJZHXVH.js");
5433
+ const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-6JTACYJV.js");
5225
5434
  return readPublicEnvelope2(this.adapter, this.name, opts);
5226
5435
  }
5227
5436
  /**
@@ -5795,7 +6004,23 @@ var PERSONAL_POLICY = Object.freeze({
5795
6004
  gates: {
5796
6005
  "rotate-passphrase": {
5797
6006
  minTier: 1,
5798
- factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
6007
+ // Any second factor satisfies the gate — off-device kinds (TOTP,
6008
+ // email-OTP, paper recovery, roaming WebAuthn) are the strongest;
6009
+ // platform-bound kinds (platform WebAuthn, password, PIN) are
6010
+ // accepted because requiring "something off-device" is overkill
6011
+ // for personal/SMB threat models. Consumers needing the off-device
6012
+ // guarantee should use STRICT_POLICY or override this gate.
6013
+ factors: [{
6014
+ anyOf: [
6015
+ "totp",
6016
+ "email-otp",
6017
+ "recovery",
6018
+ "webauthn-roaming",
6019
+ "webauthn-platform",
6020
+ "password",
6021
+ "pin"
6022
+ ]
6023
+ }]
5799
6024
  },
5800
6025
  "recover-passphrase": {
5801
6026
  minTier: 1,
@@ -5803,9 +6028,27 @@ var PERSONAL_POLICY = Object.freeze({
5803
6028
  },
5804
6029
  "enroll-authenticator": { minTier: 1 },
5805
6030
  "remove-authenticator": { minTier: 1 },
6031
+ // update-authenticator: meta-only mutation (slot rename, label
6032
+ // changes). Symmetric with enroll/remove under PERSONAL — tier-1
6033
+ // unlock alone. The structural anti-slot-swap guard inside the
6034
+ // implementation enforces wrap-material/id/method immutability
6035
+ // regardless of this gate's settings.
6036
+ "update-authenticator": { minTier: 1 },
5806
6037
  "rotate-unlock": { minTier: 2 },
5807
6038
  "enroll-user": { minTier: 1 },
5808
6039
  "revoke-user": { minTier: 1 },
6040
+ // Peer-recovery is a high-trust intentional op — co-owners
6041
+ // recovering each other should not need an off-device factor in
6042
+ // the personal/SMB threat model (the partner is already vetted by
6043
+ // virtue of being a co-owner). Tier-1 unlock is the floor; the
6044
+ // STRICT preset adds a recovery/email-OTP requirement.
6045
+ "peer-recover-user": { minTier: 1 },
6046
+ // update-user: post-grant identity mutation (role/displayName/
6047
+ // permissions). PERSONAL_POLICY treats this on par with enroll-user
6048
+ // / revoke-user — tier-1 unlock alone. The role-elevation guard
6049
+ // inside the implementation is the structural backstop that this
6050
+ // gate's settings cannot weaken.
6051
+ "update-user": { minTier: 1 },
5809
6052
  "export-bundle": { minTier: 1 },
5810
6053
  "export-plaintext": {
5811
6054
  minTier: 1,
@@ -5850,6 +6093,15 @@ var STRICT_POLICY = Object.freeze({
5850
6093
  minTier: 1,
5851
6094
  factors: [{ anyOf: ["totp", "email-otp"] }]
5852
6095
  },
6096
+ // STRICT update-authenticator: same factor floor as enroll/remove.
6097
+ // Even though meta changes don't touch wrap material, a malicious
6098
+ // rename could mislead the user about which device a slot
6099
+ // corresponds to ("MacBook Touch ID" → "iPhone Touch ID" on a
6100
+ // shared workstation). STRICT requires a fresh factor proof.
6101
+ "update-authenticator": {
6102
+ minTier: 1,
6103
+ factors: [{ anyOf: ["totp", "email-otp"] }]
6104
+ },
5853
6105
  "rotate-unlock": { minTier: 1 },
5854
6106
  "enroll-user": {
5855
6107
  minTier: 1,
@@ -5859,6 +6111,31 @@ var STRICT_POLICY = Object.freeze({
5859
6111
  minTier: 1,
5860
6112
  factors: [{ anyOf: ["totp", "email-otp"] }]
5861
6113
  },
6114
+ // STRICT peer-recovery: the issuer must present a recovery code
6115
+ // OR a fresh off-device second factor at the moment of recovery.
6116
+ // This binds the high-trust operation to a verifiable proof
6117
+ // (recovery sheet photographed by an attacker won't suffice —
6118
+ // they'd also need tier-1 unlock first; this gate is the freshness
6119
+ // binding on top). Roaming WebAuthn (YubiKey-class hardware key)
6120
+ // accepted; platform-bound kinds (Touch ID, password, PIN)
6121
+ // intentionally excluded under STRICT because they don't survive
6122
+ // device theft — the off-device requirement is the whole point.
6123
+ "peer-recover-user": {
6124
+ minTier: 1,
6125
+ factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
6126
+ },
6127
+ // STRICT update-user: matches the enroll-user / revoke-user shape
6128
+ // (off-device factor required). Update-user is admin-shaped — it
6129
+ // mutates someone else's role/permissions; STRICT requires a fresh
6130
+ // off-device factor proof so the operator affirmatively re-asserts
6131
+ // identity at the moment of mutation. Platform-bound factors
6132
+ // (Touch ID / password / PIN) intentionally excluded: same logic as
6133
+ // peer-recover-user — the off-device requirement is the whole
6134
+ // point under STRICT.
6135
+ "update-user": {
6136
+ minTier: 1,
6137
+ factors: [{ anyOf: ["totp", "email-otp"] }]
6138
+ },
5862
6139
  "export-bundle": {
5863
6140
  minTier: 1,
5864
6141
  factors: [{ anyOf: ["totp", "email-otp"] }],
@@ -6253,6 +6530,56 @@ var Noydb = class {
6253
6530
  const keyring = await this.getKeyring(vault);
6254
6531
  await revoke(this.options.store, vault, keyring, options);
6255
6532
  }
6533
+ /**
6534
+ * Mutate post-grant identity fields on an existing keyring — `role`,
6535
+ * `displayName`, and/or `permissions`. Pure plaintext-header rewrite:
6536
+ * no DEK rewrap, no KEK required, no authenticator slots touched.
6537
+ * Tier-2 enrollments and recovery codes survive.
6538
+ *
6539
+ * Different from `db.revoke + db.grant`:
6540
+ *
6541
+ * - Same `userId`, same DEK wrappings, same `granted_by`, same
6542
+ * `_users/<keyringId>` envelope. Only the specified header
6543
+ * fields move. Last-write-wins via the standard keyring put.
6544
+ * - No cascade on role demotion (admins demoted to operator keep
6545
+ * the keyrings they previously granted; the cascade rules are
6546
+ * a `db.revoke` concern, not `db.updateUser`).
6547
+ * - Tier-2 slots NOT dropped — the wrapping is unaffected.
6548
+ *
6549
+ * Role-elevation guard: BOTH the old and new role must satisfy
6550
+ * `db.grant`'s hierarchy. Owner can do anything; admin manages
6551
+ * admin/operator/viewer/client laterally; admin cannot promote to
6552
+ * owner OR demote from owner. The guard runs regardless of the
6553
+ * `update-user` policy gate's settings — gates can only be more
6554
+ * permissive than the structural floor, never less.
6555
+ *
6556
+ * Gated by `update-user`. `STRICT_POLICY` requires a TOTP/email-OTP
6557
+ * factor proof so the operator affirmatively re-asserts identity at
6558
+ * the moment of mutation; `PERSONAL_POLICY` accepts a tier-1 unlock
6559
+ * alone.
6560
+ *
6561
+ * ```ts
6562
+ * await db.updateUser('acme', {
6563
+ * userId: 'bob',
6564
+ * role: 'operator', // promote
6565
+ * permissions: { invoices: 'rw' },
6566
+ * }, { factors: [{ kind: 'totp' }] })
6567
+ * ```
6568
+ *
6569
+ * @throws `NoAccessError` when no keyring exists for the target.
6570
+ * @throws `PermissionDeniedError` when the role hierarchy rejects.
6571
+ * @throws `ValidationError` when no field is provided.
6572
+ *
6573
+ * @see #54
6574
+ */
6575
+ async updateUser(vault, options, factors) {
6576
+ await this.checkGate(vault, "update-user", factors);
6577
+ const keyring = await this.getKeyring(vault);
6578
+ await updateKeyringIdentity(this.options.store, vault, keyring, options);
6579
+ if (options.userId === this.options.user) {
6580
+ this.keyringCache.delete(vault);
6581
+ }
6582
+ }
6256
6583
  /**
6257
6584
  * Rotate the DEKs for the given collections in a vault.
6258
6585
  *
@@ -6796,6 +7123,40 @@ var Noydb = class {
6796
7123
  const keyring = await this.getKeyring(vault);
6797
7124
  return keyring.authenticators;
6798
7125
  }
7126
+ /**
7127
+ * Mutate the `meta` blob on an existing authenticator slot — slot
7128
+ * rename, label change, attachment of UI hints. The slot's `id`,
7129
+ * `method`, and wrap material (`wrapped_kek` / `wrapped_deks` + `iv`)
7130
+ * are immutable through this method. Anti-slot-swap is structural,
7131
+ * not gate-driven.
7132
+ *
7133
+ * `meta` patch semantics (#57-aligned):
7134
+ * - Top-level merge — absent keys preserved
7135
+ * - `null` value — delete that meta key
7136
+ * - Other values — replace verbatim
7137
+ *
7138
+ * Use case: per-slot nickname for "iPhone Touch ID" vs "MacBook
7139
+ * Touch ID" disambiguation in admin UIs. The slot id (auto-derived
7140
+ * from credentialId prefix) is not human-friendly; `meta.nickname`
7141
+ * is.
7142
+ *
7143
+ * Gated by `update-authenticator`. PERSONAL_POLICY: tier-1 unlock
7144
+ * alone (matches enroll/remove). STRICT_POLICY: tier-1 +
7145
+ * TOTP/email-OTP factor proof — a malicious rename on a shared
7146
+ * workstation could mislead the user about which device a slot
7147
+ * corresponds to, so STRICT requires fresh factor binding.
7148
+ *
7149
+ * @throws `NoAccessError` when no slot with the given id exists.
7150
+ * @throws `ValidationError` when no patch field is provided.
7151
+ *
7152
+ * @see #55
7153
+ */
7154
+ async updateAuthenticator(vault, slotId, options, presented) {
7155
+ await this.checkGate(vault, "update-authenticator", presented);
7156
+ const keyring = await this.getKeyring(vault);
7157
+ const next = await updateAuthenticator(this.options.store, vault, keyring, slotId, options);
7158
+ this.keyringCache.set(vault, next);
7159
+ }
6799
7160
  /**
6800
7161
  * Native WebAuthn enrollment using the **real** internal keyring (#16).
6801
7162
  *
@@ -7005,22 +7366,108 @@ var Noydb = class {
7005
7366
  async recoverPassphrase(vault, input, factors) {
7006
7367
  await this.checkGate(vault, "recover-passphrase", factors);
7007
7368
  const userId = this.options.user;
7369
+ const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
7008
7370
  const next = await recoverPassphrase(this.options.store, vault, userId, input);
7009
7371
  this.keyringCache.set(vault, next);
7372
+ const rotateRemaining = input.rotateRemainingCodes ?? true;
7373
+ const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
7374
+ if (!rotateRemaining || remainingAfterBurn === 0) {
7375
+ return { newCodes: [] };
7376
+ }
7377
+ const codeGen = input.codeGenerator ?? generateULID;
7378
+ const newCodeCount = input.newCodeCount ?? remainingAfterBurn;
7379
+ const codes = [];
7380
+ const newEntries = [];
7381
+ for (let i = 0; i < newCodeCount; i++) {
7382
+ const rawCode = codeGen();
7383
+ const entry = await mintPaperRecoveryEntry(next.deks, rawCode, generateULID());
7384
+ codes.push(rawCode);
7385
+ newEntries.push(entry);
7386
+ }
7387
+ await savePaperRecoveryEntries(this.options.store, vault, newEntries);
7388
+ return { newCodes: codes };
7389
+ }
7390
+ /**
7391
+ * Atomic peer-recovery — re-wraps an EXISTING user's keyring under
7392
+ * a fresh temp passphrase in a single store write. Closes #34's
7393
+ * partial-failure window (the previous compose-from-primitives
7394
+ * pattern was `db.revoke + db.grant`, two writes — if the issuer
7395
+ * cancelled between them the target was locked out entirely).
7396
+ *
7397
+ * Different from `db.revoke + db.grant`:
7398
+ *
7399
+ * - Same `userId`, role, permissions, capabilities preserved.
7400
+ * - DEKs unchanged → every other principal in the vault keeps
7401
+ * access. No key rotation.
7402
+ * - Allows owner→owner natively (#33). The existing
7403
+ * `db.revoke` retains its block — peer-recovery is a separate,
7404
+ * intentionally-named operation.
7405
+ * - Tier-2 slots dropped (they wrap the old KEK).
7406
+ *
7407
+ * Gated by `peer-recover-user`; `STRICT_POLICY` requires a
7408
+ * recovery / TOTP / email-OTP factor proof at the moment of
7409
+ * recovery, so the issuer affirmatively re-asserts identity.
7410
+ *
7411
+ * The recipient should call `db.rotatePassphrase` on first session
7412
+ * to choose their own phrase — the temp acts as a single-use
7413
+ * bridge.
7414
+ *
7415
+ * ```ts
7416
+ * await db.recoverUser('acme', {
7417
+ * userId: 'bob',
7418
+ * passphrase: 'temporary-correct-horse-battery-staple-printer',
7419
+ * }, { factors: [{ kind: 'recovery' }] })
7420
+ * // Bob opens createNoydb({ user: 'bob', secret: tempPhrase })
7421
+ * // and immediately calls db.rotatePassphrase to set his own.
7422
+ * ```
7423
+ *
7424
+ * @throws `NoAccessError` when no keyring exists for the target.
7425
+ * @throws `PermissionDeniedError` when the caller's role can't
7426
+ * recover the target's role (admin→owner is blocked even
7427
+ * under recovery).
7428
+ * @throws `PrivilegeEscalationError` when the caller lacks a DEK
7429
+ * the target previously had access to.
7430
+ *
7431
+ * @see #33 #34 — the issues this method closes.
7432
+ */
7433
+ async recoverUser(vault, options, factors) {
7434
+ await this.checkGate(vault, "peer-recover-user", factors);
7435
+ const callerKeyring = await this.getKeyring(vault);
7436
+ await recoverUser(this.options.store, vault, callerKeyring, options);
7437
+ if (options.userId === this.options.user) {
7438
+ this.keyringCache.delete(vault);
7439
+ }
7010
7440
  }
7011
7441
  /**
7012
7442
  * Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
7013
- * profile — the developer first calls
7014
- * `@noy-db/on-recovery/generateRecoveryCodeSet` to mint codes +
7015
- * entries, shows the codes to the user once, then hands the entries
7016
- * here.
7443
+ * profile.
7444
+ *
7445
+ * The hub wraps the user's DEK set (not the KEK) under a code-derived
7446
+ * AES-GCM key — see `team/recovery.ts` for the rationale. The mint
7447
+ * helper {@link mintPaperRecoveryEntry} is the canonical primitive;
7448
+ * pair it with `db.getKeyring(vault)` to obtain the live DEK set:
7017
7449
  *
7018
7450
  * ```ts
7019
- * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
7020
- * const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
7451
+ * import { mintPaperRecoveryEntry } from '@noy-db/hub'
7452
+ *
7453
+ * const keyring = await db.getKeyring('acme')
7454
+ * const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...]
7455
+ * const entries = await Promise.all(
7456
+ * codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)),
7457
+ * )
7021
7458
  * await db.enrollRecovery('acme', { profile: 'paper', entries })
7022
7459
  * showCodesToUser(codes)
7023
7460
  * ```
7461
+ *
7462
+ * As of pre.8, `@noy-db/on-recovery`'s `generateRecoveryCodeSet`
7463
+ * delegates to `mintPaperRecoveryEntry` internally — its output is
7464
+ * fed directly to this API. Pick whichever fits your code-gen layer:
7465
+ *
7466
+ * ```ts
7467
+ * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
7468
+ * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 })
7469
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
7470
+ * ```
7024
7471
  */
7025
7472
  async enrollRecovery(vault, enrollment) {
7026
7473
  if (enrollment.profile !== "paper") {
@@ -7074,7 +7521,29 @@ var Noydb = class {
7074
7521
  clearQuickUnlock(vault) {
7075
7522
  this.quickUnlock.delete(vault);
7076
7523
  }
7077
- /** Get or load the keyring for a vault. */
7524
+ /**
7525
+ * Public accessor for the unlocked keyring of a vault — issue #28.
7526
+ *
7527
+ * Returns the cached `UnlockedKeyring` (already in memory after
7528
+ * `createNoydb` + first vault touch); loads it on demand if absent.
7529
+ * Used by `@noy-db/on-*` ceremonies that need the live DEK set
7530
+ * (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
7531
+ * enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
7532
+ * don't have a hub-side wrapper).
7533
+ *
7534
+ * No new permission gate — this is an accessor over already-unlocked
7535
+ * state. The keyring is materialized only after the calling session
7536
+ * has unlocked the vault at tier 1, 2, or 3, so exposing it does not
7537
+ * widen access. Throws `ValidationError` when encryption is enabled
7538
+ * and no `secret` / `getKeyring` is configured.
7539
+ *
7540
+ * ```ts
7541
+ * const keyring = await db.getKeyring('acme')
7542
+ * // keyring.deks: Map<collection, CryptoKey>
7543
+ * // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
7544
+ * // keyring.role / .permissions / .authenticators
7545
+ * ```
7546
+ */
7078
7547
  async getKeyring(vault) {
7079
7548
  if (this.options.encrypt === false) {
7080
7549
  return createPlaintextKeyring(this.options.user);
@@ -7503,6 +7972,8 @@ export {
7503
7972
  mergeCrdtStates,
7504
7973
  mergePolicy,
7505
7974
  min,
7975
+ mintPaperRecoveryEntry,
7976
+ mintWrappedDeksBlob,
7506
7977
  paddedIndex,
7507
7978
  parseBytes,
7508
7979
  parseIndex,
@@ -7513,6 +7984,7 @@ export {
7513
7984
  readNoydbBundlePublicEnvelope,
7514
7985
  readPath,
7515
7986
  readPublicEnvelope,
7987
+ recoverUser,
7516
7988
  reduceRecords,
7517
7989
  ref,
7518
7990
  removeAuthenticator,
@@ -7534,6 +8006,8 @@ export {
7534
8006
  saveVaultPolicy,
7535
8007
  sha256Hex,
7536
8008
  sum,
8009
+ unwrapDeksFromBlob,
8010
+ unwrapDeksFromPaperEntry,
7537
8011
  unwrapMagicLinkGrant,
7538
8012
  validateI18nTextValue,
7539
8013
  validatePassphrase,