@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.cjs CHANGED
@@ -1919,6 +1919,7 @@ async function loadActiveDelegations(store, vault, user, delegationsDek, now = /
1919
1919
  }
1920
1920
  if (token.toUser !== user.userId) continue;
1921
1921
  if (token.until <= nowIso) continue;
1922
+ if (!user.kek) continue;
1922
1923
  let dek;
1923
1924
  try {
1924
1925
  dek = await unwrapKey(token.wrappedDek, user.kek);
@@ -2163,6 +2164,8 @@ __export(src_exports, {
2163
2164
  mergeCrdtStates: () => mergeCrdtStates,
2164
2165
  mergePolicy: () => mergePolicy,
2165
2166
  min: () => min,
2167
+ mintPaperRecoveryEntry: () => mintPaperRecoveryEntry,
2168
+ mintWrappedDeksBlob: () => mintWrappedDeksBlob,
2166
2169
  paddedIndex: () => paddedIndex,
2167
2170
  parseBytes: () => parseBytes,
2168
2171
  parseIndex: () => parseIndex,
@@ -2173,6 +2176,7 @@ __export(src_exports, {
2173
2176
  readNoydbBundlePublicEnvelope: () => readNoydbBundlePublicEnvelope,
2174
2177
  readPath: () => readPath,
2175
2178
  readPublicEnvelope: () => readPublicEnvelope,
2179
+ recoverUser: () => recoverUser,
2176
2180
  reduceRecords: () => reduceRecords,
2177
2181
  ref: () => ref,
2178
2182
  removeAuthenticator: () => removeAuthenticator,
@@ -2194,6 +2198,8 @@ __export(src_exports, {
2194
2198
  saveVaultPolicy: () => saveVaultPolicy,
2195
2199
  sha256Hex: () => sha256Hex3,
2196
2200
  sum: () => sum,
2201
+ unwrapDeksFromBlob: () => unwrapDeksFromBlob,
2202
+ unwrapDeksFromPaperEntry: () => unwrapDeksFromPaperEntry,
2197
2203
  unwrapMagicLinkGrant: () => unwrapMagicLinkGrant,
2198
2204
  validateI18nTextValue: () => validateI18nTextValue,
2199
2205
  validatePassphrase: () => validatePassphrase,
@@ -4869,6 +4875,9 @@ var SUGGESTIONS = {
4869
4875
  "repeated-adjacent": "Avoid repeating the same word twice in a row."
4870
4876
  };
4871
4877
  function validatePassphrase(s, opts) {
4878
+ if (opts?.customValidator) {
4879
+ return opts.customValidator(s);
4880
+ }
4872
4881
  const minWords = opts?.minWords ?? DEFAULT_MIN_WORDS;
4873
4882
  const minWordLength = opts?.minWordLength ?? DEFAULT_MIN_WORD_LENGTH;
4874
4883
  const rejectRepeated = opts?.rejectRepeatedAdjacent ?? true;
@@ -4881,7 +4890,8 @@ function validatePassphrase(s, opts) {
4881
4890
  if (s.includes(" ")) {
4882
4891
  return { ok: false, reason: "double-space" };
4883
4892
  }
4884
- if (!/^[a-z]+( [a-z]+)*$/.test(s)) {
4893
+ const charPattern = opts?.pattern ?? /^[a-z]+( [a-z]+)*$/;
4894
+ if (!charPattern.test(s)) {
4885
4895
  return { ok: false, reason: "invalid-chars" };
4886
4896
  }
4887
4897
  const words = s.split(" ");
@@ -5002,6 +5012,11 @@ function canRevoke(callerRole, targetRole) {
5002
5012
  if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
5003
5013
  return false;
5004
5014
  }
5015
+ function canUpdateRole(callerRole, targetRole) {
5016
+ if (callerRole === "owner") return true;
5017
+ if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
5018
+ return false;
5019
+ }
5005
5020
  async function loadKeyring(adapter, vault, userId, passphrase) {
5006
5021
  const envelope = await adapter.get(vault, "_keyring", userId);
5007
5022
  if (!envelope) {
@@ -5195,6 +5210,37 @@ async function revoke(adapter, vault, callerKeyring, options) {
5195
5210
  await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections]);
5196
5211
  }
5197
5212
  }
5213
+ async function updateKeyringIdentity(adapter, vault, callerKeyring, options) {
5214
+ if (options.role === void 0 && options.displayName === void 0 && options.permissions === void 0) {
5215
+ throw new ValidationError(
5216
+ `updateUser: at least one of role / displayName / permissions must be provided (userId: "${options.userId}").`
5217
+ );
5218
+ }
5219
+ const env = await adapter.get(vault, "_keyring", options.userId);
5220
+ if (!env) {
5221
+ throw new NoAccessError(
5222
+ `updateUser: user "${options.userId}" has no keyring in vault "${vault}".`
5223
+ );
5224
+ }
5225
+ const target = JSON.parse(env._data);
5226
+ if (!canUpdateRole(callerKeyring.role, target.role)) {
5227
+ throw new PermissionDeniedError(
5228
+ `Role "${callerKeyring.role}" cannot update a keyring with role "${target.role}"`
5229
+ );
5230
+ }
5231
+ if (options.role !== void 0 && options.role !== target.role && !canUpdateRole(callerKeyring.role, options.role)) {
5232
+ throw new PermissionDeniedError(
5233
+ `Role "${callerKeyring.role}" cannot promote target to role "${options.role}"`
5234
+ );
5235
+ }
5236
+ const next = {
5237
+ ...target,
5238
+ ...options.role !== void 0 && { role: options.role },
5239
+ ...options.displayName !== void 0 && { display_name: options.displayName },
5240
+ ...options.permissions !== void 0 && { permissions: options.permissions }
5241
+ };
5242
+ await writeKeyringFile(adapter, vault, options.userId, next);
5243
+ }
5198
5244
  async function rotateKeys(adapter, vault, callerKeyring, collections) {
5199
5245
  const newDeks = /* @__PURE__ */ new Map();
5200
5246
  for (const collName of collections) {
@@ -5393,6 +5439,11 @@ function hasAccess(keyring, collectionName) {
5393
5439
  return collectionName in keyring.permissions;
5394
5440
  }
5395
5441
  async function persistKeyring(adapter, vault, keyring) {
5442
+ if (!keyring.kek) {
5443
+ throw new ValidationError(
5444
+ "persistKeyring: keyring.kek is null \u2014 cannot wrap DEKs without the KEK. This typically means the keyring was opened via tier-3 PIN resume, session restore, or a wrap-DEKs tier-2 unlock. Re-authenticate at tier 1 (passphrase) before persisting."
5445
+ );
5446
+ }
5396
5447
  const wrappedDeks = {};
5397
5448
  for (const [collName, dek] of keyring.deks) {
5398
5449
  wrappedDeks[collName] = await wrapKey(dek, keyring.kek);
@@ -5470,18 +5521,58 @@ async function enrollAuthenticator(store, vault, keyring, options) {
5470
5521
  `enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
5471
5522
  );
5472
5523
  }
5473
- const slot = {
5524
+ const base = {
5474
5525
  id: options.id,
5475
5526
  method: options.method,
5476
5527
  enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
5477
5528
  enrolled_via_tier: options.enrolled_via_tier ?? 1,
5478
- wrapped_kek: options.wrapped_kek,
5479
5529
  meta: options.meta
5480
5530
  };
5531
+ const slot = options.wrapKind === "deks" ? {
5532
+ ...base,
5533
+ wrapKind: "deks",
5534
+ wrapped_deks: options.wrapped_deks,
5535
+ iv: options.iv
5536
+ } : {
5537
+ ...base,
5538
+ wrapped_kek: options.wrapped_kek
5539
+ };
5481
5540
  const next = appendSlot(keyring, slot);
5482
5541
  await persistKeyring(store, vault, next);
5483
5542
  return next;
5484
5543
  }
5544
+ async function updateAuthenticator(store, vault, keyring, slotId, options) {
5545
+ if (options.meta === void 0) {
5546
+ throw new ValidationError(
5547
+ `updateAuthenticator: at least one of meta must be provided (slotId: "${slotId}").`
5548
+ );
5549
+ }
5550
+ const idx = keyring.authenticators.findIndex((a) => a.id === slotId);
5551
+ if (idx === -1) {
5552
+ throw new NoAccessError(
5553
+ `updateAuthenticator: slot "${slotId}" not found in vault "${vault}".`
5554
+ );
5555
+ }
5556
+ const existing = keyring.authenticators[idx];
5557
+ const mergedMeta = { ...existing.meta };
5558
+ for (const [k, v] of Object.entries(options.meta)) {
5559
+ if (v === void 0) continue;
5560
+ if (v === null) {
5561
+ delete mergedMeta[k];
5562
+ continue;
5563
+ }
5564
+ mergedMeta[k] = v;
5565
+ }
5566
+ const next = { ...existing, meta: mergedMeta };
5567
+ const nextSlots = [...keyring.authenticators];
5568
+ nextSlots[idx] = next;
5569
+ const nextKeyring = {
5570
+ ...keyring,
5571
+ authenticators: nextSlots
5572
+ };
5573
+ await persistKeyring(store, vault, nextKeyring);
5574
+ return nextKeyring;
5575
+ }
5485
5576
  async function removeAuthenticator(store, vault, keyring, slotId) {
5486
5577
  const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
5487
5578
  if (filtered.length === keyring.authenticators.length) {
@@ -5589,50 +5680,39 @@ var RecoveryProfileNotImplementedError = class extends NoydbError {
5589
5680
 
5590
5681
  // src/team/recovery.ts
5591
5682
  init_types();
5592
- var PAPER_DOC_ID = "recovery-paper";
5593
- async function loadPaperRecoveryEntries(store, vault) {
5594
- const env = await store.get(vault, "_meta", PAPER_DOC_ID);
5595
- if (!env) return [];
5596
- try {
5597
- const doc = JSON.parse(env._data);
5598
- if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
5599
- return doc.entries;
5600
- } catch {
5601
- return [];
5683
+
5684
+ // src/team/wrapped-deks.ts
5685
+ var PBKDF2_ITERATIONS2 = 6e5;
5686
+ var SALT_BYTES2 = 32;
5687
+ var IV_BYTES2 = 12;
5688
+ var subtle2 = globalThis.crypto.subtle;
5689
+ async function mintWrappedDeksBlob(deks, credential) {
5690
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES2));
5691
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES2));
5692
+ const wrappingKey = await deriveWrappingKey(credential, salt);
5693
+ const exported = {};
5694
+ for (const [coll, dek] of deks) {
5695
+ const raw = await subtle2.exportKey("raw", dek);
5696
+ exported[coll] = bytesToBase64(new Uint8Array(raw));
5602
5697
  }
5603
- }
5604
- async function savePaperRecoveryEntries(store, vault, entries) {
5605
- const doc = {
5606
- _noydb_recovery: 1,
5607
- profile: "paper",
5608
- entries
5609
- };
5610
- const envelope = {
5611
- _noydb: NOYDB_FORMAT_VERSION,
5612
- _v: 1,
5613
- _ts: (/* @__PURE__ */ new Date()).toISOString(),
5614
- _iv: "",
5615
- _data: JSON.stringify(doc)
5698
+ const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
5699
+ const ciphertext = await subtle2.encrypt(
5700
+ { name: "AES-GCM", iv },
5701
+ wrappingKey,
5702
+ plaintext
5703
+ );
5704
+ return {
5705
+ salt: bytesToBase64(salt),
5706
+ iv: bytesToBase64(iv),
5707
+ wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
5616
5708
  };
5617
- await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
5618
5709
  }
5619
- async function burnPaperRecoveryEntry(store, vault, codeId) {
5620
- const entries = await loadPaperRecoveryEntries(store, vault);
5621
- const remaining = entries.filter((e) => e.codeId !== codeId);
5622
- await savePaperRecoveryEntries(store, vault, remaining);
5623
- }
5624
- async function hasRecoveryEnrolled(store, vault) {
5625
- const paper = await loadPaperRecoveryEntries(store, vault);
5626
- return paper.length > 0;
5627
- }
5628
- var subtle2 = globalThis.crypto.subtle;
5629
- var RECOVERY_PBKDF2_ITERATIONS = 6e5;
5630
- async function unwrapDeksFromPaperEntry(entry, code) {
5631
- const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
5710
+ async function unwrapDeksFromBlob(blob, credential) {
5711
+ const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
5632
5712
  const plaintext = await subtle2.decrypt(
5633
- { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
5713
+ { name: "AES-GCM", iv: base64ToBytes(blob.iv) },
5634
5714
  wrappingKey,
5635
- base64ToBytes(entry.wrappedDeks)
5715
+ base64ToBytes(blob.wrappedDeks)
5636
5716
  );
5637
5717
  const parsed = JSON.parse(new TextDecoder().decode(plaintext));
5638
5718
  const deks = /* @__PURE__ */ new Map();
@@ -5649,10 +5729,10 @@ async function unwrapDeksFromPaperEntry(entry, code) {
5649
5729
  }
5650
5730
  return deks;
5651
5731
  }
5652
- async function deriveRecoveryWrappingKey(code, salt) {
5732
+ async function deriveWrappingKey(credential, salt) {
5653
5733
  const ikm = await subtle2.importKey(
5654
5734
  "raw",
5655
- new TextEncoder().encode(code),
5735
+ new TextEncoder().encode(credential),
5656
5736
  "PBKDF2",
5657
5737
  false,
5658
5738
  ["deriveKey"]
@@ -5661,7 +5741,7 @@ async function deriveRecoveryWrappingKey(code, salt) {
5661
5741
  {
5662
5742
  name: "PBKDF2",
5663
5743
  salt,
5664
- iterations: RECOVERY_PBKDF2_ITERATIONS,
5744
+ iterations: PBKDF2_ITERATIONS2,
5665
5745
  hash: "SHA-256"
5666
5746
  },
5667
5747
  ikm,
@@ -5670,6 +5750,11 @@ async function deriveRecoveryWrappingKey(code, salt) {
5670
5750
  ["encrypt", "decrypt"]
5671
5751
  );
5672
5752
  }
5753
+ function bytesToBase64(b) {
5754
+ let s = "";
5755
+ for (const x of b) s += String.fromCharCode(x);
5756
+ return btoa(s);
5757
+ }
5673
5758
  function base64ToBytes(b64) {
5674
5759
  const s = atob(b64);
5675
5760
  const out = new Uint8Array(s.length);
@@ -5677,7 +5762,57 @@ function base64ToBytes(b64) {
5677
5762
  return out;
5678
5763
  }
5679
5764
 
5765
+ // src/team/recovery.ts
5766
+ var PAPER_DOC_ID = "recovery-paper";
5767
+ async function loadPaperRecoveryEntries(store, vault) {
5768
+ const env = await store.get(vault, "_meta", PAPER_DOC_ID);
5769
+ if (!env) return [];
5770
+ try {
5771
+ const doc = JSON.parse(env._data);
5772
+ if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
5773
+ return doc.entries;
5774
+ } catch {
5775
+ return [];
5776
+ }
5777
+ }
5778
+ async function savePaperRecoveryEntries(store, vault, entries) {
5779
+ const doc = {
5780
+ _noydb_recovery: 1,
5781
+ profile: "paper",
5782
+ entries
5783
+ };
5784
+ const envelope = {
5785
+ _noydb: NOYDB_FORMAT_VERSION,
5786
+ _v: 1,
5787
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
5788
+ _iv: "",
5789
+ _data: JSON.stringify(doc)
5790
+ };
5791
+ await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
5792
+ }
5793
+ async function burnPaperRecoveryEntry(store, vault, codeId) {
5794
+ const entries = await loadPaperRecoveryEntries(store, vault);
5795
+ const remaining = entries.filter((e) => e.codeId !== codeId);
5796
+ await savePaperRecoveryEntries(store, vault, remaining);
5797
+ }
5798
+ async function hasRecoveryEnrolled(store, vault) {
5799
+ const paper = await loadPaperRecoveryEntries(store, vault);
5800
+ return paper.length > 0;
5801
+ }
5802
+ async function mintPaperRecoveryEntry(deks, code, codeId) {
5803
+ const blob = await mintWrappedDeksBlob(deks, code);
5804
+ return {
5805
+ ...blob,
5806
+ codeId,
5807
+ enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
5808
+ };
5809
+ }
5810
+ async function unwrapDeksFromPaperEntry(entry, code) {
5811
+ return unwrapDeksFromBlob(entry, code);
5812
+ }
5813
+
5680
5814
  // src/team/rotate-recover.ts
5815
+ init_errors();
5681
5816
  async function rotatePassphrase(store, vault, userId, input) {
5682
5817
  if (!input.allowWeakPassphrase) {
5683
5818
  assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
@@ -5699,14 +5834,53 @@ async function rotatePassphrase(store, vault, userId, input) {
5699
5834
  for (const [coll, dek] of deks) {
5700
5835
  wrappedDeks[coll] = await wrapKey(dek, newKek);
5701
5836
  }
5837
+ const oldSlots = file.authenticators ?? [];
5838
+ const newSlots = [];
5839
+ if (input.slotCeremonies && oldSlots.length > 0) {
5840
+ for (const oldSlot of oldSlots) {
5841
+ const ceremony = input.slotCeremonies[oldSlot.id];
5842
+ if (!ceremony) continue;
5843
+ const result = await ceremony({ newKek, newDeks: deks, oldSlot });
5844
+ if (result.id !== oldSlot.id) {
5845
+ throw new ValidationError(
5846
+ `slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
5847
+ );
5848
+ }
5849
+ if (result.method !== oldSlot.method) {
5850
+ throw new ValidationError(
5851
+ `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.`
5852
+ );
5853
+ }
5854
+ const baseFields = {
5855
+ id: result.id,
5856
+ method: result.method,
5857
+ // Preserve original enrolled_at — rotation is rewrapping, not
5858
+ // re-enrollment. The slot's enrolment timestamp tracks when
5859
+ // the user originally added the slot, not when it was last
5860
+ // rewrapped. Forensics consumers reading enrolled_at are
5861
+ // tracking the slot's ORIGIN, not its CURRENT wrapping.
5862
+ enrolled_at: oldSlot.enrolled_at,
5863
+ enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
5864
+ meta: result.meta
5865
+ };
5866
+ const newSlot = result.wrapKind === "deks" ? {
5867
+ ...baseFields,
5868
+ wrapKind: "deks",
5869
+ wrapped_deks: result.wrapped_deks,
5870
+ iv: result.iv
5871
+ } : {
5872
+ ...baseFields,
5873
+ wrapped_kek: result.wrapped_kek
5874
+ };
5875
+ newSlots.push(newSlot);
5876
+ }
5877
+ }
5702
5878
  const next = {
5703
5879
  ...file,
5704
5880
  _noydb_keyring: NOYDB_KEYRING_VERSION,
5705
5881
  deks: wrappedDeks,
5706
5882
  salt: bufferToBase64(newSalt),
5707
- // Tier-2 slots reference the old KEK — drop them. User
5708
- // re-enrols afterwards via `db.enrollAuthenticator`.
5709
- authenticators: []
5883
+ authenticators: newSlots
5710
5884
  };
5711
5885
  await writeKeyringFile2(store, vault, userId, next);
5712
5886
  return {
@@ -5717,7 +5891,7 @@ async function rotatePassphrase(store, vault, userId, input) {
5717
5891
  deks,
5718
5892
  kek: newKek,
5719
5893
  salt: newSalt,
5720
- authenticators: [],
5894
+ authenticators: newSlots,
5721
5895
  ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
5722
5896
  ...file.import_capability !== void 0 && { importCapability: file.import_capability }
5723
5897
  };
@@ -5853,6 +6027,17 @@ var UserApi = class {
5853
6027
  * the envelope on first call. Optimistic-concurrency safe — a stale
5854
6028
  * `_v` (parallel writer on another device) throws `ConflictError`.
5855
6029
  *
6030
+ * Patch semantics (#57):
6031
+ * - `undefined` (or omitted key) — skip; existing value preserved
6032
+ * - `null` — delete the field from the merged result
6033
+ * - any other value — overwrite (deep-merge for plain objects,
6034
+ * replace for primitives / arrays)
6035
+ *
6036
+ * To clear a field, pass `null` rather than `undefined`. Callers
6037
+ * with shape `T = string | null` where `null` is a meaningful value
6038
+ * should use `setMe` for that specific field instead — `null` here
6039
+ * always means delete.
6040
+ *
5856
6041
  * Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
5857
6042
  * Pass `presented` to satisfy tightened policies that require a
5858
6043
  * factor proof (e.g. STRICT_POLICY's TOTP requirement).
@@ -6024,9 +6209,17 @@ function deepMerge(source, patch) {
6024
6209
  }
6025
6210
  const out = { ...source };
6026
6211
  for (const [key, patchVal] of Object.entries(patch)) {
6212
+ if (patchVal === void 0) {
6213
+ continue;
6214
+ }
6215
+ if (patchVal === null) {
6216
+ delete out[key];
6217
+ continue;
6218
+ }
6027
6219
  const sourceVal = source[key];
6028
- if (isPlainObject(sourceVal) && isPlainObject(patchVal)) {
6029
- out[key] = deepMerge(sourceVal, patchVal);
6220
+ if (isPlainObject(patchVal)) {
6221
+ const recurseSource = isPlainObject(sourceVal) ? sourceVal : {};
6222
+ out[key] = deepMerge(recurseSource, patchVal);
6030
6223
  } else {
6031
6224
  out[key] = patchVal;
6032
6225
  }
@@ -6195,8 +6388,76 @@ function sanitizeId(s) {
6195
6388
  return s.replace(/[^a-zA-Z0-9]/g, "_");
6196
6389
  }
6197
6390
 
6391
+ // src/team/peer-recover.ts
6392
+ init_types();
6393
+ init_crypto();
6394
+ init_errors();
6395
+ var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
6396
+ function canRecover(callerRole, targetRole) {
6397
+ if (callerRole === "owner") return true;
6398
+ if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
6399
+ return false;
6400
+ }
6401
+ async function recoverUser(store, vault, callerKeyring, options) {
6402
+ const env = await store.get(vault, "_keyring", options.userId);
6403
+ if (!env) {
6404
+ throw new NoAccessError(
6405
+ `recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
6406
+ );
6407
+ }
6408
+ const target = JSON.parse(env._data);
6409
+ const targetRole = options.role ?? target.role;
6410
+ if (!canRecover(callerKeyring.role, targetRole)) {
6411
+ throw new PermissionDeniedError(
6412
+ `Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
6413
+ );
6414
+ }
6415
+ if (!canRecover(callerKeyring.role, target.role)) {
6416
+ throw new PermissionDeniedError(
6417
+ `Role "${callerKeyring.role}" cannot recover role "${target.role}"`
6418
+ );
6419
+ }
6420
+ for (const coll of Object.keys(target.deks)) {
6421
+ if (!callerKeyring.deks.has(coll)) {
6422
+ throw new PrivilegeEscalationError(coll);
6423
+ }
6424
+ }
6425
+ if (options.validatePassphrase && !options.allowWeakPassphrase) {
6426
+ assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
6427
+ }
6428
+ const newSalt = generateSalt();
6429
+ const newKek = await deriveKey(options.passphrase, newSalt);
6430
+ const wrappedDeks = {};
6431
+ for (const coll of Object.keys(target.deks)) {
6432
+ const callerDek = callerKeyring.deks.get(coll);
6433
+ if (!callerDek) {
6434
+ throw new PrivilegeEscalationError(coll);
6435
+ }
6436
+ wrappedDeks[coll] = await wrapKey(callerDek, newKek);
6437
+ }
6438
+ const next = {
6439
+ ...target,
6440
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
6441
+ role: targetRole,
6442
+ display_name: options.displayName ?? target.display_name,
6443
+ deks: wrappedDeks,
6444
+ salt: bufferToBase64(newSalt),
6445
+ granted_by: callerKeyring.userId,
6446
+ authenticators: []
6447
+ };
6448
+ const envelope = {
6449
+ _noydb: 1,
6450
+ _v: 1,
6451
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
6452
+ _iv: "",
6453
+ _data: JSON.stringify(next)
6454
+ };
6455
+ await store.put(vault, "_keyring", options.userId, envelope);
6456
+ }
6457
+
6198
6458
  // src/noydb.ts
6199
6459
  init_errors();
6460
+ init_ulid();
6200
6461
  init_public_envelope();
6201
6462
 
6202
6463
  // src/vault.ts
@@ -12463,6 +12724,11 @@ var Vault = class {
12463
12724
  */
12464
12725
  async delegate(opts) {
12465
12726
  const { issueDelegation: issueDelegation2, DELEGATIONS_COLLECTION: DELEGATIONS_COLLECTION2 } = await Promise.resolve().then(() => (init_delegation(), delegation_exports));
12727
+ if (!this.keyring.kek) {
12728
+ throw new ValidationError(
12729
+ "issueDelegation: keyring.kek is null \u2014 issuing a delegation requires a tier-1 unlock. Re-authenticate at tier 1 (passphrase) first."
12730
+ );
12731
+ }
12466
12732
  const targetKek = this.keyring.kek;
12467
12733
  const delegationsDek = await this.getDEK(DELEGATIONS_COLLECTION2);
12468
12734
  return issueDelegation2(
@@ -13453,7 +13719,23 @@ var PERSONAL_POLICY = Object.freeze({
13453
13719
  gates: {
13454
13720
  "rotate-passphrase": {
13455
13721
  minTier: 1,
13456
- factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
13722
+ // Any second factor satisfies the gate — off-device kinds (TOTP,
13723
+ // email-OTP, paper recovery, roaming WebAuthn) are the strongest;
13724
+ // platform-bound kinds (platform WebAuthn, password, PIN) are
13725
+ // accepted because requiring "something off-device" is overkill
13726
+ // for personal/SMB threat models. Consumers needing the off-device
13727
+ // guarantee should use STRICT_POLICY or override this gate.
13728
+ factors: [{
13729
+ anyOf: [
13730
+ "totp",
13731
+ "email-otp",
13732
+ "recovery",
13733
+ "webauthn-roaming",
13734
+ "webauthn-platform",
13735
+ "password",
13736
+ "pin"
13737
+ ]
13738
+ }]
13457
13739
  },
13458
13740
  "recover-passphrase": {
13459
13741
  minTier: 1,
@@ -13461,9 +13743,27 @@ var PERSONAL_POLICY = Object.freeze({
13461
13743
  },
13462
13744
  "enroll-authenticator": { minTier: 1 },
13463
13745
  "remove-authenticator": { minTier: 1 },
13746
+ // update-authenticator: meta-only mutation (slot rename, label
13747
+ // changes). Symmetric with enroll/remove under PERSONAL — tier-1
13748
+ // unlock alone. The structural anti-slot-swap guard inside the
13749
+ // implementation enforces wrap-material/id/method immutability
13750
+ // regardless of this gate's settings.
13751
+ "update-authenticator": { minTier: 1 },
13464
13752
  "rotate-unlock": { minTier: 2 },
13465
13753
  "enroll-user": { minTier: 1 },
13466
13754
  "revoke-user": { minTier: 1 },
13755
+ // Peer-recovery is a high-trust intentional op — co-owners
13756
+ // recovering each other should not need an off-device factor in
13757
+ // the personal/SMB threat model (the partner is already vetted by
13758
+ // virtue of being a co-owner). Tier-1 unlock is the floor; the
13759
+ // STRICT preset adds a recovery/email-OTP requirement.
13760
+ "peer-recover-user": { minTier: 1 },
13761
+ // update-user: post-grant identity mutation (role/displayName/
13762
+ // permissions). PERSONAL_POLICY treats this on par with enroll-user
13763
+ // / revoke-user — tier-1 unlock alone. The role-elevation guard
13764
+ // inside the implementation is the structural backstop that this
13765
+ // gate's settings cannot weaken.
13766
+ "update-user": { minTier: 1 },
13467
13767
  "export-bundle": { minTier: 1 },
13468
13768
  "export-plaintext": {
13469
13769
  minTier: 1,
@@ -13508,6 +13808,15 @@ var STRICT_POLICY = Object.freeze({
13508
13808
  minTier: 1,
13509
13809
  factors: [{ anyOf: ["totp", "email-otp"] }]
13510
13810
  },
13811
+ // STRICT update-authenticator: same factor floor as enroll/remove.
13812
+ // Even though meta changes don't touch wrap material, a malicious
13813
+ // rename could mislead the user about which device a slot
13814
+ // corresponds to ("MacBook Touch ID" → "iPhone Touch ID" on a
13815
+ // shared workstation). STRICT requires a fresh factor proof.
13816
+ "update-authenticator": {
13817
+ minTier: 1,
13818
+ factors: [{ anyOf: ["totp", "email-otp"] }]
13819
+ },
13511
13820
  "rotate-unlock": { minTier: 1 },
13512
13821
  "enroll-user": {
13513
13822
  minTier: 1,
@@ -13517,6 +13826,31 @@ var STRICT_POLICY = Object.freeze({
13517
13826
  minTier: 1,
13518
13827
  factors: [{ anyOf: ["totp", "email-otp"] }]
13519
13828
  },
13829
+ // STRICT peer-recovery: the issuer must present a recovery code
13830
+ // OR a fresh off-device second factor at the moment of recovery.
13831
+ // This binds the high-trust operation to a verifiable proof
13832
+ // (recovery sheet photographed by an attacker won't suffice —
13833
+ // they'd also need tier-1 unlock first; this gate is the freshness
13834
+ // binding on top). Roaming WebAuthn (YubiKey-class hardware key)
13835
+ // accepted; platform-bound kinds (Touch ID, password, PIN)
13836
+ // intentionally excluded under STRICT because they don't survive
13837
+ // device theft — the off-device requirement is the whole point.
13838
+ "peer-recover-user": {
13839
+ minTier: 1,
13840
+ factors: [{ anyOf: ["recovery", "totp", "email-otp", "webauthn-roaming"] }]
13841
+ },
13842
+ // STRICT update-user: matches the enroll-user / revoke-user shape
13843
+ // (off-device factor required). Update-user is admin-shaped — it
13844
+ // mutates someone else's role/permissions; STRICT requires a fresh
13845
+ // off-device factor proof so the operator affirmatively re-asserts
13846
+ // identity at the moment of mutation. Platform-bound factors
13847
+ // (Touch ID / password / PIN) intentionally excluded: same logic as
13848
+ // peer-recover-user — the off-device requirement is the whole
13849
+ // point under STRICT.
13850
+ "update-user": {
13851
+ minTier: 1,
13852
+ factors: [{ anyOf: ["totp", "email-otp"] }]
13853
+ },
13520
13854
  "export-bundle": {
13521
13855
  minTier: 1,
13522
13856
  factors: [{ anyOf: ["totp", "email-otp"] }],
@@ -13911,6 +14245,56 @@ var Noydb = class {
13911
14245
  const keyring = await this.getKeyring(vault);
13912
14246
  await revoke(this.options.store, vault, keyring, options);
13913
14247
  }
14248
+ /**
14249
+ * Mutate post-grant identity fields on an existing keyring — `role`,
14250
+ * `displayName`, and/or `permissions`. Pure plaintext-header rewrite:
14251
+ * no DEK rewrap, no KEK required, no authenticator slots touched.
14252
+ * Tier-2 enrollments and recovery codes survive.
14253
+ *
14254
+ * Different from `db.revoke + db.grant`:
14255
+ *
14256
+ * - Same `userId`, same DEK wrappings, same `granted_by`, same
14257
+ * `_users/<keyringId>` envelope. Only the specified header
14258
+ * fields move. Last-write-wins via the standard keyring put.
14259
+ * - No cascade on role demotion (admins demoted to operator keep
14260
+ * the keyrings they previously granted; the cascade rules are
14261
+ * a `db.revoke` concern, not `db.updateUser`).
14262
+ * - Tier-2 slots NOT dropped — the wrapping is unaffected.
14263
+ *
14264
+ * Role-elevation guard: BOTH the old and new role must satisfy
14265
+ * `db.grant`'s hierarchy. Owner can do anything; admin manages
14266
+ * admin/operator/viewer/client laterally; admin cannot promote to
14267
+ * owner OR demote from owner. The guard runs regardless of the
14268
+ * `update-user` policy gate's settings — gates can only be more
14269
+ * permissive than the structural floor, never less.
14270
+ *
14271
+ * Gated by `update-user`. `STRICT_POLICY` requires a TOTP/email-OTP
14272
+ * factor proof so the operator affirmatively re-asserts identity at
14273
+ * the moment of mutation; `PERSONAL_POLICY` accepts a tier-1 unlock
14274
+ * alone.
14275
+ *
14276
+ * ```ts
14277
+ * await db.updateUser('acme', {
14278
+ * userId: 'bob',
14279
+ * role: 'operator', // promote
14280
+ * permissions: { invoices: 'rw' },
14281
+ * }, { factors: [{ kind: 'totp' }] })
14282
+ * ```
14283
+ *
14284
+ * @throws `NoAccessError` when no keyring exists for the target.
14285
+ * @throws `PermissionDeniedError` when the role hierarchy rejects.
14286
+ * @throws `ValidationError` when no field is provided.
14287
+ *
14288
+ * @see #54
14289
+ */
14290
+ async updateUser(vault, options, factors) {
14291
+ await this.checkGate(vault, "update-user", factors);
14292
+ const keyring = await this.getKeyring(vault);
14293
+ await updateKeyringIdentity(this.options.store, vault, keyring, options);
14294
+ if (options.userId === this.options.user) {
14295
+ this.keyringCache.delete(vault);
14296
+ }
14297
+ }
13914
14298
  /**
13915
14299
  * Rotate the DEKs for the given collections in a vault.
13916
14300
  *
@@ -14454,6 +14838,40 @@ var Noydb = class {
14454
14838
  const keyring = await this.getKeyring(vault);
14455
14839
  return keyring.authenticators;
14456
14840
  }
14841
+ /**
14842
+ * Mutate the `meta` blob on an existing authenticator slot — slot
14843
+ * rename, label change, attachment of UI hints. The slot's `id`,
14844
+ * `method`, and wrap material (`wrapped_kek` / `wrapped_deks` + `iv`)
14845
+ * are immutable through this method. Anti-slot-swap is structural,
14846
+ * not gate-driven.
14847
+ *
14848
+ * `meta` patch semantics (#57-aligned):
14849
+ * - Top-level merge — absent keys preserved
14850
+ * - `null` value — delete that meta key
14851
+ * - Other values — replace verbatim
14852
+ *
14853
+ * Use case: per-slot nickname for "iPhone Touch ID" vs "MacBook
14854
+ * Touch ID" disambiguation in admin UIs. The slot id (auto-derived
14855
+ * from credentialId prefix) is not human-friendly; `meta.nickname`
14856
+ * is.
14857
+ *
14858
+ * Gated by `update-authenticator`. PERSONAL_POLICY: tier-1 unlock
14859
+ * alone (matches enroll/remove). STRICT_POLICY: tier-1 +
14860
+ * TOTP/email-OTP factor proof — a malicious rename on a shared
14861
+ * workstation could mislead the user about which device a slot
14862
+ * corresponds to, so STRICT requires fresh factor binding.
14863
+ *
14864
+ * @throws `NoAccessError` when no slot with the given id exists.
14865
+ * @throws `ValidationError` when no patch field is provided.
14866
+ *
14867
+ * @see #55
14868
+ */
14869
+ async updateAuthenticator(vault, slotId, options, presented) {
14870
+ await this.checkGate(vault, "update-authenticator", presented);
14871
+ const keyring = await this.getKeyring(vault);
14872
+ const next = await updateAuthenticator(this.options.store, vault, keyring, slotId, options);
14873
+ this.keyringCache.set(vault, next);
14874
+ }
14457
14875
  /**
14458
14876
  * Native WebAuthn enrollment using the **real** internal keyring (#16).
14459
14877
  *
@@ -14663,22 +15081,108 @@ var Noydb = class {
14663
15081
  async recoverPassphrase(vault, input, factors) {
14664
15082
  await this.checkGate(vault, "recover-passphrase", factors);
14665
15083
  const userId = this.options.user;
15084
+ const entriesBeforeRecovery = await loadPaperRecoveryEntries(this.options.store, vault);
14666
15085
  const next = await recoverPassphrase(this.options.store, vault, userId, input);
14667
15086
  this.keyringCache.set(vault, next);
15087
+ const rotateRemaining = input.rotateRemainingCodes ?? true;
15088
+ const remainingAfterBurn = Math.max(0, entriesBeforeRecovery.length - 1);
15089
+ if (!rotateRemaining || remainingAfterBurn === 0) {
15090
+ return { newCodes: [] };
15091
+ }
15092
+ const codeGen = input.codeGenerator ?? generateULID;
15093
+ const newCodeCount = input.newCodeCount ?? remainingAfterBurn;
15094
+ const codes = [];
15095
+ const newEntries = [];
15096
+ for (let i = 0; i < newCodeCount; i++) {
15097
+ const rawCode = codeGen();
15098
+ const entry = await mintPaperRecoveryEntry(next.deks, rawCode, generateULID());
15099
+ codes.push(rawCode);
15100
+ newEntries.push(entry);
15101
+ }
15102
+ await savePaperRecoveryEntries(this.options.store, vault, newEntries);
15103
+ return { newCodes: codes };
15104
+ }
15105
+ /**
15106
+ * Atomic peer-recovery — re-wraps an EXISTING user's keyring under
15107
+ * a fresh temp passphrase in a single store write. Closes #34's
15108
+ * partial-failure window (the previous compose-from-primitives
15109
+ * pattern was `db.revoke + db.grant`, two writes — if the issuer
15110
+ * cancelled between them the target was locked out entirely).
15111
+ *
15112
+ * Different from `db.revoke + db.grant`:
15113
+ *
15114
+ * - Same `userId`, role, permissions, capabilities preserved.
15115
+ * - DEKs unchanged → every other principal in the vault keeps
15116
+ * access. No key rotation.
15117
+ * - Allows owner→owner natively (#33). The existing
15118
+ * `db.revoke` retains its block — peer-recovery is a separate,
15119
+ * intentionally-named operation.
15120
+ * - Tier-2 slots dropped (they wrap the old KEK).
15121
+ *
15122
+ * Gated by `peer-recover-user`; `STRICT_POLICY` requires a
15123
+ * recovery / TOTP / email-OTP factor proof at the moment of
15124
+ * recovery, so the issuer affirmatively re-asserts identity.
15125
+ *
15126
+ * The recipient should call `db.rotatePassphrase` on first session
15127
+ * to choose their own phrase — the temp acts as a single-use
15128
+ * bridge.
15129
+ *
15130
+ * ```ts
15131
+ * await db.recoverUser('acme', {
15132
+ * userId: 'bob',
15133
+ * passphrase: 'temporary-correct-horse-battery-staple-printer',
15134
+ * }, { factors: [{ kind: 'recovery' }] })
15135
+ * // Bob opens createNoydb({ user: 'bob', secret: tempPhrase })
15136
+ * // and immediately calls db.rotatePassphrase to set his own.
15137
+ * ```
15138
+ *
15139
+ * @throws `NoAccessError` when no keyring exists for the target.
15140
+ * @throws `PermissionDeniedError` when the caller's role can't
15141
+ * recover the target's role (admin→owner is blocked even
15142
+ * under recovery).
15143
+ * @throws `PrivilegeEscalationError` when the caller lacks a DEK
15144
+ * the target previously had access to.
15145
+ *
15146
+ * @see #33 #34 — the issues this method closes.
15147
+ */
15148
+ async recoverUser(vault, options, factors) {
15149
+ await this.checkGate(vault, "peer-recover-user", factors);
15150
+ const callerKeyring = await this.getKeyring(vault);
15151
+ await recoverUser(this.options.store, vault, callerKeyring, options);
15152
+ if (options.userId === this.options.user) {
15153
+ this.keyringCache.delete(vault);
15154
+ }
14668
15155
  }
14669
15156
  /**
14670
15157
  * Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
14671
- * profile — the developer first calls
14672
- * `@noy-db/on-recovery/generateRecoveryCodeSet` to mint codes +
14673
- * entries, shows the codes to the user once, then hands the entries
14674
- * here.
15158
+ * profile.
15159
+ *
15160
+ * The hub wraps the user's DEK set (not the KEK) under a code-derived
15161
+ * AES-GCM key — see `team/recovery.ts` for the rationale. The mint
15162
+ * helper {@link mintPaperRecoveryEntry} is the canonical primitive;
15163
+ * pair it with `db.getKeyring(vault)` to obtain the live DEK set:
14675
15164
  *
14676
15165
  * ```ts
14677
- * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
14678
- * const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
15166
+ * import { mintPaperRecoveryEntry } from '@noy-db/hub'
15167
+ *
15168
+ * const keyring = await db.getKeyring('acme')
15169
+ * const codes: string[] = ['CORRECT-HORSE-1', 'BATTERY-STAPLE-2', ...]
15170
+ * const entries = await Promise.all(
15171
+ * codes.map((code, i) => mintPaperRecoveryEntry(keyring.deks, code, `code-${i}`)),
15172
+ * )
14679
15173
  * await db.enrollRecovery('acme', { profile: 'paper', entries })
14680
15174
  * showCodesToUser(codes)
14681
15175
  * ```
15176
+ *
15177
+ * As of pre.8, `@noy-db/on-recovery`'s `generateRecoveryCodeSet`
15178
+ * delegates to `mintPaperRecoveryEntry` internally — its output is
15179
+ * fed directly to this API. Pick whichever fits your code-gen layer:
15180
+ *
15181
+ * ```ts
15182
+ * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
15183
+ * const { codes, entries } = await generateRecoveryCodeSet({ deks: keyring.deks, count: 8 })
15184
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
15185
+ * ```
14682
15186
  */
14683
15187
  async enrollRecovery(vault, enrollment) {
14684
15188
  if (enrollment.profile !== "paper") {
@@ -14732,7 +15236,29 @@ var Noydb = class {
14732
15236
  clearQuickUnlock(vault) {
14733
15237
  this.quickUnlock.delete(vault);
14734
15238
  }
14735
- /** Get or load the keyring for a vault. */
15239
+ /**
15240
+ * Public accessor for the unlocked keyring of a vault — issue #28.
15241
+ *
15242
+ * Returns the cached `UnlockedKeyring` (already in memory after
15243
+ * `createNoydb` + first vault touch); loads it on demand if absent.
15244
+ * Used by `@noy-db/on-*` ceremonies that need the live DEK set
15245
+ * (paper recovery via {@link mintPaperRecoveryEntry}, tier-3 PIN
15246
+ * enrolment via on-pin's `enrollPin`, custom on-* ceremonies that
15247
+ * don't have a hub-side wrapper).
15248
+ *
15249
+ * No new permission gate — this is an accessor over already-unlocked
15250
+ * state. The keyring is materialized only after the calling session
15251
+ * has unlocked the vault at tier 1, 2, or 3, so exposing it does not
15252
+ * widen access. Throws `ValidationError` when encryption is enabled
15253
+ * and no `secret` / `getKeyring` is configured.
15254
+ *
15255
+ * ```ts
15256
+ * const keyring = await db.getKeyring('acme')
15257
+ * // keyring.deks: Map<collection, CryptoKey>
15258
+ * // keyring.kek: CryptoKey (non-extractable; null for tier-3 sessions)
15259
+ * // keyring.role / .permissions / .authenticators
15260
+ * ```
15261
+ */
14736
15262
  async getKeyring(vault) {
14737
15263
  if (this.options.encrypt === false) {
14738
15264
  return createPlaintextKeyring(this.options.user);
@@ -16655,6 +17181,8 @@ function shortJSON(value) {
16655
17181
  mergeCrdtStates,
16656
17182
  mergePolicy,
16657
17183
  min,
17184
+ mintPaperRecoveryEntry,
17185
+ mintWrappedDeksBlob,
16658
17186
  paddedIndex,
16659
17187
  parseBytes,
16660
17188
  parseIndex,
@@ -16665,6 +17193,7 @@ function shortJSON(value) {
16665
17193
  readNoydbBundlePublicEnvelope,
16666
17194
  readPath,
16667
17195
  readPublicEnvelope,
17196
+ recoverUser,
16668
17197
  reduceRecords,
16669
17198
  ref,
16670
17199
  removeAuthenticator,
@@ -16686,6 +17215,8 @@ function shortJSON(value) {
16686
17215
  saveVaultPolicy,
16687
17216
  sha256Hex,
16688
17217
  sum,
17218
+ unwrapDeksFromBlob,
17219
+ unwrapDeksFromPaperEntry,
16689
17220
  unwrapMagicLinkGrant,
16690
17221
  validateI18nTextValue,
16691
17222
  validatePassphrase,