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

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 (95) hide show
  1. package/dist/blobs/index.cjs.map +1 -1
  2. package/dist/blobs/index.d.cts +3 -3
  3. package/dist/blobs/index.d.ts +3 -3
  4. package/dist/blobs/index.js +2 -2
  5. package/dist/bundle/index.cjs +26 -3
  6. package/dist/bundle/index.cjs.map +1 -1
  7. package/dist/bundle/index.d.cts +3 -3
  8. package/dist/bundle/index.d.ts +3 -3
  9. package/dist/bundle/index.js +3 -1
  10. package/dist/{chunk-LSZHBNDG.js → chunk-3WCRU7TI.js} +2 -2
  11. package/dist/{chunk-PSHTHSIX.js → chunk-6IJQ27XN.js} +213 -10
  12. package/dist/chunk-6IJQ27XN.js.map +1 -0
  13. package/dist/{chunk-O5GK62FJ.js → chunk-B6HF6NTZ.js} +1 -1
  14. package/dist/chunk-B6HF6NTZ.js.map +1 -0
  15. package/dist/{chunk-AVWFLPNR.js → chunk-CL37QSND.js} +2 -2
  16. package/dist/chunk-EMIGCR7X.js +39 -0
  17. package/dist/chunk-EMIGCR7X.js.map +1 -0
  18. package/dist/{chunk-GJILMRPO.js → chunk-FAAWLVTF.js} +42 -4
  19. package/dist/chunk-FAAWLVTF.js.map +1 -0
  20. package/dist/chunk-GILMPJXB.js +155 -0
  21. package/dist/chunk-GILMPJXB.js.map +1 -0
  22. package/dist/{chunk-L77MEFCH.js → chunk-INSJBB5W.js} +3 -3
  23. package/dist/{chunk-QZIACZZU.js → chunk-KPF2HHPI.js} +2 -2
  24. package/dist/{chunk-NK2NSXXK.js → chunk-N2LMZKLR.js} +2 -2
  25. package/dist/{chunk-EARQCIL7.js → chunk-NZ4XCIKS.js} +3 -3
  26. package/dist/{chunk-E445ICYI.js → chunk-UFL4DUEV.js} +5 -3
  27. package/dist/chunk-UFL4DUEV.js.map +1 -0
  28. package/dist/consent/index.d.cts +3 -3
  29. package/dist/consent/index.d.ts +3 -3
  30. package/dist/{dev-unlock-XOUecfQ9.d.ts → dev-unlock-CcJ1qIi7.d.ts} +1 -1
  31. package/dist/{dev-unlock-5SmCVGyx.d.cts → dev-unlock-Dk14V6lX.d.cts} +1 -1
  32. package/dist/{hash-Bxud16vM.d.ts → hash-1Xsqx1jl.d.ts} +1 -1
  33. package/dist/{hash-CvuKN2gH.d.cts → hash-h_2U3TFb.d.cts} +1 -1
  34. package/dist/history/index.cjs.map +1 -1
  35. package/dist/history/index.d.cts +4 -4
  36. package/dist/history/index.d.ts +4 -4
  37. package/dist/history/index.js +2 -2
  38. package/dist/i18n/index.cjs +3 -1
  39. package/dist/i18n/index.cjs.map +1 -1
  40. package/dist/i18n/index.d.cts +3 -3
  41. package/dist/i18n/index.d.ts +3 -3
  42. package/dist/i18n/index.js +3 -3
  43. package/dist/{index-DN-J-5wT.d.cts → index-6xNpPsxR.d.cts} +1 -1
  44. package/dist/{index-Cy-MKrdK.d.ts → index-Cvb0efA_.d.cts} +39 -5
  45. package/dist/{index-BRHBCmLt.d.ts → index-DJTf9yxn.d.ts} +1 -1
  46. package/dist/{index-BvUiM47h.d.cts → index-DZn6Yick.d.ts} +39 -5
  47. package/dist/index.cjs +2001 -58
  48. package/dist/index.cjs.map +1 -1
  49. package/dist/index.d.cts +315 -19
  50. package/dist/index.d.ts +315 -19
  51. package/dist/index.js +1503 -41
  52. package/dist/index.js.map +1 -1
  53. package/dist/{ledger-HWXYGUIQ.js → ledger-5V67MAIL.js} +3 -3
  54. package/dist/periods/index.cjs.map +1 -1
  55. package/dist/periods/index.d.cts +3 -3
  56. package/dist/periods/index.d.ts +3 -3
  57. package/dist/periods/index.js +3 -3
  58. package/dist/public-envelope-DFJZHXVH.js +31 -0
  59. package/dist/public-envelope-DFJZHXVH.js.map +1 -0
  60. package/dist/query/index.d.cts +1 -1
  61. package/dist/query/index.d.ts +1 -1
  62. package/dist/session/index.cjs +4 -2
  63. package/dist/session/index.cjs.map +1 -1
  64. package/dist/session/index.d.cts +4 -4
  65. package/dist/session/index.d.ts +4 -4
  66. package/dist/session/index.js +1 -1
  67. package/dist/shadow/index.d.cts +3 -3
  68. package/dist/shadow/index.d.ts +3 -3
  69. package/dist/store/index.d.cts +3 -3
  70. package/dist/store/index.d.ts +3 -3
  71. package/dist/sync/index.cjs.map +1 -1
  72. package/dist/sync/index.d.cts +2 -2
  73. package/dist/sync/index.d.ts +2 -2
  74. package/dist/sync/index.js +2 -2
  75. package/dist/team/index.cjs +3 -1
  76. package/dist/team/index.cjs.map +1 -1
  77. package/dist/team/index.d.cts +3 -3
  78. package/dist/team/index.d.ts +3 -3
  79. package/dist/team/index.js +4 -4
  80. package/dist/tx/index.d.cts +3 -3
  81. package/dist/tx/index.d.ts +3 -3
  82. package/dist/{types-Dmi7nrC9.d.ts → types-D-6bmD2c.d.ts} +1271 -3
  83. package/dist/{types-BVSfkYg6.d.cts → types-D3QLmhlk.d.cts} +1271 -3
  84. package/package.json +1 -1
  85. package/dist/chunk-E445ICYI.js.map +0 -1
  86. package/dist/chunk-GJILMRPO.js.map +0 -1
  87. package/dist/chunk-O5GK62FJ.js.map +0 -1
  88. package/dist/chunk-PSHTHSIX.js.map +0 -1
  89. /package/dist/{chunk-LSZHBNDG.js.map → chunk-3WCRU7TI.js.map} +0 -0
  90. /package/dist/{chunk-AVWFLPNR.js.map → chunk-CL37QSND.js.map} +0 -0
  91. /package/dist/{chunk-L77MEFCH.js.map → chunk-INSJBB5W.js.map} +0 -0
  92. /package/dist/{chunk-QZIACZZU.js.map → chunk-KPF2HHPI.js.map} +0 -0
  93. /package/dist/{chunk-NK2NSXXK.js.map → chunk-N2LMZKLR.js.map} +0 -0
  94. /package/dist/{chunk-EARQCIL7.js.map → chunk-NZ4XCIKS.js.map} +0 -0
  95. /package/dist/{ledger-HWXYGUIQ.js.map → ledger-5V67MAIL.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
3
+ PUBLIC_ENVELOPE_FIELDS,
4
+ resolveSchema
5
+ } from "./chunk-EMIGCR7X.js";
1
6
  import {
2
7
  DELEGATIONS_COLLECTION,
3
8
  assertTierAccess,
@@ -23,15 +28,24 @@ import {
23
28
  hasNoydbBundleMagic,
24
29
  readNoydbBundle,
25
30
  readNoydbBundleHeader,
31
+ readNoydbBundlePublicEnvelope,
26
32
  resetBrotliSupportCache,
27
33
  writeNoydbBundle
28
- } from "./chunk-GJILMRPO.js";
34
+ } from "./chunk-FAAWLVTF.js";
35
+ import {
36
+ PUBLIC_ENVELOPE_RECORD_ID,
37
+ isPublicEnvelope,
38
+ loadPublicEnvelope,
39
+ readPublicEnvelope,
40
+ savePublicEnvelope,
41
+ validatePublicEnvelopeInput
42
+ } from "./chunk-GILMPJXB.js";
29
43
  import {
30
44
  CONSENT_AUDIT_COLLECTION
31
45
  } from "./chunk-M62XNWRA.js";
32
46
  import {
33
47
  PERIODS_COLLECTION
34
- } from "./chunk-LSZHBNDG.js";
48
+ } from "./chunk-3WCRU7TI.js";
35
49
  import "./chunk-UF3BUNQZ.js";
36
50
  import {
37
51
  CollectionFrame,
@@ -55,7 +69,7 @@ import {
55
69
  isI18nTextDescriptor,
56
70
  resolveI18nText,
57
71
  validateI18nTextValue
58
- } from "./chunk-EARQCIL7.js";
72
+ } from "./chunk-NZ4XCIKS.js";
59
73
  import {
60
74
  createBundleStore,
61
75
  routeStore,
@@ -75,17 +89,24 @@ import {
75
89
  getCredential,
76
90
  listCredentials,
77
91
  putCredential
78
- } from "./chunk-L77MEFCH.js";
92
+ } from "./chunk-INSJBB5W.js";
79
93
  import {
80
94
  PresenceHandle,
81
95
  SyncEngine,
82
96
  SyncTransaction
83
- } from "./chunk-AVWFLPNR.js";
97
+ } from "./chunk-CL37QSND.js";
84
98
  import {
99
+ USER_ENVELOPE_COLLECTION,
100
+ USER_ENVELOPE_MAX_BYTES,
101
+ UserEnvelopeOversizedError,
102
+ WeakPassphraseError,
103
+ assertStrongPassphrase,
85
104
  buildRecipientKeyringFile,
86
105
  changeSecret,
87
106
  createOwnerKeyring,
107
+ deleteUserEnvelope,
88
108
  ensureCollectionDEK,
109
+ estimateEntropy,
89
110
  evaluateExportCapability,
90
111
  evaluateImportCapability,
91
112
  grant,
@@ -93,11 +114,17 @@ import {
93
114
  hasExportCapability,
94
115
  hasImportCapability,
95
116
  hasWritePermission,
117
+ listUserEnvelopeIds,
96
118
  listUsers,
119
+ listUsersWithEnvelopes,
97
120
  loadKeyring,
121
+ loadUserEnvelope,
122
+ persistKeyring,
98
123
  revoke,
99
- rotateKeys
100
- } from "./chunk-PSHTHSIX.js";
124
+ rotateKeys,
125
+ saveUserEnvelope,
126
+ validatePassphrase
127
+ } from "./chunk-6IJQ27XN.js";
101
128
  import {
102
129
  BUNDLE_STORE_POLICY,
103
130
  INDEXED_STORE_POLICY,
@@ -117,7 +144,7 @@ import {
117
144
  revokeAllSessions,
118
145
  revokeSession,
119
146
  validateSessionPolicy
120
- } from "./chunk-E445ICYI.js";
147
+ } from "./chunk-UFL4DUEV.js";
121
148
  import {
122
149
  generateULID,
123
150
  isULID
@@ -134,7 +161,7 @@ import {
134
161
  LedgerStore,
135
162
  applyPatch,
136
163
  computePatch
137
- } from "./chunk-NK2NSXXK.js";
164
+ } from "./chunk-N2LMZKLR.js";
138
165
  import {
139
166
  canonicalJson,
140
167
  envelopePayloadHash,
@@ -189,24 +216,26 @@ import {
189
216
  detectMimeType,
190
217
  isPreCompressed,
191
218
  runCompaction
192
- } from "./chunk-QZIACZZU.js";
219
+ } from "./chunk-KPF2HHPI.js";
193
220
  import {
194
221
  NOYDB_BACKUP_VERSION,
195
222
  NOYDB_FORMAT_VERSION,
196
223
  NOYDB_KEYRING_VERSION,
197
224
  NOYDB_SYNC_VERSION,
198
225
  createStore
199
- } from "./chunk-O5GK62FJ.js";
226
+ } from "./chunk-B6HF6NTZ.js";
200
227
  import {
201
228
  base64ToBuffer,
202
229
  bufferToBase64,
203
230
  decrypt,
204
231
  decryptBytes,
205
232
  decryptDeterministic,
233
+ deriveKey,
206
234
  derivePresenceKey,
207
235
  encrypt,
208
236
  encryptBytes,
209
237
  encryptDeterministic,
238
+ generateSalt,
210
239
  unwrapKey,
211
240
  wrapKey
212
241
  } from "./chunk-MR4424N3.js";
@@ -397,6 +426,728 @@ var RefRegistry = class {
397
426
  }
398
427
  };
399
428
 
429
+ // src/team/authenticators.ts
430
+ async function enrollAuthenticator(store, vault, keyring, options) {
431
+ const existing = keyring.authenticators.find((a) => a.id === options.id);
432
+ if (existing) {
433
+ throw new ValidationError(
434
+ `enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
435
+ );
436
+ }
437
+ const slot = {
438
+ id: options.id,
439
+ method: options.method,
440
+ enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
441
+ enrolled_via_tier: options.enrolled_via_tier ?? 1,
442
+ wrapped_kek: options.wrapped_kek,
443
+ meta: options.meta
444
+ };
445
+ const next = appendSlot(keyring, slot);
446
+ await persistKeyring(store, vault, next);
447
+ return next;
448
+ }
449
+ async function removeAuthenticator(store, vault, keyring, slotId) {
450
+ const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
451
+ if (filtered.length === keyring.authenticators.length) {
452
+ return keyring;
453
+ }
454
+ const next = {
455
+ ...keyring,
456
+ authenticators: filtered
457
+ };
458
+ await persistKeyring(store, vault, next);
459
+ return next;
460
+ }
461
+ function findAuthenticator(keyring, slotId) {
462
+ return keyring.authenticators.find((a) => a.id === slotId);
463
+ }
464
+ function appendSlot(keyring, slot) {
465
+ return {
466
+ ...keyring,
467
+ authenticators: [...keyring.authenticators, slot]
468
+ };
469
+ }
470
+
471
+ // src/session/unlock-state.ts
472
+ var QuickUnlockStore = class {
473
+ states = /* @__PURE__ */ new Map();
474
+ timers = /* @__PURE__ */ new Map();
475
+ /**
476
+ * Register a quick-unlock state for a vault. Replaces any existing
477
+ * state. Schedules an automatic clear when the state's `expiresAt`
478
+ * elapses.
479
+ */
480
+ set(vault, state) {
481
+ this.clearTimer(vault);
482
+ this.states.set(vault, state);
483
+ const ttl = new Date(state.expiresAt).getTime() - Date.now();
484
+ if (ttl > 0) {
485
+ const timer = setTimeout(() => this.delete(vault), ttl);
486
+ this.timers.set(vault, timer);
487
+ }
488
+ }
489
+ /** Read the state for a vault. Returns undefined when none is registered. */
490
+ get(vault) {
491
+ return this.states.get(vault);
492
+ }
493
+ /** Drop the state for a vault. Cancels the auto-clear timer. */
494
+ delete(vault) {
495
+ this.clearTimer(vault);
496
+ this.states.delete(vault);
497
+ }
498
+ /** Drop every cached state. Called on `db.close()`. */
499
+ clear() {
500
+ for (const vault of this.states.keys()) {
501
+ this.clearTimer(vault);
502
+ }
503
+ this.states.clear();
504
+ }
505
+ clearTimer(vault) {
506
+ const t = this.timers.get(vault);
507
+ if (t) clearTimeout(t);
508
+ this.timers.delete(vault);
509
+ }
510
+ };
511
+
512
+ // src/policy/errors.ts
513
+ var PolicyDeniedError = class extends NoydbError {
514
+ gate;
515
+ reason;
516
+ required;
517
+ constructor(gate, reason, required, message) {
518
+ super(
519
+ "POLICY_DENIED",
520
+ message ?? `Gate "${gate}" denied: ${reason}.`
521
+ );
522
+ this.name = "PolicyDeniedError";
523
+ this.gate = gate;
524
+ this.reason = reason;
525
+ this.required = required;
526
+ }
527
+ };
528
+ var RecoveryNotEnrolledError = class extends NoydbError {
529
+ constructor(message = 'Recovery profile not enrolled. Pass `recovery: [{ profile: "paper", codes: 10 }]` to `createNoydb()`, or set `policy.gates["recover-passphrase"].enabled = false` to opt out of recovery (passphrase loss = data loss). See docs/subsystems/session-tiers.md.') {
530
+ super("RECOVERY_NOT_ENROLLED", message);
531
+ this.name = "RecoveryNotEnrolledError";
532
+ }
533
+ };
534
+ var RecoveryProfileNotImplementedError = class extends NoydbError {
535
+ profile;
536
+ tracking;
537
+ constructor(profile, tracking) {
538
+ super(
539
+ "RECOVERY_PROFILE_NOT_IMPLEMENTED",
540
+ `Recovery profile "${profile}" is not yet implemented in this hub release. Tracking: ${tracking}. Use the "paper" profile via @noy-db/on-recovery in the meantime.`
541
+ );
542
+ this.name = "RecoveryProfileNotImplementedError";
543
+ this.profile = profile;
544
+ this.tracking = tracking;
545
+ }
546
+ };
547
+
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 [];
559
+ }
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)
573
+ };
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
+ }
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));
589
+ const plaintext = await subtle.decrypt(
590
+ { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
591
+ wrappingKey,
592
+ base64ToBytes(entry.wrappedDeks)
593
+ );
594
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
595
+ const deks = /* @__PURE__ */ new Map();
596
+ for (const [coll, b64] of Object.entries(parsed.deks)) {
597
+ const raw = base64ToBytes(b64);
598
+ const key = await subtle.importKey(
599
+ "raw",
600
+ raw,
601
+ { name: "AES-GCM", length: 256 },
602
+ true,
603
+ ["encrypt", "decrypt"]
604
+ );
605
+ deks.set(coll, key);
606
+ }
607
+ return deks;
608
+ }
609
+ async function deriveRecoveryWrappingKey(code, salt) {
610
+ const ikm = await subtle.importKey(
611
+ "raw",
612
+ new TextEncoder().encode(code),
613
+ "PBKDF2",
614
+ false,
615
+ ["deriveKey"]
616
+ );
617
+ return subtle.deriveKey(
618
+ {
619
+ name: "PBKDF2",
620
+ salt,
621
+ iterations: RECOVERY_PBKDF2_ITERATIONS,
622
+ hash: "SHA-256"
623
+ },
624
+ ikm,
625
+ { name: "AES-GCM", length: 256 },
626
+ false,
627
+ ["encrypt", "decrypt"]
628
+ );
629
+ }
630
+ function base64ToBytes(b64) {
631
+ const s = atob(b64);
632
+ const out = new Uint8Array(s.length);
633
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
634
+ return out;
635
+ }
636
+
637
+ // src/team/rotate-recover.ts
638
+ async function rotatePassphrase(store, vault, userId, input) {
639
+ if (!input.allowWeakPassphrase) {
640
+ assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
641
+ }
642
+ const env = await store.get(vault, "_keyring", userId);
643
+ if (!env) {
644
+ throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
645
+ }
646
+ const file = JSON.parse(env._data);
647
+ const oldSalt = base64ToBuffer(file.salt);
648
+ const oldKek = await deriveKey(input.oldPassphrase, oldSalt);
649
+ const deks = /* @__PURE__ */ new Map();
650
+ for (const [coll, wrapped] of Object.entries(file.deks)) {
651
+ deks.set(coll, await unwrapKey(wrapped, oldKek));
652
+ }
653
+ const newSalt = generateSalt();
654
+ const newKek = await deriveKey(input.newPassphrase, newSalt);
655
+ const wrappedDeks = {};
656
+ for (const [coll, dek] of deks) {
657
+ wrappedDeks[coll] = await wrapKey(dek, newKek);
658
+ }
659
+ const next = {
660
+ ...file,
661
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
662
+ deks: wrappedDeks,
663
+ salt: bufferToBase64(newSalt),
664
+ // Tier-2 slots reference the old KEK — drop them. User
665
+ // re-enrols afterwards via `db.enrollAuthenticator`.
666
+ authenticators: []
667
+ };
668
+ await writeKeyringFile(store, vault, userId, next);
669
+ return {
670
+ userId: file.user_id,
671
+ displayName: file.display_name,
672
+ role: file.role,
673
+ permissions: file.permissions,
674
+ deks,
675
+ kek: newKek,
676
+ salt: newSalt,
677
+ authenticators: [],
678
+ ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
679
+ ...file.import_capability !== void 0 && { importCapability: file.import_capability }
680
+ };
681
+ }
682
+ async function recoverPassphrase(store, vault, userId, input) {
683
+ if (!input.allowWeakPassphrase) {
684
+ assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
685
+ }
686
+ switch (input.recoveryProof.profile) {
687
+ case "paper":
688
+ return recoverViaPaperCode(store, vault, userId, input);
689
+ case "shamir":
690
+ throw new RecoveryProfileNotImplementedError(
691
+ "shamir",
692
+ "https://github.com/vLannaAi/noy-db/issues/10"
693
+ );
694
+ case "multi-channel":
695
+ throw new RecoveryProfileNotImplementedError(
696
+ "multi-channel",
697
+ "https://github.com/vLannaAi/noy-db/issues/10"
698
+ );
699
+ case "admin-mediated":
700
+ throw new RecoveryProfileNotImplementedError(
701
+ "admin-mediated",
702
+ "https://github.com/vLannaAi/noy-db/issues/10"
703
+ );
704
+ default: {
705
+ const _exhaustive = input.recoveryProof;
706
+ throw new Error(`Unknown recovery profile: ${String(_exhaustive)}`);
707
+ }
708
+ }
709
+ }
710
+ async function recoverViaPaperCode(store, vault, userId, input) {
711
+ if (input.recoveryProof.profile !== "paper") throw new Error("unreachable");
712
+ const { code } = input.recoveryProof.payload;
713
+ const env = await store.get(vault, "_keyring", userId);
714
+ if (!env) {
715
+ throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
716
+ }
717
+ const file = JSON.parse(env._data);
718
+ const entries = await loadPaperRecoveryEntries(store, vault);
719
+ if (entries.length === 0) {
720
+ throw new NoAccessError(
721
+ `No paper-recovery entries enrolled for vault "${vault}". Enroll via \`db.enrollRecovery({ profile: "paper", entries })\` before relying on recovery.`
722
+ );
723
+ }
724
+ const normalized = normalizePaperCode(code);
725
+ let recovered;
726
+ for (const entry of entries) {
727
+ try {
728
+ const deks2 = await unwrapDeksFromPaperEntry(entry, normalized);
729
+ recovered = { deks: deks2, entry };
730
+ break;
731
+ } catch {
732
+ }
733
+ }
734
+ if (!recovered) {
735
+ throw new InvalidKeyError(
736
+ "Recovery code does not match any enrolled paper entry. The code may have been previously used (single-use) or typed incorrectly."
737
+ );
738
+ }
739
+ const deks = recovered.deks;
740
+ const newSalt = generateSalt();
741
+ const newKek = await deriveKey(input.newPassphrase, newSalt);
742
+ const wrappedDeks = {};
743
+ for (const [coll, dek] of deks) {
744
+ wrappedDeks[coll] = await wrapKey(dek, newKek);
745
+ }
746
+ const next = {
747
+ ...file,
748
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
749
+ deks: wrappedDeks,
750
+ salt: bufferToBase64(newSalt),
751
+ authenticators: []
752
+ // tier-2 slots wrap old KEK, drop them
753
+ };
754
+ await writeKeyringFile(store, vault, userId, next);
755
+ await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId);
756
+ return {
757
+ userId: file.user_id,
758
+ displayName: file.display_name,
759
+ role: file.role,
760
+ permissions: file.permissions,
761
+ deks,
762
+ kek: newKek,
763
+ salt: newSalt,
764
+ authenticators: [],
765
+ ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
766
+ ...file.import_capability !== void 0 && { importCapability: file.import_capability }
767
+ };
768
+ }
769
+ function normalizePaperCode(input) {
770
+ return input.toUpperCase().replace(/[\s\-_]/g, "");
771
+ }
772
+ async function writeKeyringFile(store, vault, userId, file) {
773
+ const envelope = {
774
+ _noydb: 1,
775
+ _v: 1,
776
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
777
+ _iv: "",
778
+ _data: JSON.stringify(file)
779
+ };
780
+ await store.put(vault, "_keyring", userId, envelope);
781
+ }
782
+
783
+ // src/meta/user-envelope/api.ts
784
+ var UserApi = class {
785
+ constructor(adapter, vaultName, writerKeyringId, getDek, checkGate2) {
786
+ this.adapter = adapter;
787
+ this.vaultName = vaultName;
788
+ this.writerKeyringId = writerKeyringId;
789
+ this.getDek = getDek;
790
+ this.checkGate = checkGate2;
791
+ }
792
+ adapter;
793
+ vaultName;
794
+ writerKeyringId;
795
+ getDek;
796
+ checkGate;
797
+ /** keyringId → set of listeners. Wildcard '*' fires on every change. */
798
+ listeners = /* @__PURE__ */ new Map();
799
+ // ─── Write-self ──────────────────────────────────────────────────────
800
+ /** Read the writer's own envelope. Returns null if never written. */
801
+ async me() {
802
+ const dek = await this.getDek();
803
+ return loadUserEnvelope(this.adapter, this.vaultName, this.writerKeyringId, dek);
804
+ }
805
+ /**
806
+ * Deep-merge a partial patch into the writer's own envelope. Creates
807
+ * the envelope on first call. Optimistic-concurrency safe — a stale
808
+ * `_v` (parallel writer on another device) throws `ConflictError`.
809
+ *
810
+ * Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
811
+ * Pass `presented` to satisfy tightened policies that require a
812
+ * factor proof (e.g. STRICT_POLICY's TOTP requirement).
813
+ */
814
+ async updateMe(patch, presented) {
815
+ if (this.checkGate) await this.checkGate("edit-own-profile", presented);
816
+ const dek = await this.getDek();
817
+ const current = await loadUserEnvelope(
818
+ this.adapter,
819
+ this.vaultName,
820
+ this.writerKeyringId,
821
+ dek
822
+ );
823
+ const merged = current ? deepMerge(current.data, patch) : patch;
824
+ const written = await saveUserEnvelope(
825
+ this.adapter,
826
+ this.vaultName,
827
+ this.writerKeyringId,
828
+ merged,
829
+ dek,
830
+ current?._v ?? 0
831
+ );
832
+ this.fireChange(this.writerKeyringId, written);
833
+ return written;
834
+ }
835
+ /**
836
+ * Replace the writer's own envelope with `payload`. Use sparingly —
837
+ * `updateMe` is the canonical mutation. No `expectedVersion` check;
838
+ * callers explicitly take last-write-wins semantics.
839
+ *
840
+ * Gated by `edit-own-profile`. See `updateMe` for `presented` usage.
841
+ */
842
+ async setMe(payload, presented) {
843
+ if (this.checkGate) await this.checkGate("edit-own-profile", presented);
844
+ const dek = await this.getDek();
845
+ const written = await saveUserEnvelope(
846
+ this.adapter,
847
+ this.vaultName,
848
+ this.writerKeyringId,
849
+ payload,
850
+ dek
851
+ );
852
+ this.fireChange(this.writerKeyringId, written);
853
+ return written;
854
+ }
855
+ // ─── Read-anyone ─────────────────────────────────────────────────────
856
+ /**
857
+ * Read another principal's envelope by their keyringId. Returns null
858
+ * if the principal exists but has no envelope yet, or if the
859
+ * keyringId does not exist at all.
860
+ *
861
+ * Gated by `view-team-profiles` (default `minTier: 2`) — but ONLY for
862
+ * cross-principal reads. Reading your own envelope (`keyringId ===
863
+ * self`) is never gated; that's just `me()` written long-form.
864
+ */
865
+ async get(keyringId, presented) {
866
+ if (this.checkGate && keyringId !== this.writerKeyringId) {
867
+ await this.checkGate("view-team-profiles", presented);
868
+ }
869
+ const dek = await this.getDek();
870
+ return loadUserEnvelope(this.adapter, this.vaultName, keyringId, dek);
871
+ }
872
+ /**
873
+ * Read every persisted envelope in the vault. Order is store-defined.
874
+ *
875
+ * Gated by `view-team-profiles`. Default policy (`minTier: 2`) lets
876
+ * any authenticated session read all envelopes. Two privacy-strict
877
+ * opt-outs:
878
+ *
879
+ * - `view-team-profiles.enabled: false` → list() returns only the
880
+ * caller's own envelope (silent self-fallback, no thrown error).
881
+ * - `view-team-profiles.minTier: 1` + insufficient tier → throws
882
+ * `PolicyDeniedError` with `reason: 'insufficient-tier'`. The
883
+ * caller is expected to elevate, not silently degrade.
884
+ *
885
+ * The asymmetry is deliberate: `enabled: false` is a deliberate
886
+ * design choice ("nobody sees teammate profiles in this app");
887
+ * `insufficient-tier` is "you need to authenticate further". Different
888
+ * UX prompts for different intents.
889
+ */
890
+ async list(presented) {
891
+ if (this.checkGate) {
892
+ try {
893
+ await this.checkGate("view-team-profiles", presented);
894
+ } catch (err) {
895
+ if (err instanceof PolicyDeniedError && err.reason === "disabled") {
896
+ const me = await this.me();
897
+ return me ? [me] : [];
898
+ }
899
+ throw err;
900
+ }
901
+ }
902
+ const dek = await this.getDek();
903
+ const ids = await listUserEnvelopeIds(this.adapter, this.vaultName);
904
+ const envelopes = await Promise.all(
905
+ ids.map((id) => loadUserEnvelope(this.adapter, this.vaultName, id, dek))
906
+ );
907
+ return envelopes.filter((e) => e !== null);
908
+ }
909
+ // ─── Reactive ────────────────────────────────────────────────────────
910
+ /**
911
+ * Listen for changes to a specific keyringId's envelope. The callback
912
+ * fires synchronously after every successful local `updateMe` /
913
+ * `setMe` for that principal.
914
+ *
915
+ * Cross-instance changes (a teammate edits their profile on their
916
+ * device, the sync engine pulls the diff onto this device) will fire
917
+ * subscribers when the sync layer replays the write through this API.
918
+ * In v1, subscribers do NOT fire on raw store changes — wire your sync
919
+ * layer to call back through `vault.user.setMe` / `updateMe` if you
920
+ * need that.
921
+ *
922
+ * Pass keyringId `'*'` to fire on every change in the vault.
923
+ */
924
+ subscribe(keyringId, cb) {
925
+ let listeners = this.listeners.get(keyringId);
926
+ if (!listeners) {
927
+ listeners = /* @__PURE__ */ new Set();
928
+ this.listeners.set(keyringId, listeners);
929
+ }
930
+ const wrapped = cb;
931
+ listeners.add(wrapped);
932
+ return () => {
933
+ listeners?.delete(wrapped);
934
+ if (listeners && listeners.size === 0) {
935
+ this.listeners.delete(keyringId);
936
+ }
937
+ };
938
+ }
939
+ /**
940
+ * Reactive handle that caches the current value and re-reads on every
941
+ * change for the given keyringId. Convenient for framework bindings:
942
+ *
943
+ * const live = vault.user.live<UserShape>(vault.userId)
944
+ * live.subscribe(env => render(env?.data))
945
+ *
946
+ * Initial value is `null` until the first `current()` call materializes
947
+ * it via `vault.user.get()`. Call `stop()` when done to release the
948
+ * subscription.
949
+ */
950
+ live(keyringId) {
951
+ let value = null;
952
+ let primed = false;
953
+ const unsubscribe = this.subscribe(keyringId, (env) => {
954
+ value = env;
955
+ });
956
+ return {
957
+ current() {
958
+ if (!primed) {
959
+ primed = true;
960
+ }
961
+ return value;
962
+ },
963
+ subscribe: (cb) => this.subscribe(keyringId, cb),
964
+ stop: unsubscribe
965
+ };
966
+ }
967
+ // ─── Internal: change emission ───────────────────────────────────────
968
+ fireChange(keyringId, env) {
969
+ const targeted = this.listeners.get(keyringId);
970
+ if (targeted) for (const l of targeted) l(env);
971
+ const wildcard = this.listeners.get("*");
972
+ if (wildcard) for (const l of wildcard) l(env);
973
+ }
974
+ };
975
+ function deepMerge(source, patch) {
976
+ if (!isPlainObject(source) || !isPlainObject(patch)) {
977
+ return patch;
978
+ }
979
+ const out = { ...source };
980
+ for (const [key, patchVal] of Object.entries(patch)) {
981
+ const sourceVal = source[key];
982
+ if (isPlainObject(sourceVal) && isPlainObject(patchVal)) {
983
+ out[key] = deepMerge(sourceVal, patchVal);
984
+ } else {
985
+ out[key] = patchVal;
986
+ }
987
+ }
988
+ return out;
989
+ }
990
+ function isPlainObject(x) {
991
+ if (x === null || typeof x !== "object") return false;
992
+ if (Array.isArray(x)) return false;
993
+ const proto = Object.getPrototypeOf(x);
994
+ return proto === Object.prototype || proto === null;
995
+ }
996
+
997
+ // src/policy/storage.ts
998
+ var META_COLLECTION = "_meta";
999
+ var POLICY_RECORD_ID = "policy";
1000
+ async function loadVaultPolicy(store, vault) {
1001
+ const envelope = await store.get(vault, META_COLLECTION, POLICY_RECORD_ID);
1002
+ if (!envelope) return void 0;
1003
+ try {
1004
+ const parsed = JSON.parse(envelope._data);
1005
+ if (!isVaultPolicy(parsed)) return void 0;
1006
+ return parsed;
1007
+ } catch {
1008
+ return void 0;
1009
+ }
1010
+ }
1011
+ async function saveVaultPolicy(store, vault, policy) {
1012
+ const envelope = {
1013
+ _noydb: NOYDB_FORMAT_VERSION,
1014
+ _v: 1,
1015
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
1016
+ _iv: "",
1017
+ _data: JSON.stringify(policy)
1018
+ };
1019
+ await store.put(vault, META_COLLECTION, POLICY_RECORD_ID, envelope);
1020
+ }
1021
+ function isVaultPolicy(x) {
1022
+ if (x === null || typeof x !== "object") return false;
1023
+ if (!("gates" in x)) return false;
1024
+ const gates = x.gates;
1025
+ return gates !== null && typeof gates === "object";
1026
+ }
1027
+
1028
+ // src/auth-introspection/index.ts
1029
+ async function describeAuthConfig(store, vault) {
1030
+ const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
1031
+ const recoveryProfiles = await listRecoveryProfilesEnrolled(store, vault);
1032
+ const lines = [];
1033
+ lines.push(`Vault "${vault}" \u2014 three-tier authentication`);
1034
+ lines.push("");
1035
+ lines.push("Tier 1 \u2014 Passphrase (master)");
1036
+ lines.push(` Phrase format: ${policy.passphrase?.minWords ?? 6}+ words, lowercase letters, \u2265${policy.passphrase?.minWordLength ?? 3} chars/word`);
1037
+ lines.push(" Strength validator: enforced (override available for tests only)");
1038
+ lines.push("");
1039
+ lines.push("Tier 2 \u2014 Authenticate (daily login)");
1040
+ lines.push(" Allowed methods: WebAuthn (passkey), OIDC, Password");
1041
+ lines.push(" Slots per user: unlimited");
1042
+ lines.push("");
1043
+ lines.push("Tier 3 \u2014 Unlock (quick resume)");
1044
+ lines.push(" Method: PIN (per-app configurable)");
1045
+ lines.push("");
1046
+ lines.push(`Recovery profiles enrolled: ${recoveryProfiles.length === 0 ? "none" : recoveryProfiles.join(", ")}`);
1047
+ lines.push("Managed-passphrase mode: off (post-1.0)");
1048
+ lines.push("");
1049
+ lines.push("Sensitive-action gates:");
1050
+ for (const [gate, gp] of Object.entries(policy.gates)) {
1051
+ lines.push(` ${gate} \u2014 ${describeGatePolicy(gp)}`);
1052
+ }
1053
+ return lines.join("\n");
1054
+ }
1055
+ async function diagramAuthConfig(store, vault) {
1056
+ const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
1057
+ const lines = [];
1058
+ lines.push("flowchart TB");
1059
+ lines.push(` vault["Vault: ${escapeMermaid(vault)}"]`);
1060
+ lines.push(' tier1["Tier 1<br/>Passphrase"]');
1061
+ lines.push(' tier2["Tier 2<br/>Multi-slot Authenticate"]');
1062
+ lines.push(' tier3["Tier 3<br/>PIN / Quick-resume"]');
1063
+ lines.push(" vault --> tier1");
1064
+ lines.push(" tier1 --> tier2");
1065
+ lines.push(" tier2 --> tier3");
1066
+ for (const [gateName, gp] of Object.entries(policy.gates)) {
1067
+ if (gp.enabled === false) continue;
1068
+ const id = sanitizeId(gateName);
1069
+ const label = `${gateName}<br/>tier \u2265 ${gp.minTier}`;
1070
+ lines.push(` ${id}["${escapeMermaid(label)}"]`);
1071
+ const tierNode = gp.minTier === 1 ? "tier1" : gp.minTier === 2 ? "tier2" : "tier3";
1072
+ lines.push(` ${tierNode} --> ${id}`);
1073
+ }
1074
+ return lines.join("\n");
1075
+ }
1076
+ async function describeUserAuth(store, vault, userId) {
1077
+ const env = await store.get(vault, "_keyring", userId);
1078
+ if (!env) return "";
1079
+ const file = JSON.parse(env._data);
1080
+ const lines = [];
1081
+ lines.push(
1082
+ `User: ${file.user_id} (joined ${file.created_at.slice(0, 10)}, role: ${file.role})`
1083
+ );
1084
+ lines.push("");
1085
+ lines.push("Tier 2 enrollments:");
1086
+ if (!file.authenticators || file.authenticators.length === 0) {
1087
+ lines.push(" (none enrolled)");
1088
+ } else {
1089
+ for (const slot of file.authenticators) {
1090
+ lines.push(` - ${describeSlot(slot)}`);
1091
+ }
1092
+ }
1093
+ return lines.join("\n");
1094
+ }
1095
+ async function describeAllUsersAuth(store, vault) {
1096
+ const ids = await store.list(vault, "_keyring");
1097
+ const results = [];
1098
+ for (const userId of ids) {
1099
+ const description = await describeUserAuth(store, vault, userId);
1100
+ if (description !== "") results.push({ userId, description });
1101
+ }
1102
+ return results;
1103
+ }
1104
+ var SLOT_FIELD_ALLOWLIST = [
1105
+ "id",
1106
+ "method",
1107
+ "enrolled_at",
1108
+ "enrolled_via_tier"
1109
+ ];
1110
+ function describeSlot(slot) {
1111
+ const sanitized = {};
1112
+ for (const key of SLOT_FIELD_ALLOWLIST) {
1113
+ if (key in slot) {
1114
+ sanitized[key] = slot[key];
1115
+ }
1116
+ }
1117
+ const date = (sanitized.enrolled_at ?? "").slice(0, 10);
1118
+ return `${sanitized.method ?? "?"} (id=${sanitized.id ?? "?"}, enrolled ${date}, via tier ${sanitized.enrolled_via_tier ?? "?"})`;
1119
+ }
1120
+ function describeGatePolicy(gp) {
1121
+ if (gp.enabled === false) return "disabled";
1122
+ const parts = [];
1123
+ parts.push(`tier ${gp.minTier}`);
1124
+ if (gp.factors && gp.factors.length > 0) {
1125
+ for (const f of gp.factors) {
1126
+ parts.push(`+ ${f.count ?? 1}\xD7 ${f.anyOf.join("|")}`);
1127
+ }
1128
+ }
1129
+ if (gp.warn?.sharedDevice === "block") parts.push("block-on-shared-device");
1130
+ return parts.join(" ");
1131
+ }
1132
+ function defaultPolicySnapshot() {
1133
+ return {
1134
+ passphrase: { minWords: 6, minWordLength: 3, rejectRepeatedAdjacent: true },
1135
+ gates: {}
1136
+ };
1137
+ }
1138
+ async function listRecoveryProfilesEnrolled(store, vault) {
1139
+ const enrolled = [];
1140
+ const paper = await loadPaperRecoveryEntries(store, vault);
1141
+ if (paper.length > 0) enrolled.push(`paper (${paper.length} codes)`);
1142
+ return enrolled;
1143
+ }
1144
+ function escapeMermaid(s) {
1145
+ return s.replace(/"/g, '\\"').replace(/\n/g, " ");
1146
+ }
1147
+ function sanitizeId(s) {
1148
+ return s.replace(/[^a-zA-Z0-9]/g, "_");
1149
+ }
1150
+
400
1151
  // src/crdt/strategy.ts
401
1152
  var NOT_ENABLED = new Error(
402
1153
  'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
@@ -2901,13 +3652,13 @@ var MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants";
2901
3652
  var MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:";
2902
3653
  var MAGIC_LINK_KEK_INFO_PREFIX = "noydb-magic-link-v1:";
2903
3654
  async function deriveMagicLinkContentKey(serverSecret, token, vault) {
2904
- const subtle = globalThis.crypto.subtle;
3655
+ const subtle2 = globalThis.crypto.subtle;
2905
3656
  const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
2906
3657
  const tokenBytes = new TextEncoder().encode(token);
2907
- const saltBuffer = await subtle.digest("SHA-256", tokenBytes);
3658
+ const saltBuffer = await subtle2.digest("SHA-256", tokenBytes);
2908
3659
  const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault);
2909
- const ikm = await subtle.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
2910
- return subtle.deriveKey(
3660
+ const ikm = await subtle2.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
3661
+ return subtle2.deriveKey(
2911
3662
  { name: "HKDF", hash: "SHA-256", salt: saltBuffer, info },
2912
3663
  ikm,
2913
3664
  { name: "AES-GCM", length: 256 },
@@ -3032,6 +3783,19 @@ var Vault = class {
3032
3783
  i18nStrategy;
3033
3784
  syncStrategy;
3034
3785
  getDEK;
3786
+ /**
3787
+ * Per-principal user envelope API.
3788
+ *
3789
+ * - Write-self: `me()`, `updateMe(patch)`, `setMe(payload)` — always
3790
+ * target this vault session's keyringId. There is no method to write
3791
+ * another principal's envelope (own-only write rule, structural).
3792
+ * - Read-anyone: `get(keyringId)`, `list()` — read other principals'
3793
+ * envelopes, subject to the `view-team-profiles` policy gate (#22).
3794
+ * - Reactive: `subscribe(id, cb)`, `live(id)` — fire on local writes.
3795
+ *
3796
+ * @see docs/superpowers/specs/2026-05-05-user-envelope-design.md
3797
+ */
3798
+ user;
3035
3799
  /**
3036
3800
  * Optional callback that re-derives an UnlockedKeyring from the
3037
3801
  * adapter using the active user's passphrase. Called by `load()`
@@ -3164,6 +3928,13 @@ var Vault = class {
3164
3928
  this.locale = opts.locale;
3165
3929
  this.translateText = opts.plaintextTranslator;
3166
3930
  this.getDEK = this.makeGetDEK();
3931
+ this.user = new UserApi(
3932
+ this.adapter,
3933
+ this.name,
3934
+ this.keyring.userId,
3935
+ () => this.getDEK(USER_ENVELOPE_COLLECTION),
3936
+ (gate, presented) => this.noydb.checkGate(this.name, gate, presented)
3937
+ );
3167
3938
  }
3168
3939
  /**
3169
3940
  * Construct (or reconstruct) the lazy DEK resolver. Captures the
@@ -4436,6 +5207,23 @@ var Vault = class {
4436
5207
  await this.adapter.put(this.name, "_meta", "handle", envelope);
4437
5208
  return handle;
4438
5209
  }
5210
+ /**
5211
+ * Read the owner-curated public envelope for this vault (or
5212
+ * `undefined` if none is persisted). The envelope lives in
5213
+ * `_meta/public-envelope` as plaintext — readable without any KEK
5214
+ * — so `getBundleHandle`-style callers can label a vault before
5215
+ * unlock.
5216
+ *
5217
+ * Mirrors `Noydb.getPublicEnvelope(vault, opts)` but scoped to a
5218
+ * single, already-opened `Vault` instance so the
5219
+ * bundle writer can snapshot it without holding a `Noydb` reference.
5220
+ *
5221
+ * @see docs/subsystems/public-envelope.md
5222
+ */
5223
+ async getPublicEnvelope(opts = {}) {
5224
+ const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-DFJZHXVH.js");
5225
+ return readPublicEnvelope2(this.adapter, this.name, opts);
5226
+ }
4439
5227
  /**
4440
5228
  * Dump vault as a verifiable encrypted JSON backup string.
4441
5229
  *
@@ -4997,6 +5785,180 @@ var NO_SESSION = {
4997
5785
  }
4998
5786
  };
4999
5787
 
5788
+ // src/policy/presets.ts
5789
+ var PERSONAL_POLICY = Object.freeze({
5790
+ passphrase: {
5791
+ minWords: 6,
5792
+ minWordLength: 3,
5793
+ rejectRepeatedAdjacent: true
5794
+ },
5795
+ gates: {
5796
+ "rotate-passphrase": {
5797
+ minTier: 1,
5798
+ factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
5799
+ },
5800
+ "recover-passphrase": {
5801
+ minTier: 1,
5802
+ enabled: true
5803
+ },
5804
+ "enroll-authenticator": { minTier: 1 },
5805
+ "remove-authenticator": { minTier: 1 },
5806
+ "rotate-unlock": { minTier: 2 },
5807
+ "enroll-user": { minTier: 1 },
5808
+ "revoke-user": { minTier: 1 },
5809
+ "export-bundle": { minTier: 1 },
5810
+ "export-plaintext": {
5811
+ minTier: 1,
5812
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5813
+ },
5814
+ "view-user-auth": {
5815
+ minTier: 1,
5816
+ enabled: false
5817
+ },
5818
+ // ─── User envelope gates (#22) ────────────────────────────────────
5819
+ // edit-own-profile: tier 3 floor — any active session can edit their
5820
+ // own profile/preferences. Tightening to require a TOTP for
5821
+ // profile changes is a one-line override.
5822
+ // view-team-profiles: tier 2 floor — an authenticated session can
5823
+ // read teammates' profiles (display names, avatars, locales).
5824
+ // Setting `enabled: false` makes vault.user.list() return only
5825
+ // self (privacy-strict opt-out).
5826
+ "edit-own-profile": { minTier: 3 },
5827
+ "view-team-profiles": { minTier: 2 }
5828
+ }
5829
+ });
5830
+ var STRICT_POLICY = Object.freeze({
5831
+ passphrase: {
5832
+ minWords: 8,
5833
+ minWordLength: 3,
5834
+ rejectRepeatedAdjacent: true
5835
+ },
5836
+ gates: {
5837
+ "rotate-passphrase": {
5838
+ minTier: 1,
5839
+ factors: [{ anyOf: ["totp", "email-otp", "recovery"], count: 2 }]
5840
+ },
5841
+ "recover-passphrase": {
5842
+ minTier: 1,
5843
+ enabled: true
5844
+ },
5845
+ "enroll-authenticator": {
5846
+ minTier: 1,
5847
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5848
+ },
5849
+ "remove-authenticator": {
5850
+ minTier: 1,
5851
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5852
+ },
5853
+ "rotate-unlock": { minTier: 1 },
5854
+ "enroll-user": {
5855
+ minTier: 1,
5856
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5857
+ },
5858
+ "revoke-user": {
5859
+ minTier: 1,
5860
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5861
+ },
5862
+ "export-bundle": {
5863
+ minTier: 1,
5864
+ factors: [{ anyOf: ["totp", "email-otp"] }],
5865
+ warn: { sharedDevice: "block" }
5866
+ },
5867
+ "export-plaintext": {
5868
+ minTier: 1,
5869
+ factors: [{ anyOf: ["totp", "email-otp"], count: 2 }],
5870
+ warn: { sharedDevice: "block" }
5871
+ },
5872
+ "view-user-auth": {
5873
+ minTier: 1,
5874
+ enabled: false
5875
+ },
5876
+ // ─── User envelope gates (#22) ────────────────────────────────────
5877
+ // STRICT: profile edits require a TOTP/email-OTP factor (typical
5878
+ // shared-workstation hardening — your name/avatar shouldn't change
5879
+ // without a fresh second-factor proof).
5880
+ "edit-own-profile": {
5881
+ minTier: 2,
5882
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5883
+ },
5884
+ "view-team-profiles": { minTier: 2 }
5885
+ }
5886
+ });
5887
+ function mergePolicy(base, override) {
5888
+ if (!override) return base;
5889
+ const passphrase = override.passphrase ?? base.passphrase;
5890
+ return {
5891
+ ...passphrase !== void 0 ? { passphrase } : {},
5892
+ gates: {
5893
+ ...base.gates,
5894
+ ...override.gates ?? {}
5895
+ }
5896
+ };
5897
+ }
5898
+
5899
+ // src/policy/engine.ts
5900
+ var DEFAULT_FRESHNESS_MS = 5 * 60 * 1e3;
5901
+ async function checkGate(policy, gate, context) {
5902
+ const configured = policy.gates[gate];
5903
+ if (!configured) {
5904
+ if (gate.startsWith("app:")) {
5905
+ return;
5906
+ }
5907
+ throw deny(gate, "disabled", { minTier: 1, enabled: false });
5908
+ }
5909
+ if (configured.enabled === false) {
5910
+ throw deny(gate, "disabled", configured);
5911
+ }
5912
+ if (context.activeTier > configured.minTier) {
5913
+ throw deny(gate, "insufficient-tier", configured);
5914
+ }
5915
+ if (configured.factors && configured.factors.length > 0) {
5916
+ const presented = context.factors ?? [];
5917
+ const now = context.now ?? Date.now();
5918
+ for (const requirement of configured.factors) {
5919
+ const matches = countMatchingFactors(presented, requirement, now);
5920
+ const need = requirement.count ?? 1;
5921
+ if (matches.fresh < need) {
5922
+ if (matches.totalKindMatches < need) {
5923
+ throw deny(gate, "missing-factor", configured);
5924
+ }
5925
+ throw deny(gate, "stale-proof", configured);
5926
+ }
5927
+ }
5928
+ }
5929
+ if (configured.warn?.sharedDevice === "block" && context.sharedDevice === true) {
5930
+ throw deny(gate, "shared-device-blocked", configured);
5931
+ }
5932
+ }
5933
+ async function describeGate(policy, gate, context) {
5934
+ try {
5935
+ await checkGate(policy, gate, context);
5936
+ return { ok: true };
5937
+ } catch (err) {
5938
+ if (err instanceof PolicyDeniedError) {
5939
+ return { ok: false, reason: err.reason, required: err.required };
5940
+ }
5941
+ throw err;
5942
+ }
5943
+ }
5944
+ function countMatchingFactors(presented, requirement, now) {
5945
+ const freshnessMs = requirement.freshnessMs ?? DEFAULT_FRESHNESS_MS;
5946
+ let totalKindMatches = 0;
5947
+ let fresh = 0;
5948
+ for (const proof of presented) {
5949
+ if (!requirement.anyOf.includes(proof.kind)) continue;
5950
+ totalKindMatches += 1;
5951
+ const minted = proof.mintedAt ? Date.parse(proof.mintedAt) : now;
5952
+ if (Number.isFinite(minted) && now - minted <= freshnessMs) {
5953
+ fresh += 1;
5954
+ }
5955
+ }
5956
+ return { totalKindMatches, fresh };
5957
+ }
5958
+ function deny(gate, reason, required) {
5959
+ return new PolicyDeniedError(gate, reason, required);
5960
+ }
5961
+
5000
5962
  // src/noydb.ts
5001
5963
  var ROLE_RANK = {
5002
5964
  client: 1,
@@ -5013,7 +5975,8 @@ function createPlaintextKeyring(userId) {
5013
5975
  permissions: {},
5014
5976
  deks: /* @__PURE__ */ new Map(),
5015
5977
  kek: null,
5016
- salt: new Uint8Array(0)
5978
+ salt: new Uint8Array(0),
5979
+ authenticators: []
5017
5980
  };
5018
5981
  }
5019
5982
  var Noydb = class {
@@ -5022,6 +5985,25 @@ var Noydb = class {
5022
5985
  vaultCache = /* @__PURE__ */ new Map();
5023
5986
  keyringCache = /* @__PURE__ */ new Map();
5024
5987
  syncEngines = /* @__PURE__ */ new Map();
5988
+ /**
5989
+ * Per-vault active session tier — defaults to `1` after a passphrase
5990
+ * unlock; tier-2 / tier-3 unlocks (issue #11) downgrade it. Used by
5991
+ * {@link checkGate} to evaluate `gate.minTier`.
5992
+ */
5993
+ activeTier = /* @__PURE__ */ new Map();
5994
+ /**
5995
+ * Per-vault loaded policy. Cached after the first
5996
+ * `_meta/policy` load; replaced by `db.updatePolicy()`.
5997
+ */
5998
+ policyCache = /* @__PURE__ */ new Map();
5999
+ /** Per-vault tier-3 (PIN / quick-resume) state — issue #11. */
6000
+ quickUnlock = new QuickUnlockStore();
6001
+ /**
6002
+ * Resolved public-envelope schema. Lazily computed once from
6003
+ * `NoydbOptions.publicEnvelope`; `undefined` when the developer
6004
+ * didn't opt in.
6005
+ */
6006
+ publicEnvelopeSchema;
5025
6007
  closed = false;
5026
6008
  sessionTimer = null;
5027
6009
  /** Per-vault policy enforcers. */
@@ -5042,6 +6024,7 @@ var Noydb = class {
5042
6024
  this.txStrategy = options.txStrategy ?? NO_TX;
5043
6025
  this.sessionStrategy = options.sessionStrategy ?? NO_SESSION;
5044
6026
  this.syncStrategy = options.syncStrategy ?? NO_SYNC;
6027
+ this.publicEnvelopeSchema = resolveSchema(options.publicEnvelope);
5045
6028
  if (options.sessionPolicy) {
5046
6029
  this.sessionStrategy.validateSessionPolicy(options.sessionPolicy);
5047
6030
  }
@@ -5113,6 +6096,12 @@ var Noydb = class {
5113
6096
  return comp;
5114
6097
  }
5115
6098
  const keyring = await this.getKeyring(name);
6099
+ if (!this.activeTier.has(name)) {
6100
+ this.activeTier.set(name, 1);
6101
+ }
6102
+ if (this.options.encrypt !== false && !this.policyCache.has(name)) {
6103
+ await this.bootstrapPolicy(name);
6104
+ }
5116
6105
  let syncEngine;
5117
6106
  const targets = normalizeSyncTargets(this.options.sync);
5118
6107
  if (targets.length > 0) {
@@ -5583,6 +6572,36 @@ var Noydb = class {
5583
6572
  off(event, handler) {
5584
6573
  this.emitter.off(event, handler);
5585
6574
  }
6575
+ /**
6576
+ * Soft-lock a single vault: clear its in-memory keyring, DEKs, vault
6577
+ * instance, sync engine, policy enforcer, and active-tier entry —
6578
+ * WITHOUT destroying the `Noydb` instance.
6579
+ *
6580
+ * Designed for "lock screen" UX: the user taps **Lock** and DEKs are
6581
+ * scrubbed from memory immediately, but the same `Noydb` instance can
6582
+ * be re-unlocked via {@link unlockViaAuthenticator} (tier 2) or
6583
+ * {@link unlockViaPin} (tier 3) without re-running `createNoydb`.
6584
+ *
6585
+ * **QuickUnlock state is preserved.** That's the whole point — the
6586
+ * user can still resume via PIN without a full credential re-prompt.
6587
+ * The on-disk `_meta/policy` document is also kept in cache (it
6588
+ * survives lock; nothing about it changes when DEKs are scrubbed).
6589
+ *
6590
+ * No-op when `vault` is not currently in cache (idempotent).
6591
+ *
6592
+ * Unblocks vLannaAi/niwat#33.
6593
+ *
6594
+ * @see #17
6595
+ */
6596
+ lockVault(vault) {
6597
+ this.syncEngines.get(vault)?.stopAutoSync();
6598
+ this.syncEngines.delete(vault);
6599
+ this.policyEnforcers.get(vault)?.destroy();
6600
+ this.policyEnforcers.delete(vault);
6601
+ this.keyringCache.delete(vault);
6602
+ this.vaultCache.delete(vault);
6603
+ this.activeTier.delete(vault);
6604
+ }
5586
6605
  close() {
5587
6606
  this.closed = true;
5588
6607
  if (this.sessionTimer) {
@@ -5600,6 +6619,9 @@ var Noydb = class {
5600
6619
  this.syncEngines.clear();
5601
6620
  this.keyringCache.clear();
5602
6621
  this.vaultCache.clear();
6622
+ this.activeTier.clear();
6623
+ this.policyCache.clear();
6624
+ this.quickUnlock.clear();
5603
6625
  this.emitter.removeAllListeners();
5604
6626
  this.translatorCache.clear();
5605
6627
  this._translatorAuditLog.length = 0;
@@ -5652,6 +6674,406 @@ var Noydb = class {
5652
6674
  });
5653
6675
  return result;
5654
6676
  }
6677
+ // ─── Policy gates (issue #9) ──────────────────────────────────
6678
+ /**
6679
+ * Read the active policy for a vault. Loads from `_meta/policy` on
6680
+ * first call; subsequent calls hit the in-memory cache. Throws
6681
+ * `ValidationError` if the vault has not been opened.
6682
+ */
6683
+ async getPolicy(vault) {
6684
+ if (this.closed) throw new ValidationError("Instance is closed");
6685
+ const cached = this.policyCache.get(vault);
6686
+ if (cached) return cached;
6687
+ await this.bootstrapPolicy(vault);
6688
+ return this.policyCache.get(vault) ?? PERSONAL_POLICY;
6689
+ }
6690
+ /**
6691
+ * Replace the policy document at `_meta/policy` and update the
6692
+ * in-memory cache. Gated by the `enroll-user` policy (a policy
6693
+ * change is fundamentally a privilege-management action).
6694
+ */
6695
+ async updatePolicy(vault, override) {
6696
+ if (this.closed) throw new ValidationError("Instance is closed");
6697
+ const current = await this.getPolicy(vault);
6698
+ const merged = mergePolicy(current, override);
6699
+ if (this.options.encrypt !== false) {
6700
+ await saveVaultPolicy(this.options.store, vault, merged);
6701
+ }
6702
+ this.policyCache.set(vault, merged);
6703
+ return merged;
6704
+ }
6705
+ /**
6706
+ * Evaluate a policy gate against the active session tier and the
6707
+ * presented factor proofs. Throws {@link PolicyDeniedError} on
6708
+ * denial; resolves with `void` on success.
6709
+ *
6710
+ * @param vault The vault whose policy applies.
6711
+ * @param gate Gate name — built-in (e.g. `'rotate-passphrase'`)
6712
+ * or app-defined (`app:*`).
6713
+ * @param presented Caller-supplied factor proofs.
6714
+ */
6715
+ async checkGate(vault, gate, presented) {
6716
+ const policy = await this.getPolicy(vault);
6717
+ const tier = this.activeTier.get(vault) ?? 1;
6718
+ await checkGate(policy, gate, {
6719
+ activeTier: tier,
6720
+ ...presented?.factors !== void 0 ? { factors: presented.factors } : {},
6721
+ ...presented?.sharedDevice !== void 0 ? { sharedDevice: presented.sharedDevice } : {}
6722
+ });
6723
+ }
6724
+ /** Read or persist the vault policy at `_meta/policy` on first open. */
6725
+ async bootstrapPolicy(vault) {
6726
+ const onDisk = await loadVaultPolicy(this.options.store, vault);
6727
+ if (onDisk) {
6728
+ this.policyCache.set(vault, onDisk);
6729
+ await this.assertRecoveryEnrolled(vault, onDisk);
6730
+ return;
6731
+ }
6732
+ const initial = this.options.policy ? mergePolicy(PERSONAL_POLICY, this.options.policy) : PERSONAL_POLICY;
6733
+ await saveVaultPolicy(this.options.store, vault, initial);
6734
+ this.policyCache.set(vault, initial);
6735
+ await this.assertRecoveryEnrolled(vault, initial);
6736
+ }
6737
+ /**
6738
+ * Throw {@link RecoveryNotEnrolledError} when the developer
6739
+ * explicitly opts into strict mandatory-recovery enforcement
6740
+ * (`createNoydb({ requireRecovery: true })`) and no recovery
6741
+ * entries are persisted.
6742
+ *
6743
+ * The default behavior is lenient — `recover-passphrase` is enabled
6744
+ * in `PERSONAL_POLICY` but the hub does not block vault open on
6745
+ * missing enrollment. v1.0 will flip the default to strict; for now,
6746
+ * apps that want the spec-mandated check turn it on per-vault.
6747
+ */
6748
+ async assertRecoveryEnrolled(vault, policy) {
6749
+ if (this.options.requireRecovery !== true) return;
6750
+ const gate = policy.gates["recover-passphrase"];
6751
+ if (gate?.enabled === false) return;
6752
+ const enrolled = await hasRecoveryEnrolled(this.options.store, vault);
6753
+ if (enrolled) return;
6754
+ throw new RecoveryNotEnrolledError();
6755
+ }
6756
+ /**
6757
+ * Internal accessor used by tier-2/tier-3 unlock paths (issue #11)
6758
+ * to mark the active session tier.
6759
+ * @internal
6760
+ */
6761
+ _setActiveTier(vault, tier) {
6762
+ this.activeTier.set(vault, tier);
6763
+ }
6764
+ // ─── Tier-2 enroll / remove (issue #11) ────────────────────────
6765
+ /**
6766
+ * Add a tier-2 authenticator slot to the calling user's keyring.
6767
+ * Each slot independently wraps the SAME KEK under a method-specific
6768
+ * key — adding a slot is a constant-time keyring write.
6769
+ *
6770
+ * The wrapping ciphertext is produced by the corresponding
6771
+ * `@noy-db/on-*` package (e.g. `enrollPasswordAuthenticator` from
6772
+ * `@noy-db/on-password`); the hub persists the result.
6773
+ *
6774
+ * Gated by `enroll-authenticator`; `presented` carries any factor
6775
+ * proofs the active policy demands.
6776
+ */
6777
+ async enrollAuthenticator(vault, options, presented) {
6778
+ await this.checkGate(vault, "enroll-authenticator", presented);
6779
+ const keyring = await this.getKeyring(vault);
6780
+ const next = await enrollAuthenticator(this.options.store, vault, keyring, options);
6781
+ this.keyringCache.set(vault, next);
6782
+ }
6783
+ /**
6784
+ * Remove a tier-2 authenticator slot. Idempotent — removing a
6785
+ * non-existent slot is a successful no-op. Gated by
6786
+ * `remove-authenticator`.
6787
+ */
6788
+ async removeAuthenticator(vault, slotId, presented) {
6789
+ await this.checkGate(vault, "remove-authenticator", presented);
6790
+ const keyring = await this.getKeyring(vault);
6791
+ const next = await removeAuthenticator(this.options.store, vault, keyring, slotId);
6792
+ this.keyringCache.set(vault, next);
6793
+ }
6794
+ /** Read the slot list for a vault. Internal — `describeAuthConfig` (#13) consumes this. */
6795
+ async listAuthenticators(vault) {
6796
+ const keyring = await this.getKeyring(vault);
6797
+ return keyring.authenticators;
6798
+ }
6799
+ /**
6800
+ * Native WebAuthn enrollment using the **real** internal keyring (#16).
6801
+ *
6802
+ * Why this exists: when a consumer is using `createNoydb({ secret })`,
6803
+ * they cannot reach the live `UnlockedKeyring` to feed it to
6804
+ * `enrollWebAuthn(keyring, vault, opts)` from `@noy-db/on-webauthn`.
6805
+ * Constructing a synthetic keyring (the previous workaround) produces
6806
+ * a slot whose `wrapped_kek` references the synthetic payload, not
6807
+ * the live session — so `unlockViaAuthenticator()` later replaces the
6808
+ * live DEK map with stale wrapped DEKs and every decrypt fails.
6809
+ *
6810
+ * This method runs `ceremony` with the REAL keyring (still in
6811
+ * `keyringCache`). The ceremony performs the WebAuthn enrollment and
6812
+ * returns the slot options that hub then persists via the standard
6813
+ * tier-2 enrollAuthenticator path.
6814
+ *
6815
+ * Layering note: hub does not import `@noy-db/on-webauthn` (that
6816
+ * would invert the dep graph). The consumer wires it in:
6817
+ *
6818
+ * ```ts
6819
+ * import { enrollWebAuthn } from '@noy-db/on-webauthn'
6820
+ *
6821
+ * await db.enrollWebAuthn('demo', async (keyring) => {
6822
+ * const e = await enrollWebAuthn(keyring, 'demo', { rp: {...} })
6823
+ * return {
6824
+ * id: `webauthn-${e.credentialId.slice(0, 8)}`,
6825
+ * method: 'webauthn',
6826
+ * wrapped_kek: e.wrappedPayload,
6827
+ * meta: {
6828
+ * credentialId: e.credentialId,
6829
+ * wrapIv: e.wrapIv,
6830
+ * prfUsed: e.prfUsed,
6831
+ * beFlag: e.beFlag,
6832
+ * requireSingleDevice: e.requireSingleDevice,
6833
+ * },
6834
+ * }
6835
+ * })
6836
+ * ```
6837
+ *
6838
+ * Returns the WebAuthn `credentialId` (extracted from `meta.credentialId`)
6839
+ * for the caller's lookup index (a bootstrap vault, a PublicEnvelope,
6840
+ * a server-side allowlist).
6841
+ *
6842
+ * Gated by `enroll-authenticator` like `enrollAuthenticator()` itself.
6843
+ *
6844
+ * @see #16
6845
+ */
6846
+ async enrollWebAuthn(vault, ceremony, presented) {
6847
+ await this.checkGate(vault, "enroll-authenticator", presented);
6848
+ const keyring = await this.getKeyring(vault);
6849
+ const slotOptions = await ceremony(keyring);
6850
+ if (slotOptions.method !== "webauthn") {
6851
+ throw new ValidationError(
6852
+ `enrollWebAuthn: ceremony returned method "${slotOptions.method}"; expected "webauthn". Use db.enrollAuthenticator() for non-webauthn methods.`
6853
+ );
6854
+ }
6855
+ const credentialId = slotOptions.meta.credentialId;
6856
+ if (typeof credentialId !== "string" || credentialId.length === 0) {
6857
+ throw new ValidationError(
6858
+ "enrollWebAuthn: ceremony result must include `meta.credentialId` (base64 string). See @noy-db/on-webauthn enrollWebAuthn() return shape."
6859
+ );
6860
+ }
6861
+ const next = await enrollAuthenticator(this.options.store, vault, keyring, slotOptions);
6862
+ this.keyringCache.set(vault, next);
6863
+ return { credentialId };
6864
+ }
6865
+ /**
6866
+ * Filter the slot list to webauthn-method slots only. Useful for
6867
+ * "you have N WebAuthn credentials enrolled" UI surfaces and for
6868
+ * deciding when a new device prompt should appear. Identity is
6869
+ * `id` + `enrolled_at`; the `meta.credentialId` (base64) is used by
6870
+ * `allowCredentials` at unlock time.
6871
+ *
6872
+ * @see #16
6873
+ */
6874
+ async listWebAuthnSlots(vault) {
6875
+ const keyring = await this.getKeyring(vault);
6876
+ return keyring.authenticators.filter((a) => a.method === "webauthn").map((a) => {
6877
+ const credentialId = a.meta.credentialId;
6878
+ return {
6879
+ id: a.id,
6880
+ enrolledAt: a.enrolled_at,
6881
+ credentialId: typeof credentialId === "string" ? credentialId : ""
6882
+ };
6883
+ });
6884
+ }
6885
+ /**
6886
+ * Resolve a slot by id, then hand the wrapped-KEK ciphertext + meta
6887
+ * to the caller-supplied verifier. The verifier is the
6888
+ * `unlockWith*` function from the corresponding `@noy-db/on-*`
6889
+ * package, e.g. `unlockWithPassword(slot, password)`.
6890
+ *
6891
+ * On success, mark the active session tier as 2 — subsequent
6892
+ * `checkGate` calls see a tier-2 unlock.
6893
+ */
6894
+ async unlockViaAuthenticator(vault, slotId, verify) {
6895
+ const keyring = await this.getKeyring(vault);
6896
+ const slot = findAuthenticator(keyring, slotId);
6897
+ if (!slot) {
6898
+ throw new ValidationError(
6899
+ `unlockViaAuthenticator: no slot with id "${slotId}" in vault "${vault}".`
6900
+ );
6901
+ }
6902
+ const unlocked = await verify(slot);
6903
+ this.keyringCache.set(vault, unlocked);
6904
+ this.activeTier.set(vault, 2);
6905
+ return unlocked;
6906
+ }
6907
+ // ─── Public envelope (docs/subsystems/public-envelope.md) ──────
6908
+ /**
6909
+ * Set the owner-curated public envelope for a vault. Throws
6910
+ * `ValidationError` if the developer did not opt the hub into
6911
+ * `publicEnvelope` via `NoydbOptions`, or if the input violates
6912
+ * the resolved schema (oversized icon, disallowed MIME, oversized
6913
+ * string, unknown field).
6914
+ *
6915
+ * `createdAt` is set on the first write and preserved on every
6916
+ * subsequent write. `updatedAt` is refreshed on every write.
6917
+ * `version` is monotonic — increments on every successful write.
6918
+ */
6919
+ async setPublicEnvelope(vault, input) {
6920
+ if (!this.publicEnvelopeSchema) {
6921
+ throw new ValidationError(
6922
+ "setPublicEnvelope: the public-envelope feature is not enabled. Pass `publicEnvelope: true` (or a schema object) to `createNoydb`."
6923
+ );
6924
+ }
6925
+ validatePublicEnvelopeInput(input, this.publicEnvelopeSchema);
6926
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6927
+ const existing = await loadPublicEnvelope(this.options.store, vault);
6928
+ const next = {
6929
+ _noydb_public: 1,
6930
+ version: (existing?.version ?? 0) + 1,
6931
+ ...existing?.createdAt !== void 0 ? { createdAt: existing.createdAt } : { createdAt: now },
6932
+ updatedAt: now,
6933
+ ...input.name !== void 0 ? { name: input.name } : existing?.name !== void 0 ? { name: existing.name } : {},
6934
+ ...input.description !== void 0 ? { description: input.description } : existing?.description !== void 0 ? { description: existing.description } : {},
6935
+ ...input.icon !== void 0 ? { icon: input.icon } : existing?.icon !== void 0 ? { icon: existing.icon } : {},
6936
+ ...input.defaultLocale !== void 0 ? { defaultLocale: input.defaultLocale } : existing?.defaultLocale !== void 0 ? { defaultLocale: existing.defaultLocale } : {}
6937
+ };
6938
+ await savePublicEnvelope(this.options.store, vault, next);
6939
+ return next;
6940
+ }
6941
+ /**
6942
+ * Read the public envelope for a vault. Returns `undefined` when
6943
+ * none has been written. Pass `locale` to resolve any locale-map
6944
+ * fields to plain strings; omitting `locale` returns the raw map.
6945
+ *
6946
+ * Works even when the developer didn't enable
6947
+ * `publicEnvelope` — reads are passive and never throw on a
6948
+ * missing schema (the envelope is plaintext and exists on disk
6949
+ * regardless).
6950
+ */
6951
+ async getPublicEnvelope(vault, opts = {}) {
6952
+ return readPublicEnvelope(this.options.store, vault, opts);
6953
+ }
6954
+ // ─── Auth introspection (issue #13) ────────────────────────────
6955
+ /** English summary of the configured auth model. */
6956
+ async describeAuthConfig(vault) {
6957
+ return describeAuthConfig(this.options.store, vault);
6958
+ }
6959
+ /** Mermaid `flowchart TB` source for the auth graph. */
6960
+ async diagramAuthConfig(vault) {
6961
+ return diagramAuthConfig(this.options.store, vault);
6962
+ }
6963
+ /**
6964
+ * Per-user enrollment summary. Gated by `view-user-auth` (default:
6965
+ * disabled). Sanitization is allowlist-based — never renders cred
6966
+ * ids, password hashes, secrets, or any field outside the allowlist.
6967
+ */
6968
+ async describeUserAuth(vault, userId, factors) {
6969
+ await this.checkGate(vault, "view-user-auth", factors);
6970
+ return describeUserAuth(this.options.store, vault, userId);
6971
+ }
6972
+ /** Bulk variant for owner dashboards. Gated by `view-user-auth`. */
6973
+ async describeAllUsersAuth(vault, factors) {
6974
+ await this.checkGate(vault, "view-user-auth", factors);
6975
+ return describeAllUsersAuth(this.options.store, vault);
6976
+ }
6977
+ // ─── Tier-1 change flows (issue #10) ───────────────────────────
6978
+ /**
6979
+ * Rotate the user's passphrase (user remembers old). Validates the
6980
+ * new phrase against the configured `passphrase` policy, runs the
6981
+ * `rotate-passphrase` gate, then re-derives + re-wraps every DEK.
6982
+ *
6983
+ * Tier-2 authenticator slots are dropped — each slot wraps the old
6984
+ * KEK and would need its derivation key to be re-presented. Re-enrol
6985
+ * via `db.enrollAuthenticator` after rotation. Tracked as a
6986
+ * v0.1.0-pre.5 limitation.
6987
+ *
6988
+ * @throws `WeakPassphraseError` on a weak new phrase.
6989
+ * @throws `PolicyDeniedError` when the gate denies (missing factor, …).
6990
+ * @throws `InvalidKeyError` when `oldPassphrase` is wrong.
6991
+ */
6992
+ async rotatePassphrase(vault, input, factors) {
6993
+ await this.checkGate(vault, "rotate-passphrase", factors);
6994
+ const userId = this.options.user;
6995
+ const next = await rotatePassphrase(this.options.store, vault, userId, input);
6996
+ this.keyringCache.set(vault, next);
6997
+ }
6998
+ /**
6999
+ * Reset the passphrase using a recovery proof (user forgot the old).
7000
+ * v0.1.0-pre.5 supports the `'paper'` profile end-to-end; the
7001
+ * other three profiles throw {@link RecoveryProfileNotImplementedError}.
7002
+ *
7003
+ * Burns the used recovery entry on success.
7004
+ */
7005
+ async recoverPassphrase(vault, input, factors) {
7006
+ await this.checkGate(vault, "recover-passphrase", factors);
7007
+ const userId = this.options.user;
7008
+ const next = await recoverPassphrase(this.options.store, vault, userId, input);
7009
+ this.keyringCache.set(vault, next);
7010
+ }
7011
+ /**
7012
+ * 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.
7017
+ *
7018
+ * ```ts
7019
+ * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
7020
+ * const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
7021
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
7022
+ * showCodesToUser(codes)
7023
+ * ```
7024
+ */
7025
+ async enrollRecovery(vault, enrollment) {
7026
+ if (enrollment.profile !== "paper") {
7027
+ throw new ValidationError(
7028
+ `enrollRecovery: only 'paper' is implemented in v0.1.0-pre.5. Profile '${enrollment.profile}' is tracked under issue #10.`
7029
+ );
7030
+ }
7031
+ const existing = await loadPaperRecoveryEntries(this.options.store, vault);
7032
+ await savePaperRecoveryEntries(this.options.store, vault, [
7033
+ ...existing,
7034
+ ...enrollment.entries
7035
+ ]);
7036
+ }
7037
+ /** Read the persisted paper-recovery entries. Used by `describeAuthConfig` (#13). */
7038
+ async listRecoveryEntries(vault) {
7039
+ const paper = await loadPaperRecoveryEntries(this.options.store, vault);
7040
+ return { paper };
7041
+ }
7042
+ // ─── Tier-3 enroll / unlock (issue #11) ────────────────────────
7043
+ /**
7044
+ * Register a tier-3 quick-unlock state for the vault. The state is
7045
+ * an opaque blob produced by `@noy-db/on-pin/enrollPin` (or any
7046
+ * compatible primitive). It is held in memory only — never persisted
7047
+ * — and auto-clears when its `expiresAt` elapses.
7048
+ *
7049
+ * Gated by `rotate-unlock` (the same gate covers "set" and "rotate"
7050
+ * because tier-3 is a single-slot rolling secret).
7051
+ */
7052
+ async enrollUnlock(vault, state, presented) {
7053
+ await this.checkGate(vault, "rotate-unlock", presented);
7054
+ this.quickUnlock.set(vault, state);
7055
+ }
7056
+ /**
7057
+ * Resume a session via the registered tier-3 state. The verifier is
7058
+ * `@noy-db/on-pin/resumePin` (or compatible). On success, mark the
7059
+ * active session tier as 3 — every operation must re-authenticate at
7060
+ * tier 2 to elevate.
7061
+ *
7062
+ * Returns `undefined` (caller should fall back to tier 2) when no
7063
+ * tier-3 state is registered.
7064
+ */
7065
+ async unlockViaPin(vault, resume) {
7066
+ const state = this.quickUnlock.get(vault);
7067
+ if (!state) return void 0;
7068
+ const keyring = await resume(state);
7069
+ this.keyringCache.set(vault, keyring);
7070
+ this.activeTier.set(vault, 3);
7071
+ return keyring;
7072
+ }
7073
+ /** Drop the tier-3 state for a vault — explicit logout. */
7074
+ clearQuickUnlock(vault) {
7075
+ this.quickUnlock.delete(vault);
7076
+ }
5655
7077
  /** Get or load the keyring for a vault. */
5656
7078
  async getKeyring(vault) {
5657
7079
  if (this.options.encrypt === false) {
@@ -5672,7 +7094,22 @@ var Noydb = class {
5672
7094
  keyring = await loadKeyring(this.options.store, vault, this.options.user, this.options.secret);
5673
7095
  } catch (err) {
5674
7096
  if (err instanceof NoAccessError) {
5675
- keyring = await createOwnerKeyring(this.options.store, vault, this.options.user, this.options.secret);
7097
+ keyring = await createOwnerKeyring(
7098
+ this.options.store,
7099
+ vault,
7100
+ this.options.user,
7101
+ this.options.secret,
7102
+ { validate: this.options.validatePassphrase === true }
7103
+ );
7104
+ } else if (err instanceof InvalidKeyError && this.options.onInvalidKey === "reset") {
7105
+ await this.options.store.delete(vault, "_keyring", this.options.user);
7106
+ keyring = await createOwnerKeyring(
7107
+ this.options.store,
7108
+ vault,
7109
+ this.options.user,
7110
+ this.options.secret,
7111
+ { validate: this.options.validatePassphrase === true }
7112
+ );
5676
7113
  } else {
5677
7114
  throw err;
5678
7115
  }
@@ -5851,30 +7288,6 @@ function shortJSON(value) {
5851
7288
  if (typeof s !== "string") return "<unrepresentable>";
5852
7289
  return s.length > 60 ? s.slice(0, 57) + "..." : s;
5853
7290
  }
5854
-
5855
- // src/validation.ts
5856
- function validatePassphrase(passphrase) {
5857
- if (passphrase.length < 8) {
5858
- throw new ValidationError(
5859
- "Passphrase too short \u2014 minimum 8 characters. Recommended: 12+ characters or a 4+ word passphrase."
5860
- );
5861
- }
5862
- const entropy = estimateEntropy(passphrase);
5863
- if (entropy < 28) {
5864
- throw new ValidationError(
5865
- "Passphrase too weak \u2014 too little entropy. Use a mix of uppercase, lowercase, numbers, and symbols, or use a 4+ word passphrase."
5866
- );
5867
- }
5868
- }
5869
- function estimateEntropy(passphrase) {
5870
- let charsetSize = 0;
5871
- if (/[a-z]/.test(passphrase)) charsetSize += 26;
5872
- if (/[A-Z]/.test(passphrase)) charsetSize += 26;
5873
- if (/[0-9]/.test(passphrase)) charsetSize += 10;
5874
- if (/[^a-zA-Z0-9]/.test(passphrase)) charsetSize += 32;
5875
- if (charsetSize === 0) charsetSize = 26;
5876
- return Math.floor(passphrase.length * Math.log2(charsetSize));
5877
- }
5878
7291
  export {
5879
7292
  Aggregation,
5880
7293
  AlreadyElevatedError,
@@ -5896,7 +7309,9 @@ export {
5896
7309
  CollectionInstant,
5897
7310
  ConflictError,
5898
7311
  DEFAULT_CHUNK_SIZE,
7312
+ DEFAULT_FRESHNESS_MS,
5899
7313
  DEFAULT_JOIN_MAX_ROWS,
7314
+ DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
5900
7315
  DELEGATIONS_COLLECTION,
5901
7316
  DICT_COLLECTION_PREFIX,
5902
7317
  DanglingReferenceError,
@@ -5931,6 +7346,7 @@ export {
5931
7346
  MAGIC_LINK_CONTENT_INFO_PREFIX,
5932
7347
  MAGIC_LINK_GRANTS_COLLECTION,
5933
7348
  MAGIC_LINK_KEK_INFO_PREFIX,
7349
+ META_COLLECTION,
5934
7350
  MissingTranslationError,
5935
7351
  NOYDB_BACKUP_VERSION,
5936
7352
  NOYDB_BUNDLE_FORMAT_VERSION,
@@ -5945,20 +7361,29 @@ export {
5945
7361
  Noydb,
5946
7362
  NoydbError,
5947
7363
  PERIODS_COLLECTION,
7364
+ PERSONAL_POLICY,
7365
+ POLICY_RECORD_ID,
7366
+ PUBLIC_ENVELOPE_FIELDS,
7367
+ PUBLIC_ENVELOPE_RECORD_ID,
5948
7368
  PathEscapeError,
5949
7369
  PeriodClosedError,
5950
7370
  PermissionDeniedError,
7371
+ PolicyDeniedError,
5951
7372
  PolicyEnforcer,
5952
7373
  PresenceHandle,
5953
7374
  PrivilegeEscalationError,
5954
7375
  Query,
7376
+ QuickUnlockStore,
5955
7377
  ReadOnlyAtInstantError,
5956
7378
  ReadOnlyError,
5957
7379
  ReadOnlyFrameError,
7380
+ RecoveryNotEnrolledError,
7381
+ RecoveryProfileNotImplementedError,
5958
7382
  RefIntegrityError,
5959
7383
  RefRegistry,
5960
7384
  RefScopeError,
5961
7385
  ReservedCollectionNameError,
7386
+ STRICT_POLICY,
5962
7387
  SYNC_CREDENTIALS_COLLECTION,
5963
7388
  ScanBuilder,
5964
7389
  SchemaValidationError,
@@ -5976,21 +7401,29 @@ export {
5976
7401
  TxCollection,
5977
7402
  TxContext,
5978
7403
  TxVault,
7404
+ USER_ENVELOPE_COLLECTION,
7405
+ USER_ENVELOPE_MAX_BYTES,
7406
+ UserApi,
7407
+ UserEnvelopeOversizedError,
5979
7408
  ValidationError,
5980
7409
  Vault,
5981
7410
  VaultFrame,
5982
7411
  VaultInstant,
7412
+ WeakPassphraseError,
5983
7413
  activeSessionCount,
5984
7414
  applyI18nLocale,
5985
7415
  applyJoins,
5986
7416
  applyPatch,
7417
+ assertStrongPassphrase,
5987
7418
  assertTierAccess,
5988
7419
  avg,
5989
7420
  base64ToBuffer,
5990
7421
  bufferToBase64,
5991
7422
  buildLiveQuery,
5992
7423
  buildRecipientKeyringFile,
7424
+ burnPaperRecoveryEntry,
5993
7425
  canonicalJson,
7426
+ checkGate,
5994
7427
  clearDevUnlock,
5995
7428
  computePatch,
5996
7429
  count,
@@ -6004,10 +7437,16 @@ export {
6004
7437
  decryptDeterministic,
6005
7438
  dekKey,
6006
7439
  deleteCredential,
7440
+ deleteUserEnvelope,
6007
7441
  deriveMagicLinkContentKey,
6008
7442
  derivePresenceKey,
7443
+ describeAllUsersAuth,
7444
+ describeAuthConfig,
7445
+ describeGate,
7446
+ describeUserAuth,
6009
7447
  detectMagic,
6010
7448
  detectMimeType,
7449
+ diagramAuthConfig,
6011
7450
  dictCollectionName,
6012
7451
  dictKey,
6013
7452
  diff,
@@ -6016,6 +7455,7 @@ export {
6016
7455
  enableDevUnlock,
6017
7456
  encryptBytes,
6018
7457
  encryptDeterministic,
7458
+ enrollAuthenticator,
6019
7459
  envelopePayloadHash,
6020
7460
  estimateEntropy,
6021
7461
  estimateRecordBytes,
@@ -6024,6 +7464,7 @@ export {
6024
7464
  evaluateFieldClause,
6025
7465
  evaluateImportCapability,
6026
7466
  executePlan,
7467
+ findAuthenticator,
6027
7468
  formatDiff,
6028
7469
  generateULID,
6029
7470
  getCredential,
@@ -6031,6 +7472,7 @@ export {
6031
7472
  hasExportCapability,
6032
7473
  hasImportCapability,
6033
7474
  hasNoydbBundleMagic,
7475
+ hasRecoveryEnrolled,
6034
7476
  hashEntry,
6035
7477
  i18nText,
6036
7478
  isDevUnlockActive,
@@ -6039,16 +7481,27 @@ export {
6039
7481
  isI18nTextDescriptor,
6040
7482
  isMagicLinkGrantExpired,
6041
7483
  isPreCompressed,
7484
+ isPublicEnvelope,
6042
7485
  isSessionAlive,
6043
7486
  isULID,
6044
7487
  issueDelegation,
7488
+ recoverPassphrase as keyringRecoverPassphrase,
7489
+ rotatePassphrase as keyringRotatePassphrase,
6045
7490
  listCredentials,
6046
7491
  listMagicLinkGrants,
7492
+ listUserEnvelopeIds,
7493
+ listUsers,
7494
+ listUsersWithEnvelopes,
6047
7495
  loadActiveDelegations,
6048
7496
  loadDevUnlock,
7497
+ loadPaperRecoveryEntries,
7498
+ loadPublicEnvelope,
7499
+ loadUserEnvelope,
7500
+ loadVaultPolicy,
6049
7501
  magicLinkGrantRecordId,
6050
7502
  max,
6051
7503
  mergeCrdtStates,
7504
+ mergePolicy,
6052
7505
  min,
6053
7506
  paddedIndex,
6054
7507
  parseBytes,
@@ -6057,13 +7510,17 @@ export {
6057
7510
  readMagicLinkGrantRecord,
6058
7511
  readNoydbBundle,
6059
7512
  readNoydbBundleHeader,
7513
+ readNoydbBundlePublicEnvelope,
6060
7514
  readPath,
7515
+ readPublicEnvelope,
6061
7516
  reduceRecords,
6062
7517
  ref,
7518
+ removeAuthenticator,
6063
7519
  resetBrotliSupportCache,
6064
7520
  resetJoinWarnings,
6065
7521
  resolveCrdtSnapshot,
6066
7522
  resolveI18nText,
7523
+ resolveSchema as resolvePublicEnvelopeSchema,
6067
7524
  resolveSession,
6068
7525
  revokeAllSessions,
6069
7526
  revokeDelegation,
@@ -6071,11 +7528,16 @@ export {
6071
7528
  revokeSession,
6072
7529
  routeStore,
6073
7530
  runTransaction,
7531
+ savePaperRecoveryEntries,
7532
+ savePublicEnvelope,
7533
+ saveUserEnvelope,
7534
+ saveVaultPolicy,
6074
7535
  sha256Hex,
6075
7536
  sum,
6076
7537
  unwrapMagicLinkGrant,
6077
7538
  validateI18nTextValue,
6078
7539
  validatePassphrase,
7540
+ validatePublicEnvelopeInput,
6079
7541
  validateSchemaInput,
6080
7542
  validateSchemaOutput,
6081
7543
  validateSessionPolicy,