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

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-PSHTHSIX.js → chunk-6NPQTBZN.js} +103 -8
  11. package/dist/chunk-6NPQTBZN.js.map +1 -0
  12. package/dist/{chunk-QZIACZZU.js → chunk-E4OOAPBZ.js} +2 -2
  13. package/dist/chunk-EMIGCR7X.js +39 -0
  14. package/dist/chunk-EMIGCR7X.js.map +1 -0
  15. package/dist/{chunk-AVWFLPNR.js → chunk-H3DV46AQ.js} +2 -2
  16. package/dist/{chunk-NK2NSXXK.js → chunk-LMKOSLJY.js} +2 -2
  17. package/dist/{chunk-GJILMRPO.js → chunk-LRN3PNI6.js} +42 -4
  18. package/dist/chunk-LRN3PNI6.js.map +1 -0
  19. package/dist/{chunk-L77MEFCH.js → chunk-MIRZMUSQ.js} +3 -3
  20. package/dist/{chunk-O5GK62FJ.js → chunk-NXUVITPB.js} +1 -1
  21. package/dist/chunk-NXUVITPB.js.map +1 -0
  22. package/dist/{chunk-LSZHBNDG.js → chunk-QUDXYI4W.js} +2 -2
  23. package/dist/{chunk-EARQCIL7.js → chunk-QV4WLLKB.js} +3 -3
  24. package/dist/{chunk-E445ICYI.js → chunk-UFL4DUEV.js} +5 -3
  25. package/dist/chunk-UFL4DUEV.js.map +1 -0
  26. package/dist/chunk-UQQ2XFXI.js +155 -0
  27. package/dist/chunk-UQQ2XFXI.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-BgFqShBi.d.ts} +1 -1
  31. package/dist/{dev-unlock-5SmCVGyx.d.cts → dev-unlock-qVMxG2Je.d.cts} +1 -1
  32. package/dist/{hash-Bxud16vM.d.ts → hash-BhoL7iUE.d.ts} +1 -1
  33. package/dist/{hash-CvuKN2gH.d.cts → hash-Bpvl2eSe.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-BRHBCmLt.d.ts → index-DJTf9yxn.d.ts} +1 -1
  45. package/dist/{index-BvUiM47h.d.cts → index-DhK_zqOO.d.ts} +39 -5
  46. package/dist/{index-Cy-MKrdK.d.ts → index-DyRt_5vM.d.cts} +39 -5
  47. package/dist/index.cjs +1490 -48
  48. package/dist/index.cjs.map +1 -1
  49. package/dist/index.d.cts +261 -19
  50. package/dist/index.d.ts +261 -19
  51. package/dist/index.js +1107 -41
  52. package/dist/index.js.map +1 -1
  53. package/dist/{ledger-HWXYGUIQ.js → ledger-GA4DMJS6.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-R4EIEQE6.js +31 -0
  59. package/dist/public-envelope-R4EIEQE6.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-BVSfkYg6.d.cts → types-BpyE4o_n.d.cts} +895 -3
  83. package/dist/{types-Dmi7nrC9.d.ts → types-Df72wWCC.d.ts} +895 -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-QZIACZZU.js.map → chunk-E4OOAPBZ.js.map} +0 -0
  90. /package/dist/{chunk-AVWFLPNR.js.map → chunk-H3DV46AQ.js.map} +0 -0
  91. /package/dist/{chunk-NK2NSXXK.js.map → chunk-LMKOSLJY.js.map} +0 -0
  92. /package/dist/{chunk-L77MEFCH.js.map → chunk-MIRZMUSQ.js.map} +0 -0
  93. /package/dist/{chunk-LSZHBNDG.js.map → chunk-QUDXYI4W.js.map} +0 -0
  94. /package/dist/{chunk-EARQCIL7.js.map → chunk-QV4WLLKB.js.map} +0 -0
  95. /package/dist/{ledger-HWXYGUIQ.js.map → ledger-GA4DMJS6.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-LRN3PNI6.js";
35
+ import {
36
+ PUBLIC_ENVELOPE_RECORD_ID,
37
+ isPublicEnvelope,
38
+ loadPublicEnvelope,
39
+ readPublicEnvelope,
40
+ savePublicEnvelope,
41
+ validatePublicEnvelopeInput
42
+ } from "./chunk-UQQ2XFXI.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-QUDXYI4W.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-QV4WLLKB.js";
59
73
  import {
60
74
  createBundleStore,
61
75
  routeStore,
@@ -75,17 +89,20 @@ import {
75
89
  getCredential,
76
90
  listCredentials,
77
91
  putCredential
78
- } from "./chunk-L77MEFCH.js";
92
+ } from "./chunk-MIRZMUSQ.js";
79
93
  import {
80
94
  PresenceHandle,
81
95
  SyncEngine,
82
96
  SyncTransaction
83
- } from "./chunk-AVWFLPNR.js";
97
+ } from "./chunk-H3DV46AQ.js";
84
98
  import {
99
+ WeakPassphraseError,
100
+ assertStrongPassphrase,
85
101
  buildRecipientKeyringFile,
86
102
  changeSecret,
87
103
  createOwnerKeyring,
88
104
  ensureCollectionDEK,
105
+ estimateEntropy,
89
106
  evaluateExportCapability,
90
107
  evaluateImportCapability,
91
108
  grant,
@@ -95,9 +112,11 @@ import {
95
112
  hasWritePermission,
96
113
  listUsers,
97
114
  loadKeyring,
115
+ persistKeyring,
98
116
  revoke,
99
- rotateKeys
100
- } from "./chunk-PSHTHSIX.js";
117
+ rotateKeys,
118
+ validatePassphrase
119
+ } from "./chunk-6NPQTBZN.js";
101
120
  import {
102
121
  BUNDLE_STORE_POLICY,
103
122
  INDEXED_STORE_POLICY,
@@ -117,7 +136,7 @@ import {
117
136
  revokeAllSessions,
118
137
  revokeSession,
119
138
  validateSessionPolicy
120
- } from "./chunk-E445ICYI.js";
139
+ } from "./chunk-UFL4DUEV.js";
121
140
  import {
122
141
  generateULID,
123
142
  isULID
@@ -134,7 +153,7 @@ import {
134
153
  LedgerStore,
135
154
  applyPatch,
136
155
  computePatch
137
- } from "./chunk-NK2NSXXK.js";
156
+ } from "./chunk-LMKOSLJY.js";
138
157
  import {
139
158
  canonicalJson,
140
159
  envelopePayloadHash,
@@ -189,24 +208,26 @@ import {
189
208
  detectMimeType,
190
209
  isPreCompressed,
191
210
  runCompaction
192
- } from "./chunk-QZIACZZU.js";
211
+ } from "./chunk-E4OOAPBZ.js";
193
212
  import {
194
213
  NOYDB_BACKUP_VERSION,
195
214
  NOYDB_FORMAT_VERSION,
196
215
  NOYDB_KEYRING_VERSION,
197
216
  NOYDB_SYNC_VERSION,
198
217
  createStore
199
- } from "./chunk-O5GK62FJ.js";
218
+ } from "./chunk-NXUVITPB.js";
200
219
  import {
201
220
  base64ToBuffer,
202
221
  bufferToBase64,
203
222
  decrypt,
204
223
  decryptBytes,
205
224
  decryptDeterministic,
225
+ deriveKey,
206
226
  derivePresenceKey,
207
227
  encrypt,
208
228
  encryptBytes,
209
229
  encryptDeterministic,
230
+ generateSalt,
210
231
  unwrapKey,
211
232
  wrapKey
212
233
  } from "./chunk-MR4424N3.js";
@@ -397,6 +418,514 @@ var RefRegistry = class {
397
418
  }
398
419
  };
399
420
 
421
+ // src/team/authenticators.ts
422
+ async function enrollAuthenticator(store, vault, keyring, options) {
423
+ const existing = keyring.authenticators.find((a) => a.id === options.id);
424
+ if (existing) {
425
+ throw new ValidationError(
426
+ `enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
427
+ );
428
+ }
429
+ const slot = {
430
+ id: options.id,
431
+ method: options.method,
432
+ enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
433
+ enrolled_via_tier: options.enrolled_via_tier ?? 1,
434
+ wrapped_kek: options.wrapped_kek,
435
+ meta: options.meta
436
+ };
437
+ const next = appendSlot(keyring, slot);
438
+ await persistKeyring(store, vault, next);
439
+ return next;
440
+ }
441
+ async function removeAuthenticator(store, vault, keyring, slotId) {
442
+ const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
443
+ if (filtered.length === keyring.authenticators.length) {
444
+ return keyring;
445
+ }
446
+ const next = {
447
+ ...keyring,
448
+ authenticators: filtered
449
+ };
450
+ await persistKeyring(store, vault, next);
451
+ return next;
452
+ }
453
+ function findAuthenticator(keyring, slotId) {
454
+ return keyring.authenticators.find((a) => a.id === slotId);
455
+ }
456
+ function appendSlot(keyring, slot) {
457
+ return {
458
+ ...keyring,
459
+ authenticators: [...keyring.authenticators, slot]
460
+ };
461
+ }
462
+
463
+ // src/session/unlock-state.ts
464
+ var QuickUnlockStore = class {
465
+ states = /* @__PURE__ */ new Map();
466
+ timers = /* @__PURE__ */ new Map();
467
+ /**
468
+ * Register a quick-unlock state for a vault. Replaces any existing
469
+ * state. Schedules an automatic clear when the state's `expiresAt`
470
+ * elapses.
471
+ */
472
+ set(vault, state) {
473
+ this.clearTimer(vault);
474
+ this.states.set(vault, state);
475
+ const ttl = new Date(state.expiresAt).getTime() - Date.now();
476
+ if (ttl > 0) {
477
+ const timer = setTimeout(() => this.delete(vault), ttl);
478
+ this.timers.set(vault, timer);
479
+ }
480
+ }
481
+ /** Read the state for a vault. Returns undefined when none is registered. */
482
+ get(vault) {
483
+ return this.states.get(vault);
484
+ }
485
+ /** Drop the state for a vault. Cancels the auto-clear timer. */
486
+ delete(vault) {
487
+ this.clearTimer(vault);
488
+ this.states.delete(vault);
489
+ }
490
+ /** Drop every cached state. Called on `db.close()`. */
491
+ clear() {
492
+ for (const vault of this.states.keys()) {
493
+ this.clearTimer(vault);
494
+ }
495
+ this.states.clear();
496
+ }
497
+ clearTimer(vault) {
498
+ const t = this.timers.get(vault);
499
+ if (t) clearTimeout(t);
500
+ this.timers.delete(vault);
501
+ }
502
+ };
503
+
504
+ // src/policy/errors.ts
505
+ var PolicyDeniedError = class extends NoydbError {
506
+ gate;
507
+ reason;
508
+ required;
509
+ constructor(gate, reason, required, message) {
510
+ super(
511
+ "POLICY_DENIED",
512
+ message ?? `Gate "${gate}" denied: ${reason}.`
513
+ );
514
+ this.name = "PolicyDeniedError";
515
+ this.gate = gate;
516
+ this.reason = reason;
517
+ this.required = required;
518
+ }
519
+ };
520
+ var RecoveryNotEnrolledError = class extends NoydbError {
521
+ 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.') {
522
+ super("RECOVERY_NOT_ENROLLED", message);
523
+ this.name = "RecoveryNotEnrolledError";
524
+ }
525
+ };
526
+ var RecoveryProfileNotImplementedError = class extends NoydbError {
527
+ profile;
528
+ tracking;
529
+ constructor(profile, tracking) {
530
+ super(
531
+ "RECOVERY_PROFILE_NOT_IMPLEMENTED",
532
+ `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.`
533
+ );
534
+ this.name = "RecoveryProfileNotImplementedError";
535
+ this.profile = profile;
536
+ this.tracking = tracking;
537
+ }
538
+ };
539
+
540
+ // src/team/recovery.ts
541
+ var PAPER_DOC_ID = "recovery-paper";
542
+ async function loadPaperRecoveryEntries(store, vault) {
543
+ const env = await store.get(vault, "_meta", PAPER_DOC_ID);
544
+ if (!env) return [];
545
+ try {
546
+ const doc = JSON.parse(env._data);
547
+ if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
548
+ return doc.entries;
549
+ } catch {
550
+ return [];
551
+ }
552
+ }
553
+ async function savePaperRecoveryEntries(store, vault, entries) {
554
+ const doc = {
555
+ _noydb_recovery: 1,
556
+ profile: "paper",
557
+ entries
558
+ };
559
+ const envelope = {
560
+ _noydb: NOYDB_FORMAT_VERSION,
561
+ _v: 1,
562
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
563
+ _iv: "",
564
+ _data: JSON.stringify(doc)
565
+ };
566
+ await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
567
+ }
568
+ async function burnPaperRecoveryEntry(store, vault, codeId) {
569
+ const entries = await loadPaperRecoveryEntries(store, vault);
570
+ const remaining = entries.filter((e) => e.codeId !== codeId);
571
+ await savePaperRecoveryEntries(store, vault, remaining);
572
+ }
573
+ async function hasRecoveryEnrolled(store, vault) {
574
+ const paper = await loadPaperRecoveryEntries(store, vault);
575
+ return paper.length > 0;
576
+ }
577
+ var subtle = globalThis.crypto.subtle;
578
+ var RECOVERY_PBKDF2_ITERATIONS = 6e5;
579
+ async function unwrapDeksFromPaperEntry(entry, code) {
580
+ const wrappingKey = await deriveRecoveryWrappingKey(code, base64ToBytes(entry.salt));
581
+ const plaintext = await subtle.decrypt(
582
+ { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
583
+ wrappingKey,
584
+ base64ToBytes(entry.wrappedDeks)
585
+ );
586
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
587
+ const deks = /* @__PURE__ */ new Map();
588
+ for (const [coll, b64] of Object.entries(parsed.deks)) {
589
+ const raw = base64ToBytes(b64);
590
+ const key = await subtle.importKey(
591
+ "raw",
592
+ raw,
593
+ { name: "AES-GCM", length: 256 },
594
+ true,
595
+ ["encrypt", "decrypt"]
596
+ );
597
+ deks.set(coll, key);
598
+ }
599
+ return deks;
600
+ }
601
+ async function deriveRecoveryWrappingKey(code, salt) {
602
+ const ikm = await subtle.importKey(
603
+ "raw",
604
+ new TextEncoder().encode(code),
605
+ "PBKDF2",
606
+ false,
607
+ ["deriveKey"]
608
+ );
609
+ return subtle.deriveKey(
610
+ {
611
+ name: "PBKDF2",
612
+ salt,
613
+ iterations: RECOVERY_PBKDF2_ITERATIONS,
614
+ hash: "SHA-256"
615
+ },
616
+ ikm,
617
+ { name: "AES-GCM", length: 256 },
618
+ false,
619
+ ["encrypt", "decrypt"]
620
+ );
621
+ }
622
+ function base64ToBytes(b64) {
623
+ const s = atob(b64);
624
+ const out = new Uint8Array(s.length);
625
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
626
+ return out;
627
+ }
628
+
629
+ // src/team/rotate-recover.ts
630
+ async function rotatePassphrase(store, vault, userId, input) {
631
+ if (!input.allowWeakPassphrase) {
632
+ assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
633
+ }
634
+ const env = await store.get(vault, "_keyring", userId);
635
+ if (!env) {
636
+ throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
637
+ }
638
+ const file = JSON.parse(env._data);
639
+ const oldSalt = base64ToBuffer(file.salt);
640
+ const oldKek = await deriveKey(input.oldPassphrase, oldSalt);
641
+ const deks = /* @__PURE__ */ new Map();
642
+ for (const [coll, wrapped] of Object.entries(file.deks)) {
643
+ deks.set(coll, await unwrapKey(wrapped, oldKek));
644
+ }
645
+ const newSalt = generateSalt();
646
+ const newKek = await deriveKey(input.newPassphrase, newSalt);
647
+ const wrappedDeks = {};
648
+ for (const [coll, dek] of deks) {
649
+ wrappedDeks[coll] = await wrapKey(dek, newKek);
650
+ }
651
+ const next = {
652
+ ...file,
653
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
654
+ deks: wrappedDeks,
655
+ salt: bufferToBase64(newSalt),
656
+ // Tier-2 slots reference the old KEK — drop them. User
657
+ // re-enrols afterwards via `db.enrollAuthenticator`.
658
+ authenticators: []
659
+ };
660
+ await writeKeyringFile(store, vault, userId, next);
661
+ return {
662
+ userId: file.user_id,
663
+ displayName: file.display_name,
664
+ role: file.role,
665
+ permissions: file.permissions,
666
+ deks,
667
+ kek: newKek,
668
+ salt: newSalt,
669
+ authenticators: [],
670
+ ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
671
+ ...file.import_capability !== void 0 && { importCapability: file.import_capability }
672
+ };
673
+ }
674
+ async function recoverPassphrase(store, vault, userId, input) {
675
+ if (!input.allowWeakPassphrase) {
676
+ assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
677
+ }
678
+ switch (input.recoveryProof.profile) {
679
+ case "paper":
680
+ return recoverViaPaperCode(store, vault, userId, input);
681
+ case "shamir":
682
+ throw new RecoveryProfileNotImplementedError(
683
+ "shamir",
684
+ "https://github.com/vLannaAi/noy-db/issues/10"
685
+ );
686
+ case "multi-channel":
687
+ throw new RecoveryProfileNotImplementedError(
688
+ "multi-channel",
689
+ "https://github.com/vLannaAi/noy-db/issues/10"
690
+ );
691
+ case "admin-mediated":
692
+ throw new RecoveryProfileNotImplementedError(
693
+ "admin-mediated",
694
+ "https://github.com/vLannaAi/noy-db/issues/10"
695
+ );
696
+ default: {
697
+ const _exhaustive = input.recoveryProof;
698
+ throw new Error(`Unknown recovery profile: ${String(_exhaustive)}`);
699
+ }
700
+ }
701
+ }
702
+ async function recoverViaPaperCode(store, vault, userId, input) {
703
+ if (input.recoveryProof.profile !== "paper") throw new Error("unreachable");
704
+ const { code } = input.recoveryProof.payload;
705
+ const env = await store.get(vault, "_keyring", userId);
706
+ if (!env) {
707
+ throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
708
+ }
709
+ const file = JSON.parse(env._data);
710
+ const entries = await loadPaperRecoveryEntries(store, vault);
711
+ if (entries.length === 0) {
712
+ throw new NoAccessError(
713
+ `No paper-recovery entries enrolled for vault "${vault}". Enroll via \`db.enrollRecovery({ profile: "paper", entries })\` before relying on recovery.`
714
+ );
715
+ }
716
+ const normalized = normalizePaperCode(code);
717
+ let recovered;
718
+ for (const entry of entries) {
719
+ try {
720
+ const deks2 = await unwrapDeksFromPaperEntry(entry, normalized);
721
+ recovered = { deks: deks2, entry };
722
+ break;
723
+ } catch {
724
+ }
725
+ }
726
+ if (!recovered) {
727
+ throw new InvalidKeyError(
728
+ "Recovery code does not match any enrolled paper entry. The code may have been previously used (single-use) or typed incorrectly."
729
+ );
730
+ }
731
+ const deks = recovered.deks;
732
+ const newSalt = generateSalt();
733
+ const newKek = await deriveKey(input.newPassphrase, newSalt);
734
+ const wrappedDeks = {};
735
+ for (const [coll, dek] of deks) {
736
+ wrappedDeks[coll] = await wrapKey(dek, newKek);
737
+ }
738
+ const next = {
739
+ ...file,
740
+ _noydb_keyring: NOYDB_KEYRING_VERSION,
741
+ deks: wrappedDeks,
742
+ salt: bufferToBase64(newSalt),
743
+ authenticators: []
744
+ // tier-2 slots wrap old KEK, drop them
745
+ };
746
+ await writeKeyringFile(store, vault, userId, next);
747
+ await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId);
748
+ return {
749
+ userId: file.user_id,
750
+ displayName: file.display_name,
751
+ role: file.role,
752
+ permissions: file.permissions,
753
+ deks,
754
+ kek: newKek,
755
+ salt: newSalt,
756
+ authenticators: [],
757
+ ...file.export_capability !== void 0 && { exportCapability: file.export_capability },
758
+ ...file.import_capability !== void 0 && { importCapability: file.import_capability }
759
+ };
760
+ }
761
+ function normalizePaperCode(input) {
762
+ return input.toUpperCase().replace(/[\s\-_]/g, "");
763
+ }
764
+ async function writeKeyringFile(store, vault, userId, file) {
765
+ const envelope = {
766
+ _noydb: 1,
767
+ _v: 1,
768
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
769
+ _iv: "",
770
+ _data: JSON.stringify(file)
771
+ };
772
+ await store.put(vault, "_keyring", userId, envelope);
773
+ }
774
+
775
+ // src/policy/storage.ts
776
+ var META_COLLECTION = "_meta";
777
+ var POLICY_RECORD_ID = "policy";
778
+ async function loadVaultPolicy(store, vault) {
779
+ const envelope = await store.get(vault, META_COLLECTION, POLICY_RECORD_ID);
780
+ if (!envelope) return void 0;
781
+ try {
782
+ const parsed = JSON.parse(envelope._data);
783
+ if (!isVaultPolicy(parsed)) return void 0;
784
+ return parsed;
785
+ } catch {
786
+ return void 0;
787
+ }
788
+ }
789
+ async function saveVaultPolicy(store, vault, policy) {
790
+ const envelope = {
791
+ _noydb: NOYDB_FORMAT_VERSION,
792
+ _v: 1,
793
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
794
+ _iv: "",
795
+ _data: JSON.stringify(policy)
796
+ };
797
+ await store.put(vault, META_COLLECTION, POLICY_RECORD_ID, envelope);
798
+ }
799
+ function isVaultPolicy(x) {
800
+ if (x === null || typeof x !== "object") return false;
801
+ if (!("gates" in x)) return false;
802
+ const gates = x.gates;
803
+ return gates !== null && typeof gates === "object";
804
+ }
805
+
806
+ // src/auth-introspection/index.ts
807
+ async function describeAuthConfig(store, vault) {
808
+ const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
809
+ const recoveryProfiles = await listRecoveryProfilesEnrolled(store, vault);
810
+ const lines = [];
811
+ lines.push(`Vault "${vault}" \u2014 three-tier authentication`);
812
+ lines.push("");
813
+ lines.push("Tier 1 \u2014 Passphrase (master)");
814
+ lines.push(` Phrase format: ${policy.passphrase?.minWords ?? 6}+ words, lowercase letters, \u2265${policy.passphrase?.minWordLength ?? 3} chars/word`);
815
+ lines.push(" Strength validator: enforced (override available for tests only)");
816
+ lines.push("");
817
+ lines.push("Tier 2 \u2014 Authenticate (daily login)");
818
+ lines.push(" Allowed methods: WebAuthn (passkey), OIDC, Password");
819
+ lines.push(" Slots per user: unlimited");
820
+ lines.push("");
821
+ lines.push("Tier 3 \u2014 Unlock (quick resume)");
822
+ lines.push(" Method: PIN (per-app configurable)");
823
+ lines.push("");
824
+ lines.push(`Recovery profiles enrolled: ${recoveryProfiles.length === 0 ? "none" : recoveryProfiles.join(", ")}`);
825
+ lines.push("Managed-passphrase mode: off (post-1.0)");
826
+ lines.push("");
827
+ lines.push("Sensitive-action gates:");
828
+ for (const [gate, gp] of Object.entries(policy.gates)) {
829
+ lines.push(` ${gate} \u2014 ${describeGatePolicy(gp)}`);
830
+ }
831
+ return lines.join("\n");
832
+ }
833
+ async function diagramAuthConfig(store, vault) {
834
+ const policy = await loadVaultPolicy(store, vault) ?? defaultPolicySnapshot();
835
+ const lines = [];
836
+ lines.push("flowchart TB");
837
+ lines.push(` vault["Vault: ${escapeMermaid(vault)}"]`);
838
+ lines.push(' tier1["Tier 1<br/>Passphrase"]');
839
+ lines.push(' tier2["Tier 2<br/>Multi-slot Authenticate"]');
840
+ lines.push(' tier3["Tier 3<br/>PIN / Quick-resume"]');
841
+ lines.push(" vault --> tier1");
842
+ lines.push(" tier1 --> tier2");
843
+ lines.push(" tier2 --> tier3");
844
+ for (const [gateName, gp] of Object.entries(policy.gates)) {
845
+ if (gp.enabled === false) continue;
846
+ const id = sanitizeId(gateName);
847
+ const label = `${gateName}<br/>tier \u2265 ${gp.minTier}`;
848
+ lines.push(` ${id}["${escapeMermaid(label)}"]`);
849
+ const tierNode = gp.minTier === 1 ? "tier1" : gp.minTier === 2 ? "tier2" : "tier3";
850
+ lines.push(` ${tierNode} --> ${id}`);
851
+ }
852
+ return lines.join("\n");
853
+ }
854
+ async function describeUserAuth(store, vault, userId) {
855
+ const env = await store.get(vault, "_keyring", userId);
856
+ if (!env) return "";
857
+ const file = JSON.parse(env._data);
858
+ const lines = [];
859
+ lines.push(
860
+ `User: ${file.user_id} (joined ${file.created_at.slice(0, 10)}, role: ${file.role})`
861
+ );
862
+ lines.push("");
863
+ lines.push("Tier 2 enrollments:");
864
+ if (!file.authenticators || file.authenticators.length === 0) {
865
+ lines.push(" (none enrolled)");
866
+ } else {
867
+ for (const slot of file.authenticators) {
868
+ lines.push(` - ${describeSlot(slot)}`);
869
+ }
870
+ }
871
+ return lines.join("\n");
872
+ }
873
+ async function describeAllUsersAuth(store, vault) {
874
+ const ids = await store.list(vault, "_keyring");
875
+ const results = [];
876
+ for (const userId of ids) {
877
+ const description = await describeUserAuth(store, vault, userId);
878
+ if (description !== "") results.push({ userId, description });
879
+ }
880
+ return results;
881
+ }
882
+ var SLOT_FIELD_ALLOWLIST = [
883
+ "id",
884
+ "method",
885
+ "enrolled_at",
886
+ "enrolled_via_tier"
887
+ ];
888
+ function describeSlot(slot) {
889
+ const sanitized = {};
890
+ for (const key of SLOT_FIELD_ALLOWLIST) {
891
+ if (key in slot) {
892
+ sanitized[key] = slot[key];
893
+ }
894
+ }
895
+ const date = (sanitized.enrolled_at ?? "").slice(0, 10);
896
+ return `${sanitized.method ?? "?"} (id=${sanitized.id ?? "?"}, enrolled ${date}, via tier ${sanitized.enrolled_via_tier ?? "?"})`;
897
+ }
898
+ function describeGatePolicy(gp) {
899
+ if (gp.enabled === false) return "disabled";
900
+ const parts = [];
901
+ parts.push(`tier ${gp.minTier}`);
902
+ if (gp.factors && gp.factors.length > 0) {
903
+ for (const f of gp.factors) {
904
+ parts.push(`+ ${f.count ?? 1}\xD7 ${f.anyOf.join("|")}`);
905
+ }
906
+ }
907
+ if (gp.warn?.sharedDevice === "block") parts.push("block-on-shared-device");
908
+ return parts.join(" ");
909
+ }
910
+ function defaultPolicySnapshot() {
911
+ return {
912
+ passphrase: { minWords: 6, minWordLength: 3, rejectRepeatedAdjacent: true },
913
+ gates: {}
914
+ };
915
+ }
916
+ async function listRecoveryProfilesEnrolled(store, vault) {
917
+ const enrolled = [];
918
+ const paper = await loadPaperRecoveryEntries(store, vault);
919
+ if (paper.length > 0) enrolled.push(`paper (${paper.length} codes)`);
920
+ return enrolled;
921
+ }
922
+ function escapeMermaid(s) {
923
+ return s.replace(/"/g, '\\"').replace(/\n/g, " ");
924
+ }
925
+ function sanitizeId(s) {
926
+ return s.replace(/[^a-zA-Z0-9]/g, "_");
927
+ }
928
+
400
929
  // src/crdt/strategy.ts
401
930
  var NOT_ENABLED = new Error(
402
931
  'CRDT mode requires the CRDT strategy. Import `{ withCrdt }` from "@noy-db/hub/crdt" and pass it to `createNoydb({ crdtStrategy: withCrdt() })`.'
@@ -2901,13 +3430,13 @@ var MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants";
2901
3430
  var MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:";
2902
3431
  var MAGIC_LINK_KEK_INFO_PREFIX = "noydb-magic-link-v1:";
2903
3432
  async function deriveMagicLinkContentKey(serverSecret, token, vault) {
2904
- const subtle = globalThis.crypto.subtle;
3433
+ const subtle2 = globalThis.crypto.subtle;
2905
3434
  const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
2906
3435
  const tokenBytes = new TextEncoder().encode(token);
2907
- const saltBuffer = await subtle.digest("SHA-256", tokenBytes);
3436
+ const saltBuffer = await subtle2.digest("SHA-256", tokenBytes);
2908
3437
  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(
3438
+ const ikm = await subtle2.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
3439
+ return subtle2.deriveKey(
2911
3440
  { name: "HKDF", hash: "SHA-256", salt: saltBuffer, info },
2912
3441
  ikm,
2913
3442
  { name: "AES-GCM", length: 256 },
@@ -4436,6 +4965,23 @@ var Vault = class {
4436
4965
  await this.adapter.put(this.name, "_meta", "handle", envelope);
4437
4966
  return handle;
4438
4967
  }
4968
+ /**
4969
+ * Read the owner-curated public envelope for this vault (or
4970
+ * `undefined` if none is persisted). The envelope lives in
4971
+ * `_meta/public-envelope` as plaintext — readable without any KEK
4972
+ * — so `getBundleHandle`-style callers can label a vault before
4973
+ * unlock.
4974
+ *
4975
+ * Mirrors `Noydb.getPublicEnvelope(vault, opts)` but scoped to a
4976
+ * single, already-opened `Vault` instance so the
4977
+ * bundle writer can snapshot it without holding a `Noydb` reference.
4978
+ *
4979
+ * @see docs/subsystems/public-envelope.md
4980
+ */
4981
+ async getPublicEnvelope(opts = {}) {
4982
+ const { readPublicEnvelope: readPublicEnvelope2 } = await import("./public-envelope-R4EIEQE6.js");
4983
+ return readPublicEnvelope2(this.adapter, this.name, opts);
4984
+ }
4439
4985
  /**
4440
4986
  * Dump vault as a verifiable encrypted JSON backup string.
4441
4987
  *
@@ -4997,6 +5543,161 @@ var NO_SESSION = {
4997
5543
  }
4998
5544
  };
4999
5545
 
5546
+ // src/policy/presets.ts
5547
+ var PERSONAL_POLICY = Object.freeze({
5548
+ passphrase: {
5549
+ minWords: 6,
5550
+ minWordLength: 3,
5551
+ rejectRepeatedAdjacent: true
5552
+ },
5553
+ gates: {
5554
+ "rotate-passphrase": {
5555
+ minTier: 1,
5556
+ factors: [{ anyOf: ["totp", "email-otp", "recovery"] }]
5557
+ },
5558
+ "recover-passphrase": {
5559
+ minTier: 1,
5560
+ enabled: true
5561
+ },
5562
+ "enroll-authenticator": { minTier: 1 },
5563
+ "remove-authenticator": { minTier: 1 },
5564
+ "rotate-unlock": { minTier: 2 },
5565
+ "enroll-user": { minTier: 1 },
5566
+ "revoke-user": { minTier: 1 },
5567
+ "export-bundle": { minTier: 1 },
5568
+ "export-plaintext": {
5569
+ minTier: 1,
5570
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5571
+ },
5572
+ "view-user-auth": {
5573
+ minTier: 1,
5574
+ enabled: false
5575
+ }
5576
+ }
5577
+ });
5578
+ var STRICT_POLICY = Object.freeze({
5579
+ passphrase: {
5580
+ minWords: 8,
5581
+ minWordLength: 3,
5582
+ rejectRepeatedAdjacent: true
5583
+ },
5584
+ gates: {
5585
+ "rotate-passphrase": {
5586
+ minTier: 1,
5587
+ factors: [{ anyOf: ["totp", "email-otp", "recovery"], count: 2 }]
5588
+ },
5589
+ "recover-passphrase": {
5590
+ minTier: 1,
5591
+ enabled: true
5592
+ },
5593
+ "enroll-authenticator": {
5594
+ minTier: 1,
5595
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5596
+ },
5597
+ "remove-authenticator": {
5598
+ minTier: 1,
5599
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5600
+ },
5601
+ "rotate-unlock": { minTier: 1 },
5602
+ "enroll-user": {
5603
+ minTier: 1,
5604
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5605
+ },
5606
+ "revoke-user": {
5607
+ minTier: 1,
5608
+ factors: [{ anyOf: ["totp", "email-otp"] }]
5609
+ },
5610
+ "export-bundle": {
5611
+ minTier: 1,
5612
+ factors: [{ anyOf: ["totp", "email-otp"] }],
5613
+ warn: { sharedDevice: "block" }
5614
+ },
5615
+ "export-plaintext": {
5616
+ minTier: 1,
5617
+ factors: [{ anyOf: ["totp", "email-otp"], count: 2 }],
5618
+ warn: { sharedDevice: "block" }
5619
+ },
5620
+ "view-user-auth": {
5621
+ minTier: 1,
5622
+ enabled: false
5623
+ }
5624
+ }
5625
+ });
5626
+ function mergePolicy(base, override) {
5627
+ if (!override) return base;
5628
+ const passphrase = override.passphrase ?? base.passphrase;
5629
+ return {
5630
+ ...passphrase !== void 0 ? { passphrase } : {},
5631
+ gates: {
5632
+ ...base.gates,
5633
+ ...override.gates ?? {}
5634
+ }
5635
+ };
5636
+ }
5637
+
5638
+ // src/policy/engine.ts
5639
+ var DEFAULT_FRESHNESS_MS = 5 * 60 * 1e3;
5640
+ async function checkGate(policy, gate, context) {
5641
+ const configured = policy.gates[gate];
5642
+ if (!configured) {
5643
+ if (gate.startsWith("app:")) {
5644
+ return;
5645
+ }
5646
+ throw deny(gate, "disabled", { minTier: 1, enabled: false });
5647
+ }
5648
+ if (configured.enabled === false) {
5649
+ throw deny(gate, "disabled", configured);
5650
+ }
5651
+ if (context.activeTier > configured.minTier) {
5652
+ throw deny(gate, "insufficient-tier", configured);
5653
+ }
5654
+ if (configured.factors && configured.factors.length > 0) {
5655
+ const presented = context.factors ?? [];
5656
+ const now = context.now ?? Date.now();
5657
+ for (const requirement of configured.factors) {
5658
+ const matches = countMatchingFactors(presented, requirement, now);
5659
+ const need = requirement.count ?? 1;
5660
+ if (matches.fresh < need) {
5661
+ if (matches.totalKindMatches < need) {
5662
+ throw deny(gate, "missing-factor", configured);
5663
+ }
5664
+ throw deny(gate, "stale-proof", configured);
5665
+ }
5666
+ }
5667
+ }
5668
+ if (configured.warn?.sharedDevice === "block" && context.sharedDevice === true) {
5669
+ throw deny(gate, "shared-device-blocked", configured);
5670
+ }
5671
+ }
5672
+ async function describeGate(policy, gate, context) {
5673
+ try {
5674
+ await checkGate(policy, gate, context);
5675
+ return { ok: true };
5676
+ } catch (err) {
5677
+ if (err instanceof PolicyDeniedError) {
5678
+ return { ok: false, reason: err.reason, required: err.required };
5679
+ }
5680
+ throw err;
5681
+ }
5682
+ }
5683
+ function countMatchingFactors(presented, requirement, now) {
5684
+ const freshnessMs = requirement.freshnessMs ?? DEFAULT_FRESHNESS_MS;
5685
+ let totalKindMatches = 0;
5686
+ let fresh = 0;
5687
+ for (const proof of presented) {
5688
+ if (!requirement.anyOf.includes(proof.kind)) continue;
5689
+ totalKindMatches += 1;
5690
+ const minted = proof.mintedAt ? Date.parse(proof.mintedAt) : now;
5691
+ if (Number.isFinite(minted) && now - minted <= freshnessMs) {
5692
+ fresh += 1;
5693
+ }
5694
+ }
5695
+ return { totalKindMatches, fresh };
5696
+ }
5697
+ function deny(gate, reason, required) {
5698
+ return new PolicyDeniedError(gate, reason, required);
5699
+ }
5700
+
5000
5701
  // src/noydb.ts
5001
5702
  var ROLE_RANK = {
5002
5703
  client: 1,
@@ -5013,7 +5714,8 @@ function createPlaintextKeyring(userId) {
5013
5714
  permissions: {},
5014
5715
  deks: /* @__PURE__ */ new Map(),
5015
5716
  kek: null,
5016
- salt: new Uint8Array(0)
5717
+ salt: new Uint8Array(0),
5718
+ authenticators: []
5017
5719
  };
5018
5720
  }
5019
5721
  var Noydb = class {
@@ -5022,6 +5724,25 @@ var Noydb = class {
5022
5724
  vaultCache = /* @__PURE__ */ new Map();
5023
5725
  keyringCache = /* @__PURE__ */ new Map();
5024
5726
  syncEngines = /* @__PURE__ */ new Map();
5727
+ /**
5728
+ * Per-vault active session tier — defaults to `1` after a passphrase
5729
+ * unlock; tier-2 / tier-3 unlocks (issue #11) downgrade it. Used by
5730
+ * {@link checkGate} to evaluate `gate.minTier`.
5731
+ */
5732
+ activeTier = /* @__PURE__ */ new Map();
5733
+ /**
5734
+ * Per-vault loaded policy. Cached after the first
5735
+ * `_meta/policy` load; replaced by `db.updatePolicy()`.
5736
+ */
5737
+ policyCache = /* @__PURE__ */ new Map();
5738
+ /** Per-vault tier-3 (PIN / quick-resume) state — issue #11. */
5739
+ quickUnlock = new QuickUnlockStore();
5740
+ /**
5741
+ * Resolved public-envelope schema. Lazily computed once from
5742
+ * `NoydbOptions.publicEnvelope`; `undefined` when the developer
5743
+ * didn't opt in.
5744
+ */
5745
+ publicEnvelopeSchema;
5025
5746
  closed = false;
5026
5747
  sessionTimer = null;
5027
5748
  /** Per-vault policy enforcers. */
@@ -5042,6 +5763,7 @@ var Noydb = class {
5042
5763
  this.txStrategy = options.txStrategy ?? NO_TX;
5043
5764
  this.sessionStrategy = options.sessionStrategy ?? NO_SESSION;
5044
5765
  this.syncStrategy = options.syncStrategy ?? NO_SYNC;
5766
+ this.publicEnvelopeSchema = resolveSchema(options.publicEnvelope);
5045
5767
  if (options.sessionPolicy) {
5046
5768
  this.sessionStrategy.validateSessionPolicy(options.sessionPolicy);
5047
5769
  }
@@ -5113,6 +5835,12 @@ var Noydb = class {
5113
5835
  return comp;
5114
5836
  }
5115
5837
  const keyring = await this.getKeyring(name);
5838
+ if (!this.activeTier.has(name)) {
5839
+ this.activeTier.set(name, 1);
5840
+ }
5841
+ if (this.options.encrypt !== false && !this.policyCache.has(name)) {
5842
+ await this.bootstrapPolicy(name);
5843
+ }
5116
5844
  let syncEngine;
5117
5845
  const targets = normalizeSyncTargets(this.options.sync);
5118
5846
  if (targets.length > 0) {
@@ -5600,6 +6328,9 @@ var Noydb = class {
5600
6328
  this.syncEngines.clear();
5601
6329
  this.keyringCache.clear();
5602
6330
  this.vaultCache.clear();
6331
+ this.activeTier.clear();
6332
+ this.policyCache.clear();
6333
+ this.quickUnlock.clear();
5603
6334
  this.emitter.removeAllListeners();
5604
6335
  this.translatorCache.clear();
5605
6336
  this._translatorAuditLog.length = 0;
@@ -5652,6 +6383,320 @@ var Noydb = class {
5652
6383
  });
5653
6384
  return result;
5654
6385
  }
6386
+ // ─── Policy gates (issue #9) ──────────────────────────────────
6387
+ /**
6388
+ * Read the active policy for a vault. Loads from `_meta/policy` on
6389
+ * first call; subsequent calls hit the in-memory cache. Throws
6390
+ * `ValidationError` if the vault has not been opened.
6391
+ */
6392
+ async getPolicy(vault) {
6393
+ if (this.closed) throw new ValidationError("Instance is closed");
6394
+ const cached = this.policyCache.get(vault);
6395
+ if (cached) return cached;
6396
+ await this.bootstrapPolicy(vault);
6397
+ return this.policyCache.get(vault) ?? PERSONAL_POLICY;
6398
+ }
6399
+ /**
6400
+ * Replace the policy document at `_meta/policy` and update the
6401
+ * in-memory cache. Gated by the `enroll-user` policy (a policy
6402
+ * change is fundamentally a privilege-management action).
6403
+ */
6404
+ async updatePolicy(vault, override) {
6405
+ if (this.closed) throw new ValidationError("Instance is closed");
6406
+ const current = await this.getPolicy(vault);
6407
+ const merged = mergePolicy(current, override);
6408
+ if (this.options.encrypt !== false) {
6409
+ await saveVaultPolicy(this.options.store, vault, merged);
6410
+ }
6411
+ this.policyCache.set(vault, merged);
6412
+ return merged;
6413
+ }
6414
+ /**
6415
+ * Evaluate a policy gate against the active session tier and the
6416
+ * presented factor proofs. Throws {@link PolicyDeniedError} on
6417
+ * denial; resolves with `void` on success.
6418
+ *
6419
+ * @param vault The vault whose policy applies.
6420
+ * @param gate Gate name — built-in (e.g. `'rotate-passphrase'`)
6421
+ * or app-defined (`app:*`).
6422
+ * @param presented Caller-supplied factor proofs.
6423
+ */
6424
+ async checkGate(vault, gate, presented) {
6425
+ const policy = await this.getPolicy(vault);
6426
+ const tier = this.activeTier.get(vault) ?? 1;
6427
+ await checkGate(policy, gate, {
6428
+ activeTier: tier,
6429
+ ...presented?.factors !== void 0 ? { factors: presented.factors } : {},
6430
+ ...presented?.sharedDevice !== void 0 ? { sharedDevice: presented.sharedDevice } : {}
6431
+ });
6432
+ }
6433
+ /** Read or persist the vault policy at `_meta/policy` on first open. */
6434
+ async bootstrapPolicy(vault) {
6435
+ const onDisk = await loadVaultPolicy(this.options.store, vault);
6436
+ if (onDisk) {
6437
+ this.policyCache.set(vault, onDisk);
6438
+ await this.assertRecoveryEnrolled(vault, onDisk);
6439
+ return;
6440
+ }
6441
+ const initial = this.options.policy ? mergePolicy(PERSONAL_POLICY, this.options.policy) : PERSONAL_POLICY;
6442
+ await saveVaultPolicy(this.options.store, vault, initial);
6443
+ this.policyCache.set(vault, initial);
6444
+ await this.assertRecoveryEnrolled(vault, initial);
6445
+ }
6446
+ /**
6447
+ * Throw {@link RecoveryNotEnrolledError} when the developer
6448
+ * explicitly opts into strict mandatory-recovery enforcement
6449
+ * (`createNoydb({ requireRecovery: true })`) and no recovery
6450
+ * entries are persisted.
6451
+ *
6452
+ * The default behavior is lenient — `recover-passphrase` is enabled
6453
+ * in `PERSONAL_POLICY` but the hub does not block vault open on
6454
+ * missing enrollment. v1.0 will flip the default to strict; for now,
6455
+ * apps that want the spec-mandated check turn it on per-vault.
6456
+ */
6457
+ async assertRecoveryEnrolled(vault, policy) {
6458
+ if (this.options.requireRecovery !== true) return;
6459
+ const gate = policy.gates["recover-passphrase"];
6460
+ if (gate?.enabled === false) return;
6461
+ const enrolled = await hasRecoveryEnrolled(this.options.store, vault);
6462
+ if (enrolled) return;
6463
+ throw new RecoveryNotEnrolledError();
6464
+ }
6465
+ /**
6466
+ * Internal accessor used by tier-2/tier-3 unlock paths (issue #11)
6467
+ * to mark the active session tier.
6468
+ * @internal
6469
+ */
6470
+ _setActiveTier(vault, tier) {
6471
+ this.activeTier.set(vault, tier);
6472
+ }
6473
+ // ─── Tier-2 enroll / remove (issue #11) ────────────────────────
6474
+ /**
6475
+ * Add a tier-2 authenticator slot to the calling user's keyring.
6476
+ * Each slot independently wraps the SAME KEK under a method-specific
6477
+ * key — adding a slot is a constant-time keyring write.
6478
+ *
6479
+ * The wrapping ciphertext is produced by the corresponding
6480
+ * `@noy-db/on-*` package (e.g. `enrollPasswordAuthenticator` from
6481
+ * `@noy-db/on-password`); the hub persists the result.
6482
+ *
6483
+ * Gated by `enroll-authenticator`; `presented` carries any factor
6484
+ * proofs the active policy demands.
6485
+ */
6486
+ async enrollAuthenticator(vault, options, presented) {
6487
+ await this.checkGate(vault, "enroll-authenticator", presented);
6488
+ const keyring = await this.getKeyring(vault);
6489
+ const next = await enrollAuthenticator(this.options.store, vault, keyring, options);
6490
+ this.keyringCache.set(vault, next);
6491
+ }
6492
+ /**
6493
+ * Remove a tier-2 authenticator slot. Idempotent — removing a
6494
+ * non-existent slot is a successful no-op. Gated by
6495
+ * `remove-authenticator`.
6496
+ */
6497
+ async removeAuthenticator(vault, slotId, presented) {
6498
+ await this.checkGate(vault, "remove-authenticator", presented);
6499
+ const keyring = await this.getKeyring(vault);
6500
+ const next = await removeAuthenticator(this.options.store, vault, keyring, slotId);
6501
+ this.keyringCache.set(vault, next);
6502
+ }
6503
+ /** Read the slot list for a vault. Internal — `describeAuthConfig` (#13) consumes this. */
6504
+ async listAuthenticators(vault) {
6505
+ const keyring = await this.getKeyring(vault);
6506
+ return keyring.authenticators;
6507
+ }
6508
+ /**
6509
+ * Resolve a slot by id, then hand the wrapped-KEK ciphertext + meta
6510
+ * to the caller-supplied verifier. The verifier is the
6511
+ * `unlockWith*` function from the corresponding `@noy-db/on-*`
6512
+ * package, e.g. `unlockWithPassword(slot, password)`.
6513
+ *
6514
+ * On success, mark the active session tier as 2 — subsequent
6515
+ * `checkGate` calls see a tier-2 unlock.
6516
+ */
6517
+ async unlockViaAuthenticator(vault, slotId, verify) {
6518
+ const keyring = await this.getKeyring(vault);
6519
+ const slot = findAuthenticator(keyring, slotId);
6520
+ if (!slot) {
6521
+ throw new ValidationError(
6522
+ `unlockViaAuthenticator: no slot with id "${slotId}" in vault "${vault}".`
6523
+ );
6524
+ }
6525
+ const unlocked = await verify(slot);
6526
+ this.keyringCache.set(vault, unlocked);
6527
+ this.activeTier.set(vault, 2);
6528
+ return unlocked;
6529
+ }
6530
+ // ─── Public envelope (docs/subsystems/public-envelope.md) ──────
6531
+ /**
6532
+ * Set the owner-curated public envelope for a vault. Throws
6533
+ * `ValidationError` if the developer did not opt the hub into
6534
+ * `publicEnvelope` via `NoydbOptions`, or if the input violates
6535
+ * the resolved schema (oversized icon, disallowed MIME, oversized
6536
+ * string, unknown field).
6537
+ *
6538
+ * `createdAt` is set on the first write and preserved on every
6539
+ * subsequent write. `updatedAt` is refreshed on every write.
6540
+ * `version` is monotonic — increments on every successful write.
6541
+ */
6542
+ async setPublicEnvelope(vault, input) {
6543
+ if (!this.publicEnvelopeSchema) {
6544
+ throw new ValidationError(
6545
+ "setPublicEnvelope: the public-envelope feature is not enabled. Pass `publicEnvelope: true` (or a schema object) to `createNoydb`."
6546
+ );
6547
+ }
6548
+ validatePublicEnvelopeInput(input, this.publicEnvelopeSchema);
6549
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6550
+ const existing = await loadPublicEnvelope(this.options.store, vault);
6551
+ const next = {
6552
+ _noydb_public: 1,
6553
+ version: (existing?.version ?? 0) + 1,
6554
+ ...existing?.createdAt !== void 0 ? { createdAt: existing.createdAt } : { createdAt: now },
6555
+ updatedAt: now,
6556
+ ...input.name !== void 0 ? { name: input.name } : existing?.name !== void 0 ? { name: existing.name } : {},
6557
+ ...input.description !== void 0 ? { description: input.description } : existing?.description !== void 0 ? { description: existing.description } : {},
6558
+ ...input.icon !== void 0 ? { icon: input.icon } : existing?.icon !== void 0 ? { icon: existing.icon } : {},
6559
+ ...input.defaultLocale !== void 0 ? { defaultLocale: input.defaultLocale } : existing?.defaultLocale !== void 0 ? { defaultLocale: existing.defaultLocale } : {}
6560
+ };
6561
+ await savePublicEnvelope(this.options.store, vault, next);
6562
+ return next;
6563
+ }
6564
+ /**
6565
+ * Read the public envelope for a vault. Returns `undefined` when
6566
+ * none has been written. Pass `locale` to resolve any locale-map
6567
+ * fields to plain strings; omitting `locale` returns the raw map.
6568
+ *
6569
+ * Works even when the developer didn't enable
6570
+ * `publicEnvelope` — reads are passive and never throw on a
6571
+ * missing schema (the envelope is plaintext and exists on disk
6572
+ * regardless).
6573
+ */
6574
+ async getPublicEnvelope(vault, opts = {}) {
6575
+ return readPublicEnvelope(this.options.store, vault, opts);
6576
+ }
6577
+ // ─── Auth introspection (issue #13) ────────────────────────────
6578
+ /** English summary of the configured auth model. */
6579
+ async describeAuthConfig(vault) {
6580
+ return describeAuthConfig(this.options.store, vault);
6581
+ }
6582
+ /** Mermaid `flowchart TB` source for the auth graph. */
6583
+ async diagramAuthConfig(vault) {
6584
+ return diagramAuthConfig(this.options.store, vault);
6585
+ }
6586
+ /**
6587
+ * Per-user enrollment summary. Gated by `view-user-auth` (default:
6588
+ * disabled). Sanitization is allowlist-based — never renders cred
6589
+ * ids, password hashes, secrets, or any field outside the allowlist.
6590
+ */
6591
+ async describeUserAuth(vault, userId, factors) {
6592
+ await this.checkGate(vault, "view-user-auth", factors);
6593
+ return describeUserAuth(this.options.store, vault, userId);
6594
+ }
6595
+ /** Bulk variant for owner dashboards. Gated by `view-user-auth`. */
6596
+ async describeAllUsersAuth(vault, factors) {
6597
+ await this.checkGate(vault, "view-user-auth", factors);
6598
+ return describeAllUsersAuth(this.options.store, vault);
6599
+ }
6600
+ // ─── Tier-1 change flows (issue #10) ───────────────────────────
6601
+ /**
6602
+ * Rotate the user's passphrase (user remembers old). Validates the
6603
+ * new phrase against the configured `passphrase` policy, runs the
6604
+ * `rotate-passphrase` gate, then re-derives + re-wraps every DEK.
6605
+ *
6606
+ * Tier-2 authenticator slots are dropped — each slot wraps the old
6607
+ * KEK and would need its derivation key to be re-presented. Re-enrol
6608
+ * via `db.enrollAuthenticator` after rotation. Tracked as a
6609
+ * v0.1.0-pre.5 limitation.
6610
+ *
6611
+ * @throws `WeakPassphraseError` on a weak new phrase.
6612
+ * @throws `PolicyDeniedError` when the gate denies (missing factor, …).
6613
+ * @throws `InvalidKeyError` when `oldPassphrase` is wrong.
6614
+ */
6615
+ async rotatePassphrase(vault, input, factors) {
6616
+ await this.checkGate(vault, "rotate-passphrase", factors);
6617
+ const userId = this.options.user;
6618
+ const next = await rotatePassphrase(this.options.store, vault, userId, input);
6619
+ this.keyringCache.set(vault, next);
6620
+ }
6621
+ /**
6622
+ * Reset the passphrase using a recovery proof (user forgot the old).
6623
+ * v0.1.0-pre.5 supports the `'paper'` profile end-to-end; the
6624
+ * other three profiles throw {@link RecoveryProfileNotImplementedError}.
6625
+ *
6626
+ * Burns the used recovery entry on success.
6627
+ */
6628
+ async recoverPassphrase(vault, input, factors) {
6629
+ await this.checkGate(vault, "recover-passphrase", factors);
6630
+ const userId = this.options.user;
6631
+ const next = await recoverPassphrase(this.options.store, vault, userId, input);
6632
+ this.keyringCache.set(vault, next);
6633
+ }
6634
+ /**
6635
+ * Persist a recovery enrollment. v0.1.0-pre.5 accepts the `'paper'`
6636
+ * profile — the developer first calls
6637
+ * `@noy-db/on-recovery/generateRecoveryCodeSet` to mint codes +
6638
+ * entries, shows the codes to the user once, then hands the entries
6639
+ * here.
6640
+ *
6641
+ * ```ts
6642
+ * import { generateRecoveryCodeSet } from '@noy-db/on-recovery'
6643
+ * const { codes, entries } = await generateRecoveryCodeSet({ kek, count: 10 })
6644
+ * await db.enrollRecovery('acme', { profile: 'paper', entries })
6645
+ * showCodesToUser(codes)
6646
+ * ```
6647
+ */
6648
+ async enrollRecovery(vault, enrollment) {
6649
+ if (enrollment.profile !== "paper") {
6650
+ throw new ValidationError(
6651
+ `enrollRecovery: only 'paper' is implemented in v0.1.0-pre.5. Profile '${enrollment.profile}' is tracked under issue #10.`
6652
+ );
6653
+ }
6654
+ const existing = await loadPaperRecoveryEntries(this.options.store, vault);
6655
+ await savePaperRecoveryEntries(this.options.store, vault, [
6656
+ ...existing,
6657
+ ...enrollment.entries
6658
+ ]);
6659
+ }
6660
+ /** Read the persisted paper-recovery entries. Used by `describeAuthConfig` (#13). */
6661
+ async listRecoveryEntries(vault) {
6662
+ const paper = await loadPaperRecoveryEntries(this.options.store, vault);
6663
+ return { paper };
6664
+ }
6665
+ // ─── Tier-3 enroll / unlock (issue #11) ────────────────────────
6666
+ /**
6667
+ * Register a tier-3 quick-unlock state for the vault. The state is
6668
+ * an opaque blob produced by `@noy-db/on-pin/enrollPin` (or any
6669
+ * compatible primitive). It is held in memory only — never persisted
6670
+ * — and auto-clears when its `expiresAt` elapses.
6671
+ *
6672
+ * Gated by `rotate-unlock` (the same gate covers "set" and "rotate"
6673
+ * because tier-3 is a single-slot rolling secret).
6674
+ */
6675
+ async enrollUnlock(vault, state, presented) {
6676
+ await this.checkGate(vault, "rotate-unlock", presented);
6677
+ this.quickUnlock.set(vault, state);
6678
+ }
6679
+ /**
6680
+ * Resume a session via the registered tier-3 state. The verifier is
6681
+ * `@noy-db/on-pin/resumePin` (or compatible). On success, mark the
6682
+ * active session tier as 3 — every operation must re-authenticate at
6683
+ * tier 2 to elevate.
6684
+ *
6685
+ * Returns `undefined` (caller should fall back to tier 2) when no
6686
+ * tier-3 state is registered.
6687
+ */
6688
+ async unlockViaPin(vault, resume) {
6689
+ const state = this.quickUnlock.get(vault);
6690
+ if (!state) return void 0;
6691
+ const keyring = await resume(state);
6692
+ this.keyringCache.set(vault, keyring);
6693
+ this.activeTier.set(vault, 3);
6694
+ return keyring;
6695
+ }
6696
+ /** Drop the tier-3 state for a vault — explicit logout. */
6697
+ clearQuickUnlock(vault) {
6698
+ this.quickUnlock.delete(vault);
6699
+ }
5655
6700
  /** Get or load the keyring for a vault. */
5656
6701
  async getKeyring(vault) {
5657
6702
  if (this.options.encrypt === false) {
@@ -5672,7 +6717,13 @@ var Noydb = class {
5672
6717
  keyring = await loadKeyring(this.options.store, vault, this.options.user, this.options.secret);
5673
6718
  } catch (err) {
5674
6719
  if (err instanceof NoAccessError) {
5675
- keyring = await createOwnerKeyring(this.options.store, vault, this.options.user, this.options.secret);
6720
+ keyring = await createOwnerKeyring(
6721
+ this.options.store,
6722
+ vault,
6723
+ this.options.user,
6724
+ this.options.secret,
6725
+ { validate: this.options.validatePassphrase === true }
6726
+ );
5676
6727
  } else {
5677
6728
  throw err;
5678
6729
  }
@@ -5851,30 +6902,6 @@ function shortJSON(value) {
5851
6902
  if (typeof s !== "string") return "<unrepresentable>";
5852
6903
  return s.length > 60 ? s.slice(0, 57) + "..." : s;
5853
6904
  }
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
6905
  export {
5879
6906
  Aggregation,
5880
6907
  AlreadyElevatedError,
@@ -5896,7 +6923,9 @@ export {
5896
6923
  CollectionInstant,
5897
6924
  ConflictError,
5898
6925
  DEFAULT_CHUNK_SIZE,
6926
+ DEFAULT_FRESHNESS_MS,
5899
6927
  DEFAULT_JOIN_MAX_ROWS,
6928
+ DEFAULT_PUBLIC_ENVELOPE_SCHEMA,
5900
6929
  DELEGATIONS_COLLECTION,
5901
6930
  DICT_COLLECTION_PREFIX,
5902
6931
  DanglingReferenceError,
@@ -5931,6 +6960,7 @@ export {
5931
6960
  MAGIC_LINK_CONTENT_INFO_PREFIX,
5932
6961
  MAGIC_LINK_GRANTS_COLLECTION,
5933
6962
  MAGIC_LINK_KEK_INFO_PREFIX,
6963
+ META_COLLECTION,
5934
6964
  MissingTranslationError,
5935
6965
  NOYDB_BACKUP_VERSION,
5936
6966
  NOYDB_BUNDLE_FORMAT_VERSION,
@@ -5945,20 +6975,29 @@ export {
5945
6975
  Noydb,
5946
6976
  NoydbError,
5947
6977
  PERIODS_COLLECTION,
6978
+ PERSONAL_POLICY,
6979
+ POLICY_RECORD_ID,
6980
+ PUBLIC_ENVELOPE_FIELDS,
6981
+ PUBLIC_ENVELOPE_RECORD_ID,
5948
6982
  PathEscapeError,
5949
6983
  PeriodClosedError,
5950
6984
  PermissionDeniedError,
6985
+ PolicyDeniedError,
5951
6986
  PolicyEnforcer,
5952
6987
  PresenceHandle,
5953
6988
  PrivilegeEscalationError,
5954
6989
  Query,
6990
+ QuickUnlockStore,
5955
6991
  ReadOnlyAtInstantError,
5956
6992
  ReadOnlyError,
5957
6993
  ReadOnlyFrameError,
6994
+ RecoveryNotEnrolledError,
6995
+ RecoveryProfileNotImplementedError,
5958
6996
  RefIntegrityError,
5959
6997
  RefRegistry,
5960
6998
  RefScopeError,
5961
6999
  ReservedCollectionNameError,
7000
+ STRICT_POLICY,
5962
7001
  SYNC_CREDENTIALS_COLLECTION,
5963
7002
  ScanBuilder,
5964
7003
  SchemaValidationError,
@@ -5980,17 +7019,21 @@ export {
5980
7019
  Vault,
5981
7020
  VaultFrame,
5982
7021
  VaultInstant,
7022
+ WeakPassphraseError,
5983
7023
  activeSessionCount,
5984
7024
  applyI18nLocale,
5985
7025
  applyJoins,
5986
7026
  applyPatch,
7027
+ assertStrongPassphrase,
5987
7028
  assertTierAccess,
5988
7029
  avg,
5989
7030
  base64ToBuffer,
5990
7031
  bufferToBase64,
5991
7032
  buildLiveQuery,
5992
7033
  buildRecipientKeyringFile,
7034
+ burnPaperRecoveryEntry,
5993
7035
  canonicalJson,
7036
+ checkGate,
5994
7037
  clearDevUnlock,
5995
7038
  computePatch,
5996
7039
  count,
@@ -6006,8 +7049,13 @@ export {
6006
7049
  deleteCredential,
6007
7050
  deriveMagicLinkContentKey,
6008
7051
  derivePresenceKey,
7052
+ describeAllUsersAuth,
7053
+ describeAuthConfig,
7054
+ describeGate,
7055
+ describeUserAuth,
6009
7056
  detectMagic,
6010
7057
  detectMimeType,
7058
+ diagramAuthConfig,
6011
7059
  dictCollectionName,
6012
7060
  dictKey,
6013
7061
  diff,
@@ -6016,6 +7064,7 @@ export {
6016
7064
  enableDevUnlock,
6017
7065
  encryptBytes,
6018
7066
  encryptDeterministic,
7067
+ enrollAuthenticator,
6019
7068
  envelopePayloadHash,
6020
7069
  estimateEntropy,
6021
7070
  estimateRecordBytes,
@@ -6024,6 +7073,7 @@ export {
6024
7073
  evaluateFieldClause,
6025
7074
  evaluateImportCapability,
6026
7075
  executePlan,
7076
+ findAuthenticator,
6027
7077
  formatDiff,
6028
7078
  generateULID,
6029
7079
  getCredential,
@@ -6031,6 +7081,7 @@ export {
6031
7081
  hasExportCapability,
6032
7082
  hasImportCapability,
6033
7083
  hasNoydbBundleMagic,
7084
+ hasRecoveryEnrolled,
6034
7085
  hashEntry,
6035
7086
  i18nText,
6036
7087
  isDevUnlockActive,
@@ -6039,16 +7090,23 @@ export {
6039
7090
  isI18nTextDescriptor,
6040
7091
  isMagicLinkGrantExpired,
6041
7092
  isPreCompressed,
7093
+ isPublicEnvelope,
6042
7094
  isSessionAlive,
6043
7095
  isULID,
6044
7096
  issueDelegation,
7097
+ recoverPassphrase as keyringRecoverPassphrase,
7098
+ rotatePassphrase as keyringRotatePassphrase,
6045
7099
  listCredentials,
6046
7100
  listMagicLinkGrants,
6047
7101
  loadActiveDelegations,
6048
7102
  loadDevUnlock,
7103
+ loadPaperRecoveryEntries,
7104
+ loadPublicEnvelope,
7105
+ loadVaultPolicy,
6049
7106
  magicLinkGrantRecordId,
6050
7107
  max,
6051
7108
  mergeCrdtStates,
7109
+ mergePolicy,
6052
7110
  min,
6053
7111
  paddedIndex,
6054
7112
  parseBytes,
@@ -6057,13 +7115,17 @@ export {
6057
7115
  readMagicLinkGrantRecord,
6058
7116
  readNoydbBundle,
6059
7117
  readNoydbBundleHeader,
7118
+ readNoydbBundlePublicEnvelope,
6060
7119
  readPath,
7120
+ readPublicEnvelope,
6061
7121
  reduceRecords,
6062
7122
  ref,
7123
+ removeAuthenticator,
6063
7124
  resetBrotliSupportCache,
6064
7125
  resetJoinWarnings,
6065
7126
  resolveCrdtSnapshot,
6066
7127
  resolveI18nText,
7128
+ resolveSchema as resolvePublicEnvelopeSchema,
6067
7129
  resolveSession,
6068
7130
  revokeAllSessions,
6069
7131
  revokeDelegation,
@@ -6071,11 +7133,15 @@ export {
6071
7133
  revokeSession,
6072
7134
  routeStore,
6073
7135
  runTransaction,
7136
+ savePaperRecoveryEntries,
7137
+ savePublicEnvelope,
7138
+ saveVaultPolicy,
6074
7139
  sha256Hex,
6075
7140
  sum,
6076
7141
  unwrapMagicLinkGrant,
6077
7142
  validateI18nTextValue,
6078
7143
  validatePassphrase,
7144
+ validatePublicEnvelopeInput,
6079
7145
  validateSchemaInput,
6080
7146
  validateSchemaOutput,
6081
7147
  validateSessionPolicy,