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

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-NZ4XCIKS.js → chunk-2WGMYBYS.js} +3 -3
  9. package/dist/{chunk-3WCRU7TI.js → chunk-7XBQS42M.js} +2 -2
  10. package/dist/{chunk-CL37QSND.js → chunk-HC7Z5EQZ.js} +2 -2
  11. package/dist/{chunk-B6HF6NTZ.js → chunk-PJK6IOBC.js} +1 -1
  12. package/dist/chunk-PJK6IOBC.js.map +1 -0
  13. package/dist/{chunk-KPF2HHPI.js → chunk-R2ZTGEVP.js} +2 -2
  14. package/dist/{chunk-GILMPJXB.js → chunk-RSPLI376.js} +2 -2
  15. package/dist/{chunk-XCL3WP6J.js → chunk-SCZXXXU4.js} +2 -1
  16. package/dist/{chunk-XCL3WP6J.js.map → chunk-SCZXXXU4.js.map} +1 -1
  17. package/dist/{chunk-INSJBB5W.js → chunk-TOQK4KAN.js} +3 -3
  18. package/dist/{chunk-UFL4DUEV.js → chunk-VQBTTTUN.js} +1 -1
  19. package/dist/chunk-VQBTTTUN.js.map +1 -0
  20. package/dist/{chunk-FAAWLVTF.js → chunk-WN6UK7PM.js} +2 -2
  21. package/dist/{chunk-N2LMZKLR.js → chunk-Y4CMTMUW.js} +2 -2
  22. package/dist/{chunk-6IJQ27XN.js → chunk-YVFTBQHL.js} +14 -4
  23. package/dist/chunk-YVFTBQHL.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-Dk14V6lX.d.cts → dev-unlock-BZKx666y.d.cts} +1 -1
  28. package/dist/{dev-unlock-CcJ1qIi7.d.ts → dev-unlock-BygpnIWe.d.ts} +1 -1
  29. package/dist/{hash-1Xsqx1jl.d.ts → hash-B0eU2Qv9.d.ts} +1 -1
  30. package/dist/{hash-h_2U3TFb.d.cts → hash-CIyfmKsg.d.cts} +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-Dp4tKCjX.d.ts} +1 -1
  41. package/dist/{index-Cvb0efA_.d.cts → index-DsVbTDZI.d.cts} +1 -1
  42. package/dist/index.cjs +384 -57
  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 +377 -72
  47. package/dist/index.js.map +1 -1
  48. package/dist/{ledger-5V67MAIL.js → ledger-UQIMMKO5.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-3QTQADDW.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-D-6bmD2c.d.ts → types-DD9eKKNc.d.ts} +644 -72
  74. package/dist/{types-D3QLmhlk.d.cts → types-arFMsCtn.d.cts} +644 -72
  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-NZ4XCIKS.js.map → chunk-2WGMYBYS.js.map} +0 -0
  80. /package/dist/{chunk-3WCRU7TI.js.map → chunk-7XBQS42M.js.map} +0 -0
  81. /package/dist/{chunk-CL37QSND.js.map → chunk-HC7Z5EQZ.js.map} +0 -0
  82. /package/dist/{chunk-KPF2HHPI.js.map → chunk-R2ZTGEVP.js.map} +0 -0
  83. /package/dist/{chunk-GILMPJXB.js.map → chunk-RSPLI376.js.map} +0 -0
  84. /package/dist/{chunk-INSJBB5W.js.map → chunk-TOQK4KAN.js.map} +0 -0
  85. /package/dist/{chunk-FAAWLVTF.js.map → chunk-WN6UK7PM.js.map} +0 -0
  86. /package/dist/{chunk-N2LMZKLR.js.map → chunk-Y4CMTMUW.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-UQIMMKO5.js.map} +0 -0
  89. /package/dist/{public-envelope-DFJZHXVH.js.map → public-envelope-3QTQADDW.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-WN6UK7PM.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-RSPLI376.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-7XBQS42M.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-2WGMYBYS.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-TOQK4KAN.js";
93
93
  import {
94
94
  PresenceHandle,
95
95
  SyncEngine,
96
96
  SyncTransaction
97
- } from "./chunk-CL37QSND.js";
97
+ } from "./chunk-HC7Z5EQZ.js";
98
98
  import {
99
99
  USER_ENVELOPE_COLLECTION,
100
100
  USER_ENVELOPE_MAX_BYTES,
@@ -124,7 +124,7 @@ import {
124
124
  rotateKeys,
125
125
  saveUserEnvelope,
126
126
  validatePassphrase
127
- } from "./chunk-6IJQ27XN.js";
127
+ } from "./chunk-YVFTBQHL.js";
128
128
  import {
129
129
  BUNDLE_STORE_POLICY,
130
130
  INDEXED_STORE_POLICY,
@@ -144,7 +144,7 @@ import {
144
144
  revokeAllSessions,
145
145
  revokeSession,
146
146
  validateSessionPolicy
147
- } from "./chunk-UFL4DUEV.js";
147
+ } from "./chunk-VQBTTTUN.js";
148
148
  import {
149
149
  generateULID,
150
150
  isULID
@@ -161,7 +161,7 @@ import {
161
161
  LedgerStore,
162
162
  applyPatch,
163
163
  computePatch
164
- } from "./chunk-N2LMZKLR.js";
164
+ } from "./chunk-Y4CMTMUW.js";
165
165
  import {
166
166
  canonicalJson,
167
167
  envelopePayloadHash,
@@ -216,14 +216,14 @@ import {
216
216
  detectMimeType,
217
217
  isPreCompressed,
218
218
  runCompaction
219
- } from "./chunk-KPF2HHPI.js";
219
+ } from "./chunk-R2ZTGEVP.js";
220
220
  import {
221
221
  NOYDB_BACKUP_VERSION,
222
222
  NOYDB_FORMAT_VERSION,
223
223
  NOYDB_KEYRING_VERSION,
224
224
  NOYDB_SYNC_VERSION,
225
225
  createStore
226
- } from "./chunk-B6HF6NTZ.js";
226
+ } from "./chunk-PJK6IOBC.js";
227
227
  import {
228
228
  base64ToBuffer,
229
229
  bufferToBase64,
@@ -434,14 +434,22 @@ async function enrollAuthenticator(store, vault, keyring, options) {
434
434
  `enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
435
435
  );
436
436
  }
437
- const slot = {
437
+ const base = {
438
438
  id: options.id,
439
439
  method: options.method,
440
440
  enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
441
441
  enrolled_via_tier: options.enrolled_via_tier ?? 1,
442
- wrapped_kek: options.wrapped_kek,
443
442
  meta: options.meta
444
443
  };
444
+ const slot = options.wrapKind === "deks" ? {
445
+ ...base,
446
+ wrapKind: "deks",
447
+ wrapped_deks: options.wrapped_deks,
448
+ iv: options.iv
449
+ } : {
450
+ ...base,
451
+ wrapped_kek: options.wrapped_kek
452
+ };
445
453
  const next = appendSlot(keyring, slot);
446
454
  await persistKeyring(store, vault, next);
447
455
  return next;
@@ -545,51 +553,38 @@ var RecoveryProfileNotImplementedError = class extends NoydbError {
545
553
  }
546
554
  };
547
555
 
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 [];
556
+ // src/team/wrapped-deks.ts
557
+ var PBKDF2_ITERATIONS = 6e5;
558
+ var SALT_BYTES = 32;
559
+ var IV_BYTES = 12;
560
+ var subtle = globalThis.crypto.subtle;
561
+ async function mintWrappedDeksBlob(deks, credential) {
562
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
563
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
564
+ const wrappingKey = await deriveWrappingKey(credential, salt);
565
+ const exported = {};
566
+ for (const [coll, dek] of deks) {
567
+ const raw = await subtle.exportKey("raw", dek);
568
+ exported[coll] = bytesToBase64(new Uint8Array(raw));
559
569
  }
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)
570
+ const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
571
+ const ciphertext = await subtle.encrypt(
572
+ { name: "AES-GCM", iv },
573
+ wrappingKey,
574
+ plaintext
575
+ );
576
+ return {
577
+ salt: bytesToBase64(salt),
578
+ iv: bytesToBase64(iv),
579
+ wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
573
580
  };
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
581
  }
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));
582
+ async function unwrapDeksFromBlob(blob, credential) {
583
+ const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
589
584
  const plaintext = await subtle.decrypt(
590
- { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
585
+ { name: "AES-GCM", iv: base64ToBytes(blob.iv) },
591
586
  wrappingKey,
592
- base64ToBytes(entry.wrappedDeks)
587
+ base64ToBytes(blob.wrappedDeks)
593
588
  );
594
589
  const parsed = JSON.parse(new TextDecoder().decode(plaintext));
595
590
  const deks = /* @__PURE__ */ new Map();
@@ -606,10 +601,10 @@ async function unwrapDeksFromPaperEntry(entry, code) {
606
601
  }
607
602
  return deks;
608
603
  }
609
- async function deriveRecoveryWrappingKey(code, salt) {
604
+ async function deriveWrappingKey(credential, salt) {
610
605
  const ikm = await subtle.importKey(
611
606
  "raw",
612
- new TextEncoder().encode(code),
607
+ new TextEncoder().encode(credential),
613
608
  "PBKDF2",
614
609
  false,
615
610
  ["deriveKey"]
@@ -618,7 +613,7 @@ async function deriveRecoveryWrappingKey(code, salt) {
618
613
  {
619
614
  name: "PBKDF2",
620
615
  salt,
621
- iterations: RECOVERY_PBKDF2_ITERATIONS,
616
+ iterations: PBKDF2_ITERATIONS,
622
617
  hash: "SHA-256"
623
618
  },
624
619
  ikm,
@@ -627,6 +622,11 @@ async function deriveRecoveryWrappingKey(code, salt) {
627
622
  ["encrypt", "decrypt"]
628
623
  );
629
624
  }
625
+ function bytesToBase64(b) {
626
+ let s = "";
627
+ for (const x of b) s += String.fromCharCode(x);
628
+ return btoa(s);
629
+ }
630
630
  function base64ToBytes(b64) {
631
631
  const s = atob(b64);
632
632
  const out = new Uint8Array(s.length);
@@ -634,6 +634,55 @@ function base64ToBytes(b64) {
634
634
  return out;
635
635
  }
636
636
 
637
+ // src/team/recovery.ts
638
+ var PAPER_DOC_ID = "recovery-paper";
639
+ async function loadPaperRecoveryEntries(store, vault) {
640
+ const env = await store.get(vault, "_meta", PAPER_DOC_ID);
641
+ if (!env) return [];
642
+ try {
643
+ const doc = JSON.parse(env._data);
644
+ if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
645
+ return doc.entries;
646
+ } catch {
647
+ return [];
648
+ }
649
+ }
650
+ async function savePaperRecoveryEntries(store, vault, entries) {
651
+ const doc = {
652
+ _noydb_recovery: 1,
653
+ profile: "paper",
654
+ entries
655
+ };
656
+ const envelope = {
657
+ _noydb: NOYDB_FORMAT_VERSION,
658
+ _v: 1,
659
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
660
+ _iv: "",
661
+ _data: JSON.stringify(doc)
662
+ };
663
+ await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
664
+ }
665
+ async function burnPaperRecoveryEntry(store, vault, codeId) {
666
+ const entries = await loadPaperRecoveryEntries(store, vault);
667
+ const remaining = entries.filter((e) => e.codeId !== codeId);
668
+ await savePaperRecoveryEntries(store, vault, remaining);
669
+ }
670
+ async function hasRecoveryEnrolled(store, vault) {
671
+ const paper = await loadPaperRecoveryEntries(store, vault);
672
+ return paper.length > 0;
673
+ }
674
+ async function mintPaperRecoveryEntry(deks, code, codeId) {
675
+ const blob = await mintWrappedDeksBlob(deks, code);
676
+ return {
677
+ ...blob,
678
+ codeId,
679
+ enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
680
+ };
681
+ }
682
+ async function unwrapDeksFromPaperEntry(entry, code) {
683
+ return unwrapDeksFromBlob(entry, code);
684
+ }
685
+
637
686
  // src/team/rotate-recover.ts
638
687
  async function rotatePassphrase(store, vault, userId, input) {
639
688
  if (!input.allowWeakPassphrase) {
@@ -656,14 +705,53 @@ async function rotatePassphrase(store, vault, userId, input) {
656
705
  for (const [coll, dek] of deks) {
657
706
  wrappedDeks[coll] = await wrapKey(dek, newKek);
658
707
  }
708
+ const oldSlots = file.authenticators ?? [];
709
+ const newSlots = [];
710
+ if (input.slotCeremonies && oldSlots.length > 0) {
711
+ for (const oldSlot of oldSlots) {
712
+ const ceremony = input.slotCeremonies[oldSlot.id];
713
+ if (!ceremony) continue;
714
+ const result = await ceremony({ newKek, newDeks: deks, oldSlot });
715
+ if (result.id !== oldSlot.id) {
716
+ throw new ValidationError(
717
+ `slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
718
+ );
719
+ }
720
+ if (result.method !== oldSlot.method) {
721
+ throw new ValidationError(
722
+ `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.`
723
+ );
724
+ }
725
+ const baseFields = {
726
+ id: result.id,
727
+ method: result.method,
728
+ // Preserve original enrolled_at — rotation is rewrapping, not
729
+ // re-enrollment. The slot's enrolment timestamp tracks when
730
+ // the user originally added the slot, not when it was last
731
+ // rewrapped. Forensics consumers reading enrolled_at are
732
+ // tracking the slot's ORIGIN, not its CURRENT wrapping.
733
+ enrolled_at: oldSlot.enrolled_at,
734
+ enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
735
+ meta: result.meta
736
+ };
737
+ const newSlot = result.wrapKind === "deks" ? {
738
+ ...baseFields,
739
+ wrapKind: "deks",
740
+ wrapped_deks: result.wrapped_deks,
741
+ iv: result.iv
742
+ } : {
743
+ ...baseFields,
744
+ wrapped_kek: result.wrapped_kek
745
+ };
746
+ newSlots.push(newSlot);
747
+ }
748
+ }
659
749
  const next = {
660
750
  ...file,
661
751
  _noydb_keyring: NOYDB_KEYRING_VERSION,
662
752
  deks: wrappedDeks,
663
753
  salt: bufferToBase64(newSalt),
664
- // Tier-2 slots reference the old KEK — drop them. User
665
- // re-enrols afterwards via `db.enrollAuthenticator`.
666
- authenticators: []
754
+ authenticators: newSlots
667
755
  };
668
756
  await writeKeyringFile(store, vault, userId, next);
669
757
  return {
@@ -674,7 +762,7 @@ async function rotatePassphrase(store, vault, userId, input) {
674
762
  deks,
675
763
  kek: newKek,
676
764
  salt: newSalt,
677
- authenticators: [],
765
+ authenticators: newSlots,
678
766
  ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
679
767
  ...file.import_capability !== void 0 && { importCapability: file.import_capability }
680
768
  };
@@ -1148,6 +1236,70 @@ function sanitizeId(s) {
1148
1236
  return s.replace(/[^a-zA-Z0-9]/g, "_");
1149
1237
  }
1150
1238
 
1239
+ // src/team/peer-recover.ts
1240
+ var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
1241
+ function canRecover(callerRole, targetRole) {
1242
+ if (callerRole === "owner") return true;
1243
+ if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
1244
+ return false;
1245
+ }
1246
+ async function recoverUser(store, vault, callerKeyring, options) {
1247
+ const env = await store.get(vault, "_keyring", options.userId);
1248
+ if (!env) {
1249
+ throw new NoAccessError(
1250
+ `recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
1251
+ );
1252
+ }
1253
+ const target = JSON.parse(env._data);
1254
+ const targetRole = options.role ?? target.role;
1255
+ if (!canRecover(callerKeyring.role, targetRole)) {
1256
+ throw new PermissionDeniedError(
1257
+ `Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
1258
+ );
1259
+ }
1260
+ if (!canRecover(callerKeyring.role, target.role)) {
1261
+ throw new PermissionDeniedError(
1262
+ `Role "${callerKeyring.role}" cannot recover role "${target.role}"`
1263
+ );
1264
+ }
1265
+ for (const coll of Object.keys(target.deks)) {
1266
+ if (!callerKeyring.deks.has(coll)) {
1267
+ throw new PrivilegeEscalationError(coll);
1268
+ }
1269
+ }
1270
+ if (options.validatePassphrase && !options.allowWeakPassphrase) {
1271
+ assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
1272
+ }
1273
+ const newSalt = generateSalt();
1274
+ const newKek = await deriveKey(options.passphrase, newSalt);
1275
+ const wrappedDeks = {};
1276
+ for (const coll of Object.keys(target.deks)) {
1277
+ const callerDek = callerKeyring.deks.get(coll);
1278
+ if (!callerDek) {
1279
+ throw new PrivilegeEscalationError(coll);
1280
+ }
1281
+ wrappedDeks[coll] = await wrapKey(callerDek, newKek);
1282
+ }
1283
+ const next = {
1284
+ ...target,
1285
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
1286
+ role: targetRole,
1287
+ display_name: options.displayName ?? target.display_name,
1288
+ deks: wrappedDeks,
1289
+ salt: bufferToBase64(newSalt),
1290
+ granted_by: callerKeyring.userId,
1291
+ authenticators: []
1292
+ };
1293
+ const envelope = {
1294
+ _noydb: 1,
1295
+ _v: 1,
1296
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
1297
+ _iv: "",
1298
+ _data: JSON.stringify(next)
1299
+ };
1300
+ await store.put(vault, "_keyring", options.userId, envelope);
1301
+ }
1302
+
1151
1303
  // src/crdt/strategy.ts
1152
1304
  var NOT_ENABLED = new Error(
1153
1305
  'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
@@ -4804,7 +4956,12 @@ var Vault = class {
4804
4956
  * collection.
4805
4957
  */
4806
4958
  async delegate(opts) {
4807
- const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-XDJCBTI2.js");
4959
+ const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
4960
+ if (!this.keyring.kek) {
4961
+ throw new ValidationError(
4962
+ "issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
4963
+ );
4964
+ }
4808
4965
  const targetKek = this.keyring.kek;
4809
4966
  const delegationsDek = await this.getDEK(DELEGATIONS_COLLECTION2);
4810
4967
  return issueDelegation2(
@@ -4821,7 +4978,7 @@ var Vault = class {
4821
4978
  * if the id does not exist.
4822
4979
  */
4823
4980
  async revokeDelegation(id) {
4824
- const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-XDJCBTI2.js");
4981
+ const { revokeDelegation: revokeDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await import("./delegation-2DBS2EOH.js");
4825
4982
  await revokeDelegation2(this.adapter, this.name, id);
4826
4983
  void DELEGATIONS_COLLECTION2;
4827
4984
  }
@@ -5221,7 +5378,7 @@ var Vault = class {
5221
5378
  * @see docs/subsystems/public-envelope.md
5222
5379
  */
5223
5380
  async getPublicEnvelope(opts = {}) {
5224
- const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-DFJZHXVH.js");
5381
+ const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-3QTQADDW.js");
5225
5382
  return readPublicEnvelope2(this.adapter, this.name, opts);
5226
5383
  }
5227
5384
  /**
@@ -5795,7 +5952,23 @@ var PERSONAL_POLICY = Object.freeze({
5795
5952
  gates: {
5796
5953
  "rotate-passphrase": {
5797
5954
  minTier: 1,
5798
- factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
5955
+ // Any second factor satisfies the gate — off-device kinds (TOTP,
5956
+ // email-OTP, paper recovery, roaming WebAuthn) are the strongest;
5957
+ // platform-bound kinds (platform WebAuthn, password, PIN) are
5958
+ // accepted because requiring "something off-device" is overkill
5959
+ // for personal/SMB threat models. Consumers needing the off-device
5960
+ // guarantee should use STRICT_POLICY or override this gate.
5961
+ factors: [{
5962
+ anyOf: [
5963
+ "totp",
5964
+ "email-otp",
5965
+ "recovery",
5966
+ "webauthn-roaming",
5967
+ "webauthn-platform",
5968
+ "password",
5969
+ "pin"
5970
+ ]
5971
+ }]
5799
5972
  },
5800
5973
  "recover-passphrase": {
5801
5974
  minTier: 1,
@@ -5806,6 +5979,12 @@ var PERSONAL_POLICY = Object.freeze({
5806
5979
  "rotate-unlock": { minTier: 2 },
5807
5980
  "enroll-user": { minTier: 1 },
5808
5981
  "revoke-user": { minTier: 1 },
5982
+ // Peer-recovery is a high-trust intentional op — co-owners
5983
+ // recovering each other should not need an off-device factor in
5984
+ // the personal/SMB threat model (the partner is already vetted by
5985
+ // virtue of being a co-owner). Tier-1 unlock is the floor; the
5986
+ // STRICT preset adds a recovery/email-OTP requirement.
5987
+ "peer-recover-user": { minTier: 1 },
5809
5988
  "export-bundle": { minTier: 1 },
5810
5989
  "export-plaintext": {
5811
5990
  minTier: 1,
@@ -5859,6 +6038,19 @@ var STRICT_POLICY = Object.freeze({
5859
6038
  minTier: 1,
5860
6039
  factors: [{ anyOf: ["totp", "email-otp"] }]
5861
6040
  },
6041
+ // STRICT peer-recovery: the issuer must present a recovery code
6042
+ // OR a fresh off-device second factor at the moment of recovery.
6043
+ // This binds the high-trust operation to a verifiable proof
6044
+ // (recovery sheet photographed by an attacker won't suffice —
6045
+ // they'd also need tier-1 unlock first; this gate is the freshness
6046
+ // binding on top). Roaming WebAuthn (YubiKey-class hardware key)
6047
+ // accepted; platform-bound kinds (Touch ID, password, PIN)
6048
+ // intentionally excluded under STRICT because they don't survive
6049
+ // device theft — the off-device requirement is the whole point.
6050
+ "peer-recover-user": {
6051
+ minTier: 1,
6052
+ factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
6053
+ },
5862
6054
  "export-bundle": {
5863
6055
  minTier: 1,
5864
6056
  factors: [{ anyOf: ["totp", "email-otp"] }],
@@ -7005,22 +7197,108 @@ var Noydb = class {
7005
7197
  async recoverPassphrase(vault, input, factors) {
7006
7198
  await this.checkGate(vault, "recover-passphrase", factors);
7007
7199
  const userId = this.options.user;
7200
+ const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
7008
7201
  const next = await recoverPassphrase(this.options.store, vault, userId, input);
7009
7202
  this.keyringCache.set(vault, next);
7203
+ const rotateRemaining = input.rotateRemainingCodes ?? true;
7204
+ const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
7205
+ if (!rotateRemaining || remainingAfterBurn === 0) {
7206
+ return { newCodes: [] };
7207
+ }
7208
+ const codeGen = input.codeGenerator ?? generateULID;
7209
+ const newCodeCount = input.newCodeCount ?? remainingAfterBurn;
7210
+ const codes = [];
7211
+ const newEntries = [];
7212
+ for (let i = 0; i < newCodeCount; i++) {
7213
+ const rawCode = codeGen();
7214
+ const entry = await mintPaperRecoveryEntry(next.deks, rawCode, generateULID());
7215
+ codes.push(rawCode);
7216
+ newEntries.push(entry);
7217
+ }
7218
+ await savePaperRecoveryEntries(this.options.store, vault, newEntries);
7219
+ return { newCodes: codes };
7220
+ }
7221
+ /**
7222
+ * Atomic peer-recovery — re-wraps an EXISTING user's keyring under
7223
+ * a fresh temp passphrase in a single store write. Closes #34's
7224
+ * partial-failure window (the previous compose-from-primitives
7225
+ * pattern was `db.revoke + db.grant`, two writes — if the issuer
7226
+ * cancelled between them the target was locked out entirely).
7227
+ *
7228
+ * Different from `db.revoke + db.grant`:
7229
+ *
7230
+ * - Same `userId`, role, permissions, capabilities preserved.
7231
+ * - DEKs unchanged → every other principal in the vault keeps
7232
+ * access. No key rotation.
7233
+ * - Allows owner→owner natively (#33). The existing
7234
+ * `db.revoke` retains its block — peer-recovery is a separate,
7235
+ * intentionally-named operation.
7236
+ * - Tier-2 slots dropped (they wrap the old KEK).
7237
+ *
7238
+ * Gated by `peer-recover-user`; `STRICT_POLICY` requires a
7239
+ * recovery / TOTP / email-OTP factor proof at the moment of
7240
+ * recovery, so the issuer affirmatively re-asserts identity.
7241
+ *
7242
+ * The recipient should call `db.rotatePassphrase` on first session
7243
+ * to choose their own phrase — the temp acts as a single-use
7244
+ * bridge.
7245
+ *
7246
+ * ```ts
7247
+ * await db.recoverUser('acme', {
7248
+ * userId: 'bob',
7249
+ * passphrase: 'temporary-correct-horse-battery-staple-printer',
7250
+ * }, { factors: [{ kind: 'recovery' }] })
7251
+ * // Bob opens createNoydb({ user: 'bob', secret: tempPhrase })
7252
+ * // and immediately calls db.rotatePassphrase to set his own.
7253
+ * ```
7254
+ *
7255
+ * @throws `NoAccessError` when no keyring exists for the target.
7256
+ * @throws `PermissionDeniedError` when the caller's role can't
7257
+ * recover the target's role (admin→owner is blocked even
7258
+ * under recovery).
7259
+ * @throws `PrivilegeEscalationError` when the caller lacks a DEK
7260
+ * the target previously had access to.
7261
+ *
7262
+ * @see #33 #34 — the issues this method closes.
7263
+ */
7264
+ async recoverUser(vault, options, factors) {
7265
+ await this.checkGate(vault, "peer-recover-user", factors);
7266
+ const callerKeyring = await this.getKeyring(vault);
7267
+ await recoverUser(this.options.store, vault, callerKeyring, options);
7268
+ if (options.userId === this.options.user) {
7269
+ this.keyringCache.delete(vault);
7270
+ }
7010
7271
  }
7011
7272
  /**
7012
7273
  * 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.
7274
+ * profile.
7275
+ *
7276
+ * The hub wraps the user's DEK set (not the KEK) under a code-derived
7277
+ * AES-GCM key — see `team/recovery.ts` for the rationale. The mint
7278
+ * helper {@link mintPaperRecoveryEntry} is the canonical primitive;
7279
+ * pair it with `db.getKeyring(vault)` to obtain the live DEK set:
7017
7280
  *
7018
7281
  * ```ts
7019
- * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
7020
- * const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
7282
+ * import { mintPaperRecoveryEntry } from '@noy-db/hub'
7283
+ *
7284
+ * const keyring = await db.getKeyring('acme')
7285
+ * const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...]
7286
+ * const entries = await Promise.all(
7287
+ * codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)),
7288
+ * )
7021
7289
  * await db.enrollRecovery('acme', { profile: 'paper', entries })
7022
7290
  * showCodesToUser(codes)
7023
7291
  * ```
7292
+ *
7293
+ * As of pre.8, `@noy-db/on-recovery`'s `generateRecoveryCodeSet`
7294
+ * delegates to `mintPaperRecoveryEntry` internally — its output is
7295
+ * fed directly to this API. Pick whichever fits your code-gen layer:
7296
+ *
7297
+ * ```ts
7298
+ * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
7299
+ * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 })
7300
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
7301
+ * ```
7024
7302
  */
7025
7303
  async enrollRecovery(vault, enrollment) {
7026
7304
  if (enrollment.profile !== "paper") {
@@ -7074,7 +7352,29 @@ var Noydb = class {
7074
7352
  clearQuickUnlock(vault) {
7075
7353
  this.quickUnlock.delete(vault);
7076
7354
  }
7077
- /** Get or load the keyring for a vault. */
7355
+ /**
7356
+ * Public accessor for the unlocked keyring of a vault — issue #28.
7357
+ *
7358
+ * Returns the cached `UnlockedKeyring` (already in memory after
7359
+ * `createNoydb` + first vault touch); loads it on demand if absent.
7360
+ * Used by `@noy-db/on-*` ceremonies that need the live DEK set
7361
+ * (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
7362
+ * enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
7363
+ * don't have a hub-side wrapper).
7364
+ *
7365
+ * No new permission gate — this is an accessor over already-unlocked
7366
+ * state. The keyring is materialized only after the calling session
7367
+ * has unlocked the vault at tier 1, 2, or 3, so exposing it does not
7368
+ * widen access. Throws `ValidationError` when encryption is enabled
7369
+ * and no `secret` / `getKeyring` is configured.
7370
+ *
7371
+ * ```ts
7372
+ * const keyring = await db.getKeyring('acme')
7373
+ * // keyring.deks: Map<collection, CryptoKey>
7374
+ * // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
7375
+ * // keyring.role / .permissions / .authenticators
7376
+ * ```
7377
+ */
7078
7378
  async getKeyring(vault) {
7079
7379
  if (this.options.encrypt === false) {
7080
7380
  return createPlaintextKeyring(this.options.user);
@@ -7503,6 +7803,8 @@ export {
7503
7803
  mergeCrdtStates,
7504
7804
  mergePolicy,
7505
7805
  min,
7806
+ mintPaperRecoveryEntry,
7807
+ mintWrappedDeksBlob,
7506
7808
  paddedIndex,
7507
7809
  parseBytes,
7508
7810
  parseIndex,
@@ -7513,6 +7815,7 @@ export {
7513
7815
  readNoydbBundlePublicEnvelope,
7514
7816
  readPath,
7515
7817
  readPublicEnvelope,
7818
+ recoverUser,
7516
7819
  reduceRecords,
7517
7820
  ref,
7518
7821
  removeAuthenticator,
@@ -7534,6 +7837,8 @@ export {
7534
7837
  saveVaultPolicy,
7535
7838
  sha256Hex,
7536
7839
  sum,
7840
+ unwrapDeksFromBlob,
7841
+ unwrapDeksFromPaperEntry,
7537
7842
  unwrapMagicLinkGrant,
7538
7843
  validateI18nTextValue,
7539
7844
  validatePassphrase,