@noy-db/hub 0.1.0-pre.9 → 0.2.0-pre.1
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.
- package/dist/aggregate/index.cjs +91 -36
- package/dist/aggregate/index.cjs.map +1 -1
- package/dist/aggregate/index.d.cts +2 -2
- package/dist/aggregate/index.d.ts +2 -2
- package/dist/aggregate/index.js +16 -9
- package/dist/aggregate/index.js.map +1 -1
- package/dist/blobs/index.cjs.map +1 -1
- package/dist/blobs/index.d.cts +6 -6
- package/dist/blobs/index.d.ts +6 -6
- package/dist/blobs/index.js +4 -4
- package/dist/bundle/index.cjs +298 -7
- package/dist/bundle/index.cjs.map +1 -1
- package/dist/bundle/index.d.cts +6 -6
- package/dist/bundle/index.d.ts +6 -6
- package/dist/bundle/index.js +15 -4
- package/dist/{chunk-GOUT6DND.js → chunk-23TTQXVO.js} +173 -91
- package/dist/chunk-23TTQXVO.js.map +1 -0
- package/dist/{chunk-CIMZBAZB.js → chunk-2AXFIYHT.js} +1 -1
- package/dist/chunk-2AXFIYHT.js.map +1 -0
- package/dist/chunk-34YSDCDP.js +73 -0
- package/dist/chunk-34YSDCDP.js.map +1 -0
- package/dist/{chunk-AVVPZ4BC.js → chunk-4TFSM22V.js} +4 -4
- package/dist/{chunk-QGZRWRSL.js → chunk-537VFZTR.js} +4 -4
- package/dist/{chunk-M62XNWRA.js → chunk-5DWL3JBF.js} +2 -2
- package/dist/{chunk-PTVMYYON.js → chunk-5SCJ5UEF.js} +3 -3
- package/dist/chunk-5ZGZ6HIZ.js +100 -0
- package/dist/chunk-5ZGZ6HIZ.js.map +1 -0
- package/dist/chunk-6HPZY4ON.js +291 -0
- package/dist/chunk-6HPZY4ON.js.map +1 -0
- package/dist/{chunk-EXHNQEV4.js → chunk-7H6DOO3E.js} +239 -11
- package/dist/chunk-7H6DOO3E.js.map +1 -0
- package/dist/{chunk-ACLDOTNQ.js → chunk-ADQ5MQ54.js} +275 -3
- package/dist/chunk-ADQ5MQ54.js.map +1 -0
- package/dist/chunk-CBAHB2BF.js +893 -0
- package/dist/chunk-CBAHB2BF.js.map +1 -0
- package/dist/chunk-DPMFBCV6.js +296 -0
- package/dist/chunk-DPMFBCV6.js.map +1 -0
- package/dist/chunk-DYBQG5PQ.js +34 -0
- package/dist/chunk-DYBQG5PQ.js.map +1 -0
- package/dist/{chunk-ZFKD4QMV.js → chunk-DYECX3IX.js} +3 -3
- package/dist/chunk-EGQYGYIU.js +51 -0
- package/dist/chunk-EGQYGYIU.js.map +1 -0
- package/dist/chunk-FCXOFQAJ.js +79 -0
- package/dist/chunk-FCXOFQAJ.js.map +1 -0
- package/dist/chunk-HB3Z2GCR.js +124 -0
- package/dist/chunk-HB3Z2GCR.js.map +1 -0
- package/dist/{chunk-SCZXXXU4.js → chunk-I6MX32UC.js} +7 -32
- package/dist/chunk-I6MX32UC.js.map +1 -0
- package/dist/{chunk-VQBTTTUN.js → chunk-KESP7GOK.js} +4 -4
- package/dist/{chunk-VQBTTTUN.js.map → chunk-KESP7GOK.js.map} +1 -1
- package/dist/{chunk-NXFEYLVG.js → chunk-MIQHZESA.js} +4 -3
- package/dist/{chunk-NXFEYLVG.js.map → chunk-MIQHZESA.js.map} +1 -1
- package/dist/chunk-MKSA2V7A.js +19 -0
- package/dist/chunk-MKSA2V7A.js.map +1 -0
- package/dist/{chunk-M5INGEFC.js → chunk-MRIBLZL3.js} +3 -1
- package/dist/chunk-MRIBLZL3.js.map +1 -0
- package/dist/{chunk-MDDTIZUO.js → chunk-NIOHFJPJ.js} +6 -6
- package/dist/chunk-OMLIZL2P.js +61 -0
- package/dist/chunk-OMLIZL2P.js.map +1 -0
- package/dist/{chunk-USKYUS74.js → chunk-P7EQ2S5O.js} +2 -2
- package/dist/{chunk-WDM5XGGS.js → chunk-PA6R5ZCI.js} +181 -11
- package/dist/chunk-PA6R5ZCI.js.map +1 -0
- package/dist/chunk-PEULZC6M.js +118 -0
- package/dist/chunk-PEULZC6M.js.map +1 -0
- package/dist/chunk-RD5LYKD6.js +82 -0
- package/dist/chunk-RD5LYKD6.js.map +1 -0
- package/dist/chunk-SIZWEV2Y.js +145 -0
- package/dist/chunk-SIZWEV2Y.js.map +1 -0
- package/dist/{chunk-QAVUREFT.js → chunk-UA4RI7OT.js} +12 -6
- package/dist/chunk-UA4RI7OT.js.map +1 -0
- package/dist/chunk-UMLVJTYV.js +20 -0
- package/dist/chunk-UMLVJTYV.js.map +1 -0
- package/dist/chunk-UZXLQCHP.js +53 -0
- package/dist/chunk-UZXLQCHP.js.map +1 -0
- package/dist/{chunk-2CSJGFCB.js → chunk-VMIO4IXG.js} +5 -5
- package/dist/{chunk-MR4424N3.js → chunk-WCA2NROQ.js} +2 -2
- package/dist/{chunk-TDR6T5CJ.js → chunk-XGSOTWYX.js} +91 -132
- package/dist/chunk-XGSOTWYX.js.map +1 -0
- package/dist/{chunk-NPC4LFV5.js → chunk-YMYK7US4.js} +2 -2
- package/dist/{chunk-RKJ6OL7K.js → chunk-YS3POABP.js} +1 -1
- package/dist/chunk-YS3POABP.js.map +1 -0
- package/dist/chunk-Z72JH4KG.js +209 -0
- package/dist/chunk-Z72JH4KG.js.map +1 -0
- package/dist/{chunk-R36SIKES.js → chunk-ZNOEIM6Y.js} +2 -2
- package/dist/consent/index.cjs.map +1 -1
- package/dist/consent/index.d.cts +6 -6
- package/dist/consent/index.d.ts +6 -6
- package/dist/consent/index.js +3 -3
- package/dist/{crypto-IVKU7YTT.js → crypto-A7FRXYHC.js} +3 -3
- package/dist/{delegation-2DBS2EOH.js → delegation-YBA4X4JN.js} +5 -4
- package/dist/derivations/index.cjs +351 -0
- package/dist/derivations/index.cjs.map +1 -0
- package/dist/derivations/index.d.cts +71 -0
- package/dist/derivations/index.d.ts +71 -0
- package/dist/derivations/index.js +27 -0
- package/dist/{dev-unlock-BdPp68qn.d.ts → dev-unlock-D9s-loPr.d.ts} +1 -1
- package/dist/{dev-unlock-Da1B0TIK.d.cts → dev-unlock-DRwVSy2S.d.cts} +1 -1
- package/dist/executor-7E3VFGW7.js +11 -0
- package/dist/executor-CEWX2FQI.js +8 -0
- package/dist/executor-CEWX2FQI.js.map +1 -0
- package/dist/executor-X4SQ3ZLC.js +8 -0
- package/dist/executor-X4SQ3ZLC.js.map +1 -0
- package/dist/fanout-sidecar-VJ52RIEY.js +51 -0
- package/dist/fanout-sidecar-VJ52RIEY.js.map +1 -0
- package/dist/guards/index.cjs +315 -0
- package/dist/guards/index.cjs.map +1 -0
- package/dist/guards/index.d.cts +30 -0
- package/dist/guards/index.d.ts +30 -0
- package/dist/guards/index.js +29 -0
- package/dist/guards/index.js.map +1 -0
- package/dist/{hash-lsoL3eEW.d.ts → hash-DXXXusyk.d.ts} +1 -1
- package/dist/{hash-BEfzPKwo.d.cts → hash-DtRih9MQ.d.cts} +1 -1
- package/dist/history/index.cjs +8 -1
- package/dist/history/index.cjs.map +1 -1
- package/dist/history/index.d.cts +7 -7
- package/dist/history/index.d.ts +7 -7
- package/dist/history/index.js +6 -6
- package/dist/i18n/index.cjs +81 -0
- package/dist/i18n/index.cjs.map +1 -1
- package/dist/i18n/index.d.cts +6 -6
- package/dist/i18n/index.d.ts +6 -6
- package/dist/i18n/index.js +19 -6
- package/dist/i18n/index.js.map +1 -1
- package/dist/{index-8QDuznDr.d.ts → index-4agOpzqd.d.ts} +174 -3
- package/dist/{index-6xNpPsxR.d.cts → index-CNwA-B6-.d.ts} +303 -5
- package/dist/{index-DJTf9yxn.d.ts → index-CmVgTkqk.d.cts} +303 -5
- package/dist/{index-CywCC1qZ.d.cts → index-hdFvZkBP.d.cts} +174 -3
- package/dist/index.cjs +5615 -979
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +207 -16
- package/dist/index.d.ts +207 -16
- package/dist/index.js +2302 -741
- package/dist/index.js.map +1 -1
- package/dist/indexing/index.cjs +2 -0
- package/dist/indexing/index.cjs.map +1 -1
- package/dist/indexing/index.d.cts +3 -3
- package/dist/indexing/index.d.ts +3 -3
- package/dist/indexing/index.js +4 -4
- package/dist/{lazy-builder-CZVLKh0Z.d.cts → lazy-builder-C-rPfWG0.d.cts} +1 -1
- package/dist/{lazy-builder-BwEoBQZ9.d.ts → lazy-builder-Rpd-V3jP.d.ts} +1 -1
- package/dist/{ledger-QZTTHQAQ.js → ledger-3TXNP47J.js} +6 -6
- package/dist/ledger-3TXNP47J.js.map +1 -0
- package/dist/materialized-views/index.cjs +837 -0
- package/dist/materialized-views/index.cjs.map +1 -0
- package/dist/materialized-views/index.d.cts +183 -0
- package/dist/materialized-views/index.d.ts +183 -0
- package/dist/materialized-views/index.js +45 -0
- package/dist/materialized-views/index.js.map +1 -0
- package/dist/overlay-views/index.cjs +359 -0
- package/dist/overlay-views/index.cjs.map +1 -0
- package/dist/overlay-views/index.d.cts +81 -0
- package/dist/overlay-views/index.d.ts +81 -0
- package/dist/overlay-views/index.js +23 -0
- package/dist/overlay-views/index.js.map +1 -0
- package/dist/periods/index.cjs +7 -1
- package/dist/periods/index.cjs.map +1 -1
- package/dist/periods/index.d.cts +6 -6
- package/dist/periods/index.d.ts +6 -6
- package/dist/periods/index.js +6 -6
- package/dist/{predicate-SBHmi6D0.d.cts → predicate-Dnu81tsS.d.cts} +25 -1
- package/dist/{predicate-SBHmi6D0.d.ts → predicate-Dnu81tsS.d.ts} +25 -1
- package/dist/{public-envelope-6JTACYJV.js → public-envelope-PY6NKFLI.js} +4 -4
- package/dist/public-envelope-PY6NKFLI.js.map +1 -0
- package/dist/query/index.cjs +302 -124
- package/dist/query/index.cjs.map +1 -1
- package/dist/query/index.d.cts +3 -3
- package/dist/query/index.d.ts +3 -3
- package/dist/query/index.js +26 -11
- package/dist/read-only-facade-ITU6L7BL.js +7 -0
- package/dist/read-only-facade-ITU6L7BL.js.map +1 -0
- package/dist/registry-3L3N3PTG.js +10 -0
- package/dist/registry-3L3N3PTG.js.map +1 -0
- package/dist/registry-O47PUPSY.js +8 -0
- package/dist/registry-O47PUPSY.js.map +1 -0
- package/dist/registry-RFGGMVNJ.js +7 -0
- package/dist/registry-RFGGMVNJ.js.map +1 -0
- package/dist/registry-WLLMODKN.js +8 -0
- package/dist/registry-WLLMODKN.js.map +1 -0
- package/dist/session/index.cjs +7 -1
- package/dist/session/index.cjs.map +1 -1
- package/dist/session/index.d.cts +7 -7
- package/dist/session/index.d.ts +7 -7
- package/dist/session/index.js +10 -3
- package/dist/session/index.js.map +1 -1
- package/dist/shadow/index.cjs.map +1 -1
- package/dist/shadow/index.d.cts +6 -6
- package/dist/shadow/index.d.ts +6 -6
- package/dist/shadow/index.js +2 -2
- package/dist/stale-HSC5YO2O.js +13 -0
- package/dist/stale-HSC5YO2O.js.map +1 -0
- package/dist/store/index.cjs +14 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +6 -6
- package/dist/store/index.d.ts +6 -6
- package/dist/store/index.js +5 -2
- package/dist/{strategy-D-SrOLCl.d.cts → strategy-DSTrsZ8t.d.cts} +72 -19
- package/dist/{strategy-D-SrOLCl.d.ts → strategy-DSTrsZ8t.d.ts} +72 -19
- package/dist/sync/index.cjs.map +1 -1
- package/dist/sync/index.d.cts +5 -5
- package/dist/sync/index.d.ts +5 -5
- package/dist/sync/index.js +4 -4
- package/dist/team/index.cjs +1554 -2
- package/dist/team/index.cjs.map +1 -1
- package/dist/team/index.d.cts +6 -6
- package/dist/team/index.d.ts +6 -6
- package/dist/team/index.js +76 -9
- package/dist/tx/index.cjs +296 -44
- package/dist/tx/index.cjs.map +1 -1
- package/dist/tx/index.d.cts +6 -6
- package/dist/tx/index.d.ts +6 -6
- package/dist/tx/index.js +2 -2
- package/dist/{types-Bnb82f5R.d.cts → types-C4lwMKKF.d.cts} +2605 -328
- package/dist/{types-Bo7NSXJr.d.ts → types-DW9RGSSs.d.ts} +2605 -328
- package/dist/util/index.cjs.map +1 -1
- package/dist/util/index.js +1 -1
- package/dist/with-derivation-C8LDlV7t.d.cts +13 -0
- package/dist/with-derivation-g-pGoMzL.d.ts +13 -0
- package/dist/with-guard-DWOCK4Ca.d.ts +18 -0
- package/dist/with-guard-jI1x9Z3k.d.cts +18 -0
- package/dist/with-materialized-view-DaKR-N6J.d.ts +27 -0
- package/dist/with-materialized-view-DcTx4H3j.d.cts +27 -0
- package/dist/with-overlayed-view-D-6oWAgM.d.cts +13 -0
- package/dist/with-overlayed-view-N7jYuNOS.d.ts +13 -0
- package/package.json +53 -2
- package/dist/chunk-4PWAI7Q4.js +0 -79
- package/dist/chunk-4PWAI7Q4.js.map +0 -1
- package/dist/chunk-ACLDOTNQ.js.map +0 -1
- package/dist/chunk-BTDCBVJW.js +0 -160
- package/dist/chunk-BTDCBVJW.js.map +0 -1
- package/dist/chunk-CIMZBAZB.js.map +0 -1
- package/dist/chunk-EXHNQEV4.js.map +0 -1
- package/dist/chunk-GOUT6DND.js.map +0 -1
- package/dist/chunk-M5INGEFC.js.map +0 -1
- package/dist/chunk-QAVUREFT.js.map +0 -1
- package/dist/chunk-RKJ6OL7K.js.map +0 -1
- package/dist/chunk-SCZXXXU4.js.map +0 -1
- package/dist/chunk-TDR6T5CJ.js.map +0 -1
- package/dist/chunk-WDM5XGGS.js.map +0 -1
- /package/dist/{chunk-AVVPZ4BC.js.map → chunk-4TFSM22V.js.map} +0 -0
- /package/dist/{chunk-QGZRWRSL.js.map → chunk-537VFZTR.js.map} +0 -0
- /package/dist/{chunk-M62XNWRA.js.map → chunk-5DWL3JBF.js.map} +0 -0
- /package/dist/{chunk-PTVMYYON.js.map → chunk-5SCJ5UEF.js.map} +0 -0
- /package/dist/{chunk-ZFKD4QMV.js.map → chunk-DYECX3IX.js.map} +0 -0
- /package/dist/{chunk-MDDTIZUO.js.map → chunk-NIOHFJPJ.js.map} +0 -0
- /package/dist/{chunk-USKYUS74.js.map → chunk-P7EQ2S5O.js.map} +0 -0
- /package/dist/{chunk-2CSJGFCB.js.map → chunk-VMIO4IXG.js.map} +0 -0
- /package/dist/{chunk-MR4424N3.js.map → chunk-WCA2NROQ.js.map} +0 -0
- /package/dist/{chunk-NPC4LFV5.js.map → chunk-YMYK7US4.js.map} +0 -0
- /package/dist/{chunk-R36SIKES.js.map → chunk-ZNOEIM6Y.js.map} +0 -0
- /package/dist/{crypto-IVKU7YTT.js.map → crypto-A7FRXYHC.js.map} +0 -0
- /package/dist/{delegation-2DBS2EOH.js.map → delegation-YBA4X4JN.js.map} +0 -0
- /package/dist/{ledger-QZTTHQAQ.js.map → derivations/index.js.map} +0 -0
- /package/dist/{public-envelope-6JTACYJV.js.map → executor-7E3VFGW7.js.map} +0 -0
package/dist/team/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/team/index.ts","../../src/types.ts","../../src/errors.ts","../../src/crypto.ts","../../src/team/keyring.ts","../../src/store/sync-policy.ts","../../src/team/sync.ts","../../src/team/sync-transaction.ts","../../src/team/presence.ts","../../src/team/sync-credentials.ts"],"sourcesContent":["/**\n * `@noy-db/hub/team` — subpath export for multi-user / sync / keyring.\n *\n * Solo-user apps that never call `grant()`, `db.push()`, or open a\n * sync target can exclude this subpath entirely — bundle savings\n * estimated at ~4-6 KB.\n *\n * The main `@noy-db/hub` entry still re-exports every symbol for\n * backward compatibility through.x.\n *\n * Named re-exports (not `export *`) so tsup keeps the barrel\n * populated even with `sideEffects: false`.\n */\n\n// ─── Keyring / multi-user ───────────────────────────────────\nexport type { UnlockedKeyring } from './keyring.js'\n\n// ─── Export-capability helpers ───────────────────────────\nexport {\n hasExportCapability,\n evaluateExportCapability,\n} from './keyring.js'\n\n// ─── Import-capability helpers (issue ) ─────────────────────────\nexport {\n hasImportCapability,\n evaluateImportCapability,\n} from './keyring.js'\n\n// ─── Sync engine ────────────────────────────────────────────\nexport { SyncEngine } from './sync.js'\n\n// ─── Sync transactions ──────────────────────────────────\nexport { SyncTransaction } from './sync-transaction.js'\n\n// ─── Presence / live cursors ────────────────────────────\nexport { PresenceHandle } from './presence.js'\n\n// ─── _sync_credentials reserved collection ──────────────\nexport {\n putCredential,\n getCredential,\n deleteCredential,\n listCredentials,\n credentialStatus,\n SYNC_CREDENTIALS_COLLECTION,\n} from './sync-credentials.js'\nexport type { SyncCredential } from './sync-credentials.js'\n","/**\n * Core types — the {@link NoydbStore} interface, envelope format, roles, and\n * all configuration shapes consumed by {@link createNoydb}.\n *\n * ## What lives here\n *\n * - **{@link NoydbStore}** — the 6-method contract every backend must implement\n * (`get`, `put`, `delete`, `list`, `loadAll`, `saveAll`).\n * - **{@link EncryptedEnvelope}** — the wire format stored by backends:\n * `{ _noydb, _v, _ts, _iv, _data }`. Backends only ever see this shape.\n * - **{@link Role} / {@link Permission}** — the access-control vocabulary\n * (`owner`, `admin`, `operator`, `viewer`, `client`).\n * - **{@link NoydbOptions}** — the full configuration object passed to\n * {@link createNoydb}.\n *\n * ## Extending the store interface\n *\n * All optional store capabilities (`ping`, `listPage`, `listSince`,\n * `presencePublish`, `presenceSubscribe`, `listVaults`) are additive extensions\n * discovered via `'method' in store`. Implementing them unlocks features but\n * is never required — core always falls back to the 6-method baseline.\n *\n * @module\n */\n\nimport type { StandardSchemaV1 } from './schema.js'\nimport type { SyncPolicy } from './store/sync-policy.js'\nimport type { BlobStrategy } from './blobs/strategy.js'\nimport type { IndexStrategy } from './indexing/strategy.js'\nimport type { AggregateStrategy } from './aggregate/strategy.js'\nimport type { CrdtStrategy } from './crdt/strategy.js'\nimport type { ConsentStrategy } from './consent/strategy.js'\nimport type { PeriodsStrategy } from './periods/strategy.js'\nimport type { ShadowStrategy } from './shadow/strategy.js'\nimport type { TxStrategy } from './tx/strategy.js'\nimport type { HistoryStrategy } from './history/strategy.js'\nimport type { I18nStrategy } from './i18n/strategy.js'\nimport type { SessionStrategy } from './session/strategy.js'\nimport type { SyncStrategy } from './team/sync-strategy.js'\nimport type { UnlockedKeyring } from './team/keyring.js'\nimport type { VaultPolicy } from './policy/types.js'\nimport type { PublicEnvelopeSchema } from './meta/public-envelope/types.js'\n\n/** Format version for encrypted record envelopes. */\nexport const NOYDB_FORMAT_VERSION = 1 as const\n\n/** Format version for keyring files. */\nexport const NOYDB_KEYRING_VERSION = 1 as const\n\n/** Format version for backup files. */\nexport const NOYDB_BACKUP_VERSION = 1 as const\n\n/** Format version for sync metadata. */\nexport const NOYDB_SYNC_VERSION = 1 as const\n\n// ─── Roles & Permissions ───────────────────────────────────────────────\n\n/**\n * Access role assigned to a user within a vault.\n *\n * Roles control both the operations a user can perform and which DEKs\n * they receive in their keyring:\n *\n * | Role | Collections | Can grant/revoke | Can export |\n * |------------|-----------------|:----------------:|:----------:|\n * | `owner` | all (rw) | Yes (all roles) | Yes |\n * | `admin` | all (rw) | Yes (≤ admin) | Yes |\n * | `operator` | explicit (rw) | No | ACL-scoped |\n * | `viewer` | all (ro) | No | Yes |\n * | `client` | explicit (ro) | No | ACL-scoped |\n */\nexport type Role = 'owner' | 'admin' | 'operator' | 'viewer' | 'client'\n\n/**\n * Read-write or read-only access on a collection.\n * Stored per-collection in the user's keyring.\n */\nexport type Permission = 'rw' | 'ro'\n\n/**\n * Map of collection name → permission level for a user's keyring entry.\n * `'*'` is the wildcard collection matching all collections in the vault.\n */\nexport type Permissions = Record<string, Permission>\n\n// ─── Encrypted Envelope ────────────────────────────────────────────────\n\n/** The encrypted wrapper stored by adapters. Adapters only ever see this. */\nexport interface EncryptedEnvelope {\n readonly _noydb: typeof NOYDB_FORMAT_VERSION\n readonly _v: number\n readonly _ts: string\n readonly _iv: string\n readonly _data: string\n /** User who created this version (unencrypted metadata). */\n readonly _by?: string\n /**\n * Hierarchical access tier. Omitted → tier 0.\n *\n * Unencrypted on purpose — the store reads it to route the envelope\n * to the right DEK slot without having to try-decrypt against every\n * tier. Only leaks the tier of each record, not any value\n * equivalence.\n */\n readonly _tier?: number\n /**\n * User id who last elevated this record. Used by\n * `demote()` to gate the reverse operation: only the original\n * elevator or an owner can demote a record back down. Cleared on\n * every successful demote so a later re-elevate requires the new\n * actor to own the demotion right.\n */\n readonly _elevatedBy?: string\n /**\n * Deterministic-encryption index. Map of field name →\n * base64 deterministic ciphertext. Present only when the collection\n * declares `deterministicFields` and the feature is acknowledged. The\n * field names are unencrypted (they're the index keys); the values\n * are AES-GCM ciphertext with an HKDF-derived deterministic IV.\n *\n * Enables blind equality search (`collection.findByDet(field,\n * value)`) without decrypting every record. Leaks equality as a known\n * side channel.\n */\n readonly _det?: Record<string, string>\n}\n\n/**\n * Placeholder returned by `getAtTier()` in `'ghost'` mode when a\n * record is at a tier the caller cannot decrypt. Record existence is\n * advertised — the id and tier are visible — but contents are\n * withheld. `canElevateFrom` lists user ids authorized to elevate\n * access for this caller when known; absent when the workflow is\n * not configured.\n */\nexport interface GhostRecord {\n readonly _ghost: true\n readonly _tier: number\n readonly canElevateFrom?: readonly string[]\n}\n\n/** Control what lower-tier reads see above their clearance. */\nexport type TierMode = 'invisibility' | 'ghost'\n\n/**\n * Event emitted when a record at a tier above the caller's inherent\n * clearance is read or written successfully (via elevation or\n * delegation). Always written to the ledger; subscribers get a\n * real-time feed.\n */\nexport interface CrossTierAccessEvent {\n readonly actor: string\n readonly collection: string\n readonly id: string\n readonly tier: number\n /** How the caller gained tier access: they elevated it, or a delegation is active. */\n readonly authorization: 'elevation' | 'delegation' | 'inherent'\n readonly op: 'get' | 'put' | 'elevate' | 'demote'\n readonly ts: string\n /**\n * When `authorization === 'elevation'`, the audit reason string the\n * caller passed to `vault.elevate(...)`. Empty for inherent /\n * delegation paths.\n */\n readonly reason?: string\n /**\n * When `authorization === 'elevation'`, the tier the caller's\n * keyring effectively held BEFORE elevation. Useful for audit\n * dashboards distinguishing \"operator elevating to 2\" from\n * \"inherent tier-2 write.\"\n */\n readonly elevatedFrom?: number\n}\n\n/**\n * A single deterministic-ciphertext index slot on an envelope. Stored\n * as `iv:data` (both base64, colon-separated) so a single string per\n * field keeps the envelope compact.\n */\nexport type DeterministicCipher = string\n\n// ─── Vault Snapshot ──────────────────────────────────────────────\n\n/** All records across all collections for a compartment. */\nexport type VaultSnapshot = Record<string, Record<string, EncryptedEnvelope>>\n\n/**\n * Result of a single page fetch via the optional `listPage` adapter extension.\n *\n * `items` carries the actual encrypted envelopes (not just ids) so the\n * caller can decrypt and emit a single record without an extra `get()`\n * round-trip per id. `nextCursor` is `null` on the final page.\n */\nexport interface ListPageResult {\n /** Encrypted envelopes for this page, in adapter-defined order. */\n items: Array<{ id: string; envelope: EncryptedEnvelope }>\n /** Opaque cursor for the next page, or `null` if this was the last page. */\n nextCursor: string | null\n}\n\n// ─── Store Interface ───────────────────────────────────────────────────\n\nexport interface NoydbStore {\n /**\n * Optional human-readable adapter name (e.g. 'memory', 'file', 'dynamo').\n * Used in diagnostic messages and the listPage fallback warning. Adapters\n * are encouraged to set this so logs are clearer about which backend is\n * involved when something goes wrong.\n */\n name?: string\n\n /** Get a single record. Returns null if not found. */\n get(vault: string, collection: string, id: string): Promise<EncryptedEnvelope | null>\n\n /** Put a record. Throws ConflictError if expectedVersion doesn't match. */\n put(\n vault: string,\n collection: string,\n id: string,\n envelope: EncryptedEnvelope,\n expectedVersion?: number,\n ): Promise<void>\n\n /** Delete a record. */\n delete(vault: string, collection: string, id: string): Promise<void>\n\n /** List all record IDs in a collection. */\n list(vault: string, collection: string): Promise<string[]>\n\n /** Load all records for a vault (initial hydration). */\n loadAll(vault: string): Promise<VaultSnapshot>\n\n /** Save all records for a vault (bulk write / restore). */\n saveAll(vault: string, data: VaultSnapshot): Promise<void>\n\n /** Optional connectivity check for sync engine. */\n ping?(): Promise<boolean>\n\n /**\n * Optional: list record IDs in a collection that have `_ts` after `since`.\n * Used by partial sync (`pull({ modifiedSince })`). Adapters that omit this\n * fall back to a full `loadAll` + client-side timestamp filter.\n */\n listSince?(vault: string, collection: string, since: string): Promise<string[]>\n\n /**\n * Optional pagination extension. Adapters that implement `listPage` get\n * the streaming `Collection.scan()` fast path; adapters that don't are\n * silently fallen back to a full `loadAll()` + slice (with a one-time\n * console.warn).\n *\n * `cursor` is opaque to the core — each adapter encodes its own paging\n * state (DynamoDB: base64 LastEvaluatedKey JSON; S3: ContinuationToken;\n * memory/file/browser: numeric offset of a sorted id list). Pass\n * `undefined` to start from the beginning.\n *\n * `limit` is a soft upper bound on `items.length`. Adapters MAY return\n * fewer items even when more exist (e.g. if the underlying store has\n * its own page size cap), and MUST signal \"no more pages\" by returning\n * `nextCursor: null`.\n *\n * The 6-method core contract is unchanged — this is an additive\n * extension discovered via `'listPage' in adapter`.\n */\n listPage?(\n vault: string,\n collection: string,\n cursor?: string,\n limit?: number,\n ): Promise<ListPageResult>\n\n /**\n * Optional pub/sub for real-time presence.\n * Publish an encrypted payload to a presence channel.\n * Falls back to storage-based polling when absent.\n */\n presencePublish?(channel: string, payload: string): Promise<void>\n\n /**\n * Optional pub/sub for real-time presence.\n * Subscribe to a presence channel. Returns an unsubscribe function.\n * Falls back to storage-based polling when absent.\n */\n presenceSubscribe?(channel: string, callback: (payload: string) => void): () => void\n\n /**\n * Optional cross-vault enumeration extension.\n *\n * Returns the names of every top-level vault the store\n * currently stores. Used by `Noydb.listAccessibleVaults()` to\n * enumerate the universe of vaults before filtering down to\n * the ones the calling principal can actually unwrap.\n *\n * **Why this is optional:** the storage shape of compartments\n * differs across backends. Memory and file stores store\n * vaults as top-level keys / directories and can enumerate\n * them in O(1) calls. DynamoDB stores everything in a single table\n * keyed by `(compartment#collection, id)` — enumerating compartments\n * requires either a Scan (expensive, eventually consistent, leaks\n * ciphertext metadata) or a dedicated GSI that the consumer\n * provisioned. S3 needs a prefix list (cheap if enabled, ACL-sensitive\n * otherwise). Browser localStorage can scan keys by prefix.\n *\n * Stores that cannot implement `listVaults` cheaply or\n * cleanly should omit it. Core surfaces a `StoreCapabilityError`\n * with a clear message when a caller invokes\n * `listAccessibleVaults()` against a store that doesn't\n * provide this method, so consumers know to either upgrade their\n * store, provide a candidate list explicitly to `queryAcross()`,\n * or fall back to maintaining the compartment index out of band.\n *\n * **Privacy note:** `listVaults` returns *every* compartment\n * the store has, not just the ones the caller can access. The\n * existence-leak filtering (returning only compartments whose\n * keyring the caller can unwrap) happens in core, not in the\n * store. The store is trusted to know its own contents — that\n * is not a leak in the threat model. The leak the API guards\n * against is the *return value* of `listAccessibleVaults()`\n * exposing existence to a downstream observer who only sees that\n * function's output.\n *\n * The 6-method core contract is unchanged — this is an additive\n * extension discovered via `'listVaults' in store`.\n */\n listVaults?(): Promise<string[]>\n\n /**\n * Optional: generate a presigned URL for direct client download.\n * Only meaningful for object stores (S3, GCS) that support URL signing.\n * Returns a time-limited URL that fetches the encrypted envelope directly.\n * The caller must decrypt client-side (the URL returns ciphertext).\n */\n presignUrl?(vault: string, collection: string, id: string, expiresInSeconds?: number): Promise<string>\n\n /**\n * Optional: estimate current storage usage.\n * Returns `{ usedBytes, quotaBytes }` or null if the store cannot estimate.\n * Used by quota-aware routing to detect overflow conditions.\n */\n estimateUsage?(): Promise<{ usedBytes: number; quotaBytes: number } | null>\n\n /**\n * Optional multi-record atomic write.\n *\n * When present, `db.transaction(async (tx) => { ... })` uses this to\n * commit every staged op in one storage-layer transaction — either\n * all ops land or none do, regardless of which records they touch.\n * Every `TxOp.expectedVersion` (when set) must be honored atomically\n * alongside the write; any violation throws `ConflictError` and the\n * whole batch fails.\n *\n * Stores that omit this fall through to the hub's per-record OCC\n * fallback: pre-flight CAS check, then sequential `put`/`delete`\n * with best-effort unwind on mid-batch failure (see\n * `runTransaction` for the exact semantics and crash window).\n *\n * Native implementations: `to-memory` (single Map mutation),\n * `to-dynamo` (`TransactWriteItems`), `to-browser-idb` (one\n * `readwrite` transaction). File / S3 cannot implement this\n * atomically and should omit the method.\n */\n tx?(ops: readonly TxOp[]): Promise<void>\n}\n\n/**\n * A single staged operation inside a `db.transaction(fn)` commit. The\n * hub assembles `TxOp[]` from the user's `tx.collection().put/delete`\n * calls, encrypts any `record` values into `envelope`, and hands the\n * array to `NoydbStore.tx()` when the store supports atomic batch\n * writes. Stores that implement `tx()` MUST honor every\n * `expectedVersion` atomically against the stored envelope version.\n */\nexport interface TxOp {\n readonly type: 'put' | 'delete'\n readonly vault: string\n readonly collection: string\n readonly id: string\n /** Populated for `type: 'put'` — the encrypted envelope to write. */\n readonly envelope?: EncryptedEnvelope\n /** Optional per-record CAS. Mismatch must throw `ConflictError`. */\n readonly expectedVersion?: number\n}\n\n// ─── Store Factory Helper ──────────────────────────────────────────────\n\n/** Type-safe helper for creating store factories. */\nexport function createStore<TOptions>(\n factory: (options: TOptions) => NoydbStore,\n): (options: TOptions) => NoydbStore {\n return factory\n}\n\n// ─── Keyring ───────────────────────────────────────────────────────────\n\n/**\n * Interchange formats `@noy-db/as-*` packages can produce. `'*'` is a\n * wildcard granting every current + future plaintext format.\n */\nexport type ExportFormat =\n | 'xlsx'\n | 'csv'\n | 'json'\n | 'ndjson'\n | 'xml'\n | 'sql'\n | 'pdf'\n | 'blob'\n | 'zip'\n | '*'\n\n/**\n * Owner-granted export capability on a keyring.\n *\n * Two independent dimensions:\n *\n * - `plaintext` — per-format allowlist for record formatters + blob\n * extractors that emit plaintext bytes (`as-xlsx`, `as-csv`,\n * `as-blob`, `as-zip`, …). **Defaults to empty** for every role;\n * the owner/admin must positively grant per-format (or `'*'`).\n * - `bundle` — boolean for `.noydb` encrypted container export\n * (`as-noydb`). **Default policy: on for owner/admin, off for\n * operator/viewer/client** — applied when the field is absent or\n * undefined (see `hasExportCapability`).\n */\nexport interface ExportCapability {\n readonly plaintext?: readonly ExportFormat[]\n readonly bundle?: boolean\n}\n\n/**\n * Owner-granted import capability on a keyring (sibling of\n * `ExportCapability`, issue ).\n *\n * Two independent dimensions:\n *\n * - `plaintext` — per-format allowlist for `as-*` readers that ingest\n * plaintext bytes (`as-csv`, `as-json`, `as-ndjson`, `as-zip`, …).\n * Defaults to empty for every role; the owner/admin must positively\n * grant per-format (or `'*'`).\n * - `bundle` — boolean gate for `.noydb` bundle import. **Defaults to\n * `false` for every role**, including owner/admin. Import is more\n * dangerous than export (corrupts vs leaks), so the policy is\n * default-closed across the board — the owner explicitly opts a\n * keyring in via `db.grant({ importCapability: { bundle: true } })`.\n */\nexport interface ImportCapability {\n readonly plaintext?: readonly ExportFormat[]\n readonly bundle?: boolean\n}\n\n/**\n * Forward-declared on-disk shape for `VaultPolicy` — the actual policy\n * model lives in `policy/types.ts` (#9). Declared here as `unknown`-typed\n * map so types.ts has no dependency on the policy module while the\n * `KeyringFile.policy` field can still round-trip foreign documents.\n *\n * @internal\n */\nexport type VaultPolicyOnDisk = Record<string, unknown>\n\n/**\n * Recovery profile enrolled at vault creation (issue #10).\n *\n * - `paper` — `on-recovery` codes (the only end-to-end profile in v0.1.0-pre.5).\n * - `shamir` / `multi-channel` / `admin-mediated` — API surface ships;\n * per-profile dispatch lands in follow-up issues. Calling\n * `db.recoverPassphrase` against these throws\n * {@link RecoveryProfileNotImplementedError}.\n */\nexport type RecoveryEnrollment =\n | {\n readonly profile: 'paper'\n /** Number of single-use codes to print at enrollment. */\n readonly codes: number\n }\n | {\n readonly profile: 'shamir'\n readonly k: number\n readonly n: number\n readonly trustees: ReadonlyArray<string>\n }\n | {\n readonly profile: 'multi-channel'\n readonly email?: string\n readonly pin?: boolean\n readonly paperCodes?: number\n }\n | {\n readonly profile: 'admin-mediated'\n readonly grantorUserId: string\n }\n\n/**\n * One tier-2 authenticator slot inside a keyring file. Each slot\n * independently wraps the SAME KEK under a method-specific derived key\n * (LUKS pattern). Adding or removing a slot is a constant-time keyring\n * write — no DEK re-keying required.\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — Authenticate (multi-slot)\n */\n/**\n * Shared fields across all authenticator slot variants. The variant\n * (`KeyringAuthenticatorWrappingKEK` vs `KeyringAuthenticatorWrappingDEKs`)\n * carries the actual wrapped material; everything below is identity +\n * metadata only.\n */\ninterface KeyringAuthenticatorBase {\n /** Caller-chosen identifier — e.g. `'webauthn-yubikey-blue'`, `'oidc-google'`, `'password-daily'`. */\n readonly id: string\n /** Method family — selects which `@noy-db/on-*` package handles unlock. */\n readonly method: 'webauthn' | 'oidc' | 'password'\n /** ISO-8601 timestamp at which the slot was added. */\n readonly enrolled_at: string\n /**\n * Which session tier ENROLLED this slot. Tier 1 enrolls a fresh slot;\n * tier 2 may add a sibling slot when the active policy permits.\n */\n readonly enrolled_via_tier: 1 | 2\n /**\n * Method-specific metadata: WebAuthn cred id, OIDC issuer/sub, PBKDF2\n * salt for `on-password`, etc. The schema is open by design — the\n * `@noy-db/on-*` package owns the contents.\n */\n readonly meta: Record<string, unknown>\n}\n\n/**\n * Slot that wraps the KEK directly under a method-derived AES-KW key.\n * Used by ceremonies where the on-* package can produce/recover an\n * extractable KEK from its own credential — WebAuthn (PRF-derived\n * wrapping key) and split-key OIDC.\n *\n * `wrapKind` is optional/absent on slots written before pre.8 — those\n * legacy slots are treated as wrap-KEK by default at unlock time.\n */\nexport interface KeyringAuthenticatorWrappingKEK extends KeyringAuthenticatorBase {\n readonly wrapKind?: 'kek'\n /** Base64 wrapped-KEK ciphertext under the method-derived key. */\n readonly wrapped_kek: string\n /** XOR guard — wrap-KEK slots must NOT carry wrap-DEKs material. */\n readonly wrapped_deks?: never\n /** XOR guard — wrap-KEK slots must NOT carry wrap-DEKs material. */\n readonly iv?: never\n}\n\n/**\n * Slot that wraps the DEK set (not the KEK) under a method-derived\n * AES-GCM key — sidesteps the non-extractable-KEK constraint by\n * encrypting the serialized `{ deks: { collection: rawDekBase64 } }`\n * directly. Mirrors the format used by `mintPaperRecoveryEntry`\n * (`PaperRecoveryEntry`) and `@noy-db/on-pin`'s `PinResumeState` —\n * the unified wrap-DEKs primitive across tier-0 / tier-2 / tier-3.\n *\n * Trade-off: a slot of this kind reconstructs `UnlockedKeyring` with\n * `kek: null` after unlock. That is semantically correct for tier-2\n * (sensitive ops like `enrollAuthenticator` / `rotatePassphrase`\n * require a tier-1 unlock anyway) and matches how `@noy-db/on-pin`\n * already behaves at tier 3.\n *\n * @see `mintPaperRecoveryEntry` in `team/recovery.ts` — same shape on\n * a different on-disk path (`_meta/recovery-paper`).\n */\nexport interface KeyringAuthenticatorWrappingDEKs extends KeyringAuthenticatorBase {\n readonly wrapKind: 'deks'\n /** Base64 AES-GCM ciphertext of `{ deks: { collection: base64rawDek } }`. */\n readonly wrapped_deks: string\n /** Base64 AES-GCM IV used for the `wrapped_deks` ciphertext. */\n readonly iv: string\n /** XOR guard — wrap-DEKs slots must NOT carry wrap-KEK material. */\n readonly wrapped_kek?: never\n}\n\n/**\n * Discriminated union over the two wrap-format variants. Reads from\n * disk should always go through this type so the variant is preserved.\n *\n * Discriminator: `wrapKind`. Absent → wrap-KEK (legacy / WebAuthn /\n * OIDC). Present and `'deks'` → wrap-DEKs (password / future on-* that\n * want to sidestep extractable-KEK).\n *\n * The type-level XOR enforces \"exactly one of `wrapped_kek` /\n * `wrapped_deks` is present\" — a structural guarantee that the runtime\n * dispatch is safe.\n */\nexport type KeyringAuthenticator =\n | KeyringAuthenticatorWrappingKEK\n | KeyringAuthenticatorWrappingDEKs\n\nexport interface KeyringFile {\n readonly _noydb_keyring: typeof NOYDB_KEYRING_VERSION\n readonly user_id: string\n readonly display_name: string\n readonly role: Role\n readonly permissions: Permissions\n readonly deks: Record<string, string>\n readonly salt: string\n readonly created_at: string\n readonly granted_by: string\n /**\n * Tier-2 authenticator slots (multi-slot keyring extension).\n * Optional / append-only: keyring files written before the\n * extension load with an empty list. Each slot independently wraps\n * the same KEK; any one of them unlocks.\n *\n * @see KeyringAuthenticator\n */\n readonly authenticators?: readonly KeyringAuthenticator[]\n /**\n * Per-keyring policy override (reserved). The on-disk format\n * accepts the field for forward compatibility with the Option C\n * merge engine deferred to a later release; v1.0 reads only the\n * vault-level `_meta/policy` document, so this field is parsed and\n * round-tripped but never enforced.\n */\n readonly policy?: VaultPolicyOnDisk\n /**\n * Optional — authorization spec capability bits. Absent on keyrings written\n * before the RFC implementation. Loading falls back to role-based\n * defaults (owner/admin get bundle-on, everyone else off).\n */\n readonly export_capability?: ExportCapability\n /**\n * Optional bundle-slot expiry. ISO-8601 timestamp; past\n * the cutoff `loadKeyring` throws `KeyringExpiredError` before any\n * DEK unwrap is attempted. Useful for time-boxed audit access:\n * \"this slot works for 30 days then becomes opaque to its holder.\"\n *\n * Absent on live keyrings written via `db.grant()` — the field is\n * meaningful for `BundleRecipient` slots produced by\n * `writeNoydbBundle({ recipients: [...] })`. Setting it on a live\n * keyring is allowed but unusual.\n */\n readonly expires_at?: string\n /**\n * Optional — issue import-capability bits. Absent on keyrings\n * written before landed. Loading falls back to default-closed\n * for every role and every format.\n */\n readonly import_capability?: ImportCapability\n /**\n * hierarchical access clearance. Absent → 0 (advisory;\n * the real check is whether the DEK map carries a `collection#tier`\n * entry for the requested tier). Owners and admins default to the\n * highest tier they have DEKs for at grant time.\n */\n readonly clearance?: number\n}\n\n// ─── Backup ────────────────────────────────────────────────────────────\n\nexport interface VaultBackup {\n readonly _noydb_backup: typeof NOYDB_BACKUP_VERSION\n readonly _compartment: string\n readonly _exported_at: string\n readonly _exported_by: string\n readonly keyrings: Record<string, KeyringFile>\n readonly collections: VaultSnapshot\n /**\n * Internal collections (`_ledger`, `_ledger_deltas`, `_history`, `_sync`, …)\n * captured alongside the data collections. Optional for backwards\n * compat with backups, which only stored data collections —\n * loading a backup leaves the ledger empty (and `verifyBackupIntegrity`\n * skips the chain check, surfacing only a console warning).\n */\n readonly _internal?: VaultSnapshot\n /**\n * Verifiable-backup metadata. Embeds the ledger head at\n * dump time so `load()` can cross-check that the loaded chain matches\n * exactly what was exported. A backup whose chain has been tampered\n * with — either by modifying ledger entries or by modifying data\n * envelopes that the chain references — fails this check.\n *\n * Optional for backwards compat with backups; missing means\n * \"legacy backup, load with a warning, no integrity check\".\n */\n readonly ledgerHead?: {\n /** Hex sha256 of the canonical JSON of the last ledger entry. */\n readonly hash: string\n /** Sequential index of the last ledger entry. */\n readonly index: number\n /** ISO timestamp captured at dump time. */\n readonly ts: string\n }\n}\n\n// ─── Export ────────────────────────────────────────────────────────────\n\n/**\n * Options for `Vault.exportStream()` and `Vault.exportJSON()`.\n *\n * The defaults match the most common consumer pattern: one chunk per\n * collection, no ledger metadata. Per-record streaming and ledger-head\n * inclusion are opt-in because both add structure most consumers don't\n * need.\n */\nexport interface ExportStreamOptions {\n /**\n * `'collection'` (default) yields one chunk per collection with all\n * records bundled in `chunk.records`. `'record'` yields one chunk per\n * record, useful for arbitrarily large collections that should never\n * be materialized as a single array.\n */\n readonly granularity?: 'collection' | 'record'\n\n /**\n * When `true`, every chunk includes the current compartment ledger\n * head under `chunk.ledgerHead`. The value is identical across every\n * chunk in a single export (one ledger per compartment). Forward-\n * compatible with future partition work where the head would become\n * per-partition. Default: `false`.\n */\n readonly withLedgerHead?: boolean\n /**\n * When set to a BCP 47 locale string (e.g. `'th'`), `exportJSON()`\n * resolves all `dictKey` labels to that locale and omits the raw\n * `dictionaries` snapshot from the output. Has no effect\n * on `exportStream()` — format packages use the `chunk.dictionaries`\n * snapshot directly and apply their own locale strategy.\n *\n * Default: `undefined` — embed the raw snapshot under `_dictionaries`.\n */\n readonly resolveLabels?: string\n}\n\n/**\n * One chunk yielded by `Vault.exportStream()`.\n *\n * `granularity: 'collection'` yields one chunk per collection with the\n * full record array in `records`. `granularity: 'record'` yields one\n * chunk per record with `records` containing exactly one element — the\n * `schema` and `refs` metadata is repeated on every chunk so consumers\n * doing per-record streaming don't have to thread state across yields.\n */\nexport interface ExportChunk<T = unknown> {\n /** Collection name (no leading underscore — internal collections are filtered out). */\n readonly collection: string\n\n /**\n * Standard Schema validator attached to the collection at `collection()`\n * construction time, or `null` if no schema was provided. Surfaced so\n * downstream serializers (`@noy-db/as-*` packages, custom\n * exporters) can produce schema-aware output (typed CSV headers, XSD\n * generation, etc.) without poking at collection internals.\n */\n readonly schema: StandardSchemaV1<unknown, T> | null\n\n /**\n * Foreign-key references declared on the collection via the `refs`\n * option, as the `{ field → { target, mode } }` map produced by\n * `RefRegistry.getOutbound`. Empty object when no refs were declared.\n */\n readonly refs: Record<string, { readonly target: string; readonly mode: 'strict' | 'warn' | 'cascade' }>\n\n /**\n * Decrypted, ACL-scoped, schema-validated records. Length 1 in\n * `granularity: 'record'` mode, full collection in `granularity: 'collection'`\n * mode. Records are returned by reference from the collection's eager\n * cache where applicable — consumers must treat them as immutable.\n */\n readonly records: T[]\n\n /**\n * Dictionary snapshots for every `dictKey` field declared on this\n * collection. Captured once at stream-start and held\n * constant across all chunks within the same export — a rename\n * mid-export does not change the snapshot. `undefined` when the\n * collection has no `dictKeyFields`.\n *\n * Shape: `{ [fieldName]: { [stableKey]: { [locale]: label } } }`\n *\n * @example\n * ```ts\n * chunk.dictionaries?.status?.paid?.th // → 'ชำระแล้ว'\n * ```\n */\n readonly dictionaries?: Record<\n string, // field name\n Record<string, Record<string, string>> // stable key → locale → label\n >\n\n /**\n * Vault ledger head at export time. Present only when\n * `exportStream({ withLedgerHead: true })` was called. Identical\n * across every chunk in the same export — included on every chunk\n * for forward-compatibility with future per-partition ledgers, where\n * the value will differ per chunk.\n */\n readonly ledgerHead?: {\n readonly hash: string\n readonly index: number\n readonly ts: string\n }\n}\n\n// ─── Sync ──────────────────────────────────────────────────────────────\n\nexport interface DirtyEntry {\n readonly vault: string\n readonly collection: string\n readonly id: string\n readonly action: 'put' | 'delete'\n readonly version: number\n readonly timestamp: string\n}\n\nexport interface SyncMetadata {\n readonly _noydb_sync: typeof NOYDB_SYNC_VERSION\n readonly last_push: string | null\n readonly last_pull: string | null\n readonly dirty: DirtyEntry[]\n}\n\nexport interface Conflict {\n readonly vault: string\n readonly collection: string\n readonly id: string\n readonly local: EncryptedEnvelope\n readonly remote: EncryptedEnvelope\n readonly localVersion: number\n readonly remoteVersion: number\n /**\n * Present only when the collection uses `conflictPolicy: 'manual'`.\n * Call `resolve(winner)` to commit the winning envelope, or\n * `resolve(null)` to defer (conflict stays queued for the next sync).\n * Called synchronously inside the `sync:conflict` event handler.\n */\n readonly resolve?: (winner: EncryptedEnvelope | null) => void\n}\n\nexport type ConflictStrategy =\n | 'local-wins'\n | 'remote-wins'\n | 'version'\n | ((conflict: Conflict) => 'local' | 'remote')\n\n/**\n * Collection-level conflict policy.\n * Overrides the db-level `conflict` option for the specific collection.\n *\n * - `'last-writer-wins'` — higher `_ts` wins (timestamp LWW).\n * - `'first-writer-wins'` — lower `_v` wins (earlier version is preserved).\n * - `'manual'` — emits `sync:conflict` with a `resolve` callback. Call\n * `resolve(winner)` synchronously to commit or `resolve(null)` to defer.\n * - Custom fn — synchronous `(local: T, remote: T) => T`. Must be pure.\n */\nexport type ConflictPolicy<T> =\n | 'last-writer-wins'\n | 'first-writer-wins'\n | 'manual'\n | ((local: T, remote: T) => T)\n\n/**\n * Envelope-level resolver registered per collection with the SyncEngine.\n * Receives the `id` of the conflicting record and both envelopes.\n * Returns the winning envelope, or `null` to defer resolution.\n * @internal\n */\nexport type CollectionConflictResolver = (\n id: string,\n local: EncryptedEnvelope,\n remote: EncryptedEnvelope,\n) => Promise<EncryptedEnvelope | null>\n\n/** Options for targeted push operations. */\nexport interface PushOptions {\n /** Only push records belonging to these collections. Omit to push all dirty. */\n collections?: string[]\n}\n\n/** Options for targeted pull operations. */\nexport interface PullOptions {\n /** Only pull these collections. Omit to pull all. */\n collections?: string[]\n /**\n * Only pull records with `_ts` strictly after this ISO timestamp.\n * Adapters that implement `listSince` use it directly; others fall back\n * to a full scan with client-side filtering.\n */\n modifiedSince?: string\n}\n\nexport interface PushResult {\n readonly pushed: number\n readonly conflicts: Conflict[]\n readonly errors: Error[]\n}\n\nexport interface PullResult {\n readonly pulled: number\n readonly conflicts: Conflict[]\n readonly errors: Error[]\n}\n\n/** Result of a sync transaction commit. */\nexport interface SyncTransactionResult {\n readonly status: 'committed' | 'conflict'\n readonly pushed: number\n readonly conflicts: Conflict[]\n}\n\nexport interface SyncStatus {\n readonly dirty: number\n readonly lastPush: string | null\n readonly lastPull: string | null\n readonly online: boolean\n}\n\n// ─── Sync Target ─────────────────────────────────────────\n\nexport type SyncTargetRole = 'sync-peer' | 'backup' | 'archive'\n\n/**\n * A sync target with role and optional per-target policy.\n *\n * | Role | Direction | Conflict resolution | Typical use |\n * |-------------|---------------|---------------------|--------------------------|\n * | `sync-peer` | Bidirectional | ConflictStrategy | DynamoDB live sync |\n * | `backup` | Push-only | N/A (receives merged)| S3 dump, Google Drive |\n * | `archive` | Push-only | N/A | IPFS, Git tags, S3 Lock |\n */\nexport interface SyncTarget {\n /** The store to sync with. */\n readonly store: NoydbStore\n /** Role determines sync direction and conflict handling. */\n readonly role: SyncTargetRole\n /** Per-target sync policy. Inherits store-category default when absent. */\n readonly policy?: SyncPolicy\n /** Human-readable label for DevTools and audit logs. */\n readonly label?: string\n}\n\n// ─── Events ────────────────────────────────────────────────────────────\n\nexport interface ChangeEvent {\n readonly vault: string\n readonly collection: string\n readonly id: string\n readonly action: 'put' | 'delete'\n}\n\nexport interface NoydbEventMap {\n 'change': ChangeEvent\n 'error': Error\n 'sync:push': PushResult\n 'sync:pull': PullResult\n 'sync:conflict': Conflict\n 'sync:online': void\n 'sync:offline': void\n 'sync:backup-error': { vault: string; target: string; error: Error }\n 'history:save': { vault: string; collection: string; id: string; version: number }\n 'history:prune': { vault: string; collection: string; id: string; pruned: number }\n /**\n * Emitted when a persisted-index side-car put/delete fails after the\n * main record write already succeeded. The main record is durable; the\n * index mirror may have drifted. Operators reconcile via\n * `collection.reconcileIndex(field)`.\n */\n 'index:write-partial': {\n vault: string\n collection: string\n id: string\n action: 'put' | 'delete'\n error: Error\n }\n /**\n * emitted by `Collection.ensurePersistedIndexesLoaded()`\n * once per field on first lazy-mode query when\n * `reconcileOnOpen: 'auto' | 'dry-run'` is configured. `applied` is\n * `0` in `'dry-run'` mode. `skipped` is reserved for a future\n * drift-stamp optimization that short-circuits the reconcile when\n * the mirror version matches what's on disk — currently always\n * `false` (the full reconcile runs every session).\n */\n 'index:reconciled': {\n vault: string\n collection: string\n field: string\n missing: readonly string[]\n stale: readonly string[]\n applied: number\n skipped: boolean\n }\n}\n\n// ─── Grant / Revoke ────────────────────────────────────────────────────\n\nexport interface GrantOptions {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly passphrase: string\n readonly permissions?: Permissions\n /**\n * Optional `@noy-db/as-*` export capability. Omit or\n * leave undefined to apply role-based defaults (see\n * `hasExportCapability` and `ExportCapability`).\n */\n readonly exportCapability?: ExportCapability\n /**\n * Optional `@noy-db/as-*` import capability (issue ). Omit or\n * leave undefined for default-closed semantics — no plaintext format\n * is grantable until positively listed; bundle import is denied.\n */\n readonly importCapability?: ImportCapability\n /**\n * Skip phrase-format strength validation (issue #7). Defaults to\n * false — `grant()` rejects phrases that don't meet the configured\n * `PassphrasePolicy`. Test fixtures and CLI scripts pass `true`.\n */\n readonly allowWeakPassphrase?: boolean\n /**\n * Initial user-envelope payload for the new principal. Sealed under\n * the same vault DEK (the reserved `_users` collection's DEK) and\n * persisted alongside the keyring during grant.\n *\n * **Bootstrap-only.** Once the new user activates and writes their\n * own envelope, the own-only write rule kicks in — admins cannot\n * edit a teammate's envelope after activation. Use this field for\n * pre-fill at invite time (e.g. \"displayName: Bob, locale: en-US\")\n * and let the user take over from there.\n *\n * Hub does not introspect the payload; it is JSON-serialized and\n * encrypted opaquely. Apps own the schema.\n *\n * @see docs/superpowers/specs/2026-05-05-user-envelope-design.md → Lifecycle\n */\n readonly initialProfile?: unknown\n}\n\n/**\n * Caller payload for `db.updateUser` (#54). Mutate one or more\n * identity fields on an existing keyring without rotating any keys.\n *\n * `role`, `displayName`, and `permissions` live in the plaintext header\n * of `_keyring/<userId>` (the sync engine reads them without keys).\n * Mutating them is a JSON header swap — no DEK rewrap, no KEK\n * required, no authenticator slots touched. Tier-2 slots and recovery\n * enrollments survive unchanged. Last-write-wins through the existing\n * keyring put (same concurrency story as `db.grant` / `db.revoke`).\n *\n * Top-level fields are partial-merge: absent fields are not modified.\n * `permissions`, however, is a **full replacement** at the map level —\n * passing `{ invoices: 'rw' }` REPLACES the entire permissions map,\n * silently dropping any other entries. To partially update, read the\n * current keyring and merge: `permissions: { ...current, invoices: 'rw' }`.\n * To clear all permissions, pass `permissions: {}` explicitly.\n *\n * Role-elevation guard: the same hierarchy as `db.grant`. Admins can\n * change `admin` / `operator` / `viewer` / `client` to and from each\n * other; admins cannot promote to or demote from `owner`. Owners can\n * do anything. Non-admin callers (operator/viewer/client) cannot call\n * `db.updateUser` at all — for self-displayName changes, use\n * `vault.user.updateMe` (the user-envelope API).\n *\n * @see #54\n */\nexport interface UpdateUserOptions {\n readonly userId: string\n readonly role?: Role\n readonly displayName?: string\n readonly permissions?: Permissions\n}\n\nexport interface RevokeOptions {\n readonly userId: string\n readonly rotateKeys?: boolean\n\n /**\n * Cascade behavior when the revoked user is an admin who has granted\n * other admins.\n *\n * - `'strict'` (default) — recursively revoke every admin that the\n * target (transitively) granted. The cascade walks the\n * `granted_by` field on each keyring file and stops at non-admin\n * leaves. All affected collections are accumulated and rotated in\n * a single pass at the end, so cascade cost is O(records in\n * affected collections), not O(records × cascade depth).\n *\n * - `'warn'` — leave the descendant admins in place but emit a\n * `console.warn` listing them. Useful for diagnostic dry runs and\n * for environments where the operator wants to clean up the\n * delegation tree manually.\n *\n * No effect when the target is not an admin (operators, viewers, and\n * clients cannot grant other users, so they have no delegation\n * subtree to cascade through). Defaults to `'strict'`.\n */\n readonly cascade?: 'strict' | 'warn'\n}\n\n// ─── Cross-vault queries ──────────────────────────────\n\n/**\n * One entry returned by `Noydb.listAccessibleVaults()`. Carries\n * the compartment id and the role the calling principal holds in it,\n * so the consumer can decide how to fan out without re-checking\n * permissions per vault.\n */\nexport interface AccessibleVault {\n readonly id: string\n readonly role: Role\n}\n\n/**\n * Options for `Noydb.listAccessibleVaults()`.\n */\nexport interface ListAccessibleVaultsOptions {\n /**\n * Minimum role the caller must hold to include a compartment in the\n * result. Compartments where the caller's role is strictly *below*\n * this threshold are silently excluded. Defaults to `'client'`,\n * which means \"every vault I can unwrap is returned.\" Set to\n * `'admin'` for \"vaults where I can grant/revoke,\" or\n * `'owner'` for \"vaults I own.\"\n *\n * The privilege ordering used:\n * `client (1) < viewer (2) < operator (3) < admin (4) < owner (5)`\n *\n * Note: `viewer` and `client` are conceptually peers in the ACL\n * (neither can grant), but `viewer` has read-all access while\n * `client` has only explicit-collection read. The numeric order\n * reflects \"how much can this principal see,\" not \"how much can\n * this principal modify.\"\n */\n readonly minRole?: Role\n}\n\n/**\n * Options for `Noydb.queryAcross()`.\n */\nexport interface QueryAcrossOptions {\n /**\n * Maximum number of compartments to process in parallel. Defaults\n * to `1` (sequential) — conservative because the per-compartment\n * callback typically does its own I/O and an unbounded fan-out can\n * exhaust adapter connections (DynamoDB throughput, S3 socket\n * limits, browser fetch concurrency).\n *\n * Set to `4` or `8` for cloud-backed compartments where parallelism\n * is the whole point of fanning out. Set to `1` (default) for local\n * adapters where the disk I/O serializes anyway.\n */\n readonly concurrency?: number\n}\n\n/**\n * One entry in the array returned by `Noydb.queryAcross()`. Either\n * `result` is set (callback succeeded for this compartment) or\n * `error` is set (callback threw, or compartment failed to open).\n *\n * Per-compartment errors do **not** abort the overall fan-out — every\n * compartment is given a chance to run its callback, and the\n * partition between success and failure is exposed in the return\n * value. Consumers that want fail-fast semantics can check\n * `r.error !== undefined` and short-circuit themselves.\n */\nexport type QueryAcrossResult<T> =\n | { readonly vault: string; readonly result: T; readonly error?: undefined }\n | { readonly vault: string; readonly result?: undefined; readonly error: Error }\n\n// ─── User Info ─────────────────────────────────────────────────────────\n\nexport interface UserInfo {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly permissions: Permissions\n readonly createdAt: string\n readonly grantedBy: string\n}\n\n// ─── Session ───────────────────────────────────────────────\n\n/**\n * Operations that a session policy can require re-authentication for.\n * Passed as the `requireReAuthFor` array in `SessionPolicy`.\n */\nexport type ReAuthOperation = 'export' | 'grant' | 'revoke' | 'rotate' | 'changeSecret'\n\n/**\n * Session policy controlling lifetime, re-auth requirements, and\n * background-lock behavior.\n *\n * All timeout values are in milliseconds. `undefined` means \"no limit.\"\n * The policy is evaluated lazily — it does not start timers itself;\n * enforcement happens at the Noydb call site.\n */\nexport interface SessionPolicy {\n /**\n * Idle timeout in ms. If no NOYDB operation is performed for this\n * duration, the session is revoked on the next operation attempt\n * (which will throw `SessionExpiredError`). The idle clock resets\n * on every successful operation.\n *\n * Default: `undefined` (no idle timeout).\n */\n readonly idleTimeoutMs?: number\n\n /**\n * Absolute timeout in ms from session creation. After this duration\n * the session is unconditionally revoked regardless of activity.\n *\n * Default: `undefined` (no absolute timeout).\n */\n readonly absoluteTimeoutMs?: number\n\n /**\n * Operations that require the user to re-authenticate (re-enter their\n * passphrase or perform a fresh WebAuthn assertion) before proceeding,\n * even if the session is still alive.\n *\n * Common pattern: `requireReAuthFor: ['export', 'grant']` — allow\n * read/write operations in the background but demand a fresh credential\n * for high-risk mutations.\n *\n * Default: `[]` (no extra re-auth requirements).\n */\n readonly requireReAuthFor?: readonly ReAuthOperation[]\n\n /**\n * If `true`, the session is revoked when the page goes to the background\n * (visibilitychange event, `document.hidden === true`). Useful for\n * high-sensitivity deployments where leaving the tab is treated as\n * a session boundary.\n *\n * No-op in non-browser environments (Node.js, workers without document).\n * Default: `false`.\n */\n readonly lockOnBackground?: boolean\n}\n\n// ─── i18n / Locale ─────────────────────────────────────\n\n/**\n * Locale-aware read options. Pass to `Collection.get()`, `list()`,\n * `query()`, and `scan()` to trigger per-record locale resolution for\n * `dictKey` and `i18nText` fields.\n *\n * - **`locale: 'raw'`** — skip resolution for `i18nText` fields and\n * return the full `{ [locale]: string }` map. Dict key fields still\n * return the stable key (no `<field>Label` added).\n * - **`fallback`** — single locale code or ordered list. Use `'any'` as\n * the last element to fall back to any present translation.\n *\n * When neither the call-level locale nor the compartment's default locale\n * is set, reading a record with `i18nText` fields throws\n * `LocaleNotSpecifiedError`.\n */\nexport interface LocaleReadOptions {\n /**\n * The target locale code (e.g. `'th'`), or `'raw'` to return the full\n * language map without resolution.\n */\n readonly locale?: string\n /**\n * Fallback locale or ordered fallback chain. Use `'any'` as the last\n * element to fall back to any present translation.\n */\n readonly fallback?: string | readonly string[]\n}\n\n// ─── plaintextTranslator hook ──────────────────────────────\n\n/**\n * Context passed to the consumer-supplied `plaintextTranslator` function.\n * The hook receives the source text plus enough metadata to route it to the\n * right translation service and record what it did.\n */\nexport interface PlaintextTranslatorContext {\n /** The plaintext string to translate. */\n readonly text: string\n /** BCP 47 source locale (the locale the text is written in). */\n readonly from: string\n /** BCP 47 target locale to translate into. */\n readonly to: string\n /** The schema field name that triggered the translation. */\n readonly field: string\n /** The collection the record is being put into. */\n readonly collection: string\n}\n\n/**\n * A consumer-supplied async function that translates a single string\n * from one locale to another. noy-db ships no built-in translator.\n *\n * **Security:** this function receives plaintext. The consumer is\n * responsible for the data policy of whatever service it calls. See\n * `NOYDB_SPEC.md § Zero-Knowledge Storage` and the `plaintextTranslator`\n * JSDoc on `NoydbOptions` for the full invariant statement.\n */\nexport type PlaintextTranslatorFn = (\n ctx: PlaintextTranslatorContext,\n) => Promise<string>\n\n/**\n * One entry in the in-process translator audit log. Cleared when\n * `db.close()` is called — same lifetime as the KEK and DEKs.\n *\n * Deliberately omits any content hash or translated-text fingerprint\n * to prevent correlation attacks on the audit trail.\n */\nexport interface TranslatorAuditEntry {\n readonly type: 'translator-invocation'\n /** Schema field name that was translated. */\n readonly field: string\n /** Collection the record belongs to. */\n readonly collection: string\n /** Source locale. */\n readonly fromLocale: string\n /** Target locale. */\n readonly toLocale: string\n /**\n * Consumer-provided translator name from\n * `NoydbOptions.plaintextTranslatorName`. Defaults to `'anonymous'`\n * when not supplied.\n */\n readonly translatorName: string\n /** ISO 8601 timestamp of the invocation. */\n readonly timestamp: string\n /**\n * `true` when the result was served from the in-process cache rather\n * than by calling the translator function. Present only on cache hits\n * so the absence of the field also communicates a cache miss.\n */\n readonly cached?: true\n}\n\n// ─── Presence ─────────────────────────────────────────────\n\n/**\n * A presence peer entry. `lastSeen` is an ISO timestamp set by core on each\n * `update()` call. Stale entries (lastSeen older than `staleMs`) are filtered\n * before delivering to the subscriber callback.\n */\nexport interface PresencePeer<P> {\n readonly userId: string\n readonly payload: P\n readonly lastSeen: string\n}\n\n// ─── CRDT ─────────────────────────────────────────────────\n\n// Re-exported from crdt.ts so consumers only need one import path.\nexport type { CrdtMode, CrdtState, LwwMapState, RgaState, YjsState } from './crdt/crdt.js'\n\n// ─── Blob / Attachment Store ────────────────────────\n\n/**\n * Second store shape for blob-store backends (Drive, WebDAV, Git, iCloud)\n * that operate on whole-vault bundles rather than per-record KV.\n *\n * Implement `readBundle` / `writeBundle` instead of the six-method KV\n * contract. Use `wrapBundleStore()` from `@noy-db/hub` to convert to a\n * `NoydbStore` that the rest of the API consumes transparently.\n *\n * Named `NoydbBundleStore` (not `NoydbBundleAdapter`) for consistency\n * with the hub / to-* / in-* rename. Concrete implementations ship\n * in `@noy-db/to-*` packages starting in.\n */\nexport interface NoydbBundleStore {\n /** Discriminant for engine auto-detection of store shape. */\n readonly kind: 'bundle'\n /** Human-readable name for diagnostics (e.g. `'drive'`, `'webdav'`). */\n readonly name?: string\n /**\n * Read the entire vault as raw bytes. Returns `null` if no bundle exists\n * yet (first open of a brand-new vault).\n */\n readBundle(vaultId: string): Promise<{ bytes: Uint8Array; version: string } | null>\n /**\n * Write the entire vault as raw bytes. `expectedVersion` is the version\n * token from the last `readBundle` (or `null` for a first write).\n * Implementations MUST reject the write if the stored version has advanced\n * past `expectedVersion` — throw `BundleVersionConflictError`.\n * Returns the new version token on success.\n */\n writeBundle(\n vaultId: string,\n bytes: Uint8Array,\n expectedVersion: string | null,\n ): Promise<{ version: string }>\n /** Delete a vault bundle. Idempotent — no-op if the bundle does not exist. */\n deleteBundle(vaultId: string): Promise<void>\n /** List all vault bundles managed by this store. */\n listBundles(): Promise<Array<{ vaultId: string; version: string; size: number }>>\n}\n\n/**\n * Content-addressed blob object stored in the vault-level blob index.\n * Identified by HMAC-SHA-256(blobDEK, plaintext) — opaque to the store.\n *\n * Shared across all collections within a vault for deduplication: two\n * records that attach identical byte content reference the same `eTag`\n * and share a single set of encrypted chunks in `_blob_chunks`.\n */\nexport interface BlobObject {\n /** HMAC-SHA-256 hex of the original plaintext bytes, keyed by `_blob` DEK. */\n readonly eTag: string\n /** Original uncompressed size in bytes. */\n readonly size: number\n /** Compressed size in bytes (the payload that is actually encrypted and chunked). */\n readonly compressedSize: number\n /** Compression algorithm applied before encryption. */\n readonly compression: 'gzip' | 'none'\n /** Raw chunk size in bytes used at write time. Readers MUST use this value. */\n readonly chunkSize: number\n /** Total number of chunks written. Reader expects exactly this many. */\n readonly chunkCount: number\n /** MIME type if provided or auto-detected at upload time. */\n readonly mimeType?: string\n /** ISO timestamp of first upload. */\n readonly createdAt: string\n /** Live reference count — slots + published versions pointing to this blob. */\n readonly refCount: number\n /**\n * Hint indicating which store holds the chunk data.\n * Used by `routeStore` size-tiered routing: `'default'` for small blobs\n * stored inline (e.g. DynamoDB), `'blobs'` for large blobs in the overflow\n * store (e.g. S3). Absent when no routing is configured.\n */\n readonly storeHint?: 'default' | 'blobs'\n}\n\n// ─── Attachment types ─────────────────────────────────────────\n\n/** Single attachment metadata entry stored inside a record's attachment envelope. */\nexport interface AttachmentEntry {\n /** Content-addressed identifier (HMAC-SHA-256 of plaintext). */\n readonly eTag: string\n /** User-visible filename for the slot. */\n readonly filename: string\n /** Original uncompressed size in bytes. */\n readonly size: number\n /** MIME type, if provided or auto-detected at upload time. */\n readonly mimeType?: string\n /** ISO timestamp of the upload. */\n readonly uploadedAt: string\n /** User ID of the uploader, if available. */\n readonly uploadedBy?: string\n}\n\n/** Attachment entry annotated with its slot name, as returned by `AttachmentHandle.list()`. */\nexport type AttachmentInfo = AttachmentEntry & { readonly name: string }\n\n/** Options for `AttachmentHandle.put()`. */\nexport interface AttachmentPutOptions {\n /** Compress the attachment with gzip before encryption. Default: `true`. */\n compress?: boolean\n /** Chunk size in bytes. Default: `DEFAULT_CHUNK_SIZE` (256 KB). */\n chunkSize?: number\n /** MIME type to store with the attachment. Auto-detected from magic bytes if omitted. */\n mimeType?: string\n /** User ID to record as the uploader. Falls back to the active user's ID. */\n uploadedBy?: string\n}\n\n/** Options for `AttachmentHandle.response()`. */\nexport interface AttachmentResponseOptions {\n /**\n * Set `Content-Disposition: inline` so the browser renders the file\n * instead of downloading it. Default: `false` (attachment disposition).\n */\n inline?: boolean\n}\n\n/**\n * Slot record — mutable metadata linking a named slot on a record\n * to a `BlobObject` via its eTag.\n *\n * Multiple slots (even across different records) may reference the same\n * `eTag` — the underlying chunks are shared. Updating metadata creates\n * a new envelope version (`_v++`) while the blob data is unchanged.\n */\nexport interface SlotRecord {\n /** Reference to the `BlobObject` in `_blob_index`. */\n readonly eTag: string\n /** User-visible filename for the slot. */\n readonly filename: string\n /** Original uncompressed size in bytes (denormalized from `BlobObject`). */\n readonly size: number\n /** MIME type. Takes precedence over the MIME type stored in `BlobObject`. */\n readonly mimeType?: string\n /** ISO timestamp of the upload that set this slot. */\n readonly uploadedAt: string\n /** User ID of the uploader, if available. */\n readonly uploadedBy?: string\n}\n\n/** Result of `BlobSet.list()` — slot record plus its named slot key. */\nexport interface SlotInfo extends SlotRecord {\n /** The slot name (key in the record's slot map). */\n readonly name: string\n}\n\n/**\n * Explicitly published version snapshot — an independent reference to a\n * blob at a specific point in time.\n */\nexport interface VersionRecord {\n /** User-defined label (e.g. `'issued-2025-01'`, `'amendment-2025-02'`). */\n readonly label: string\n /** eTag of the blob snapshot at publish time — independent of the current slot. */\n readonly eTag: string\n /** ISO timestamp when the version was published. */\n readonly publishedAt: string\n /** User ID of the publisher, if available. */\n readonly publishedBy?: string\n}\n\n/** Options for `BlobSet.put()`. */\nexport interface BlobPutOptions {\n /** MIME type hint. If omitted, auto-detected from magic bytes. */\n mimeType?: string\n /**\n * Raw chunk size in bytes. Priority: this value > store.maxBlobBytes > 256 KB.\n */\n chunkSize?: number\n /**\n * Whether to gzip-compress bytes before encrypting. Default: `true`.\n * Auto-set to `false` for pre-compressed MIME types (JPEG, PNG, ZIP, etc.).\n */\n compress?: boolean\n /** User ID to record as `uploadedBy`. Defaults to the Noydb session user. */\n uploadedBy?: string\n}\n\n/** Options for `BlobSet.response()` and `BlobSet.responseVersion()`. */\nexport interface BlobResponseOptions {\n /**\n * When `true`, sets `Content-Disposition: inline; filename=\"...\"` so\n * the browser renders the file in the tab. Default (`false`) sets\n * `attachment; filename=\"...\"` which triggers a download.\n */\n inline?: boolean\n /** Override the filename in the Content-Disposition header. */\n filename?: string\n}\n\n// ─── Store Capabilities ─────────────────────────────\n\nexport type StoreAuthKind =\n | 'none'\n | 'filesystem'\n | 'api-key'\n | 'iam'\n | 'oauth'\n | 'kerberos'\n | 'browser-origin'\n\nexport interface StoreAuth {\n kind: StoreAuthKind | StoreAuthKind[]\n required: boolean\n flow: 'static' | 'oauth' | 'kerberos' | 'implicit'\n}\n\nexport interface StoreCapabilities {\n /**\n * true — the store's expectedVersion check and write are atomic at the\n * storage layer. Two concurrent puts with the same expectedVersion will\n * produce exactly one success and one ConflictError.\n * false — check and write are separate operations with a race window.\n */\n casAtomic: boolean\n auth: StoreAuth\n /**\n * true — the store implements {@link NoydbStore.tx} and commits\n * every op atomically at the storage layer. The hub's\n * `db.transaction(fn)` will delegate to `tx(ops)` and surface a\n * single pass/fail outcome. false (or absent) — no native\n * multi-record atomicity; the hub falls back to per-record OCC\n * with best-effort unwind on partial failure.\n */\n txAtomic?: boolean\n /**\n * Maximum raw bytes per blob chunk record.\n * `undefined` — no limit (S3, file, IDB); blob stored as single chunk.\n * `256 * 1024` — DynamoDB (400 KB item limit minus envelope overhead).\n * `5 * 1024 * 1024` — localStorage quota safety.\n */\n maxBlobBytes?: number\n}\n\n// ─── Factory Options ───────────────────────────────────────────────────\n\nexport interface NoydbOptions {\n /** Primary store (local storage). */\n readonly store: NoydbStore\n /**\n * tree-shake seam — optional blob strategy. Pass `withBlobs()`\n * from `@noy-db/hub/blobs` to enable `collection.blob(id)` storage.\n * When omitted, hub's blob machinery stays out of the bundle (ESM\n * tree-shaking) and `collection.blob(id)` throws with a pointer at\n * the subpath. `BlobStrategy` is `@internal` — users only construct\n * it via the subpath factory.\n *\n * @internal\n */\n readonly blobStrategy?: BlobStrategy\n /**\n * tree-shake seam — optional indexing strategy. Pass\n * `withIndexing()` from `@noy-db/hub/indexing` to enable eager-mode\n * `==/in` fast-paths, lazy-mode `.lazyQuery()`, rebuild/reconcile,\n * and auto-reconcile. When omitted, indexing code never reaches the\n * bundle; `.lazyQuery()` throws with a pointer at the subpath, and\n * eager-mode collections fall back to linear scans regardless of\n * `indexes: [...]` declarations. `IndexStrategy` is `@internal` —\n * users only construct it via the subpath factory.\n *\n * @internal\n */\n readonly indexStrategy?: IndexStrategy\n /**\n * tree-shake seam — optional aggregate strategy. Pass\n * `withAggregate()` from `@noy-db/hub/aggregate` to enable\n * `.aggregate()` and `.groupBy()` on Query. When omitted, those\n * methods throw with a pointer at the subpath; the ~886 LOC of\n * Aggregation + GroupedQuery machinery never reaches the bundle.\n * Streaming `scan().aggregate()` works independently of this\n * strategy — it doesn't use the `Aggregation` class.\n *\n * @internal\n */\n readonly aggregateStrategy?: AggregateStrategy\n /**\n * tree-shake seam — optional CRDT strategy. Required when\n * any collection is declared with `crdt: 'lww-map' | 'rga' | 'yjs'`;\n * otherwise the first put/sync-merge hitting the CRDT path throws.\n * When omitted, ~221 LOC of LWW-Map / RGA / merge helpers never\n * reach the bundle.\n *\n * @internal\n */\n readonly crdtStrategy?: CrdtStrategy\n /**\n * tree-shake seam — optional consent-audit strategy. Pass\n * `withConsent()` from `@noy-db/hub/consent` to enable per-op audit\n * writes into `_consent_audit` when a consent scope is active.\n * When omitted, `vault.consentAudit()` returns `[]` and writes are\n * no-ops; the consent module's ~194 LOC never reaches the bundle.\n *\n * @internal\n */\n readonly consentStrategy?: ConsentStrategy\n /**\n * tree-shake seam — optional periods strategy. Pass\n * `withPeriods()` from `@noy-db/hub/periods` to enable\n * `vault.closePeriod()` / `.openPeriod()` / write-guard on closed\n * periods. When omitted, `vault.listPeriods()` returns `[]` and\n * the write-guard is a no-op; the ~363 LOC of period validation +\n * ledger appending stay out of the bundle.\n *\n * @internal\n */\n readonly periodsStrategy?: PeriodsStrategy\n /**\n * tree-shake seam — optional VaultFrame strategy. Pass\n * `withShadow()` from `@noy-db/hub/shadow` to enable\n * `vault.frame()`. Without it, calling `vault.frame()` throws.\n *\n * @internal\n */\n readonly shadowStrategy?: ShadowStrategy\n /**\n * tree-shake seam — optional multi-record transactions. Pass\n * `withTransactions()` from `@noy-db/hub/tx` to enable\n * `db.transaction(fn)`. Without it, calling the method throws.\n *\n * @internal\n */\n readonly txStrategy?: TxStrategy\n /**\n * tree-shake seam — optional history + ledger + time-machine.\n * Pass `withHistory()` from `@noy-db/hub/history` to enable\n * per-record version snapshots, the hash-chained audit ledger, JSON\n * Patch deltas, `vault.ledger()`, `vault.at()`, and the\n * `collection.history()` / `getVersion()` / `revert()` / `diff()` /\n * `clearHistory()` / `pruneRecordHistory()` read APIs. When omitted,\n * snapshots/prune/clear are silent no-ops, the read APIs throw with\n * a pointer at the subpath, and ~1,880 LOC stay out of the bundle.\n *\n * @internal\n */\n readonly historyStrategy?: HistoryStrategy\n /**\n * tree-shake seam — optional i18n strategy. Pass `withI18n()`\n * from `@noy-db/hub/i18n` to enable `i18nText`/`dictKey` field\n * resolution on reads, `i18nText` validation on writes, and\n * `vault.dictionary(name)`. When omitted, locale resolution is the\n * identity (raw values returned), the validators throw with a\n * pointer to the subpath, and ~854 LOC of dictionary + locale\n * machinery stay out of the bundle.\n *\n * @internal\n */\n readonly i18nStrategy?: I18nStrategy\n /**\n * tree-shake seam — optional session-policy strategy. Pass\n * `withSession()` from `@noy-db/hub/session` to enable\n * `sessionPolicy` validation, `PolicyEnforcer` lifecycle (idle /\n * absolute timeouts, lockOnBackground), and global session-token\n * revocation. When omitted, setting `sessionPolicy` throws at\n * `createNoydb()` time, and ~495 LOC of policy + token machinery\n * stay out of the bundle.\n *\n * @internal\n */\n readonly sessionStrategy?: SessionStrategy\n /**\n * tree-shake seam — optional sync engine + presence strategy.\n * Pass `withSync()` from `@noy-db/hub/sync` to enable\n * `db.push()` / `pull()` / replication, `db.transaction(vault)`\n * for sync-aware transactions, and `collection.presence()`. When\n * omitted, configuring `sync` / calling these surfaces throws with\n * a pointer at the subpath, and ~856 LOC of replication + presence\n * machinery stay out of the bundle. Keyring stays core; grant/\n * revoke/magic-link/delegation tree-shake via direct imports.\n *\n * @internal\n */\n readonly syncStrategy?: SyncStrategy\n /** Optional remote store(s) for sync. Accepts a single store, a SyncTarget, or an array. */\n readonly sync?: NoydbStore | SyncTarget | SyncTarget[]\n /** User identifier. */\n readonly user: string\n /** Passphrase for key derivation. Required unless encrypt is false or `getKeyring` is provided. */\n readonly secret?: string\n /**\n * Optional callback that returns an unlocked keyring for a given vault.\n * Use this to plug in WebAuthn / OIDC / Shamir / any unlock path that\n * produces an `UnlockedKeyring` outside the passphrase model.\n *\n * When set, `secret` MUST NOT also be set — `createNoydb` throws if both\n * are supplied. When neither is set (and `encrypt !== false`), `createNoydb`\n * also throws.\n *\n * The callback is called lazily, on the first operation that needs the\n * keyring for a given vault. Noydb caches the returned keyring per-vault\n * for the lifetime of the instance, so the callback is invoked at most\n * once per `(instance, vault)` pair (assuming the callback resolves\n * successfully). If the callback rejects, the rejection surfaces from the\n * first vault operation that triggered the unlock; subsequent operations\n * will retry the callback.\n *\n * @example\n * ```ts\n * import { createNoydb } from '@noy-db/hub'\n * import { unlockWebAuthn } from '@noy-db/on-webauthn'\n *\n * const enrollment = await loadEnrollment()\n * const db = await createNoydb({\n * store,\n * user: 'alice',\n * getKeyring: (vault) => unlockWebAuthn(enrollment),\n * })\n * ```\n *\n * Note: this callback is responsible for both the \"open existing vault\"\n * and the \"create new vault\" cases. Unlike the passphrase path, there is\n * no automatic `NoAccessError` → `createOwnerKeyring` fallback, because\n * the callback owner has the UI context to decide which path to run.\n * For first-time bootstrap, use a passphrase or recovery code, enroll\n * WebAuthn from the unlocked keyring, then swap to `getKeyring` on\n * subsequent sessions.\n */\n readonly getKeyring?: (vault: string) => Promise<UnlockedKeyring>\n /** Auth method. Default: 'passphrase'. */\n readonly auth?: 'passphrase' | 'biometric'\n /** Enable encryption. Default: true. */\n readonly encrypt?: boolean\n /** Conflict resolution strategy. Default: 'version'. */\n readonly conflict?: ConflictStrategy\n /**\n * Sync scheduling policy. Controls when push/pull fire.\n * Default inferred from store category: per-record → `on-change`,\n * bundle → `debounce 30s`.\n */\n readonly syncPolicy?: SyncPolicy\n /**\n * @deprecated Use `syncPolicy` instead. Kept for backward compatibility.\n * When both are supplied, `syncPolicy` takes precedence.\n */\n readonly autoSync?: boolean\n /**\n * @deprecated Use `syncPolicy` instead. Kept for backward compatibility.\n */\n readonly syncInterval?: number\n /**\n * Session timeout in ms. Clears keys after inactivity. Default: none.\n * @deprecated Use `sessionPolicy.idleTimeoutMs` instead. This field is\n * still honored for backwards compatibility but `sessionPolicy` takes\n * precedence when both are supplied.\n */\n readonly sessionTimeout?: number\n /**\n * Session policy controlling lifetime, re-auth requirements, and\n * background-lock behavior. When supplied, replaces the\n * legacy `sessionTimeout` field.\n */\n readonly sessionPolicy?: SessionPolicy\n /**\n * Validate passphrase strength against the phrase format\n * (`@noy-db/hub` issue #7) on first-time keyring creation. When\n * `true`, weak phrases throw {@link WeakPassphraseError} from\n * `createNoydb()` / `db.rotatePassphrase()`. Default: `false` for\n * back-compat in v0.1.x; planned to flip to `true` at v1.0.\n */\n readonly validatePassphrase?: boolean\n /**\n * Vault-level policy gate document (issue #9). When present, the hub\n * persists the merged policy at `_meta/policy` on first-time vault\n * creation and gates sensitive operations (`db.rotatePassphrase`,\n * `db.export*`, …) against it. Omitted ⇒ the engine uses\n * {@link PERSONAL_POLICY}. Use {@link STRICT_POLICY} for regulated\n * deployments.\n *\n * The on-disk document is the source of truth — the policy field\n * is only honored at vault creation; subsequent runs read from\n * `_meta/policy`. Use `db.updatePolicy()` to change it deliberately.\n *\n * Imported from `@noy-db/hub` as a type-only reference; the runtime\n * import lives in `policy/index.ts`.\n */\n readonly policy?: VaultPolicy\n /**\n * Mandatory recovery profile enrollment (issue #10). Vaults with\n * `recover-passphrase` enabled MUST register at least one profile\n * before being production-ready, otherwise `createNoydb()` throws\n * {@link RecoveryNotEnrolledError}. Set\n * `policy.gates['recover-passphrase'].enabled = false` to\n * deliberately opt out of recovery (passphrase loss = data loss).\n *\n * v0.1.0-pre.5 supports the `'paper'` profile end-to-end. Other\n * profiles ship the API shape and throw\n * {@link RecoveryProfileNotImplementedError} during use.\n */\n readonly recovery?: ReadonlyArray<RecoveryEnrollment>\n /**\n * When `true`, `createNoydb` rejects vaults with no recovery\n * entries persisted (per the spec's mandatory-enrollment\n * requirement). Default `false` for v0.1.x back-compat; planned to\n * flip to `true` at v1.0. Apps in regulated environments should\n * turn this on now.\n */\n readonly requireRecovery?: boolean\n /**\n * What to do when `openVault` finds an existing keyring in the store that\n * cannot be decrypted with the supplied credentials (`InvalidKeyError`).\n *\n * - `'error'` (default) — propagate the error. The app must prompt the user\n * to supply the correct credentials or clear both the data and auth stores.\n * - `'reset'` — delete the stale keyring and re-initialise the vault from\n * scratch using the current credentials. Use this when the data store can\n * become detached from the auth store (e.g. the user cleared the IndexedDB\n * data records but not the keyring row, or a WebAuthn credential was rotated).\n * **All previously encrypted data is unrecoverable after a reset.**\n *\n * Only applies to the passphrase (`secret`) path. When `getKeyring` is used,\n * the callback is responsible for handling stale-keyring detection itself.\n */\n readonly onInvalidKey?: 'error' | 'reset'\n /**\n * Enable the public envelope subsystem (`docs/subsystems/public-envelope.md`).\n * Pass `true` for the default schema (every standard field, 256 KB\n * icon cap, 200-char text cap), or a `PublicEnvelopeSchema` to\n * narrow what the owner can set. Off by default — vaults written\n * by hubs without this option carry no envelope, full stop.\n */\n readonly publicEnvelope?: true | PublicEnvelopeSchema\n /** Audit history configuration. */\n readonly history?: HistoryConfig\n /**\n * Consumer-supplied translation function for `i18nText` fields with\n * `autoTranslate: true`.\n *\n * ⚠ **`plaintextTranslator` receives unencrypted text.** Configuring\n * this hook causes plaintext to leave noy-db's zero-knowledge boundary\n * over whatever channel the consumer's implementation uses. noy-db ships\n * no built-in translator and adds no translator SDKs as dependencies.\n * The consumer chooses and owns the data policy of the external service.\n *\n * Per-field opt-in via `autoTranslate: true` on `i18nText()`. Calling\n * `put()` on a collection with `autoTranslate: true` fields while this\n * option is absent throws `TranslatorNotConfiguredError`.\n *\n * See `NOYDB_SPEC.md § Zero-Knowledge Storage` for the invariant text.\n */\n readonly plaintextTranslator?: PlaintextTranslatorFn\n /**\n * Human-readable name for the translator, recorded in the in-process\n * audit log (e.g. `'deepl-pro-with-dpa'`, `'self-hosted-llama-7b'`).\n * Defaults to `'anonymous'` when not supplied.\n */\n readonly plaintextTranslatorName?: string\n}\n\n// ─── History / Audit Trail ─────────────────────────────────────────────\n\n/** History configuration. */\nexport interface HistoryConfig {\n /** Enable history tracking. Default: true. */\n readonly enabled?: boolean\n /** Maximum history entries per record. Oldest pruned on overflow. Default: unlimited. */\n readonly maxVersions?: number\n}\n\n/** Options for querying history. */\nexport interface HistoryOptions {\n /** Start date (inclusive), ISO 8601. */\n readonly from?: string\n /** End date (inclusive), ISO 8601. */\n readonly to?: string\n /** Maximum entries to return. */\n readonly limit?: number\n}\n\n/** Options for pruning history. */\nexport interface PruneOptions {\n /** Keep only the N most recent versions. */\n readonly keepVersions?: number\n /** Delete versions older than this date, ISO 8601. */\n readonly beforeDate?: string\n}\n\n/** A decrypted history entry. */\nexport interface HistoryEntry<T> {\n readonly version: number\n readonly timestamp: string\n readonly userId: string\n readonly record: T\n}\n\n// ─── Bulk operations ──────────────────────────────────────\n\n/** Per-item options for `Collection.putMany()`. */\nexport interface PutManyItemOptions {\n /**\n * Optimistic-concurrency check: fail this item if the stored version\n * is not `expectedVersion`. Honored only in `atomic: true` mode;\n * ignored in the default best-effort loop.\n */\n readonly expectedVersion?: number\n}\n\n/**\n * Batch-level options for `Collection.putMany()` and `deleteMany()`.\n *\n * `atomic: true` switches the call from best-effort loop\n * to all-or-nothing: a pre-flight CAS check runs first, then every op\n * is executed; any mid-batch failure triggers a best-effort revert.\n * On failure in atomic mode the whole call throws — you won't get a\n * partial `PutManyResult`. On success the result mirrors the default\n * loop's shape.\n */\nexport interface PutManyOptions {\n readonly atomic?: boolean\n}\n\n/** Result of `Collection.putMany()`. */\nexport interface PutManyResult {\n /** `true` iff every entry succeeded. */\n readonly ok: boolean\n /** IDs that were successfully written. */\n readonly success: readonly string[]\n /** Entries that failed, with the error that prevented each write. */\n readonly failures: ReadonlyArray<{ readonly id: string; readonly error: Error }>\n}\n\n/** Result of `Collection.deleteMany()`. Same shape as `PutManyResult`. */\nexport interface DeleteManyResult {\n readonly ok: boolean\n readonly success: readonly string[]\n readonly failures: ReadonlyArray<{ readonly id: string; readonly error: Error }>\n}\n","/**\n * All NOYDB error classes — a single import surface for `catch` blocks and\n * `instanceof` checks.\n *\n * ## Class hierarchy\n *\n * ```\n * Error\n * └─ NoydbError (code: string)\n * ├─ Crypto errors\n * │ ├─ DecryptionError — AES-GCM tag failure\n * │ ├─ TamperedError — ciphertext modified after write\n * │ └─ InvalidKeyError — wrong passphrase / corrupt keyring\n * ├─ Access errors\n * │ ├─ NoAccessError — no DEK for this collection\n * │ ├─ ReadOnlyError — ro permission, write attempted\n * │ ├─ PermissionDeniedError — role too low for operation\n * │ ├─ PrivilegeEscalationError — grant wider than grantor holds\n * │ └─ StoreCapabilityError — optional store method missing\n * ├─ Sync errors\n * │ ├─ ConflictError — optimistic-lock version mismatch\n * │ ├─ BundleVersionConflictError — bundle push rejected by remote\n * │ └─ NetworkError — push/pull network failure\n * ├─ Data errors\n * │ ├─ NotFoundError — get(id) on missing record\n * │ ├─ ValidationError — application-level guard failed\n * │ └─ SchemaValidationError — Standard Schema v1 rejection\n * ├─ Query errors\n * │ ├─ JoinTooLargeError — join row ceiling exceeded\n * │ ├─ DanglingReferenceError — strict ref() points at nothing\n * │ ├─ GroupCardinalityError — groupBy bucket cap exceeded\n * │ ├─ IndexRequiredError — lazy-mode query touches unindexed field\n * │ └─ IndexWriteFailureError — index side-car put/delete failed post-main\n * ├─ i18n / Dictionary errors\n * │ ├─ ReservedCollectionNameError\n * │ ├─ DictKeyMissingError\n * │ ├─ DictKeyInUseError\n * │ ├─ MissingTranslationError\n * │ ├─ LocaleNotSpecifiedError\n * │ └─ TranslatorNotConfiguredError\n * ├─ Backup errors\n * │ ├─ BackupLedgerError — hash-chain verification failed\n * │ └─ BackupCorruptedError — envelope hash mismatch in dump\n * ├─ Bundle errors\n * │ └─ BundleIntegrityError — .noydb body sha256 mismatch\n * └─ Session errors\n * ├─ SessionExpiredError\n * ├─ SessionNotFoundError\n * └─ SessionPolicyError\n * ```\n *\n * ## Catching all NOYDB errors\n *\n * ```ts\n * import { NoydbError, InvalidKeyError, ConflictError } from '@noy-db/hub'\n *\n * try {\n * await vault.unlock(passphrase)\n * } catch (e) {\n * if (e instanceof InvalidKeyError) { showBadPassphraseUI(); return }\n * if (e instanceof NoydbError) { logToSentry(e.code, e); return }\n * throw e // unexpected — re-throw\n * }\n * ```\n *\n * @module\n */\n\n/**\n * Base class for all NOYDB errors.\n *\n * Every error thrown by `@noy-db/hub` extends this class, so consumers can\n * catch all NOYDB errors in a single `catch (e) { if (e instanceof NoydbError) ... }`\n * block. The `code` field is a machine-readable string (e.g. `'DECRYPTION_FAILED'`)\n * suitable for `switch` statements and logging pipelines.\n */\nexport class NoydbError extends Error {\n /** Machine-readable error code. Stable across library versions. */\n readonly code: string\n\n constructor(code: string, message: string) {\n super(message)\n this.name = 'NoydbError'\n this.code = code\n }\n}\n\n// ─── Crypto Errors ─────────────────────────────────────────────────────\n\n/**\n * Thrown when AES-GCM decryption fails.\n *\n * The most common cause is a wrong passphrase or a corrupted ciphertext.\n * A `DecryptionError` at the wrong passphrase level is caught internally\n * and re-thrown as `InvalidKeyError` — so in practice this surfaces for\n * per-record corruption rather than authentication failures.\n */\nexport class DecryptionError extends NoydbError {\n constructor(message = 'Decryption failed') {\n super('DECRYPTION_FAILED', message)\n this.name = 'DecryptionError'\n }\n}\n\n/**\n * Thrown when GCM tag verification fails, indicating the ciphertext was\n * modified after encryption.\n *\n * AES-256-GCM is authenticated encryption — the tag over the ciphertext\n * is checked on every decrypt. If any byte was flipped (accidental\n * corruption or deliberate tampering), decryption throws this error.\n * Treat it as a security alert: the stored bytes are not what NOYDB wrote.\n */\nexport class TamperedError extends NoydbError {\n constructor(message = 'Data integrity check failed — record may have been tampered with') {\n super('TAMPERED', message)\n this.name = 'TamperedError'\n }\n}\n\n/**\n * Thrown when key unwrapping fails, typically because the passphrase is wrong\n * or the keyring file is corrupted.\n *\n * NOYDB uses AES-KW (RFC 3394) to wrap DEKs with the KEK. If AES-KW\n * unwrapping fails, it means either the KEK was derived from the wrong\n * passphrase (PBKDF2 with 600K iterations) or the keyring bytes are\n * corrupted. This is the error shown to the user on a failed unlock attempt.\n */\nexport class InvalidKeyError extends NoydbError {\n constructor(message = 'Invalid key — wrong passphrase or corrupted keyring') {\n super('INVALID_KEY', message)\n this.name = 'InvalidKeyError'\n }\n}\n\n// ─── Access Errors ─────────────────────────────────────────────────────\n\n/**\n * Thrown when the authenticated user does not have a DEK for the requested\n * collection — i.e. the collection is not in their keyring at all.\n *\n * This is the \"no key for this door\" error. It is different from\n * `ReadOnlyError` (user has a key but it only grants ro) and from\n * `PermissionDeniedError` (user's role doesn't allow the operation).\n */\nexport class NoAccessError extends NoydbError {\n constructor(message = 'No access — user does not have a key for this collection') {\n super('NO_ACCESS', message)\n this.name = 'NoAccessError'\n }\n}\n\n/**\n * Thrown when a user with read-only (`ro`) permission attempts a write\n * operation (`put` or `delete`) on a collection.\n *\n * The user has a DEK for the collection (they can decrypt and read), but\n * their keyring grants only `ro`. To fix: re-grant the user with `rw`\n * permission, or do not attempt writes as a viewer/client role.\n */\nexport class ReadOnlyError extends NoydbError {\n constructor(message = 'Read-only — user has ro permission on this collection') {\n super('READ_ONLY', message)\n this.name = 'ReadOnlyError'\n }\n}\n\n/**\n * Thrown when a write is attempted against a historical view produced\n * by `vault.at(timestamp)`. Time-machine views are read-only by\n * contract — mutating the past would require either the shadow-vault\n * mechanism or a ledger-history rewrite (which breaks\n * the tamper-evidence guarantee).\n *\n * Distinct from {@link ReadOnlyError} (keyring-level) and\n * {@link PermissionDeniedError} (role-level): this error is about the\n * *view* being historical, independent of the caller's permissions.\n */\nexport class ReadOnlyAtInstantError extends NoydbError {\n constructor(operation: string, timestamp: string) {\n super(\n 'READ_ONLY_AT_INSTANT',\n `Cannot ${operation}() on a vault view anchored at ${timestamp} — time-machine views are read-only`,\n )\n this.name = 'ReadOnlyAtInstantError'\n }\n}\n\n/**\n * Thrown when a write is attempted against a shadow-vault frame\n * produced by `vault.frame()`. Frames are read-only by contract —\n * the use case is screen-sharing / demos / compliance review where\n * the operator wants to prevent accidental edits.\n *\n * Behavioural enforcement only — the underlying keyring still holds\n * write-capable DEKs. See {@link VaultFrame} for the full caveat.\n */\nexport class ReadOnlyFrameError extends NoydbError {\n constructor(operation: string) {\n super(\n 'READ_ONLY_FRAME',\n `Cannot ${operation}() on a vault frame — frames are read-only presentations of the current vault`,\n )\n this.name = 'ReadOnlyFrameError'\n }\n}\n\n/**\n * Thrown when the authenticated user's role does not permit the requested\n * operation — e.g. a `viewer` calling `grantAccess()`, or an `operator`\n * calling `rotateKeys()`.\n *\n * This is a role-level check (what the user's role allows), distinct from\n * `NoAccessError` (collection not in keyring) and `ReadOnlyError` (in\n * keyring, but write not allowed).\n */\nexport class PermissionDeniedError extends NoydbError {\n constructor(message = 'Permission denied — insufficient role for this operation') {\n super('PERMISSION_DENIED', message)\n this.name = 'PermissionDeniedError'\n }\n}\n\n/**\n * Thrown when an `@noy-db/as-*` export is attempted without the\n * required capability bit on the invoking keyring.\n *\n * Two sub-cases discriminated by the `tier` field:\n *\n * - `tier: 'plaintext'` — a plaintext-tier export (`as-xlsx`,\n * `as-csv`, `as-blob`, `as-zip`, …) was attempted but the\n * keyring's `exportCapability.plaintext` does not include the\n * requested `format` (nor the `'*'` wildcard). Default for every\n * role is `plaintext: []` — the owner must positively grant.\n * - `tier: 'bundle'` — an encrypted `as-noydb` bundle export was\n * attempted but the keyring's `exportCapability.bundle` is\n * `false`. Default for `owner`/`admin` is `true`; for\n * `operator`/`viewer`/`client` it is `false`.\n *\n * Distinct from `PermissionDeniedError` (role-level check) and\n * `NoAccessError` (collection not readable). Surfaces separately so\n * UI layers can show a \"request the export capability from your\n * admin\" flow rather than a generic permission error.\n */\nexport class ExportCapabilityError extends NoydbError {\n readonly tier: 'plaintext' | 'bundle'\n readonly format?: string\n readonly userId: string\n\n constructor(opts: {\n tier: 'plaintext' | 'bundle'\n userId: string\n format?: string\n message?: string\n }) {\n const msg =\n opts.message ??\n (opts.tier === 'plaintext'\n ? `Export capability denied — keyring \"${opts.userId}\" is not granted plaintext-export capability for format \"${opts.format ?? '<unknown>'}\". Ask a vault owner or admin to grant it via vault.grant({ exportCapability: { plaintext: ['${opts.format ?? '<format>'}'] } }).`\n : `Export capability denied — keyring \"${opts.userId}\" is not granted encrypted-bundle export capability. Ask a vault owner or admin to grant it via vault.grant({ exportCapability: { bundle: true } }).`)\n super('EXPORT_CAPABILITY', msg)\n this.name = 'ExportCapabilityError'\n this.tier = opts.tier\n this.userId = opts.userId\n if (opts.format !== undefined) this.format = opts.format\n }\n}\n\n/**\n * Thrown when a keyring file's `expires_at` cutoff has passed.\n * Surfaced by `loadKeyring` before any DEK unwrap is attempted —\n * past the cutoff the slot refuses to open even with the right\n * passphrase. Distinct from PBKDF2 / unwrap errors so consumer code\n * can show a precise \"this bundle slot has expired\" message instead\n * of the generic decryption-failure UX.\n *\n * Used predominantly on `BundleRecipient` slots produced by\n * `writeNoydbBundle({ recipients: [...] })` to time-box audit access.\n */\nexport class KeyringExpiredError extends NoydbError {\n readonly userId: string\n readonly expiresAt: string\n constructor(opts: { userId: string; expiresAt: string }) {\n super(\n 'KEYRING_EXPIRED',\n `Keyring \"${opts.userId}\" expired at ${opts.expiresAt}. ` +\n 'The slot refuses to unlock past its expiry timestamp.',\n )\n this.name = 'KeyringExpiredError'\n this.userId = opts.userId\n this.expiresAt = opts.expiresAt\n }\n}\n\n/**\n * Thrown when an `@noy-db/as-*` import is attempted but the invoking\n * keyring lacks the required import-capability bit (issue ).\n *\n * - `tier: 'plaintext'` — a plaintext-tier import (`as-csv`, `as-json`,\n * `as-ndjson`, `as-zip`, …) was attempted but the keyring's\n * `importCapability.plaintext` does not include the requested\n * `format` (nor the `'*'` wildcard).\n * - `tier: 'bundle'` — a `.noydb` bundle import was attempted but the\n * keyring's `importCapability.bundle` is not `true`.\n *\n * Default for every role on every dimension is closed — owners and\n * admins must positively grant the capability. Distinct from\n * `PermissionDeniedError` and `NoAccessError` so UI layers can show a\n * specific \"request the import capability\" flow.\n */\nexport class ImportCapabilityError extends NoydbError {\n readonly tier: 'plaintext' | 'bundle'\n readonly format?: string\n readonly userId: string\n\n constructor(opts: {\n tier: 'plaintext' | 'bundle'\n userId: string\n format?: string\n message?: string\n }) {\n const msg =\n opts.message ??\n (opts.tier === 'plaintext'\n ? `Import capability denied — keyring \"${opts.userId}\" is not granted plaintext-import capability for format \"${opts.format ?? '<unknown>'}\". Ask a vault owner or admin to grant it via vault.grant({ importCapability: { plaintext: ['${opts.format ?? '<format>'}'] } }).`\n : `Import capability denied — keyring \"${opts.userId}\" is not granted encrypted-bundle import capability. Ask a vault owner or admin to grant it via vault.grant({ importCapability: { bundle: true } }).`)\n super('IMPORT_CAPABILITY', msg)\n this.name = 'ImportCapabilityError'\n this.tier = opts.tier\n this.userId = opts.userId\n if (opts.format !== undefined) this.format = opts.format\n }\n}\n\n/**\n * Thrown when a grant would give the grantee a permission the grantor\n * does not themselves hold — the \"admin cannot grant what admin cannot\n * do\" rule from the admin-delegation work.\n *\n * Distinct from `PermissionDeniedError` so callers can tell the two\n * cases apart in logs and tests:\n *\n * - `PermissionDeniedError` — \"you are not allowed to perform this\n * operation at all\" (wrong role).\n * - `PrivilegeEscalationError` — \"you are allowed to grant, but not\n * with these specific permissions\" (widening attempt).\n *\n * Under the admin model the grantee of an admin-grants-admin call\n * inherits the caller's entire DEK set by construction, so this error\n * is structurally unreachable in typical flows. The check and error\n * class exist so that future per-collection admin scoping cannot\n * accidentally bypass the subset rule — the guard is already wired in.\n *\n * `offendingCollection` carries the first collection name that failed\n * the subset check, to make the violation actionable in error output.\n */\n/**\n * Thrown when a caller invokes an API that requires an optional\n * store capability the active store does not implement.\n *\n * Today the only call site is `Noydb.listAccessibleVaults()`,\n * which depends on the optional `NoydbStore.listVaults()`\n * method. The error message names the missing method and the calling\n * API so consumers know exactly which combination is unsupported,\n * and the `capability` field is machine-readable so library code can\n * pattern-match in catch blocks (e.g. fall back to a candidate-list\n * shape).\n *\n * The class lives in `errors.ts` rather than as a generic\n * `ValidationError` because the diagnostic shape is different: a\n * `ValidationError` says \"the inputs you passed are wrong\"; this\n * error says \"the inputs are fine, but the store you wired up\n * doesn't support what you're asking for.\" Different fix, different\n * documentation.\n */\nexport class StoreCapabilityError extends NoydbError {\n /** The store method/capability that was missing. */\n readonly capability: string\n\n constructor(capability: string, callerApi: string, storeName?: string) {\n super(\n 'STORE_CAPABILITY',\n `${callerApi} requires the optional store capability \"${capability}\" ` +\n `but the active store${storeName ? ` (${storeName})` : ''} does not implement it. ` +\n `Use a store that supports \"${capability}\" (store-memory, store-file) or pass an explicit ` +\n `vault list to bypass enumeration.`,\n )\n this.name = 'StoreCapabilityError'\n this.capability = capability\n }\n}\n\nexport class PrivilegeEscalationError extends NoydbError {\n readonly offendingCollection: string\n\n constructor(offendingCollection: string, message?: string) {\n super(\n 'PRIVILEGE_ESCALATION',\n message ??\n `Privilege escalation: grantor has no DEK for collection \"${offendingCollection}\" and cannot grant access to it.`,\n )\n this.name = 'PrivilegeEscalationError'\n this.offendingCollection = offendingCollection\n }\n}\n\n/**\n * Thrown by `Collection.put` / `.delete` when the target record's\n * envelope `_ts` falls within a closed accounting period.\n *\n * Distinct from `ReadOnlyError` (keyring-level), `ReadOnlyAtInstantError`\n * (historical view), and `ReadOnlyFrameError` (shadow vault): this\n * error is about the STORED RECORD being sealed by an operator call\n * to `vault.closePeriod()`, independent of caller permissions or\n * view type. The `periodName` and `endDate` fields name the sealing\n * period so audit UIs can surface a \"this record is locked in\n * FY2026-Q1 (closed 2026-03-31)\" message without parsing the error\n * string.\n *\n * To apply a correction after close, book a compensating entry in a\n * new period rather than unlocking the old one. Re-opening a closed\n * period is deliberately unsupported.\n */\nexport class PeriodClosedError extends NoydbError {\n readonly periodName: string\n readonly endDate: string\n readonly recordTs: string\n\n constructor(periodName: string, endDate: string, recordTs: string) {\n super(\n 'PERIOD_CLOSED',\n `Cannot modify record (last written ${recordTs}) — sealed by closed period ` +\n `\"${periodName}\" (endDate: ${endDate}). Post a compensating entry in a ` +\n `new period instead.`,\n )\n this.name = 'PeriodClosedError'\n this.periodName = periodName\n this.endDate = endDate\n this.recordTs = recordTs\n }\n}\n\n// ─── Hierarchical Access Errors ─────────────────────\n\n/**\n * Thrown when a user tries to act at a tier they are not cleared for.\n *\n * This is the umbrella error for tier write refusals:\n * - `put({ tier: N })` when the user's keyring lacks tier-N DEK.\n * - `elevate(id, N)` when the caller cannot reach tier N.\n *\n * Distinct from `TierAccessDeniedError` which covers *read* refusals on\n * the invisibility/ghost path.\n */\nexport class TierNotGrantedError extends NoydbError {\n readonly tier: number\n readonly collection: string\n\n constructor(collection: string, tier: number) {\n super(\n 'TIER_NOT_GRANTED',\n `User has no DEK for tier ${tier} in collection \"${collection}\"`,\n )\n this.name = 'TierNotGrantedError'\n this.collection = collection\n this.tier = tier\n }\n}\n\n/**\n * Thrown when an elevated-handle operation runs after the elevation's\n * TTL expired. Reads continue at the original tier; only writes\n * through the scoped handle flip to throwing once expired.\n */\nexport class ElevationExpiredError extends NoydbError {\n readonly tier: number\n readonly expiresAt: number\n\n constructor(opts: { tier: number; expiresAt: number }) {\n super(\n 'ELEVATION_EXPIRED',\n `Elevation to tier ${opts.tier} expired at ${new Date(opts.expiresAt).toISOString()}`,\n )\n this.name = 'ElevationExpiredError'\n this.tier = opts.tier\n this.expiresAt = opts.expiresAt\n }\n}\n\n/**\n * Thrown by `vault.elevate(...)` when an elevation is already active\n * on the vault. Adopters must `release()` the existing handle before\n * starting a new elevation.\n */\nexport class AlreadyElevatedError extends NoydbError {\n readonly activeTier: number\n\n constructor(activeTier: number) {\n super(\n 'ALREADY_ELEVATED',\n `Vault is already elevated to tier ${activeTier}; release the existing handle first`,\n )\n this.name = 'AlreadyElevatedError'\n this.activeTier = activeTier\n }\n}\n\n/**\n * Thrown when `demote()` is called by someone who is not the original\n * elevator and not an owner.\n */\nexport class TierDemoteDeniedError extends NoydbError {\n constructor(id: string, tier: number) {\n super(\n 'TIER_DEMOTE_DENIED',\n `Only the original elevator or an owner can demote record \"${id}\" from tier ${tier}`,\n )\n this.name = 'TierDemoteDeniedError'\n }\n}\n\n/**\n * Thrown when `db.delegate()` is called against a user that has no\n * keyring in the target vault — the delegation token cannot be\n * constructed without the target user's KEK wrap.\n */\nexport class DelegationTargetMissingError extends NoydbError {\n readonly toUser: string\n\n constructor(toUser: string) {\n super(\n 'DELEGATION_TARGET_MISSING',\n `Delegation target user \"${toUser}\" has no keyring in this vault`,\n )\n this.name = 'DelegationTargetMissingError'\n this.toUser = toUser\n }\n}\n\n// ─── Sync Errors ───────────────────────────────────────────────────────\n\n/**\n * Thrown when a `put()` detects an optimistic concurrency conflict.\n *\n * NOYDB uses version numbers (`_v`) for optimistic locking. If a `put()`\n * is called with `expectedVersion: N` but the stored record is at version\n * `M ≠ N`, the write is rejected and the caller must re-read, re-apply their\n * change, and retry. The `version` field carries the actual stored version\n * so callers can decide whether to retry or surface the conflict to the user.\n */\nexport class ConflictError extends NoydbError {\n /** The actual stored version at the time of conflict. */\n readonly version: number\n\n constructor(version: number, message = 'Version conflict') {\n super('CONFLICT', message)\n this.name = 'ConflictError'\n this.version = version\n }\n}\n\n/**\n * Thrown by `LedgerStore.append()` after exhausting its CAS retry\n * budget under multi-writer contention. Two browser tabs, a\n * web app + an offline mobile peer, or a server worker pool all\n * producing ledger entries against the same vault can race on the\n * \"read head, write head+1\" cycle; the optimistic-CAS retry loop\n * resolves the race for `casAtomic: true` stores, but pathological\n * contention (or a buggy peer) can still exhaust the budget. When\n * that happens, the chain is intact — the failed writer simply\n * couldn't claim a slot. Caller's choice whether to retry, queue,\n * or surface the failure to the user.\n */\nexport class LedgerContentionError extends NoydbError {\n readonly attempts: number\n\n constructor(attempts: number) {\n super(\n 'LEDGER_CONTENTION',\n `LedgerStore.append: failed to claim a chain slot after ${attempts} optimistic-CAS retries`,\n )\n this.name = 'LedgerContentionError'\n this.attempts = attempts\n }\n}\n\n/**\n * Thrown when a bundle push is rejected because the remote has been updated\n * since the local bundle was last pulled.\n *\n * Unlike `ConflictError` (per-record), this is a whole-bundle conflict —\n * the remote's bundle handle has changed. The caller must pull the new\n * bundle, merge, and re-push. `remoteVersion` is the handle of the newer\n * remote bundle for use in diagnostics.\n */\nexport class BundleVersionConflictError extends NoydbError {\n /** The bundle handle of the newer remote version that rejected the push. */\n readonly remoteVersion: string\n\n constructor(remoteVersion: string, message = 'Bundle version conflict — remote has been updated') {\n super('BUNDLE_VERSION_CONFLICT', message)\n this.name = 'BundleVersionConflictError'\n this.remoteVersion = remoteVersion\n }\n}\n\n/**\n * Thrown when a sync operation (push or pull) fails due to a network error.\n *\n * NOYDB's offline-first design means network errors are expected during sync.\n * Callers should catch `NetworkError`, surface connectivity status in the UI,\n * and rely on the `SyncScheduler` to retry when connectivity is restored.\n */\nexport class NetworkError extends NoydbError {\n constructor(message = 'Network error') {\n super('NETWORK_ERROR', message)\n this.name = 'NetworkError'\n }\n}\n\n// ─── Data Errors ───────────────────────────────────────────────────────\n\n/**\n * Thrown when `collection.get(id)` is called with an ID that does not exist.\n *\n * NOYDB collections are memory-first, so this error is synchronous and cheap —\n * it does not make a network round-trip. Callers that expect the record to be\n * absent should use `collection.getOrNull(id)` instead.\n */\nexport class NotFoundError extends NoydbError {\n constructor(message = 'Record not found') {\n super('NOT_FOUND', message)\n this.name = 'NotFoundError'\n }\n}\n\n/**\n * Thrown when application-level validation fails before encryption.\n *\n * Distinct from `SchemaValidationError` (Standard Schema v1 validator)\n * and `MissingTranslationError` (i18nText). `ValidationError` is the\n * general-purpose validation base — use it for custom guards in `put()`\n * hooks or store middleware.\n */\nexport class ValidationError extends NoydbError {\n constructor(message = 'Validation error') {\n super('VALIDATION_ERROR', message)\n this.name = 'ValidationError'\n }\n}\n\n/**\n * Thrown when a Standard Schema v1 validator rejects a record on\n * `put()` (input validation) or on read (output validation). Carries\n * the raw issue list so callers can render field-level errors.\n *\n * `direction` distinguishes the two cases:\n * - `'input'`: the user passed bad data into `put()`. This is a\n * normal error case that application code should handle — typically\n * by showing validation messages in the UI.\n * - `'output'`: stored data does not match the current schema. This\n * indicates a schema drift (the schema was changed without\n * migrating the existing records) and should be treated as a bug\n * — the application should not swallow it silently.\n *\n * The `issues` type is deliberately `readonly unknown[]` on this class\n * so that `errors.ts` doesn't need to import from `schema.ts` (and\n * create a dependency cycle). Callers who know they're holding a\n * `SchemaValidationError` can cast to the more precise\n * `readonly StandardSchemaV1Issue[]` from `schema.ts`.\n */\nexport class SchemaValidationError extends NoydbError {\n readonly issues: readonly unknown[]\n readonly direction: 'input' | 'output'\n\n constructor(\n message: string,\n issues: readonly unknown[],\n direction: 'input' | 'output',\n ) {\n super('SCHEMA_VALIDATION_FAILED', message)\n this.name = 'SchemaValidationError'\n this.issues = issues\n this.direction = direction\n }\n}\n\n// ─── Query DSL Errors ─────────────────────────────────────────────────\n\n/**\n * Thrown when `.groupBy().aggregate()` produces more than the hard\n * cardinality cap (default 100_000 groups)..\n *\n * The cap exists because `.groupBy()` materializes one bucket per\n * distinct key value in memory, and runaway cardinality — a groupBy\n * on a high-uniqueness field like `id` or `createdAt` — is almost\n * always a query mistake rather than legitimate use. A hard error is\n * better than silent OOM: the consumer sees an actionable message\n * naming the field and the observed cardinality, with guidance to\n * either narrow the query with `.where()` or accept the ceiling\n * override.\n *\n * A separate one-shot warning fires at 10% of the cap (10_000\n * groups) so consumers get a heads-up before the hard error — same\n * pattern as `JoinTooLargeError` and the `.join()` row ceiling.\n *\n * **Not overridable in.** The 100k cap is a fixed constant so\n * the failure mode is consistent across the codebase; a\n * `{ maxGroups }` override can be added later without a break if a\n * real consumer asks.\n */\nexport class GroupCardinalityError extends NoydbError {\n /** The field being grouped on. */\n readonly field: string\n /** Observed number of distinct groups at the moment the cap tripped. */\n readonly cardinality: number\n /** The cap that was exceeded. */\n readonly maxGroups: number\n\n constructor(field: string, cardinality: number, maxGroups: number) {\n super(\n 'GROUP_CARDINALITY',\n `.groupBy(\"${field}\") produced ${cardinality} distinct groups, ` +\n `exceeding the ${maxGroups}-group ceiling. This is almost always a ` +\n `query mistake — grouping on a high-uniqueness field like \"id\" or ` +\n `\"createdAt\" produces one bucket per record. Narrow the query with ` +\n `.where() before grouping, or group on a lower-cardinality field ` +\n `(status, category, clientId). If you genuinely need high-cardinality ` +\n `grouping, file an issue with your use case.`,\n )\n this.name = 'GroupCardinalityError'\n this.field = field\n this.cardinality = cardinality\n this.maxGroups = maxGroups\n }\n}\n\n/**\n * Thrown in lazy mode when a `.query()` / `.where()` / `.orderBy()` clause\n * references a field that does not have a declared index.\n *\n * Lazy-mode queries only work when every touched field is indexed.\n * This is deliberate — silent scan-fallback would hide the performance\n * cliff that lazy-mode indexes exist to prevent.\n *\n * Payload:\n * - `collection` — name of the collection queried\n * - `touchedFields` — every field referenced by the query (filter + order)\n * - `missingFields` — subset of `touchedFields` that have no declared index\n */\nexport class IndexRequiredError extends NoydbError {\n readonly collection: string\n readonly touchedFields: readonly string[]\n readonly missingFields: readonly string[]\n\n constructor(args: { collection: string; touchedFields: readonly string[]; missingFields: readonly string[] }) {\n super(\n 'INDEX_REQUIRED',\n `Collection \"${args.collection}\": query references unindexed fields in lazy mode ` +\n `(missing: ${args.missingFields.join(', ')}). ` +\n `Declare an index on each field, or use collection.scan() for non-indexed iteration.`,\n )\n this.name = 'IndexRequiredError'\n this.collection = args.collection\n this.touchedFields = [...args.touchedFields]\n this.missingFields = [...args.missingFields]\n }\n}\n\n/**\n * Thrown (or surfaced via the `index:write-partial` event) when one or more\n * per-indexed-field side-car writes fail after the main record write has\n * already succeeded.\n *\n * Not thrown out of `.put()` / `.delete()` directly — those succeed when the\n * main record succeeds. Instead, `IndexWriteFailureError` instances are collected\n * into the session-scoped reconcile queue and emitted on the Collection\n * emitter as `index:write-partial`.\n *\n * Payload:\n * - `recordId` — the id of the main record whose side-car writes failed\n * - `field` — the indexed field whose side-car write failed\n * - `op` — `'put'` or `'delete'`, indicating which mutation was in flight\n * - `cause` — the underlying error from the store\n */\nexport class IndexWriteFailureError extends NoydbError {\n readonly recordId: string\n readonly field: string\n readonly op: 'put' | 'delete'\n override readonly cause: unknown\n\n constructor(args: { recordId: string; field: string; op: 'put' | 'delete'; cause: unknown }) {\n super(\n 'INDEX_WRITE_FAILURE',\n `Index side-car ${args.op} failed for field \"${args.field}\" on record \"${args.recordId}\"`,\n )\n this.name = 'IndexWriteFailureError'\n this.recordId = args.recordId\n this.field = args.field\n this.op = args.op\n this.cause = args.cause\n }\n}\n\n// ─── Bundle Format Errors ─────────────────────────────────\n\n/**\n * Thrown by `readNoydbBundle()` when the body bytes don't match\n * the integrity hash declared in the bundle header — i.e. someone\n * modified the bytes between write and read.\n *\n * Distinct from a generic `Error` (which would be thrown for\n * format violations like a missing magic prefix or malformed\n * header JSON) so consumers can pattern-match the corruption case\n * and handle it differently from a producer bug. A\n * `BundleIntegrityError` indicates \"the bytes you got are not\n * what was written\"; a plain `Error` from `parsePrefixAndHeader`\n * indicates \"what was written wasn't a valid bundle in the first\n * place.\"\n *\n * Also thrown when decompression fails after the integrity hash\n * passed — that's a producer bug (the wrong algorithm byte was\n * written) but it surfaces with the same error class because the\n * end result is \"the body cannot be turned back into a dump.\"\n */\nexport class BundleIntegrityError extends NoydbError {\n constructor(message: string) {\n super('BUNDLE_INTEGRITY', `.noydb bundle integrity check failed: ${message}`)\n this.name = 'BundleIntegrityError'\n }\n}\n\n// ─── i18n / Dictionary Errors ──────────────────────────\n\n/**\n * Thrown when `vault.collection()` is called with a name that is\n * reserved for NOYDB internal use (any name starting with `_dict_`).\n *\n * Dictionary collections are accessed exclusively via\n * `vault.dictionary(name)` — attempting to open one as a regular\n * collection would bypass the dictionary invariants (ACL, rename\n * tracking, reserved-name policy).\n */\nexport class ReservedCollectionNameError extends NoydbError {\n /** The rejected collection name. */\n readonly collectionName: string\n\n constructor(collectionName: string) {\n super(\n 'RESERVED_COLLECTION_NAME',\n `\"${collectionName}\" is a reserved collection name. ` +\n `Use vault.dictionary(\"${collectionName.replace(/^_dict_/, '')}\") ` +\n `to access dictionary collections.`,\n )\n this.name = 'ReservedCollectionNameError'\n this.collectionName = collectionName\n }\n}\n\n/**\n * Thrown by `DictionaryHandle.get()` and `DictionaryHandle.delete()` when\n * the requested key does not exist in the dictionary.\n *\n * Distinct from `NotFoundError` (which is for data records) so callers\n * can distinguish \"data record missing\" from \"dictionary key missing\"\n * without inspecting error messages.\n */\nexport class DictKeyMissingError extends NoydbError {\n /** The dictionary name. */\n readonly dictionaryName: string\n /** The key that was not found. */\n readonly key: string\n\n constructor(dictionaryName: string, key: string) {\n super(\n 'DICT_KEY_MISSING',\n `Dictionary \"${dictionaryName}\" has no entry for key \"${key}\".`,\n )\n this.name = 'DictKeyMissingError'\n this.dictionaryName = dictionaryName\n this.key = key\n }\n}\n\n/**\n * Thrown by `DictionaryHandle.delete()` in strict mode when the key to\n * be deleted is still referenced by one or more records.\n *\n * The caller must either rename the key first (the only sanctioned\n * mass-mutation path) or pass `{ mode: 'warn' }` to skip the check\n * (development only).\n */\nexport class DictKeyInUseError extends NoydbError {\n /** The dictionary name. */\n readonly dictionaryName: string\n /** The key that is still referenced. */\n readonly key: string\n /** Name of the first collection found to reference this key. */\n readonly usedBy: string\n /** Number of records in `usedBy` that reference this key. */\n readonly count: number\n\n constructor(\n dictionaryName: string,\n key: string,\n usedBy: string,\n count: number,\n ) {\n super(\n 'DICT_KEY_IN_USE',\n `Cannot delete key \"${key}\" from dictionary \"${dictionaryName}\": ` +\n `${count} record(s) in \"${usedBy}\" still reference it. ` +\n `Use dictionary.rename(\"${key}\", newKey) to rewrite references first.`,\n )\n this.name = 'DictKeyInUseError'\n this.dictionaryName = dictionaryName\n this.key = key\n this.usedBy = usedBy\n this.count = count\n }\n}\n\n/**\n * Thrown by `Collection.put()` when an `i18nText` field is missing one\n * or more required translations.\n *\n * The `missing` array names each locale code that was absent from the\n * field value. The `field` property names the field so callers can\n * render a field-level error message without parsing the string.\n */\nexport class MissingTranslationError extends NoydbError {\n /** The field name whose translation(s) are missing. */\n readonly field: string\n /** Locale codes that were required but absent. */\n readonly missing: readonly string[]\n\n constructor(field: string, missing: readonly string[], message?: string) {\n super(\n 'MISSING_TRANSLATION',\n message ??\n `Field \"${field}\": missing required translation(s): ${missing.join(', ')}.`,\n )\n this.name = 'MissingTranslationError'\n this.field = field\n this.missing = missing\n }\n}\n\n/**\n * Thrown when reading an `i18nText` field without specifying a locale —\n * either at the call site (`get(id, { locale })`) or on the vault\n * (`openVault(name, { locale })`).\n *\n * Also thrown when `resolveI18nText()` exhausts the fallback chain and\n * no translation is available for the requested locale.\n *\n * The `field` property names the field that triggered the error so the\n * caller can surface it in the UI.\n */\nexport class LocaleNotSpecifiedError extends NoydbError {\n /** The field name that required a locale. */\n readonly field: string\n\n constructor(field: string, message?: string) {\n super(\n 'LOCALE_NOT_SPECIFIED',\n message ??\n `Cannot read i18nText field \"${field}\" without a locale. ` +\n `Pass { locale } to get()/list()/query() or set a default via ` +\n `openVault(name, { locale }).`,\n )\n this.name = 'LocaleNotSpecifiedError'\n this.field = field\n }\n}\n\n// ─── Translator Errors ─────────────────────────────────────\n\n/**\n * Thrown when a collection has an `i18nText` field with\n * `autoTranslate: true` but no `plaintextTranslator` was configured\n * on `createNoydb()`.\n *\n * The error is raised at `put()` time (not at schema construction) so\n * the mis-configuration is surfaced by the first write rather than\n * silently at startup.\n */\nexport class TranslatorNotConfiguredError extends NoydbError {\n /** The field that requested auto-translation. */\n readonly field: string\n /** The collection the put was targeting. */\n readonly collection: string\n\n constructor(field: string, collection: string) {\n super(\n 'TRANSLATOR_NOT_CONFIGURED',\n `Field \"${field}\" in collection \"${collection}\" has autoTranslate: true, ` +\n `but no plaintextTranslator was configured on createNoydb(). ` +\n `Either configure a plaintextTranslator or remove autoTranslate from the schema.`,\n )\n this.name = 'TranslatorNotConfiguredError'\n this.field = field\n this.collection = collection\n }\n}\n\n// ─── Backup Errors ─────────────────────────────────────────\n\n/**\n * Thrown when `Vault.load()` finds that a backup's hash chain\n * doesn't verify, or that its embedded `ledgerHead.hash` doesn't\n * match the chain head reconstructed from the loaded entries.\n *\n * Distinct from `BackupCorruptedError` so callers can choose to\n * recover from one but not the other (e.g., a corrupted JSON file is\n * unrecoverable; a chain mismatch might mean the backup is from an\n * incompatible noy-db version).\n */\nexport class BackupLedgerError extends NoydbError {\n /** First-broken-entry index, if known. */\n readonly divergedAt?: number\n\n constructor(message: string, divergedAt?: number) {\n super('BACKUP_LEDGER', message)\n this.name = 'BackupLedgerError'\n if (divergedAt !== undefined) this.divergedAt = divergedAt\n }\n}\n\n/**\n * Thrown when `Vault.load()` finds that the backup's data\n * collection content doesn't match the ledger's recorded\n * `payloadHash`es. This is the \"envelope was tampered with after\n * dump\" detection — the chain itself can be intact, but if any\n * encrypted record bytes were swapped, this check catches it.\n */\nexport class BackupCorruptedError extends NoydbError {\n /** The (collection, id) pair whose envelope failed the hash check. */\n readonly collection: string\n readonly id: string\n\n constructor(collection: string, id: string, message: string) {\n super('BACKUP_CORRUPTED', message)\n this.name = 'BackupCorruptedError'\n this.collection = collection\n this.id = id\n }\n}\n\n// ─── Session Errors ───────────────────────────────────────\n\n/**\n * Thrown by `resolveSession()` when the session token's `expiresAt`\n * timestamp is in the past. The session key is also removed from the\n * in-memory store when this is thrown, so retrying with the same sessionId\n * will produce `SessionNotFoundError`.\n *\n * Separate from `SessionNotFoundError` so callers can distinguish between\n * \"session is gone\" (key store cleared, tab reloaded) and \"session is\n * still in the store but has exceeded its lifetime\" (idle timeout, absolute\n * timeout, policy-driven expiry). The remediation differs: expired sessions\n * should prompt a fresh unlock; not-found sessions may indicate a bug or a\n * cross-tab scenario where the session was never established.\n */\nexport class SessionExpiredError extends NoydbError {\n readonly sessionId: string\n\n constructor(sessionId: string) {\n super('SESSION_EXPIRED', `Session \"${sessionId}\" has expired. Re-unlock to continue.`)\n this.name = 'SessionExpiredError'\n this.sessionId = sessionId\n }\n}\n\n/**\n * Thrown by `resolveSession()` when the session key cannot be found in\n * the module-level store. This happens when:\n * - The session was explicitly revoked via `revokeSession()`.\n * - The JS context was reloaded (tab navigation, page refresh, worker restart).\n * - `Noydb.close()` was called (which calls `revokeAllSessions()`).\n * - The sessionId is wrong or was generated by a different JS context.\n *\n * The session token (if the caller holds it) is permanently useless after\n * this error — the key is gone and cannot be recovered.\n */\nexport class SessionNotFoundError extends NoydbError {\n readonly sessionId: string\n\n constructor(sessionId: string) {\n super('SESSION_NOT_FOUND', `Session key for \"${sessionId}\" not found. The session may have been revoked or the page reloaded.`)\n this.name = 'SessionNotFoundError'\n this.sessionId = sessionId\n }\n}\n\n/**\n * Thrown when a session policy blocks an operation — for example,\n * `requireReAuthFor: ['export']` is set and the caller attempts to\n * call `exportStream()` without re-authenticating for this session.\n *\n * The `operation` field names the specific operation that was blocked\n * (e.g. `'export'`, `'grant'`, `'rotate'`) so the caller can surface\n * a targeted prompt (\"Please re-enter your passphrase to export data\").\n */\nexport class SessionPolicyError extends NoydbError {\n readonly operation: string\n\n constructor(operation: string, message?: string) {\n super(\n 'SESSION_POLICY',\n message ?? `Operation \"${operation}\" requires re-authentication per the active session policy.`,\n )\n this.name = 'SessionPolicyError'\n this.operation = operation\n }\n}\n\n// ─── Query / Join Errors ────────────────────────────────────\n\n/**\n * Thrown when a `.join()` would exceed its configured row ceiling on\n * either side. The ceiling defaults to 50,000 per side and can be\n * overridden via the `{ maxRows }` option on `.join()`.\n *\n * Carries both row counts so the error message can show which side\n * tripped the limit (e.g. \"left had 60,000 rows, right had 1,200,\n * max was 50,000\"). The `side` field is machine-readable so test\n * code and devtools can match on it without regex-parsing the\n * message.\n *\n * The row ceiling exists because joins are bounded in-memory\n * operations over materialized record sets. Consumers whose\n * collections genuinely exceed the ceiling should track \n * (streaming joins over `scan()`) or filter the left side further\n * with `where()` / `limit()` before joining.\n */\nexport class JoinTooLargeError extends NoydbError {\n readonly leftRows: number\n readonly rightRows: number\n readonly maxRows: number\n readonly side: 'left' | 'right'\n\n constructor(opts: {\n leftRows: number\n rightRows: number\n maxRows: number\n side: 'left' | 'right'\n message: string\n }) {\n super('JOIN_TOO_LARGE', opts.message)\n this.name = 'JoinTooLargeError'\n this.leftRows = opts.leftRows\n this.rightRows = opts.rightRows\n this.maxRows = opts.maxRows\n this.side = opts.side\n }\n}\n\n/**\n * Thrown by `.join()` in strict `ref()` mode when a left-side record\n * points at a right-side id that does not exist in the target\n * collection.\n *\n * Distinct from `RefIntegrityError` so test code can pattern-match\n * on the *read-time* dangling case without catching *write-time*\n * integrity violations. Both indicate \"ref points at nothing\" but\n * happen at different lifecycle phases and deserve different\n * remediation in documentation: a RefIntegrityError on `put()`\n * means the input is invalid; a DanglingReferenceError on `.join()`\n * means stored data has drifted and `vault.checkIntegrity()`\n * is the right tool to find the full set of orphans.\n */\nexport class DanglingReferenceError extends NoydbError {\n readonly field: string\n readonly target: string\n readonly refId: string\n\n constructor(opts: {\n field: string\n target: string\n refId: string\n message: string\n }) {\n super('DANGLING_REFERENCE', opts.message)\n this.name = 'DanglingReferenceError'\n this.field = opts.field\n this.target = opts.target\n this.refId = opts.refId\n }\n}\n\n/**\n * Thrown by {@link sanitizeFilename} when an input filename cannot be\n * made safe — NUL byte, empty after normalization, missing\n * `opaqueId` for the opaque profile, `..` segment, or a `maxBytes`\n * cap too small to hold a single code point.\n */\nexport class FilenameSanitizationError extends NoydbError {\n constructor(message: string) {\n super('FILENAME_SANITIZATION', message)\n this.name = 'FilenameSanitizationError'\n }\n}\n\n/**\n * Thrown when a write target resolves OUTSIDE the requested\n * directory after sanitization — the canonical Zip-Slip class. The\n * sanitizer's job is to strip path-traversal segments; this error\n * is the defense-in-depth fallback at the FS write site.\n */\nexport class PathEscapeError extends NoydbError {\n readonly attempted: string\n readonly targetDir: string\n\n constructor(opts: { attempted: string; targetDir: string }) {\n super(\n 'PATH_ESCAPE',\n `Sanitized filename \"${opts.attempted}\" resolves outside target dir \"${opts.targetDir}\"`,\n )\n this.name = 'PathEscapeError'\n this.attempted = opts.attempted\n this.targetDir = opts.targetDir\n }\n}\n","/**\n * Cryptographic primitives — thin wrappers around the Web Crypto API.\n *\n * ## Design principle\n *\n * **Zero npm crypto dependencies.** Every operation uses `globalThis.crypto.subtle`,\n * which is available natively in Node.js ≥ 18, all modern browsers, and\n * Deno/Bun. This avoids supply-chain risk from third-party crypto packages and\n * ensures the library stays auditable.\n *\n * ## Algorithms\n *\n * | Use case | Algorithm | Parameters |\n * |----------|-----------|------------|\n * | Key derivation | PBKDF2-SHA256 | 600,000 iterations, 32-byte salt |\n * | Record encryption | AES-256-GCM | 12-byte random IV per operation |\n * | DEK wrapping | AES-KW (RFC 3394) | 256-bit KEK |\n * | Binary encrypt | AES-256-GCM | same as record encryption |\n * | Integrity | HMAC-SHA256 | for presence channels |\n * | Content hash | SHA-256 | for ledger and bundle integrity |\n *\n * ## Key lifecycle\n *\n * ```\n * passphrase + salt\n * └─► deriveKey() → KEK (CryptoKey, extractable: false)\n * └─► wrapKey() → wrapped DEK bytes [stored in keyring]\n * └─► unwrapKey() → DEK (CryptoKey) [memory only during session]\n * └─► encrypt() / decrypt() → ciphertext / plaintext\n * ```\n *\n * IVs are generated fresh by {@link generateIV} on every encrypt call.\n * Reusing an IV with the same key would break GCM's authentication guarantee —\n * this function should be the only place IVs are produced.\n *\n * @module\n */\n\nimport { DecryptionError, InvalidKeyError, TamperedError } from './errors.js'\n\nconst PBKDF2_ITERATIONS = 600_000\nconst SALT_BYTES = 32\nconst IV_BYTES = 12\nconst KEY_BITS = 256\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Key Derivation ────────────────────────────────────────────────────\n\n/** Derive a KEK from a passphrase and salt using PBKDF2-SHA256. */\nexport async function deriveKey(\n passphrase: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const keyMaterial = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(passphrase),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n keyMaterial,\n { name: 'AES-KW', length: KEY_BITS },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// ─── DEK Generation ────────────────────────────────────────────────────\n\n/** Generate a random AES-256-GCM data encryption key. */\nexport async function generateDEK(): Promise<CryptoKey> {\n return subtle.generateKey(\n { name: 'AES-GCM', length: KEY_BITS },\n true, // extractable — needed for AES-KW wrapping\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Key Wrapping ──────────────────────────────────────────────────────\n\n/** Wrap (encrypt) a DEK with a KEK using AES-KW. Returns base64 string. */\nexport async function wrapKey(dek: CryptoKey, kek: CryptoKey): Promise<string> {\n const wrapped = await subtle.wrapKey('raw', dek, kek, 'AES-KW')\n return bufferToBase64(wrapped)\n}\n\n/** Unwrap (decrypt) a DEK from base64 string using a KEK. */\nexport async function unwrapKey(\n wrappedBase64: string,\n kek: CryptoKey,\n): Promise<CryptoKey> {\n try {\n return await subtle.unwrapKey(\n 'raw',\n base64ToBuffer(wrappedBase64) as BufferSource,\n kek,\n 'AES-KW',\n { name: 'AES-GCM', length: KEY_BITS },\n true,\n ['encrypt', 'decrypt'],\n )\n } catch {\n throw new InvalidKeyError()\n }\n}\n\n// ─── Encrypt / Decrypt ─────────────────────────────────────────────────\n\nexport interface EncryptResult {\n iv: string // base64\n data: string // base64\n}\n\n/** Encrypt plaintext JSON string with AES-256-GCM. Fresh IV per call. */\nexport async function encrypt(\n plaintext: string,\n dek: CryptoKey,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const encoded = new TextEncoder().encode(plaintext)\n\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n encoded,\n )\n\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/** Decrypt AES-256-GCM ciphertext. Throws on wrong key or tampered data. */\nexport async function decrypt(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<string> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n\n try {\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n ciphertext as BufferSource,\n )\n return new TextDecoder().decode(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n// ─── Binary Encrypt / Decrypt ────────\n\n/**\n * Encrypt raw bytes with AES-256-GCM using a fresh random IV.\n * Used by the attachment store so binary blobs avoid double base64 encoding\n * (the existing `encrypt()` function calls `TextEncoder` on a string — here\n * we pass the `Uint8Array` directly to `subtle.encrypt`).\n */\nexport async function encryptBytes(\n data: Uint8Array,\n dek: CryptoKey,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n data as unknown as BufferSource,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext back to raw bytes.\n * Counterpart to `encryptBytes`. Throws `TamperedError` on auth-tag failure.\n */\nexport async function decryptBytes(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<Uint8Array> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n try {\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n ciphertext as BufferSource,\n )\n return new Uint8Array(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n/**\n * SHA-256 hex digest of raw bytes. Used to derive content-addressed\n * eTags for blob deduplication. Computed on plaintext bytes\n * before compression and encryption so the eTag identifies content, not\n * ciphertext, and survives re-encryption (key rotation, re-upload).\n */\nexport async function sha256Hex(data: Uint8Array): Promise<string> {\n const hash = await subtle.digest('SHA-256', data as unknown as BufferSource)\n return Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\n// ─── HMAC-SHA-256 ─────────────────────────────\n\n/**\n * Compute HMAC-SHA-256(key, data) and return hex string.\n *\n * Used to derive content-addressed eTags that are opaque to the store:\n * ```\n * eTag = hmacSha256Hex(blobDEK, plaintext)\n * ```\n *\n * Unlike a plain SHA-256, the HMAC is keyed by the vault-shared `_blob` DEK,\n * so an attacker with store access cannot pre-compute eTags for known files.\n * Deduplication still works within a vault (same key + same content = same eTag).\n */\nexport async function hmacSha256Hex(key: CryptoKey, data: Uint8Array): Promise<string> {\n // Export AES-GCM DEK raw bytes → import as HMAC key\n const rawKey = await subtle.exportKey('raw', key)\n const hmacKey = await subtle.importKey(\n 'raw',\n rawKey,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const sig = await subtle.sign('HMAC', hmacKey, data as unknown as BufferSource)\n return Array.from(new Uint8Array(sig))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\n// ─── AAD-aware Binary Encrypt / Decrypt ──\n\n/**\n * Encrypt raw bytes with AES-256-GCM using Additional Authenticated Data.\n *\n * The AAD binds each chunk to its parent blob and position, preventing\n * chunk reorder, substitution, and truncation attacks:\n * ```\n * AAD = UTF-8(\"{eTag}:{chunkIndex}:{chunkCount}\")\n * ```\n *\n * The AAD is NOT stored — the reader reconstructs it from `BlobObject`\n * metadata and passes it to `decryptBytesWithAAD`.\n */\nexport async function encryptBytesWithAAD(\n data: Uint8Array,\n dek: CryptoKey,\n aad: Uint8Array,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const ciphertext = await subtle.encrypt(\n {\n name: 'AES-GCM',\n iv: iv as BufferSource,\n additionalData: aad as BufferSource,\n },\n dek,\n data as unknown as BufferSource,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext with AAD verification.\n *\n * If the AAD does not match the one used at encryption time (e.g. because\n * a chunk was reordered or substituted from another blob), the GCM auth\n * tag fails and this throws `TamperedError`.\n */\nexport async function decryptBytesWithAAD(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n aad: Uint8Array,\n): Promise<Uint8Array> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n try {\n const plaintext = await subtle.decrypt(\n {\n name: 'AES-GCM',\n iv: iv as BufferSource,\n additionalData: aad as BufferSource,\n },\n dek,\n ciphertext as BufferSource,\n )\n return new Uint8Array(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n// ─── Presence Key Derivation ──────────────────────────────\n\n/**\n * Derive an AES-256-GCM presence key from a collection DEK using HKDF-SHA256.\n *\n * The presence key is domain-separated from the data DEK by the fixed salt\n * `'noydb-presence'` and the `info` = collection name. This means:\n * - The adapter never sees the presence key.\n * - Presence payloads rotate automatically when the collection DEK is rotated.\n * - Revoked users cannot derive the new presence key after a DEK rotation.\n *\n * @param dek The collection's AES-256-GCM DEK (extractable).\n * @param collectionName Used as the HKDF `info` parameter for domain separation.\n * @returns A non-extractable AES-256-GCM key suitable for presence payload encryption.\n */\nexport async function derivePresenceKey(dek: CryptoKey, collectionName: string): Promise<CryptoKey> {\n // Step 1: export DEK raw bytes\n const rawDek = await subtle.exportKey('raw', dek)\n\n // Step 2: import as HKDF key material\n const hkdfKey = await subtle.importKey(\n 'raw',\n rawDek,\n 'HKDF',\n false,\n ['deriveBits'],\n )\n\n // Step 3: derive 256 bits with salt='noydb-presence' and info=collectionName\n const salt = new TextEncoder().encode('noydb-presence')\n const info = new TextEncoder().encode(collectionName)\n const bits = await subtle.deriveBits(\n { name: 'HKDF', hash: 'SHA-256', salt, info },\n hkdfKey,\n KEY_BITS,\n )\n\n // Step 4: import derived bits as AES-GCM key\n return subtle.importKey(\n 'raw',\n bits,\n { name: 'AES-GCM', length: KEY_BITS },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Deterministic Encryption ────────────────────────────\n\n/**\n * Derive a deterministic 12-byte IV from `{ DEK, context, plaintext }`\n * via HKDF-SHA256. Given the same three inputs, the IV is identical, so\n * `encryptDeterministic` produces the same ciphertext on every call —\n * which is precisely what enables blind equality search on encrypted\n * fields.\n *\n * **The side channel this opens.** Two records whose field value is the\n * same produce the same ciphertext. An observer with store access can\n * therefore tell which records share a value — not *what* the value is,\n * but the equivalence class. This is the well-known trade-off of\n * deterministic encryption and is why the feature is strictly opt-in\n * per field, guarded by `acknowledgeDeterministicRisk: true` at\n * collection creation.\n *\n * The context string MUST include the collection name and field name,\n * so:\n * - The same plaintext in two different fields encrypts differently\n * (no cross-field equality leak).\n * - The same plaintext in two different collections (different DEKs)\n * encrypts differently by virtue of the key, even before HKDF\n * domain separation kicks in.\n */\nasync function deriveDeterministicIV(\n dek: CryptoKey,\n context: string,\n plaintext: string,\n): Promise<Uint8Array> {\n const rawDek = await subtle.exportKey('raw', dek)\n const hkdfKey = await subtle.importKey('raw', rawDek, 'HKDF', false, ['deriveBits'])\n const salt = new TextEncoder().encode('noydb-deterministic-v1')\n const info = new TextEncoder().encode(`${context}\\x00${plaintext}`)\n const bits = await subtle.deriveBits(\n { name: 'HKDF', hash: 'SHA-256', salt, info },\n hkdfKey,\n IV_BYTES * 8,\n )\n return new Uint8Array(bits)\n}\n\n/**\n * Encrypt a plaintext string with AES-256-GCM and a deterministic,\n * HKDF-derived IV.\n *\n * The same `{ dek, context, plaintext }` triple always produces the\n * same `{ iv, data }` — call this twice and you can string-compare the\n * ciphertexts to check equality of the inputs without decrypting them.\n *\n * @param context Domain-separation string — by convention\n * `'<collection>/<field>'`. Different contexts encrypt\n * the same plaintext to different ciphertexts, so\n * `email` in collection `users` does not collide with\n * `email` in collection `customers`.\n */\nexport async function encryptDeterministic(\n plaintext: string,\n dek: CryptoKey,\n context: string,\n): Promise<EncryptResult> {\n const iv = await deriveDeterministicIV(dek, context, plaintext)\n const encoded = new TextEncoder().encode(plaintext)\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n encoded,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Counterpart to {@link encryptDeterministic}. The IV is stored\n * alongside the ciphertext (exactly like the randomized path), so\n * decrypt uses the stored IV and verifies the GCM auth tag — a tampered\n * ciphertext throws `TamperedError` just like randomized AES-GCM.\n */\nexport async function decryptDeterministic(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<string> {\n return decrypt(ivBase64, dataBase64, dek)\n}\n\n// ─── Random Generation ─────────────────────────────────────────────────\n\n/** Generate a random 12-byte IV for AES-GCM. */\nexport function generateIV(): Uint8Array {\n return globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES))\n}\n\n/** Generate a random 32-byte salt for PBKDF2. */\nexport function generateSalt(): Uint8Array {\n return globalThis.crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n}\n\n// ─── Base64 Helpers ────────────────────────────────────────────────────\n\nexport function bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {\n const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)\n let binary = ''\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!)\n }\n return btoa(binary)\n}\n\nexport function base64ToBuffer(base64: string): Uint8Array<ArrayBuffer> {\n const binary = atob(base64)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i)\n }\n return bytes\n}\n","import type { NoydbStore, KeyringFile, KeyringAuthenticator, Role, Permissions, GrantOptions, RevokeOptions, UpdateUserOptions, UserInfo, EncryptedEnvelope, ExportCapability, ExportFormat, ImportCapability, VaultPolicyOnDisk } from '../types.js'\nimport { NOYDB_KEYRING_VERSION, NOYDB_FORMAT_VERSION } from '../types.js'\nimport {\n deriveKey,\n generateDEK,\n generateSalt,\n wrapKey,\n unwrapKey,\n encrypt,\n decrypt,\n bufferToBase64,\n base64ToBuffer,\n} from '../crypto.js'\nimport { NoAccessError, PermissionDeniedError, PrivilegeEscalationError, KeyringExpiredError, ValidationError } from '../errors.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../validation.js'\nimport {\n saveUserEnvelope,\n loadUserEnvelope as loadUserEnvelopeFn,\n deleteUserEnvelope,\n USER_ENVELOPE_COLLECTION,\n type UserEnvelope as UserEnvelopeReader,\n} from '../meta/user-envelope/index.js'\n\n// ─── Roles that can grant/revoke ───────────────────────────────────────\n\n/**\n * Roles that an `admin` is allowed to grant and revoke.\n *\n * Includes `'admin'` itself: the model bottlenecked all admin\n * onboarding through the single `owner` principal, which made lateral\n * delegation impossible and left a single-owner bus-factor risk\n * unresolved even when multiple trusted humans existed. opens up\n * admin↔admin lateral delegation, with two guardrails:\n *\n * 1. **No privilege escalation.** Enforced in `grant()`: every DEK\n * wrapped into the new admin's keyring must be present in the\n * grantor's own DEK set. Today this is structurally trivially\n * true (admin grants always inherit the full caller DEK set),\n * but the check is wired in so future per-collection admin scoping\n * cannot accidentally bypass it. See `PrivilegeEscalationError`.\n *\n * 2. **Cascade on revoke.** Enforced in `revoke()`: when an admin is\n * revoked, every admin they (transitively) granted is either\n * revoked too (`cascade: 'strict'`, default) or left in place with\n * a console warning (`cascade: 'warn'`). The walk uses the\n * `granted_by` field on each keyring file as the parent pointer.\n */\nconst ADMIN_GRANTABLE_TARGETS: readonly Role[] = ['operator', 'viewer', 'client', 'admin']\n\nfunction canGrant(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\nfunction canRevoke(callerRole: Role, targetRole: Role): boolean {\n if (targetRole === 'owner') return false // owner cannot be revoked\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\n/**\n * Whether `callerRole` can mutate a keyring whose role is (or becomes)\n * `targetRole`. Used by `updateKeyringIdentity` (#54).\n *\n * Mirrors `canGrant`'s hierarchy: admins manage admin/operator/viewer/\n * client laterally; admins cannot create or destroy `owner`-shaped\n * keyrings. Owner can do anything.\n *\n * Both the OLD role and the NEW role must satisfy this check —\n * otherwise admin could elevate themselves (`admin → owner`) or demote\n * an owner (`owner → admin`) under cover of \"update.\"\n */\nfunction canUpdateRole(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\n// ─── Unlocked Keyring ──────────────────────────────────────────────────\n\n/** In-memory representation of an unlocked keyring. */\nexport interface UnlockedKeyring {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly permissions: Permissions\n readonly deks: Map<string, CryptoKey>\n /**\n * The KEK, when this keyring was unlocked via tier 1 (passphrase) or\n * a wrap-KEK tier-2 method (WebAuthn / OIDC). `null` when the\n * keyring was opened via:\n *\n * - Unencrypted mode (no KEK exists)\n * - Tier-3 PIN quick-resume (`@noy-db/on-pin`)\n * - Wrap-DEKs tier-2 unlock (`@noy-db/on-password`'s\n * `verifyPasswordSlot` after #26 Path C)\n * - Session-state restore (`session/session.ts`)\n * - Dev-unlock fixture (`session/dev-unlock.ts`)\n *\n * Consumers performing tier-1 operations that need the KEK\n * (DEK rewrap, keyring persist, delegation issue/unwrap) must\n * null-check and throw a clear error if absent — re-authenticate\n * at tier 1 first to recover the KEK.\n *\n * Tightened from `CryptoKey` to `CryptoKey | null` in pre.8 (#41).\n * The runtime contract has always allowed null; the type now\n * matches reality.\n */\n readonly kek: CryptoKey | null\n readonly salt: Uint8Array\n /**\n * `@noy-db/as-*` export capability. Absent when the\n * keyring was written before this RFC landed — role-based defaults\n * apply via `hasExportCapability`.\n */\n readonly exportCapability?: ExportCapability\n /**\n * `@noy-db/as-*` import capability (issue ). Absent when the\n * keyring was written before landed — default-closed semantics\n * apply via `hasImportCapability` (no plaintext format granted, no\n * bundle import granted, regardless of role).\n */\n readonly importCapability?: ImportCapability\n /**\n * Tier-2 authenticator slots — readonly snapshot loaded from the\n * keyring file. Mutations go through `enrollAuthenticator` /\n * `removeAuthenticator` (issue #11), which write back via\n * `persistKeyring`. Always defined; loads with an empty array for\n * keyrings written before the multi-slot extension landed.\n */\n readonly authenticators: readonly KeyringAuthenticator[]\n /**\n * Reserved per-keyring policy override (forward-compat for Option C\n * — see {@link VaultPolicyOnDisk}). v1.0 round-trips this field but\n * never enforces it; the gate engine uses `_meta/policy` only.\n */\n readonly policy?: VaultPolicyOnDisk\n}\n\n// ─── Load / Create ─────────────────────────────────────────────────────\n\n/** Load and unlock a user's keyring for a vault. */\nexport async function loadKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n): Promise<UnlockedKeyring> {\n const envelope = await adapter.get(vault, '_keyring', userId)\n\n if (!envelope) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\"`)\n }\n\n const keyringFile = JSON.parse(envelope._data) as KeyringFile\n\n // — refuse to unwrap an expired slot. Check happens before any\n // KEK derivation so an expired slot doesn't leak timing on the\n // passphrase. Comparison uses Date.parse → ms-since-epoch; an\n // unparseable expires_at is treated as \"no expiry\" so a malformed\n // value can't silently lock users out (it'll surface in tests).\n if (keyringFile.expires_at !== undefined) {\n const cutoff = Date.parse(keyringFile.expires_at)\n if (Number.isFinite(cutoff) && Date.now() >= cutoff) {\n throw new KeyringExpiredError({ userId: keyringFile.user_id, expiresAt: keyringFile.expires_at })\n }\n }\n\n const salt = base64ToBuffer(keyringFile.salt)\n const kek = await deriveKey(passphrase, salt)\n\n const deks = new Map<string, CryptoKey>()\n for (const [collName, wrappedDek] of Object.entries(keyringFile.deks)) {\n const dek = await unwrapKey(wrappedDek, kek)\n deks.set(collName, dek)\n }\n\n return {\n userId: keyringFile.user_id,\n displayName: keyringFile.display_name,\n role: keyringFile.role,\n permissions: keyringFile.permissions,\n deks,\n kek,\n salt,\n authenticators: keyringFile.authenticators ?? [],\n ...(keyringFile.export_capability !== undefined && { exportCapability: keyringFile.export_capability }),\n ...(keyringFile.import_capability !== undefined && { importCapability: keyringFile.import_capability }),\n ...(keyringFile.policy !== undefined && { policy: keyringFile.policy }),\n }\n}\n\n/**\n * Create the initial owner keyring for a new vault.\n *\n * Pass `{ validate: true }` (or a `PassphrasePolicy`) to gate creation\n * on the phrase-format strength rules — `Noydb` threads this from\n * `NoydbOptions.validatePassphrase`. Direct callers (CLI, scripts,\n * test fixtures) opt in explicitly.\n */\nexport async function createOwnerKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n passphraseOpts?: PassphrasePolicy & { validate?: boolean; allowWeakPassphrase?: boolean },\n): Promise<UnlockedKeyring> {\n if (passphraseOpts?.validate && !passphraseOpts.allowWeakPassphrase) {\n assertStrongPassphrase(passphrase, passphraseOpts)\n }\n const salt = generateSalt()\n const kek = await deriveKey(passphrase, salt)\n\n // Eager-provision the _users DEK at owner creation. This guarantees\n // every subsequent grant inherits it via the existing\n // collName.startsWith('_') propagation in grant() — so multi-principal\n // user-envelope reads (alice reading bob's profile) work for new\n // vaults without any per-keyring DEK rotation. Pre-existing vaults\n // get the DEK lazily on first vault.user.* access (which only\n // materializes a single-principal DEK that won't propagate\n // retroactively — that's the documented \"lazy creation for\n // pre-existing keyrings\" rollout note in the spec).\n const userEnvelopeDek = await generateDEK()\n const wrappedUserEnvelopeDek = await wrapKey(userEnvelopeDek, kek)\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: userId,\n display_name: userId,\n role: 'owner',\n permissions: {},\n deks: { [USER_ENVELOPE_COLLECTION]: wrappedUserEnvelopeDek },\n salt: bufferToBase64(salt),\n created_at: new Date().toISOString(),\n granted_by: userId,\n }\n\n await writeKeyringFile(adapter, vault, userId, keyringFile)\n\n return {\n userId,\n displayName: userId,\n role: 'owner',\n permissions: {},\n deks: new Map([[USER_ENVELOPE_COLLECTION, userEnvelopeDek]]),\n kek,\n salt,\n authenticators: [],\n }\n}\n\n// ─── Grant ─────────────────────────────────────────────────────────────\n\n/** Grant access to a new user. Caller must have grant privilege. */\nexport async function grant(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: GrantOptions,\n): Promise<void> {\n if (!canGrant(callerKeyring.role, options.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot grant role \"${options.role}\"`,\n )\n }\n\n // Optional strength validation — opt-in via grant({ validatePassphrase: true })\n // or via the calling Noydb's NoydbOptions.validatePassphrase flag.\n // The override `allowWeakPassphrase: true` skips even when validate is on.\n if (\n (options as { validatePassphrase?: boolean }).validatePassphrase &&\n !options.allowWeakPassphrase\n ) {\n assertStrongPassphrase(options.passphrase)\n }\n\n // Determine which collections the new user gets access to\n const permissions = resolvePermissions(options.role, options.permissions)\n\n // Derive the new user's KEK from their passphrase\n const newSalt = generateSalt()\n const newKek = await deriveKey(options.passphrase, newSalt)\n\n // Wrap the appropriate DEKs with the new user's KEK\n const wrappedDeks: Record<string, string> = {}\n for (const collName of Object.keys(permissions)) {\n const dek = callerKeyring.deks.get(collName)\n if (dek) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // For owner/admin/viewer roles, wrap ALL known DEKs\n if (options.role === 'owner' || options.role === 'admin' || options.role === 'viewer') {\n for (const [collName, dek] of callerKeyring.deks) {\n if (!(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n }\n\n // For ALL roles, propagate system-prefixed collection DEKs\n // (`_ledger`, `_history`, `_sync`, …). These are internal collections\n // that any user with access to the vault must be able to\n // read and write — for example, the hash-chained ledger writes\n // an entry on every put/delete, so operators and clients with write\n // access to a single data collection still need the `_ledger` DEK.\n //\n // Trade-off: a granted user can decrypt every system-collection\n // entry, including ones they would not otherwise have access to\n // (e.g., an operator on `invoices` can read ledger entries for\n // mutations in `salaries`). This is a metadata leak, not a\n // plaintext leak — the ledger entries record collection names,\n // record ids, and ciphertext hashes, but never plaintext records.\n // Per-collection ledger DEKs are tracked as a follow-up.\n for (const [collName, dek] of callerKeyring.deks) {\n if (collName.startsWith('_') && !(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // Anti-privilege-escalation check. Every DEK we just\n // wrapped into the new keyring must come from the caller's own DEK\n // set — the grantor cannot give the grantee access to a collection\n // they themselves can't read. Today this is structurally trivially\n // satisfied because every wrapped DEK was looked up in\n // `callerKeyring.deks` above, but the explicit check is wired in\n // so a future change (per-collection admin scoping, escrow-based\n // re-wrapping, etc.) cannot accidentally let a widening grant\n // through. See `PrivilegeEscalationError` for the rationale.\n for (const collName of Object.keys(wrappedDeks)) {\n if (!callerKeyring.deks.has(collName)) {\n throw new PrivilegeEscalationError(collName)\n }\n }\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: options.userId,\n display_name: options.displayName,\n role: options.role,\n permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: callerKeyring.userId,\n ...(options.exportCapability !== undefined && { export_capability: options.exportCapability }),\n ...(options.importCapability !== undefined && { import_capability: options.importCapability }),\n }\n\n await writeKeyringFile(adapter, vault, options.userId, keyringFile)\n\n // User envelope bootstrap. Seeded with `options.initialProfile` if\n // provided, otherwise an empty `{}`. Encrypted with the caller's\n // _users DEK — which is the same DEK that was wrapped into the new\n // keyring's `wrappedDeks[USER_ENVELOPE_COLLECTION]` above (system-\n // collection propagation), so the new user can decrypt it on first\n // open. Skipped silently if the caller has no _users DEK (pre-feature\n // vault upgrade path — documented \"lazy creation for pre-existing\n // keyrings\" in the spec).\n const userEnvelopeDek = callerKeyring.deks.get(USER_ENVELOPE_COLLECTION)\n if (userEnvelopeDek) {\n const initialPayload = options.initialProfile ?? {}\n await saveUserEnvelope(\n adapter,\n vault,\n options.userId,\n initialPayload,\n userEnvelopeDek,\n )\n }\n}\n\n// ─── Revoke ────────────────────────────────────────────────────────────\n\n/**\n * Walk every keyring in the vault to find admins that the given\n * `rootUserId` (transitively) granted, via the `granted_by` parent\n * pointer recorded on each keyring file.\n *\n * Returns the set of descendant admin user-ids in DFS order, NOT\n * including the root itself. Non-admin descendants are excluded\n * because operators/viewers/clients cannot grant other users — they\n * are leaves in the delegation tree and cleaning them up is the\n * caller's job (or the next rotate, since they'd lose key access\n * anyway when the cascading admin's collections rotate).\n *\n * The walk uses a visited set keyed by user-id so cycles introduced\n * by re-grants (admin-A revoked, then re-granted later by admin-B who\n * was originally granted by A) terminate cleanly.\n */\nasync function findAdminDescendants(\n adapter: NoydbStore,\n vault: string,\n rootUserId: string,\n): Promise<string[]> {\n const allUserIds = await adapter.list(vault, '_keyring')\n\n // Build a map: parentUserId → child KeyringFiles. We only ever\n // descend into admins, so non-admin children are skipped at the\n // edge level rather than after a recursive call.\n const childrenByParent = new Map<string, string[]>()\n for (const userId of allUserIds) {\n const env = await adapter.get(vault, '_keyring', userId)\n if (!env) continue\n const kf = JSON.parse(env._data) as KeyringFile\n if (kf.role !== 'admin') continue // only admins can grant — leaves are uninteresting\n if (kf.user_id === rootUserId) continue // self-edges are noise\n const list = childrenByParent.get(kf.granted_by) ?? []\n list.push(kf.user_id)\n childrenByParent.set(kf.granted_by, list)\n }\n\n const visited = new Set<string>()\n const order: string[] = []\n const stack: string[] = [...(childrenByParent.get(rootUserId) ?? [])]\n while (stack.length > 0) {\n const next = stack.pop()!\n if (visited.has(next)) continue\n visited.add(next)\n order.push(next)\n for (const grandchild of childrenByParent.get(next) ?? []) {\n if (!visited.has(grandchild)) stack.push(grandchild)\n }\n }\n return order\n}\n\n/** Revoke a user's access. Optionally rotate keys for affected collections. */\nexport async function revoke(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: RevokeOptions,\n): Promise<void> {\n // Load the target's keyring to check their role\n const targetEnvelope = await adapter.get(vault, '_keyring', options.userId)\n if (!targetEnvelope) {\n throw new NoAccessError(`User \"${options.userId}\" has no keyring in vault \"${vault}\"`)\n }\n\n const targetKeyring = JSON.parse(targetEnvelope._data) as KeyringFile\n\n if (!canRevoke(callerKeyring.role, targetKeyring.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot revoke role \"${targetKeyring.role}\"`,\n )\n }\n\n // Cascade-on-revoke. Only meaningful when the target is\n // an admin — operators/viewers/clients cannot grant other users so\n // they have no delegation subtree to walk.\n const cascadeMode = options.cascade ?? 'strict'\n const usersToRevoke: string[] = [options.userId]\n const affectedCollections = new Set(Object.keys(targetKeyring.deks))\n\n if (targetKeyring.role === 'admin') {\n const descendants = await findAdminDescendants(adapter, vault, options.userId)\n if (descendants.length > 0) {\n if (cascadeMode === 'warn') {\n // Diagnostic mode: leave the descendants in place but make\n // them visible. The owner / a different admin can clean up\n // manually. The single console.warn is intentionally noisy\n // (a list, not a count) so the operator sees exactly which\n // keyrings will become orphans.\n console.warn(\n `[noy-db] revoke(${options.userId}): cascade='warn' — leaving ` +\n `${descendants.length} descendant admin(s) in place: ` +\n `${descendants.join(', ')}. These admins were granted by the revoked user ` +\n `(transitively) and will become orphans in the delegation tree.`,\n )\n } else {\n // Strict mode (default): pull every descendant into the\n // revoke set. We collect their affected collections too so\n // the single rotation pass at the end covers everything.\n for (const userId of descendants) {\n const descEnv = await adapter.get(vault, '_keyring', userId)\n if (!descEnv) continue\n const descKf = JSON.parse(descEnv._data) as KeyringFile\n usersToRevoke.push(userId)\n for (const c of Object.keys(descKf.deks)) affectedCollections.add(c)\n }\n }\n }\n }\n\n // Delete every keyring in the revoke set. Order doesn't matter\n // because each keyring file is independent on disk; we don't have\n // referential integrity to maintain across deletes.\n for (const userId of usersToRevoke) {\n await adapter.delete(vault, '_keyring', userId)\n // Cascade-delete the principal's user envelope. Idempotent — no\n // error when the envelope was never written (e.g. the user was\n // granted but never authenticated to write their own profile).\n await deleteUserEnvelope(adapter, vault, userId)\n }\n\n // Single rotation pass at the end. The cost is O(records in\n // affected collections), NOT O(records × cascade depth) — every\n // descendant's collections were unioned into `affectedCollections`\n // before we got here, so the rotation re-encrypts each affected\n // record exactly once regardless of how deep the cascade went.\n if (options.rotateKeys !== false && affectedCollections.size > 0) {\n await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections])\n }\n}\n\n// ─── Update User (#54) ─────────────────────────────────────────────────\n\n/**\n * Mutate `role`, `displayName`, and/or `permissions` on an existing\n * keyring. Pure plaintext-header rewrite — no DEK rewrap, no KEK\n * required, no authenticator slots touched. Tier-2 enrollments and\n * recovery codes survive the operation.\n *\n * Role-elevation guard: BOTH the old role AND the new role must\n * satisfy `canUpdateRole(callerRole, _)`. This blocks the two\n * privilege-escalation shapes:\n * - admin elevates someone (or themselves) to owner\n * - admin demotes an owner to a role they then control\n *\n * Owner is always allowed. Admin manages admin / operator / viewer /\n * client laterally.\n *\n * Identity preserved: same userId, same DEK wrappings. Last-write-wins\n * through the standard keyring put (same concurrency story as `grant`\n * and `revoke`).\n *\n * @throws `NoAccessError` when no keyring exists for the target.\n * @throws `PermissionDeniedError` when the role hierarchy rejects.\n * @throws `ValidationError` when the diff is empty (nothing to update).\n *\n * @see #54\n */\nexport async function updateKeyringIdentity(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: UpdateUserOptions,\n): Promise<void> {\n if (\n options.role === undefined &&\n options.displayName === undefined &&\n options.permissions === undefined\n ) {\n throw new ValidationError(\n `updateUser: at least one of role / displayName / permissions must be provided ` +\n `(userId: \"${options.userId}\").`,\n )\n }\n\n const env = await adapter.get(vault, '_keyring', options.userId)\n if (!env) {\n throw new NoAccessError(\n `updateUser: user \"${options.userId}\" has no keyring in vault \"${vault}\".`,\n )\n }\n const target = JSON.parse(env._data) as KeyringFile\n\n // Role-elevation guard. The OLD role must be one this caller is\n // allowed to manage, AND the NEW role (if changing) must be too.\n // Two-sided check: blocks admin→owner promotion (new side) and\n // demoting an owner (old side).\n if (!canUpdateRole(callerKeyring.role, target.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot update a keyring with role \"${target.role}\"`,\n )\n }\n if (\n options.role !== undefined &&\n options.role !== target.role &&\n !canUpdateRole(callerKeyring.role, options.role)\n ) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot promote target to role \"${options.role}\"`,\n )\n }\n\n const next: KeyringFile = {\n ...target,\n ...(options.role !== undefined && { role: options.role }),\n ...(options.displayName !== undefined && { display_name: options.displayName }),\n ...(options.permissions !== undefined && { permissions: options.permissions }),\n }\n\n await writeKeyringFile(adapter, vault, options.userId, next)\n}\n\n// ─── Key Rotation ──────────────────────────────────────────────────────\n\n/**\n * Rotate DEKs for specified collections:\n * 1. Generate new DEKs\n * 2. Re-encrypt all records in affected collections\n * 3. Re-wrap new DEKs for all remaining users\n */\nexport async function rotateKeys(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n collections: string[],\n): Promise<void> {\n // Generate new DEKs for each affected collection\n const newDeks = new Map<string, CryptoKey>()\n for (const collName of collections) {\n newDeks.set(collName, await generateDEK())\n }\n\n // Re-encrypt all records in affected collections\n for (const collName of collections) {\n const oldDek = callerKeyring.deks.get(collName)\n const newDek = newDeks.get(collName)!\n if (!oldDek) continue\n\n const ids = await adapter.list(vault, collName)\n for (const id of ids) {\n const envelope = await adapter.get(vault, collName, id)\n if (!envelope || !envelope._iv) continue\n\n // Decrypt with old DEK\n const plaintext = await decrypt(envelope._iv, envelope._data, oldDek)\n\n // Re-encrypt with new DEK\n const { iv, data } = await encrypt(plaintext, newDek)\n const newEnvelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: envelope._v,\n _ts: new Date().toISOString(),\n _iv: iv,\n _data: data,\n }\n await adapter.put(vault, collName, id, newEnvelope)\n }\n }\n\n // Update caller's keyring with new DEKs\n for (const [collName, newDek] of newDeks) {\n callerKeyring.deks.set(collName, newDek)\n }\n await persistKeyring(adapter, vault, callerKeyring)\n\n // Update all remaining users' keyrings with re-wrapped new DEKs\n const userIds = await adapter.list(vault, '_keyring')\n for (const userId of userIds) {\n if (userId === callerKeyring.userId) continue\n\n const userEnvelope = await adapter.get(vault, '_keyring', userId)\n if (!userEnvelope) continue\n\n const userKeyringFile = JSON.parse(userEnvelope._data) as KeyringFile\n // Note: we can't derive other users' KEKs to re-wrap DEKs for them.\n // Rotation requires users to re-unlock and be re-granted after the caller\n // re-wraps with the raw DEKs held in memory. See rotation flow below.\n // The trick: import the user's KEK from their salt? No — we need their passphrase.\n //\n // Per the spec: the caller (owner/admin) wraps the new DEKs with each remaining\n // user's KEK. But we can't derive their KEK without their passphrase.\n //\n // Real solution from the spec: the caller wraps the DEK using the approach of\n // reading each user's existing wrapping. Since we can't derive their KEK,\n // we use a RE-KEYING approach: the new DEK is wrapped with a key-wrapping-key\n // that we CAN derive — we use the existing wrapped DEK as proof that the user\n // had access, and we replace it with the new wrapped DEK.\n //\n // Practical approach: Since the owner/admin has all raw DEKs in memory,\n // and each user's keyring contains their salt, we need the users to\n // re-authenticate to get the new wrapped keys. This is the standard approach.\n //\n // For NOYDB Phase 2: we'll update the keyring file to include a \"pending_rekey\"\n // flag. Users will get new DEKs on next login when the owner provides them.\n //\n // SIMPLER approach used here: Since the owner performed the rotation,\n // the owner has both old and new DEKs. We store a \"rekey token\" that the\n // user can use to unwrap: we wrap the new DEK with the OLD DEK (which the\n // user can still unwrap from their keyring, since their keyring has the old\n // wrapped DEK and their KEK can unwrap it).\n\n // Actually even simpler: we just need the user's KEK. We don't have it.\n // The spec says the owner wraps new DEKs for each remaining user.\n // This requires knowing each user's KEK (or having a shared secret).\n //\n // The CORRECT implementation from the spec: the owner/admin has all DEKs.\n // Each user's keyring stores DEKs wrapped with THAT USER's KEK.\n // To re-wrap, we need each user's KEK — which we can't get.\n //\n // Real-world solution: use a KEY ESCROW approach where the owner stores\n // each user's wrapping key (not their passphrase, but a key derived from\n // the grant process). During grant, the owner stores a copy of the new user's\n // KEK (wrapped with the owner's KEK) so they can re-wrap later.\n //\n // For now: mark the user's keyring as needing rekey. The user will need to\n // re-authenticate (owner provides new passphrase or re-grants).\n\n // Update: simplest correct approach — during grant, we store the user's KEK\n // wrapped with the owner's KEK in a separate escrow field. Then during rotation,\n // the owner unwraps the user's KEK from escrow and wraps the new DEKs.\n //\n // BUT: that means we need to change the KeyringFile format.\n // For Phase 2 MVP: just delete the user's old DEK entries and require re-grant.\n // This is secure (revoked keys are gone) but inconvenient (remaining users\n // need re-grant for rotated collections).\n\n // PHASE 2 APPROACH: Remove the affected collection DEKs from remaining users'\n // keyrings. The owner must re-grant access to those collections.\n // This is correct and secure — just requires the owner to re-run grant().\n\n const updatedDeks = { ...userKeyringFile.deks }\n for (const collName of collections) {\n delete updatedDeks[collName]\n }\n\n const updatedPermissions = { ...userKeyringFile.permissions }\n for (const collName of collections) {\n delete updatedPermissions[collName]\n }\n\n const updatedKeyring: KeyringFile = {\n ...userKeyringFile,\n deks: updatedDeks,\n permissions: updatedPermissions,\n }\n\n await writeKeyringFile(adapter, vault, userId, updatedKeyring)\n }\n}\n\n// ─── Change Secret ─────────────────────────────────────────────────────\n\n/**\n * Change the user's passphrase. Re-wraps every DEK under the new KEK.\n *\n * Pass `{ validate: true }` (or a `PassphrasePolicy`) to gate the new\n * phrase on the strength rules. `db.rotatePassphrase()` adds a\n * `checkGate('rotate-passphrase')` step on top of this primitive and\n * always validates.\n */\nexport async function changeSecret(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n newPassphrase: string,\n passphraseOpts?: PassphrasePolicy & { validate?: boolean; allowWeakPassphrase?: boolean },\n): Promise<UnlockedKeyring> {\n if (passphraseOpts?.validate && !passphraseOpts.allowWeakPassphrase) {\n assertStrongPassphrase(newPassphrase, passphraseOpts)\n }\n const newSalt = generateSalt()\n const newKek = await deriveKey(newPassphrase, newSalt)\n\n // Re-wrap all DEKs with the new KEK\n const wrappedDeks: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: keyring.userId,\n display_name: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: keyring.userId,\n }\n\n await writeKeyringFile(adapter, vault, keyring.userId, keyringFile)\n\n return {\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: keyring.deks, // Same DEKs, different wrapping\n kek: newKek,\n salt: newSalt,\n // Tier-2 slots are NOT preserved through `changeSecret` —\n // each slot wraps the OLD KEK, so the new keyring has no\n // authenticator slots until the user re-enrolls. The higher-level\n // `db.rotatePassphrase()` (#10) preserves slots by rewrapping the\n // KEK reference, not the KEK itself.\n authenticators: [],\n ...(keyring.policy !== undefined && { policy: keyring.policy }),\n }\n}\n\n// ─── Bundle recipients ──────────────────────────────────────────\n\n/**\n * Recipient slot in a re-keyed `.noydb` bundle. Each slot becomes its\n * own keyring file inside the bundle, sealed with its own passphrase.\n * Same role/permission semantics as `db.grant()` but no adapter side\n * effect — the slot only exists inside the bundle bytes.\n *\n * @public\n */\nexport interface BundleRecipient {\n /** User id stamped onto the keyring file in the bundle. */\n readonly id: string\n /** Optional display name. Defaults to `id`. */\n readonly displayName?: string\n /** Passphrase the recipient will type to unlock. */\n readonly passphrase: string\n /** Role on the destination vault. Defaults to `'viewer'`. */\n readonly role?: Role\n /**\n * Per-collection permissions. When omitted, role defaults apply.\n * Restricting permissions here ALSO restricts which DEKs are wrapped\n * into the slot — a slot with `{ invoices: 'ro' }` cannot decrypt\n * other collections even though their ciphertext sits in the bundle.\n */\n readonly permissions?: Permissions\n /**\n * Optional `as-*` export grants on the destination vault.\n * Mirrors the `exportCapability` field on a live keyring.\n */\n readonly exportCapability?: ExportCapability\n /**\n * Optional `as-*` import grants on the destination vault.\n * Mirrors the `importCapability` field on a live keyring.\n * Default-closed: no plaintext format granted, no bundle import.\n */\n readonly importCapability?: ImportCapability\n /**\n * Optional bundle-slot expiry. ISO-8601 timestamp; past the\n * cutoff this slot's keyring refuses to load with\n * `KeyringExpiredError`. Time-boxed audit access pattern: \"this\n * slot works for 30 days then becomes opaque to its holder.\"\n */\n readonly expiresAt?: string\n}\n\n/**\n * Build a `KeyringFile` for one bundle recipient, given the source\n * vault's unwrapped DEKs. Mirrors `grant()` minus the adapter write —\n * the produced file is meant to be embedded in the bundle's\n * `keyrings` map, never persisted to the source vault.\n *\n * Privilege-escalation check still runs: every DEK wrapped into the\n * recipient's keyring must come from the source's own DEK set.\n *\n * @internal\n */\nexport async function buildRecipientKeyringFile(\n callerKeyring: UnlockedKeyring,\n recipient: BundleRecipient,\n): Promise<KeyringFile> {\n const role: Role = recipient.role ?? 'viewer'\n const permissions = resolvePermissions(role, recipient.permissions)\n\n const newSalt = generateSalt()\n const newKek = await deriveKey(recipient.passphrase, newSalt)\n\n const wrappedDeks: Record<string, string> = {}\n\n // Collections the recipient was explicitly granted permission to.\n for (const collName of Object.keys(permissions)) {\n const dek = callerKeyring.deks.get(collName)\n if (dek) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // owner / admin / viewer: wrap every known DEK (matches grant).\n if (role === 'owner' || role === 'admin' || role === 'viewer') {\n for (const [collName, dek] of callerKeyring.deks) {\n if (!(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n }\n\n // Always propagate system-prefixed collection DEKs (`_ledger`, etc.) —\n // the recipient needs them to verify the bundle on import.\n for (const [collName, dek] of callerKeyring.deks) {\n if (collName.startsWith('_') && !(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // Anti-privilege-escalation: every wrapped DEK must come from the\n // caller's own DEK set. Belt-and-braces with the lookups above.\n for (const collName of Object.keys(wrappedDeks)) {\n if (!callerKeyring.deks.has(collName)) {\n throw new PrivilegeEscalationError(collName)\n }\n }\n\n return {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: recipient.id,\n display_name: recipient.displayName ?? recipient.id,\n role,\n permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: callerKeyring.userId,\n ...(recipient.exportCapability !== undefined\n ? { export_capability: recipient.exportCapability }\n : {}),\n ...(recipient.importCapability !== undefined\n ? { import_capability: recipient.importCapability }\n : {}),\n ...(recipient.expiresAt !== undefined\n ? { expires_at: recipient.expiresAt }\n : {}),\n }\n}\n\n// ─── List Users ────────────────────────────────────────────────────────\n\n/** List all users with access to a vault. */\nexport async function listUsers(\n adapter: NoydbStore,\n vault: string,\n): Promise<UserInfo[]> {\n const userIds = await adapter.list(vault, '_keyring')\n const users: UserInfo[] = []\n\n for (const userId of userIds) {\n const envelope = await adapter.get(vault, '_keyring', userId)\n if (!envelope) continue\n const kf = JSON.parse(envelope._data) as KeyringFile\n users.push({\n userId: kf.user_id,\n displayName: kf.display_name,\n role: kf.role,\n permissions: kf.permissions,\n createdAt: kf.created_at,\n grantedBy: kf.granted_by,\n })\n }\n\n return users\n}\n\n/**\n * Joined enumeration: every keyring + its `_users/<keyringId>`\n * envelope side by side. Convenience for admin UIs that want to\n * render team-member lists with profile data (\"Bob — operator —\n * 'Bob the Auditor' avatar X locale fr-FR\") in a single pass.\n *\n * `userEnvelopeDek` is the vault's `_users` collection DEK\n * (`vault.getDEK('_users')`); used to decrypt every envelope.\n *\n * Principals without a persisted envelope (legacy keyrings predating\n * the user-envelope feature) come back with `envelope: null`. The\n * caller chooses how to render — usually \"fall back to keyring's\n * `displayName`\".\n *\n * Order matches `listUsers()` (store-defined; sort if you need a\n * stable display order).\n */\nexport async function listUsersWithEnvelopes<T = unknown>(\n adapter: NoydbStore,\n vault: string,\n userEnvelopeDek: CryptoKey,\n): Promise<Array<{ user: UserInfo; envelope: UserEnvelopeReader<T> | null }>> {\n const users = await listUsers(adapter, vault)\n const out: Array<{ user: UserInfo; envelope: UserEnvelopeReader<T> | null }> = []\n for (const user of users) {\n const envelope = await loadUserEnvelopeFn<T>(\n adapter,\n vault,\n user.userId,\n userEnvelopeDek,\n )\n out.push({ user, envelope })\n }\n return out\n}\n\n\n// ─── DEK Management ────────────────────────────────────────────────────\n\n/** Ensure a DEK exists for a collection. Generates one if new. */\nexport async function ensureCollectionDEK(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<(collectionName: string) => Promise<CryptoKey>> {\n // Dedupe concurrent first-time DEK creates per collection. Without\n // this, two concurrent `getDEK('foo')` calls both pass the `existing`\n // check (the Map is empty), both generate fresh DEKs, and the second\n // `set` overwrites the first — making any envelope encrypted with\n // the discarded DEK fail to decrypt later (TamperedError on read).\n // Pre-existing race exposed by the multi-writer ledger work in #296.\n const inFlight = new Map<string, Promise<CryptoKey>>()\n return async (collectionName: string): Promise<CryptoKey> => {\n const existing = keyring.deks.get(collectionName)\n if (existing) return existing\n const pending = inFlight.get(collectionName)\n if (pending) return pending\n\n const promise = (async () => {\n const dek = await generateDEK()\n keyring.deks.set(collectionName, dek)\n await persistKeyring(adapter, vault, keyring)\n return dek\n })()\n inFlight.set(collectionName, promise)\n try {\n return await promise\n } finally {\n inFlight.delete(collectionName)\n }\n }\n}\n\n// ─── Permission Checks ─────────────────────────────────────────────────\n\n/** Check if a user has write permission for a collection. */\nexport function hasWritePermission(keyring: UnlockedKeyring, collectionName: string): boolean {\n if (keyring.role === 'owner' || keyring.role === 'admin') return true\n if (keyring.role === 'viewer' || keyring.role === 'client') return false\n return keyring.permissions[collectionName] === 'rw'\n}\n\n/** Check if a user has any access to a collection. */\nexport function hasAccess(keyring: UnlockedKeyring, collectionName: string): boolean {\n if (keyring.role === 'owner' || keyring.role === 'admin' || keyring.role === 'viewer') return true\n return collectionName in keyring.permissions\n}\n\n// ─── Helpers ───────────────────────────────────────────────────────────\n\n/** Persist a keyring file to the adapter. */\nexport async function persistKeyring(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<void> {\n if (!keyring.kek) {\n throw new ValidationError(\n 'persistKeyring: keyring.kek is null — cannot wrap DEKs without the KEK. ' +\n 'This typically means the keyring was opened via tier-3 PIN resume, ' +\n 'session restore, or a wrap-DEKs tier-2 unlock. Re-authenticate at ' +\n 'tier 1 (passphrase) before persisting.',\n )\n }\n const wrappedDeks: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n wrappedDeks[collName] = await wrapKey(dek, keyring.kek)\n }\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: keyring.userId,\n display_name: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(keyring.salt),\n created_at: new Date().toISOString(),\n granted_by: keyring.userId,\n ...(keyring.exportCapability !== undefined && { export_capability: keyring.exportCapability }),\n ...(keyring.importCapability !== undefined && { import_capability: keyring.importCapability }),\n ...(keyring.authenticators.length > 0 && { authenticators: keyring.authenticators }),\n ...(keyring.policy !== undefined && { policy: keyring.policy }),\n }\n\n await writeKeyringFile(adapter, vault, keyring.userId, keyringFile)\n}\n\n// ─── Export capability ──────────────────────────────────────\n\n/**\n * Role-based default policy for the encrypted-bundle capability.\n *\n * Applied when `keyring.exportCapability` is absent or\n * `exportCapability.bundle` is undefined:\n *\n * - `owner` / `admin` → `true` (happy-path backup without friction)\n * - `operator` / `viewer` / `client` → `false` (explicit grant required)\n *\n * Rationale: a bundle is inert without the KEK, so an owner backing up\n * their own vault doesn't need friction; a non-admin role producing a\n * bundle for an external party does, because the bundle outlives\n * keyring revocation.\n */\nfunction defaultBundleCapability(role: Role): boolean {\n return role === 'owner' || role === 'admin'\n}\n\n/**\n * Check whether a keyring is authorised for a given `@noy-db/as-*`\n * export tier.\n *\n * - `tier: 'plaintext'` — returns true iff `exportCapability.plaintext`\n * contains the requested `format` or the `'*'` wildcard. Default for\n * every role is empty — no grant, no plaintext export.\n * - `tier: 'bundle'` — returns `exportCapability.bundle` if present, or\n * the role-based default otherwise (owner/admin → true, else false).\n *\n * `@noy-db/as-*` packages MUST call this before invoking the underlying\n * export primitive. Rogue forks that skip the check are caught by code\n * review — the single-entry-point contract is a convention, not a\n * runtime invariant. Vault-level gated wrappers\n * (`vault.exportRecords` / `exportBlobs` / `writeBundle`) will land in a\n * follow-up PR to enforce at the primitive level.\n */\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'bundle',\n): boolean\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n const cap = keyring.exportCapability\n if (tier === 'plaintext') {\n const allowed = cap?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n // tier === 'bundle'\n return cap?.bundle ?? defaultBundleCapability(keyring.role)\n}\n\n/**\n * Same-shape inspector for an `ExportCapability` value that isn't yet\n * attached to a keyring (e.g. for previewing a grant before applying).\n * Role must be supplied separately so bundle defaults can be computed.\n */\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'bundle',\n): boolean\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n if (tier === 'plaintext') {\n const allowed = capability?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n return capability?.bundle ?? defaultBundleCapability(role)\n}\n\n// ─── Import capability (issue ) ────────────────────────────────────\n\n/**\n * Check whether a keyring is authorised for a given `@noy-db/as-*`\n * import tier (issue ).\n *\n * - `tier: 'plaintext'` — true iff `importCapability.plaintext`\n * contains the requested `format` or the `'*'` wildcard.\n * - `tier: 'bundle'` — true iff `importCapability.bundle === true`.\n *\n * **Default-closed for every role on every dimension** — including\n * owner. Import is more dangerous than export (corrupts vs leaks), so\n * the policy refuses to assume intent. Owners must positively grant\n * the capability via `vault.grant({ importCapability: ... })`.\n */\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'bundle',\n): boolean\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n const cap = keyring.importCapability\n if (tier === 'plaintext') {\n const allowed = cap?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n // tier === 'bundle' — closed default for every role\n return cap?.bundle === true\n}\n\n/**\n * Same-shape inspector for an `ImportCapability` value that isn't yet\n * attached to a keyring (e.g. previewing a grant before applying).\n * `role` is accepted for symmetry with `evaluateExportCapability` even\n * though the import policy ignores it — bundle defaults are\n * role-agnostic and closed.\n */\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n role: Role,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n role: Role,\n tier: 'bundle',\n): boolean\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n _role: Role,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n if (tier === 'plaintext') {\n const allowed = capability?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n return capability?.bundle === true\n}\n\nfunction resolvePermissions(role: Role, explicit?: Permissions): Permissions {\n if (role === 'owner' || role === 'admin' || role === 'viewer') return {}\n return explicit ?? {}\n}\n\nasync function writeKeyringFile(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n keyringFile: KeyringFile,\n): Promise<void> {\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(keyringFile),\n }\n await adapter.put(vault, '_keyring', userId, envelope)\n}\n","/**\n * Sync scheduling policy.\n *\n * ## What it controls\n *\n * A {@link SyncPolicy} has two halves:\n * - **push** ({@link PushPolicy}) — when dirty local writes are sent to the remote.\n * - **pull** ({@link PullPolicy}) — when the remote is polled for new data.\n *\n * ## Choosing a policy\n *\n * The right policy depends on the backend's operational characteristics:\n *\n * | Backend type | Recommended policy |\n * |---|---|\n * | Per-record (DynamoDB, S3, IDB) | {@link INDEXED_STORE_POLICY} — `on-change` push, `manual` pull |\n * | Bundle (Drive, WebDAV, Git) | {@link BUNDLE_STORE_POLICY} — `debounce` push, `interval` pull |\n *\n * Consumers can override via `createNoydb({ syncPolicy: { ... } })`:\n *\n * ```ts\n * const db = await createNoydb({\n * store: jsonFile({ dir: './data' }),\n * syncPolicy: {\n * push: { mode: 'debounce', debounceMs: 5_000 },\n * pull: { mode: 'on-focus' },\n * },\n * })\n * ```\n *\n * ## Scheduler lifecycle\n *\n * {@link SyncScheduler} owns all timers, debounce logic, and browser lifecycle\n * hooks (`visibilitychange`, `pagehide`, `beforeExit`). Call `scheduler.start()`\n * after opening a vault and `scheduler.stop()` when closing it. The scheduler\n * delegates actual push/pull work to {@link SyncSchedulerCallbacks} provided\n * by the {@link SyncEngine}.\n *\n * @module\n */\n\n// ─── Policy types ───────────────────────────────────────────────────────\n\n/**\n * When push operations are triggered automatically.\n *\n * - `'manual'` — only on explicit `sync.push()` calls.\n * - `'on-change'` — immediately after every local write (respecting `minIntervalMs`).\n * - `'debounce'` — after `debounceMs` of inactivity following a write.\n * - `'interval'` — on a fixed timer regardless of writes.\n */\nexport type PushMode = 'manual' | 'on-change' | 'debounce' | 'interval'\n\n/**\n * When pull operations are triggered automatically.\n *\n * - `'manual'` — only on explicit `sync.pull()` calls.\n * - `'interval'` — on a fixed `intervalMs` timer.\n * - `'on-focus'` — when the browser tab regains visibility.\n */\nexport type PullMode = 'manual' | 'interval' | 'on-focus'\n\n/**\n * Push half of a sync policy. Controls the trigger mode and timing guards\n * for outbound sync operations.\n */\nexport interface PushPolicy {\n /** Push trigger mode. */\n readonly mode: PushMode\n /** Debounce delay in ms. Only used when `mode: 'debounce'`. Default: 30_000. */\n readonly debounceMs?: number\n /** Interval in ms between automatic pushes. Used by `'interval'` and as floor for `'debounce'`. */\n readonly intervalMs?: number\n /**\n * Hard floor between pushes regardless of mode. Prevents burst writes\n * from hammering the remote. Default: 0 (no floor).\n */\n readonly minIntervalMs?: number\n /**\n * Force a push on page unload (`pagehide` / `visibilitychange → hidden`)\n * in browsers, `beforeExit` in Node. Default: true for non-manual modes.\n */\n readonly onUnload?: boolean\n}\n\n/**\n * Pull half of a sync policy. Controls when and how often inbound sync\n * operations are triggered.\n */\nexport interface PullPolicy {\n /** Pull trigger mode. */\n readonly mode: PullMode\n /** Interval in ms between automatic pulls. Used by `'interval'` mode. Default: 60_000. */\n readonly intervalMs?: number\n}\n\n/**\n * Combined push + pull sync scheduling policy for a vault.\n *\n * Pass via `createNoydb({ syncPolicy })` to override the default policy\n * derived from the active store type. Pre-built defaults are available\n * as `INDEXED_STORE_POLICY` and `BUNDLE_STORE_POLICY`.\n */\nexport interface SyncPolicy {\n readonly push: PushPolicy\n readonly pull: PullPolicy\n}\n\n// ─── Default policies by store category ─────────────────────────────────\n\n/** Default for per-record stores (DynamoDB, S3, file, IDB). */\nexport const INDEXED_STORE_POLICY: SyncPolicy = {\n push: { mode: 'on-change', minIntervalMs: 0, onUnload: true },\n pull: { mode: 'manual' },\n}\n\n/** Default for bundle stores (Drive, WebDAV, Git). */\nexport const BUNDLE_STORE_POLICY: SyncPolicy = {\n push: { mode: 'debounce', debounceMs: 30_000, minIntervalMs: 120_000, onUnload: true },\n pull: { mode: 'interval', intervalMs: 60_000 },\n}\n\n// ─── Sync scheduler ─────────────────────────────────────────────────────\n\n/**\n * Current operational state of the `SyncScheduler`.\n *\n * - `'idle'` — no pending or active sync operations.\n * - `'pending'` — local writes are queued, waiting for debounce/interval to fire.\n * - `'pushing'` — push in progress.\n * - `'pulling'` — pull in progress.\n * - `'error'` — last sync operation failed; `lastError` holds the cause.\n */\nexport type SyncSchedulerState = 'idle' | 'pending' | 'pushing' | 'pulling' | 'error'\n\n/**\n * Snapshot of the sync scheduler's state, returned by `SyncScheduler.status`.\n * Safe to expose in a reactive UI status indicator.\n */\nexport interface SyncSchedulerStatus {\n readonly state: SyncSchedulerState\n readonly lastPushAt: string | null\n readonly lastPullAt: string | null\n readonly lastError: Error | null\n readonly pendingWrites: number\n}\n\n/**\n * Callbacks injected into `SyncScheduler` by the SyncEngine.\n *\n * The scheduler owns timers and lifecycle hooks; it delegates actual push/pull\n * work to these callbacks to stay decoupled from the sync implementation.\n */\nexport interface SyncSchedulerCallbacks {\n push(): Promise<void>\n pull(): Promise<void>\n getDirtyCount(): number\n}\n\n/**\n * Manages sync timing according to a `SyncPolicy`.\n *\n * The scheduler owns all timers and lifecycle hooks. It delegates actual\n * push/pull work to callbacks provided by the SyncEngine.\n */\nexport class SyncScheduler {\n private readonly policy: SyncPolicy\n private readonly callbacks: SyncSchedulerCallbacks\n\n private _state: SyncSchedulerState = 'idle'\n private _lastPushAt: string | null = null\n private _lastPullAt: string | null = null\n private _lastError: Error | null = null\n private _lastPushTime = 0 // monotonic ms for minIntervalMs enforcement\n\n // Timers\n private debounceTimer: ReturnType<typeof setTimeout> | null = null\n private pushIntervalTimer: ReturnType<typeof setInterval> | null = null\n private pullIntervalTimer: ReturnType<typeof setInterval> | null = null\n\n // Bound handlers for cleanup\n private readonly boundOnVisibilityChange: (() => void) | null = null\n private readonly boundOnBeforeExit: (() => void) | null = null\n private readonly boundOnPageHide: (() => void) | null = null\n\n private started = false\n\n constructor(policy: SyncPolicy, callbacks: SyncSchedulerCallbacks) {\n this.policy = policy\n this.callbacks = callbacks\n\n // Pre-bind handlers\n if (this.shouldRegisterUnload()) {\n this.boundOnVisibilityChange = this.handleVisibilityChange.bind(this)\n this.boundOnPageHide = this.handlePageHide.bind(this)\n this.boundOnBeforeExit = this.handleBeforeExit.bind(this)\n }\n }\n\n /** Current scheduler status snapshot. */\n get status(): SyncSchedulerStatus {\n return {\n state: this._state,\n lastPushAt: this._lastPushAt,\n lastPullAt: this._lastPullAt,\n lastError: this._lastError,\n pendingWrites: this.callbacks.getDirtyCount(),\n }\n }\n\n /** Start the scheduler — registers timers, event listeners. */\n start(): void {\n if (this.started) return\n this.started = true\n\n // Push: interval mode\n if (this.policy.push.mode === 'interval' && this.policy.push.intervalMs) {\n this.pushIntervalTimer = setInterval(() => {\n void this.executePush()\n }, this.policy.push.intervalMs)\n }\n\n // Pull: interval mode\n if (this.policy.pull.mode === 'interval' && this.policy.pull.intervalMs) {\n this.pullIntervalTimer = setInterval(() => {\n void this.executePull()\n }, this.policy.pull.intervalMs)\n }\n\n // Pull: on-focus mode\n if (this.policy.pull.mode === 'on-focus' && typeof document !== 'undefined') {\n document.addEventListener('visibilitychange', this.handleFocusPull)\n }\n\n // Unload hooks\n if (this.shouldRegisterUnload()) {\n if (typeof document !== 'undefined' && this.boundOnVisibilityChange) {\n document.addEventListener('visibilitychange', this.boundOnVisibilityChange)\n }\n if (typeof globalThis.addEventListener === 'function' && this.boundOnPageHide) {\n globalThis.addEventListener('pagehide', this.boundOnPageHide)\n }\n if (typeof process !== 'undefined' && this.boundOnBeforeExit) {\n process.on('beforeExit', this.boundOnBeforeExit)\n }\n }\n }\n\n /** Stop the scheduler — clears timers, removes event listeners. */\n stop(): void {\n if (!this.started) return\n this.started = false\n\n if (this.debounceTimer) {\n clearTimeout(this.debounceTimer)\n this.debounceTimer = null\n }\n if (this.pushIntervalTimer) {\n clearInterval(this.pushIntervalTimer)\n this.pushIntervalTimer = null\n }\n if (this.pullIntervalTimer) {\n clearInterval(this.pullIntervalTimer)\n this.pullIntervalTimer = null\n }\n\n // Focus pull\n if (this.policy.pull.mode === 'on-focus' && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.handleFocusPull)\n }\n\n // Unload hooks\n if (typeof document !== 'undefined' && this.boundOnVisibilityChange) {\n document.removeEventListener('visibilitychange', this.boundOnVisibilityChange)\n }\n if (typeof globalThis.removeEventListener === 'function' && this.boundOnPageHide) {\n globalThis.removeEventListener('pagehide', this.boundOnPageHide)\n }\n if (typeof process !== 'undefined' && this.boundOnBeforeExit) {\n process.removeListener('beforeExit', this.boundOnBeforeExit)\n }\n }\n\n /**\n * Notify the scheduler that a local write occurred.\n * For `on-change` mode: triggers immediate push (respecting minIntervalMs).\n * For `debounce` mode: resets the debounce timer.\n * For `manual` / `interval`: no-op.\n */\n notifyChange(): void {\n if (!this.started) return\n\n if (this.policy.push.mode === 'on-change') {\n void this.executePush()\n } else if (this.policy.push.mode === 'debounce') {\n this.resetDebounce()\n }\n }\n\n /** Force an immediate push, bypassing the scheduler. */\n async forcePush(): Promise<void> {\n await this.executePush()\n }\n\n /** Force an immediate pull, bypassing the scheduler. */\n async forcePull(): Promise<void> {\n await this.executePull()\n }\n\n // ─── Internal ─────────────────────────────────────────────────────\n\n private async executePush(): Promise<void> {\n if (this._state === 'pushing') return // already in progress\n\n // minIntervalMs enforcement\n const minInterval = this.policy.push.minIntervalMs ?? 0\n if (minInterval > 0) {\n const elapsed = Date.now() - this._lastPushTime\n if (elapsed < minInterval) {\n // Schedule for later if debounce mode\n if (this.policy.push.mode === 'debounce') {\n this.scheduleDebounce(minInterval - elapsed)\n }\n return\n }\n }\n\n // Nothing to push\n if (this.callbacks.getDirtyCount() === 0) {\n this._state = 'idle'\n return\n }\n\n this._state = 'pushing'\n try {\n await this.callbacks.push()\n this._lastPushAt = new Date().toISOString()\n this._lastPushTime = Date.now()\n this._lastError = null\n this._state = this.callbacks.getDirtyCount() > 0 ? 'pending' : 'idle'\n } catch (err) {\n this._lastError = err instanceof Error ? err : new Error(String(err))\n this._state = 'error'\n }\n }\n\n private async executePull(): Promise<void> {\n if (this._state === 'pulling') return\n\n const previousState = this._state\n this._state = 'pulling'\n try {\n await this.callbacks.pull()\n this._lastPullAt = new Date().toISOString()\n this._lastError = null\n this._state = previousState === 'pending' ? 'pending' : 'idle'\n } catch (err) {\n this._lastError = err instanceof Error ? err : new Error(String(err))\n this._state = 'error'\n }\n }\n\n private resetDebounce(): void {\n if (this.debounceTimer) clearTimeout(this.debounceTimer)\n const ms = this.policy.push.debounceMs ?? 30_000\n this._state = 'pending'\n this.scheduleDebounce(ms)\n }\n\n private scheduleDebounce(ms: number): void {\n if (this.debounceTimer) clearTimeout(this.debounceTimer)\n this.debounceTimer = setTimeout(() => {\n this.debounceTimer = null\n void this.executePush()\n }, ms)\n }\n\n private shouldRegisterUnload(): boolean {\n const onUnload = this.policy.push.onUnload\n if (onUnload !== undefined) return onUnload\n return this.policy.push.mode !== 'manual'\n }\n\n // ─── Event handlers ───────────────────────────────────────────────\n\n private handleVisibilityChange(): void {\n if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {\n this.fireUnloadPush()\n }\n }\n\n private handlePageHide(): void {\n this.fireUnloadPush()\n }\n\n private handleBeforeExit(): void {\n this.fireUnloadPush()\n }\n\n private handleFocusPull = (): void => {\n if (typeof document !== 'undefined' && document.visibilityState === 'visible') {\n void this.executePull()\n }\n }\n\n private fireUnloadPush(): void {\n if (this.callbacks.getDirtyCount() === 0) return\n // Best-effort synchronous-ish push on unload\n void this.callbacks.push().catch(() => {})\n }\n}\n","import type {\n NoydbStore,\n DirtyEntry,\n Conflict,\n ConflictStrategy,\n CollectionConflictResolver,\n PushOptions,\n PullOptions,\n PushResult,\n PullResult,\n SyncStatus,\n EncryptedEnvelope,\n SyncMetadata,\n SyncTargetRole,\n} from '../types.js'\nimport { NOYDB_SYNC_VERSION } from '../types.js'\nimport { ConflictError } from '../errors.js'\nimport type { NoydbEventEmitter } from '../events.js'\nimport type { SyncPolicy } from '../store/sync-policy.js'\nimport { SyncScheduler } from '../store/sync-policy.js'\n\n/** Sync engine: dirty tracking, push, pull, conflict resolution, scheduling. */\nexport class SyncEngine {\n private readonly local: NoydbStore\n private readonly remote: NoydbStore\n private readonly strategy: ConflictStrategy\n private readonly emitter: NoydbEventEmitter\n private readonly vault: string\n readonly role: SyncTargetRole\n readonly label: string | undefined\n\n private dirty: DirtyEntry[] = []\n private lastPush: string | null = null\n private lastPull: string | null = null\n private loaded = false\n private autoSyncInterval: ReturnType<typeof setInterval> | null = null\n private isOnline = true\n\n /** Sync scheduler. Manages push/pull timing. */\n readonly scheduler: SyncScheduler | null\n\n /** Per-collection conflict resolvers registered by Collection instances. */\n private readonly conflictResolvers = new Map<string, CollectionConflictResolver>()\n\n constructor(opts: {\n local: NoydbStore\n remote: NoydbStore\n vault: string\n strategy: ConflictStrategy\n emitter: NoydbEventEmitter\n syncPolicy?: SyncPolicy\n role?: SyncTargetRole\n label?: string\n }) {\n this.local = opts.local\n this.remote = opts.remote\n this.vault = opts.vault\n this.strategy = opts.strategy\n this.emitter = opts.emitter\n this.role = opts.role ?? 'sync-peer'\n this.label = opts.label\n\n // Create scheduler if a policy is provided\n const policy = opts.syncPolicy\n if (policy && policy.push.mode !== 'manual') {\n this.scheduler = new SyncScheduler(policy, {\n push: () => this.push().then(() => {}),\n pull: () => this.pull().then(() => {}),\n getDirtyCount: () => this.dirty.length,\n })\n } else {\n this.scheduler = null\n }\n }\n\n /** Start the sync scheduler. Called after vault is fully opened. */\n startScheduler(): void {\n this.scheduler?.start()\n }\n\n /** Stop the sync scheduler. Called on close. */\n stopScheduler(): void {\n this.scheduler?.stop()\n }\n\n /**\n * Register a per-collection conflict resolver.\n * Called by Collection when `conflictPolicy` is set.\n */\n registerConflictResolver(collection: string, resolver: CollectionConflictResolver): void {\n this.conflictResolvers.set(collection, resolver)\n }\n\n /** Record a local change for later push. */\n async trackChange(collection: string, id: string, action: 'put' | 'delete', version: number): Promise<void> {\n await this.ensureLoaded()\n\n // Deduplicate: if same collection+id already in dirty, update it\n const idx = this.dirty.findIndex(d => d.collection === collection && d.id === id)\n const entry: DirtyEntry = {\n vault: this.vault,\n collection,\n id,\n action,\n version,\n timestamp: new Date().toISOString(),\n }\n\n if (idx >= 0) {\n this.dirty[idx] = entry\n } else {\n this.dirty.push(entry)\n }\n\n await this.persistMeta()\n\n // Notify scheduler of the write (triggers on-change or debounce)\n this.scheduler?.notifyChange()\n }\n\n /** Push dirty records to remote adapter. Accepts optional `PushOptions` for partial sync. */\n async push(options?: PushOptions): Promise<PushResult> {\n await this.ensureLoaded()\n\n let pushed = 0\n const conflicts: Conflict[] = []\n const errors: Error[] = []\n const completed: number[] = []\n\n for (let i = 0; i < this.dirty.length; i++) {\n const entry = this.dirty[i]!\n\n // Partial sync: skip collections not in the filter\n if (options?.collections && !options.collections.includes(entry.collection)) {\n continue\n }\n\n try {\n if (entry.action === 'delete') {\n await this.remote.delete(this.vault, entry.collection, entry.id)\n completed.push(i)\n pushed++\n } else {\n const envelope = await this.local.get(this.vault, entry.collection, entry.id)\n if (!envelope) {\n // Record was deleted locally after being marked dirty\n completed.push(i)\n continue\n }\n\n try {\n await this.remote.put(\n this.vault,\n entry.collection,\n entry.id,\n envelope,\n entry.version - 1,\n )\n completed.push(i)\n pushed++\n } catch (err) {\n if (err instanceof ConflictError) {\n const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id)\n if (remoteEnvelope) {\n const { handled, conflict } = await this.handleConflict(\n entry.collection,\n entry.id,\n envelope,\n remoteEnvelope,\n 'push',\n )\n conflicts.push(conflict)\n if (handled === 'local') {\n await this.remote.put(this.vault, entry.collection, entry.id, conflict.local)\n completed.push(i)\n pushed++\n } else if (handled === 'remote') {\n await this.local.put(this.vault, entry.collection, entry.id, conflict.remote)\n completed.push(i)\n } else if (handled === 'merged' && conflict.local !== envelope) {\n // Merged envelope is stored in conflict.local (the winner)\n const merged = conflict.local\n await this.remote.put(this.vault, entry.collection, entry.id, merged)\n await this.local.put(this.vault, entry.collection, entry.id, merged)\n completed.push(i)\n pushed++\n }\n // handled === 'deferred': leave in dirty log\n }\n } else {\n throw err\n }\n }\n }\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n }\n\n // Remove completed entries from dirty log (reverse order to preserve indices)\n for (const i of completed.sort((a, b) => b - a)) {\n this.dirty.splice(i, 1)\n }\n\n this.lastPush = new Date().toISOString()\n await this.persistMeta()\n\n const result: PushResult = { pushed, conflicts, errors }\n this.emitter.emit('sync:push', result)\n return result\n }\n\n /** Pull remote records to local adapter. Accepts optional `PullOptions` for partial sync. */\n async pull(options?: PullOptions): Promise<PullResult> {\n await this.ensureLoaded()\n\n let pulled = 0\n const conflicts: Conflict[] = []\n const errors: Error[] = []\n\n try {\n const remoteSnapshot = await this.remote.loadAll(this.vault)\n\n for (const [collName, records] of Object.entries(remoteSnapshot)) {\n // Partial sync: skip collections not in the filter\n if (options?.collections && !options.collections.includes(collName)) {\n continue\n }\n\n for (const [id, remoteEnvelope] of Object.entries(records)) {\n // Partial sync: modifiedSince filter\n if (options?.modifiedSince && remoteEnvelope._ts <= options.modifiedSince) {\n continue\n }\n\n try {\n const localEnvelope = await this.local.get(this.vault, collName, id)\n\n if (!localEnvelope) {\n // New record from remote\n await this.local.put(this.vault, collName, id, remoteEnvelope)\n pulled++\n } else if (remoteEnvelope._v > localEnvelope._v) {\n // Remote is newer — check if we have a dirty entry for this\n const isDirty = this.dirty.some(d => d.collection === collName && d.id === id)\n if (isDirty) {\n // Both changed — conflict\n const { handled, conflict } = await this.handleConflict(\n collName,\n id,\n localEnvelope,\n remoteEnvelope,\n 'pull',\n )\n conflicts.push(conflict)\n if (handled === 'remote') {\n await this.local.put(this.vault, collName, id, conflict.remote)\n this.dirty = this.dirty.filter(d => !(d.collection === collName && d.id === id))\n pulled++\n } else if (handled === 'merged' && conflict.local !== localEnvelope) {\n const merged = conflict.local\n await this.local.put(this.vault, collName, id, merged)\n this.dirty = this.dirty.filter(d => !(d.collection === collName && d.id === id))\n pulled++\n }\n // 'local' or 'deferred': push handles it\n } else {\n // Remote is newer, no local changes — update\n await this.local.put(this.vault, collName, id, remoteEnvelope)\n pulled++\n }\n }\n // Same version or local is newer — skip (push will handle)\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n }\n }\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n\n this.lastPull = new Date().toISOString()\n await this.persistMeta()\n\n const result: PullResult = { pulled, conflicts, errors }\n this.emitter.emit('sync:pull', result)\n return result\n }\n\n /** Bidirectional sync: pull then push. */\n async sync(options?: { push?: PushOptions; pull?: PullOptions }): Promise<{ pull: PullResult; push: PushResult }> {\n const pullResult = await this.pull(options?.pull)\n const pushResult = await this.push(options?.push)\n return { pull: pullResult, push: pushResult }\n }\n\n /**\n * Push a specific subset of dirty entries (for sync transactions, ).\n * Entries are matched by collection+id from the dirty log; matched entries\n * are removed from the dirty log on success.\n */\n async pushFiltered(predicate: (entry: DirtyEntry) => boolean): Promise<PushResult> {\n await this.ensureLoaded()\n\n let pushed = 0\n const conflicts: Conflict[] = []\n const errors: Error[] = []\n const completed: number[] = []\n\n for (let i = 0; i < this.dirty.length; i++) {\n const entry = this.dirty[i]!\n if (!predicate(entry)) continue\n\n try {\n if (entry.action === 'delete') {\n await this.remote.delete(this.vault, entry.collection, entry.id)\n completed.push(i)\n pushed++\n } else {\n const envelope = await this.local.get(this.vault, entry.collection, entry.id)\n if (!envelope) {\n completed.push(i)\n continue\n }\n\n try {\n await this.remote.put(\n this.vault,\n entry.collection,\n entry.id,\n envelope,\n entry.version - 1,\n )\n completed.push(i)\n pushed++\n } catch (err) {\n if (err instanceof ConflictError) {\n const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id)\n if (remoteEnvelope) {\n const { handled, conflict } = await this.handleConflict(\n entry.collection,\n entry.id,\n envelope,\n remoteEnvelope,\n 'push',\n )\n conflicts.push(conflict)\n if (handled === 'local') {\n await this.remote.put(this.vault, entry.collection, entry.id, conflict.local)\n completed.push(i)\n pushed++\n } else if (handled === 'remote') {\n await this.local.put(this.vault, entry.collection, entry.id, conflict.remote)\n completed.push(i)\n } else if (handled === 'merged' && conflict.local !== envelope) {\n const merged = conflict.local\n await this.remote.put(this.vault, entry.collection, entry.id, merged)\n await this.local.put(this.vault, entry.collection, entry.id, merged)\n completed.push(i)\n pushed++\n }\n }\n } else {\n throw err\n }\n }\n }\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n }\n\n for (const i of completed.sort((a, b) => b - a)) {\n this.dirty.splice(i, 1)\n }\n\n this.lastPush = new Date().toISOString()\n await this.persistMeta()\n\n const result: PushResult = { pushed, conflicts, errors }\n this.emitter.emit('sync:push', result)\n return result\n }\n\n /** Get current sync status. */\n status(): SyncStatus {\n return {\n dirty: this.dirty.length,\n lastPush: this.lastPush,\n lastPull: this.lastPull,\n online: this.isOnline,\n }\n }\n\n // ─── Auto-Sync ───────────────────────────────────────────────────\n\n /** Start auto-sync: listen for online/offline events, optional periodic sync. */\n startAutoSync(intervalMs?: number): void {\n // Online/offline detection\n if (typeof globalThis.addEventListener === 'function') {\n globalThis.addEventListener('online', this.handleOnline)\n globalThis.addEventListener('offline', this.handleOffline)\n }\n\n // Periodic sync\n if (intervalMs && intervalMs > 0) {\n this.autoSyncInterval = setInterval(() => {\n if (this.isOnline) {\n void this.sync()\n }\n }, intervalMs)\n }\n }\n\n /** Stop auto-sync and scheduler. */\n stopAutoSync(): void {\n this.stopScheduler()\n if (typeof globalThis.removeEventListener === 'function') {\n globalThis.removeEventListener('online', this.handleOnline)\n globalThis.removeEventListener('offline', this.handleOffline)\n }\n if (this.autoSyncInterval) {\n clearInterval(this.autoSyncInterval)\n this.autoSyncInterval = null\n }\n }\n\n private handleOnline = (): void => {\n this.isOnline = true\n this.emitter.emit('sync:online', undefined as never)\n void this.sync()\n }\n\n private handleOffline = (): void => {\n this.isOnline = false\n this.emitter.emit('sync:offline', undefined as never)\n }\n\n /**\n * Resolve a conflict, checking per-collection resolvers first,\n * then falling back to the db-level `ConflictStrategy`.\n *\n * Returns the resolved `Conflict` object (possibly with `resolve` set for\n * manual mode) and a `handled` discriminant:\n * - `'local'` — keep the local envelope; push it to remote.\n * - `'remote'` — keep the remote envelope; update local.\n * - `'merged'` — a custom merge fn produced a new envelope stored as `conflict.local`.\n * - `'deferred'` — manual mode, resolve was not called synchronously.\n */\n private async handleConflict(\n collection: string,\n id: string,\n local: EncryptedEnvelope,\n remote: EncryptedEnvelope,\n _phase: 'push' | 'pull',\n ): Promise<{ handled: 'local' | 'remote' | 'merged' | 'deferred'; conflict: Conflict }> {\n const resolver = this.conflictResolvers.get(collection)\n\n if (resolver) {\n // Per-collection resolver is responsible for emitting sync:conflict\n // (manual policy emits with a resolve callback; LWW/FWW/custom are silent).\n const winner = await resolver(id, local, remote)\n const base: Conflict = {\n vault: this.vault,\n collection,\n id,\n local,\n remote,\n localVersion: local._v,\n remoteVersion: remote._v,\n }\n if (winner === null) return { handled: 'deferred', conflict: base }\n if (winner === local) return { handled: 'local', conflict: base }\n if (winner === remote) return { handled: 'remote', conflict: base }\n // Custom merge fn produced a new envelope — store as conflict.local for the caller\n return {\n handled: 'merged',\n conflict: { ...base, local: winner, localVersion: winner._v },\n }\n }\n\n // Fall back to db-level strategy — emit once\n const baseConflict: Conflict = {\n vault: this.vault,\n collection,\n id,\n local,\n remote,\n localVersion: local._v,\n remoteVersion: remote._v,\n }\n this.emitter.emit('sync:conflict', baseConflict)\n const side = this.legacyResolve(baseConflict)\n return { handled: side, conflict: baseConflict }\n }\n\n /** DB-level ConflictStrategy resolution (legacy, kept for backward compat). */\n private legacyResolve(conflict: Conflict): 'local' | 'remote' {\n if (typeof this.strategy === 'function') {\n return this.strategy(conflict)\n }\n switch (this.strategy) {\n case 'local-wins': return 'local'\n case 'remote-wins': return 'remote'\n case 'version':\n default:\n return conflict.localVersion >= conflict.remoteVersion ? 'local' : 'remote'\n }\n }\n\n // ─── Persistence ─────────────────────────────────────────────────\n\n private async ensureLoaded(): Promise<void> {\n if (this.loaded) return\n\n const envelope = await this.local.get(this.vault, '_sync', 'meta')\n if (envelope) {\n const meta = JSON.parse(envelope._data) as SyncMetadata\n this.dirty = [...meta.dirty]\n this.lastPush = meta.last_push\n this.lastPull = meta.last_pull\n }\n\n this.loaded = true\n }\n\n private async persistMeta(): Promise<void> {\n const meta: SyncMetadata = {\n _noydb_sync: NOYDB_SYNC_VERSION,\n last_push: this.lastPush,\n last_pull: this.lastPull,\n dirty: this.dirty,\n }\n\n const envelope: EncryptedEnvelope = {\n _noydb: 1,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(meta),\n }\n\n await this.local.put(this.vault, '_sync', 'meta', envelope)\n }\n}\n","import type { SyncTransactionResult } from '../types.js'\nimport type { SyncEngine } from './sync.js'\nimport type { Vault } from '../vault.js'\n\ninterface TxOp {\n readonly type: 'put' | 'delete'\n readonly collection: string\n readonly id: string\n readonly record?: unknown\n}\n\n/**\n * Sync transaction.\n *\n * Stages local writes and then pushes only those records to remote in a\n * single batch. If any record conflicts during the push, the result\n * carries `status: 'conflict'` — no automatic rollback is performed;\n * the caller handles conflict resolution.\n *\n * Obtain via `db.transaction(compartmentName)`.\n */\nexport class SyncTransaction {\n private readonly comp: Vault\n private readonly engine: SyncEngine\n private readonly ops: TxOp[] = []\n\n /** @internal — constructed by `Noydb.transaction()` */\n constructor(comp: Vault, engine: SyncEngine) {\n this.comp = comp\n this.engine = engine\n }\n\n /** Stage a record write. Does not write to any adapter until `commit()`. */\n put(collection: string, id: string, record: unknown): this {\n this.ops.push({ type: 'put', collection, id, record })\n return this\n }\n\n /** Stage a record delete. Does not write to any adapter until `commit()`. */\n delete(collection: string, id: string): this {\n this.ops.push({ type: 'delete', collection, id })\n return this\n }\n\n /**\n * Commit the transaction.\n *\n * Phase 1 — writes all staged operations to the local adapter via the\n * collection layer (encryption + dirty-log tracking).\n *\n * Phase 2 — pushes only the records that were written in this\n * transaction to the remote adapter. Existing dirty entries from\n * outside this transaction are not affected.\n *\n * If any record conflicts during the push, `status` is `'conflict'`\n * and `conflicts` lists the affected records. No automatic rollback is\n * performed.\n */\n async commit(): Promise<SyncTransactionResult> {\n // Phase 1: write all staged ops to local via collection layer\n for (const op of this.ops) {\n if (op.type === 'put') {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n await (this.comp.collection<any>(op.collection)).put(op.id, op.record as any)\n } else {\n await this.comp.collection(op.collection).delete(op.id)\n }\n }\n\n // Phase 2: push only the records from this transaction\n const opSet = new Set<string>()\n for (const op of this.ops) {\n opSet.add(`${op.collection}::${op.id}`)\n }\n\n const pushResult = await this.engine.pushFiltered(\n (entry) => opSet.has(`${entry.collection}::${entry.id}`),\n )\n\n return {\n status: pushResult.conflicts.length > 0 ? 'conflict' : 'committed',\n pushed: pushResult.pushed,\n conflicts: pushResult.conflicts,\n }\n }\n}\n","/**\n * Presence handle — real-time awareness of who is viewing/editing a collection.\n * encrypted ephemeral channel keyed by collection DEK via HKDF.\n *\n * The presence key is derived from the collection DEK so:\n * - The adapter never learns user identities from presence payloads.\n * - Presence rotates automatically when the DEK rotates (revoked users\n * can no longer participate after a DEK rotation).\n *\n * Two transport strategies:\n * 1. **Pub/sub** (real-time): used when the adapter implements\n * `presencePublish` and `presenceSubscribe`.\n * 2. **Storage-poll** (fallback): presence records are written to a\n * reserved `_presence_<collection>` collection on the sync adapter\n * (if available) or local adapter, and polled periodically.\n */\n\nimport type { NoydbStore, PresencePeer } from '../types.js'\nimport { encrypt, decrypt, generateIV, bufferToBase64, derivePresenceKey } from '../crypto.js'\n\n/** Options for constructing a PresenceHandle. @internal */\nexport interface PresenceHandleOpts {\n /** Local adapter for storage-poll fallback. */\n adapter: NoydbStore\n /** Remote (sync) adapter — preferred for broadcasting presence if available. */\n syncAdapter?: NoydbStore\n /** Vault name — used as part of the channel and storage key. */\n vault: string\n /** Collection name — used as HKDF `info` and channel suffix. */\n collectionName: string\n /** Calling user's ID, embedded unencrypted in storage records. */\n userId: string\n /** Whether encryption is active. When false, presence payloads are stored as JSON. */\n encrypted: boolean\n /** Callback that resolves the collection DEK (used to derive the presence key). */\n getDEK: (collectionName: string) => Promise<CryptoKey>\n /** How long (ms) before a peer's presence is considered stale. Default: 30_000. */\n staleMs?: number\n /** Poll interval (ms) for the storage-poll fallback. Default: 5_000. */\n pollIntervalMs?: number\n}\n\n/**\n * Internal storage envelope for the storage-poll fallback.\n * Written to `_presence_<collection>` as `{ userId, lastSeen, iv, data }`.\n */\ninterface StoragePresenceRecord {\n userId: string\n lastSeen: string\n iv: string // base64 AES-GCM IV (empty when not encrypted)\n data: string // base64 ciphertext or JSON string when not encrypted\n}\n\n/** Presence handle for a single collection. */\nexport class PresenceHandle<P> {\n private readonly adapter: NoydbStore\n private readonly syncAdapter: NoydbStore | undefined\n private readonly vault: string\n private readonly collectionName: string\n private readonly userId: string\n private readonly encrypted: boolean\n private readonly getDEK: (collectionName: string) => Promise<CryptoKey>\n private readonly staleMs: number\n private readonly pollIntervalMs: number\n private readonly channel: string\n private readonly storageCollection: string\n\n private presenceKey: CryptoKey | null = null\n private subscribers: Array<(peers: PresencePeer<P>[]) => void> = []\n private unsubscribePubSub: (() => void) | null = null\n private pollTimer: ReturnType<typeof setInterval> | null = null\n private stopped = false\n\n constructor(opts: PresenceHandleOpts) {\n this.adapter = opts.adapter\n this.syncAdapter = opts.syncAdapter\n this.vault = opts.vault\n this.collectionName = opts.collectionName\n this.userId = opts.userId\n this.encrypted = opts.encrypted\n this.getDEK = opts.getDEK\n this.staleMs = opts.staleMs ?? 30_000\n this.pollIntervalMs = opts.pollIntervalMs ?? 5_000\n // Channel used by pub/sub adapters — vault-scoped so two collections\n // in the same vault don't bleed into each other's presence channels.\n this.channel = `${opts.vault}:${opts.collectionName}:presence`\n // Reserved collection name for the storage-poll fallback.\n this.storageCollection = `_presence_${opts.collectionName}`\n }\n\n /**\n * Announce yourself (or update your cursor/status).\n * Encrypts `payload` with the presence key and publishes it.\n */\n async update(payload: P): Promise<void> {\n if (this.stopped) return\n\n const key = await this.getPresenceKey()\n const now = new Date().toISOString()\n const plaintext = JSON.stringify({ userId: this.userId, lastSeen: now, payload })\n let encryptedPayload: string\n\n if (this.encrypted && key) {\n const iv = generateIV()\n const ivB64 = bufferToBase64(iv)\n const { data } = await encrypt(plaintext, key)\n encryptedPayload = JSON.stringify({ iv: ivB64, data })\n } else {\n encryptedPayload = plaintext\n }\n\n // Pub/sub path — publish to any adapter that supports it\n const pubAdapter = this.getPubSubAdapter()\n if (pubAdapter?.presencePublish) {\n await pubAdapter.presencePublish(this.channel, encryptedPayload)\n }\n\n // Storage-poll path — write a record to the storage adapter\n await this.writeStorageRecord(payload, now)\n }\n\n /**\n * Subscribe to presence updates. The callback receives a filtered, decrypted\n * list of all currently-active peers (excluding yourself, excluding stale).\n *\n * Returns an unsubscribe function. Also call `stop()` to release all resources.\n */\n subscribe(cb: (peers: PresencePeer<P>[]) => void): () => void {\n if (this.stopped) return () => {}\n\n this.subscribers.push(cb)\n\n // Start pub/sub listener on first subscriber\n if (this.subscribers.length === 1) {\n this.startListening()\n }\n\n return () => {\n this.subscribers = this.subscribers.filter(s => s !== cb)\n if (this.subscribers.length === 0) this.stopListening()\n }\n }\n\n /** Stop all listening and clear resources. */\n stop(): void {\n this.stopped = true\n this.stopListening()\n this.subscribers = []\n }\n\n // ─── Private ────────────────────────────────────────────────────────\n\n private async getPresenceKey(): Promise<CryptoKey | null> {\n if (!this.encrypted) return null\n if (!this.presenceKey) {\n try {\n const dek = await this.getDEK(this.collectionName)\n this.presenceKey = await derivePresenceKey(dek, this.collectionName)\n } catch {\n // no-op — presence degrades gracefully if crypto fails\n }\n }\n return this.presenceKey\n }\n\n private getPubSubAdapter(): NoydbStore | undefined {\n // Prefer the sync adapter (it broadcasts to other devices)\n if (this.syncAdapter?.presencePublish) return this.syncAdapter\n if (this.adapter.presencePublish) return this.adapter\n return undefined\n }\n\n private startListening(): void {\n const pubAdapter = this.getPubSubAdapter()\n\n if (pubAdapter?.presenceSubscribe) {\n // Real-time pub/sub path\n this.unsubscribePubSub = pubAdapter.presenceSubscribe(\n this.channel,\n (encryptedPayload) => { void this.handlePubSubMessage(encryptedPayload) },\n )\n } else {\n // Storage-poll fallback\n this.pollTimer = setInterval(\n () => { void this.pollStoragePresence() },\n this.pollIntervalMs,\n )\n // Kick off an immediate poll\n void this.pollStoragePresence()\n }\n }\n\n private stopListening(): void {\n if (this.unsubscribePubSub) {\n this.unsubscribePubSub()\n this.unsubscribePubSub = null\n }\n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = null\n }\n }\n\n private async handlePubSubMessage(encryptedPayload: string): Promise<void> {\n try {\n const peer = await this.decryptPresencePayload(encryptedPayload)\n if (!peer || peer.userId === this.userId) return\n\n const cutoff = new Date(Date.now() - this.staleMs).toISOString()\n if (peer.lastSeen < cutoff) return\n\n // Deliver only this new peer to subscribers; a full snapshot poll follows\n // on next interval. For pub/sub, we could maintain a map of active peers,\n // but for simplicity: emit a snapshot read from storage.\n await this.pollStoragePresence()\n } catch {\n // Decrypt failure — stale key or tampered payload, ignore\n }\n }\n\n private async decryptPresencePayload(\n encryptedPayload: string,\n ): Promise<{ userId: string; lastSeen: string; payload: P } | null> {\n const key = await this.getPresenceKey()\n\n if (!this.encrypted || !key) {\n return JSON.parse(encryptedPayload) as { userId: string; lastSeen: string; payload: P }\n }\n\n const { iv: ivB64, data } = JSON.parse(encryptedPayload) as { iv: string; data: string }\n const plaintext = await decrypt(ivB64, data, key)\n return JSON.parse(plaintext) as { userId: string; lastSeen: string; payload: P }\n }\n\n private async writeStorageRecord(payload: P, now: string): Promise<void> {\n const key = await this.getPresenceKey()\n const plaintext = JSON.stringify(payload)\n let iv = ''\n let data: string\n\n if (this.encrypted && key) {\n const ivBytes = generateIV()\n iv = bufferToBase64(ivBytes)\n const result = await encrypt(plaintext, key)\n data = result.data\n } else {\n data = plaintext\n }\n\n const record: StoragePresenceRecord = { userId: this.userId, lastSeen: now, iv, data }\n const json = JSON.stringify(record)\n\n // Use the sync adapter if available (so other devices can read it);\n // fall back to local adapter.\n const storeAdapter = this.syncAdapter ?? this.adapter\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: now,\n _iv: '',\n _data: json,\n }\n try {\n await storeAdapter.put(\n this.vault,\n this.storageCollection,\n this.userId,\n envelope,\n )\n } catch {\n // Presence write failure is non-fatal — the user is still present locally\n }\n }\n\n private async pollStoragePresence(): Promise<void> {\n if (this.stopped || this.subscribers.length === 0) return\n\n try {\n const storeAdapter = this.syncAdapter ?? this.adapter\n const ids = await storeAdapter.list(this.vault, this.storageCollection)\n const cutoff = new Date(Date.now() - this.staleMs).toISOString()\n const peers: PresencePeer<P>[] = []\n\n for (const id of ids) {\n if (id === this.userId) continue // skip ourselves\n const envelope = await storeAdapter.get(this.vault, this.storageCollection, id)\n if (!envelope) continue\n\n const record = JSON.parse(envelope._data) as StoragePresenceRecord\n if (record.lastSeen < cutoff) continue\n\n let peerPayload: P\n if (this.encrypted && this.presenceKey && record.iv) {\n const plaintext = await decrypt(record.iv, record.data, this.presenceKey)\n peerPayload = JSON.parse(plaintext) as P\n } else {\n peerPayload = JSON.parse(record.data) as P\n }\n\n peers.push({ userId: record.userId, payload: peerPayload, lastSeen: record.lastSeen })\n }\n\n for (const cb of this.subscribers) {\n cb(peers)\n }\n } catch {\n // Poll failure is non-fatal\n }\n }\n}\n","/**\n * _sync_credentials reserved collection —\n *\n * Stores per-adapter OAuth tokens (and any other long-lived sync secrets) as\n * encrypted records inside the vault itself. Tokens are wrapped with the\n * compartment's own DEK, live on disk as ciphertext like any other record, and\n * are accessed only through the dedicated API in this module — never via\n * `vault.collection('_sync_credentials')`.\n *\n * Design decisions\n * ────────────────\n *\n * **Why a reserved collection, not a separate store?**\n * The compartment's existing encryption stack (AES-256-GCM + collection DEK)\n * is exactly the right primitive for protecting OAuth tokens at rest. Using a\n * separate store would require a new encryption surface, new adapter calls,\n * and a new backup/restore path — all of which already exist for collections.\n *\n * **Why not exposed as a regular collection?**\n * The same reason `_keyring` and `_ledger` aren't: they have invariants that\n * must be enforced (naming scheme, no cross-user leakage, no schema\n * validation, no history/ledger writes for privacy). Routing through a\n * dedicated API enforces those invariants.\n *\n * **Token lifecycle:**\n * - `putCredential(vault, adapterId, token)` — store or overwrite\n * - `getCredential(vault, adapterId)` — load and decrypt\n * - `deleteCredential(vault, adapterId)` — remove\n * - `listCredentials(vault)` — enumerate adapter IDs (not tokens)\n *\n * The `adapterId` is the record ID within the `_sync_credentials` collection.\n * It should be a stable, human-readable identifier for the adapter instance\n * (e.g. `'google-drive'`, `'dropbox'`, `'s3-prod'`).\n *\n * **ACL:** only `owner` and `admin` roles can read/write sync credentials.\n * Operators, viewers, and clients cannot call this API. The check is made\n * against the caller's keyring role at call time.\n */\n\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../types.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { encrypt, decrypt } from '../crypto.js'\nimport { ensureCollectionDEK } from './keyring.js'\nimport { PermissionDeniedError } from '../errors.js'\n\n/** The reserved collection name. Never collides with user collections. */\nexport const SYNC_CREDENTIALS_COLLECTION = '_sync_credentials'\n\n// ─── Token types ──────────────────────────────────────────────────────\n\n/**\n * An OAuth/auth token stored in `_sync_credentials`.\n *\n * Fields mirror the OAuth2 token response shape. `customData` is an escape\n * hatch for adapter-specific secrets (API keys, connection strings, etc.)\n * that don't fit the OAuth2 shape.\n */\nexport interface SyncCredential {\n /** Stable identifier for the adapter instance (e.g. 'google-drive'). */\n readonly adapterId: string\n /** OAuth token type, usually 'Bearer'. */\n readonly tokenType: string\n /** The access token. Expires at `expiresAt` if set. */\n readonly accessToken: string\n /** Long-lived refresh token for renewing the access token. */\n readonly refreshToken?: string\n /** ISO timestamp when `accessToken` expires. Absent means \"no expiry\". */\n readonly expiresAt?: string\n /** Space-separated OAuth scopes. */\n readonly scopes?: string\n /** Adapter-specific opaque data (API keys, endpoints, etc.). */\n readonly customData?: Record<string, string>\n}\n\n// ─── Access check ─────────────────────────────────────────────────────\n\nfunction requireAdminAccess(keyring: UnlockedKeyring): void {\n if (keyring.role !== 'owner' && keyring.role !== 'admin') {\n throw new PermissionDeniedError(\n `Sync credentials require owner or admin role. Current role: \"${keyring.role}\"`,\n )\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/**\n * Store or overwrite a sync credential for the given adapter.\n *\n * The credential is encrypted with the `_sync_credentials` collection DEK\n * (auto-generated on first use). The record ID is the `adapterId`.\n *\n * Requires owner or admin role.\n */\nexport async function putCredential(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n credential: SyncCredential,\n): Promise<void> {\n requireAdminAccess(keyring)\n\n const getDek = await ensureCollectionDEK(adapter, vault, keyring)\n const dek = await getDek(SYNC_CREDENTIALS_COLLECTION)\n\n const { iv, data } = await encrypt(JSON.stringify(credential), dek)\n\n const existing = await adapter.get(vault, SYNC_CREDENTIALS_COLLECTION, credential.adapterId)\n const version = existing ? existing._v + 1 : 1\n\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: version,\n _ts: new Date().toISOString(),\n _iv: iv,\n _data: data,\n _by: keyring.userId,\n }\n\n await adapter.put(\n vault,\n SYNC_CREDENTIALS_COLLECTION,\n credential.adapterId,\n envelope,\n existing ? existing._v : undefined,\n )\n}\n\n/**\n * Load and decrypt a sync credential for the given adapter ID.\n *\n * Returns `null` if no credential exists for this adapter.\n * Requires owner or admin role.\n */\nexport async function getCredential(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n adapterId: string,\n): Promise<SyncCredential | null> {\n requireAdminAccess(keyring)\n\n const getDek = await ensureCollectionDEK(adapter, vault, keyring)\n const dek = await getDek(SYNC_CREDENTIALS_COLLECTION)\n\n const envelope = await adapter.get(vault, SYNC_CREDENTIALS_COLLECTION, adapterId)\n if (!envelope) return null\n\n const plaintext = await decrypt(envelope._iv, envelope._data, dek)\n return JSON.parse(plaintext) as SyncCredential\n}\n\n/**\n * Delete a sync credential by adapter ID.\n *\n * No-op if the credential doesn't exist. Requires owner or admin role.\n */\nexport async function deleteCredential(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n adapterId: string,\n): Promise<void> {\n requireAdminAccess(keyring)\n await adapter.delete(vault, SYNC_CREDENTIALS_COLLECTION, adapterId)\n}\n\n/**\n * List all adapter IDs that have stored credentials.\n *\n * Returns only the IDs, never the credential payloads. Useful for\n * displaying \"connected adapters\" in UI without decrypting tokens.\n * Requires owner or admin role.\n */\nexport async function listCredentials(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<string[]> {\n requireAdminAccess(keyring)\n return adapter.list(vault, SYNC_CREDENTIALS_COLLECTION)\n}\n\n/**\n * Check whether a credential exists and whether its access token has expired.\n *\n * Returns `{ exists: false }` if no credential is stored, or\n * `{ exists: true, expired: boolean }` based on the `expiresAt` field.\n * Requires owner or admin role.\n */\nexport async function credentialStatus(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n adapterId: string,\n): Promise<{ exists: false } | { exists: true; expired: boolean }> {\n const credential = await getCredential(adapter, vault, keyring, adapterId)\n if (!credential) return { exists: false }\n\n const expired = credential.expiresAt\n ? Date.now() > new Date(credential.expiresAt).getTime()\n : false\n\n return { exists: true, expired }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/team/index.ts","../../src/types.ts","../../src/errors.ts","../../src/crypto.ts","../../src/directory/storage.ts","../../src/directory/visibility.ts","../../src/validation.ts","../../src/meta/user-envelope/types.ts","../../src/meta/user-envelope/storage.ts","../../src/team/keyring.ts","../../src/team/authenticators.ts","../../src/policy/errors.ts","../../src/team/wrapped-deks.ts","../../src/team/recovery.ts","../../src/team/rotate-recover.ts","../../src/team/peer-recover.ts","../../src/team/tiers.ts","../../src/team/magic-link-grant.ts","../../src/store/sync-policy.ts","../../src/team/sync.ts","../../src/team/sync-transaction.ts","../../src/team/presence.ts","../../src/team/sync-credentials.ts"],"sourcesContent":["/**\n * `@noy-db/hub/team` — subpath export for multi-user / sync / keyring.\n *\n * Solo-user apps that never call `grant()`, `db.push()`, or open a\n * sync target can exclude this subpath entirely — bundle savings\n * estimated at ~4-6 KB.\n *\n * The main `@noy-db/hub` entry still re-exports every symbol for\n * backward compatibility — direct subpath import is purely a\n * tree-shaking opt-in.\n *\n * Named re-exports (not `export *`) so tsup keeps the barrel\n * populated even with `sideEffects: false`.\n */\n\n// ─── Keyring / multi-user ───────────────────────────────────\nexport type { UnlockedKeyring } from './keyring.js'\nexport {\n loadKeyring,\n createOwnerKeyring,\n grant,\n revoke,\n changeSecret,\n listUsers,\n listUsersWithEnvelopes,\n ensureCollectionDEK,\n persistKeyring,\n updateKeyringIdentity,\n buildRecipientKeyringFile,\n} from './keyring.js'\nexport type { BundleRecipient, ListUsersOptions } from './keyring.js'\n\n// ─── Tier-2 authenticator slots (#11) ───────────────────\nexport {\n enrollAuthenticator,\n removeAuthenticator,\n updateAuthenticator,\n findAuthenticator,\n} from './authenticators.js'\nexport type {\n EnrollAuthenticatorOptions,\n EnrollAuthenticatorWrappingKEKOptions,\n EnrollAuthenticatorWrappingDEKsOptions,\n UpdateAuthenticatorOptions,\n} from './authenticators.js'\n\n// ─── Tier-1 change flows (#10, #29, #36) ────────────────\nexport {\n rotatePassphrase,\n recoverPassphrase,\n} from './rotate-recover.js'\nexport type {\n RotatePassphraseInput,\n RecoverPassphraseInput,\n RecoverPassphraseResult,\n RecoveryProof,\n SlotRewrapContext,\n SlotRewrapCeremony,\n} from './rotate-recover.js'\n\n// ─── Atomic peer-recovery (#33, #34) ────────────────────\nexport { recoverUser } from './peer-recover.js'\nexport type { RecoverUserOptions } from './peer-recover.js'\n\n// ─── Paper recovery primitives (#28, #39) ───────────────\nexport {\n mintPaperRecoveryEntry,\n unwrapDeksFromPaperEntry,\n loadPaperRecoveryEntries,\n savePaperRecoveryEntries,\n burnPaperRecoveryEntry,\n} from './recovery.js'\nexport type { PaperRecoveryEntry } from './recovery.js'\n\n// ─── Shared wrap-DEKs primitive (#26 Path C, #44) ───────\nexport {\n mintWrappedDeksBlob,\n unwrapDeksFromBlob,\n} from './wrapped-deks.js'\nexport type { WrappedDeksBlob } from './wrapped-deks.js'\n\n// ─── Magic-link grant primitives (consumed by @noy-db/on-magic-link) ─\nexport {\n writeMagicLinkGrant,\n readMagicLinkGrantRecord,\n listMagicLinkGrants,\n unwrapMagicLinkGrant,\n revokeMagicLinkGrant,\n deriveMagicLinkContentKey,\n magicLinkGrantRecordId,\n isMagicLinkGrantExpired,\n} from './magic-link-grant.js'\n\n// ─── Export-capability helpers ───────────────────────────\nexport {\n hasExportCapability,\n evaluateExportCapability,\n} from './keyring.js'\n\n// ─── Import-capability helpers ─────────────────────────\nexport {\n hasImportCapability,\n evaluateImportCapability,\n} from './keyring.js'\n\n// ─── Sync engine ────────────────────────────────────────────\nexport { SyncEngine } from './sync.js'\n\n// ─── Sync transactions ──────────────────────────────────\nexport { SyncTransaction } from './sync-transaction.js'\n\n// ─── Presence / live cursors ────────────────────────────\nexport { PresenceHandle } from './presence.js'\n\n// ─── _sync_credentials reserved collection ──────────────\nexport {\n putCredential,\n getCredential,\n deleteCredential,\n listCredentials,\n credentialStatus,\n SYNC_CREDENTIALS_COLLECTION,\n} from './sync-credentials.js'\nexport type { SyncCredential } from './sync-credentials.js'\n","/**\n * Core types — the {@link NoydbStore} interface, envelope format, roles, and\n * all configuration shapes consumed by {@link createNoydb}.\n *\n * ## What lives here\n *\n * - **{@link NoydbStore}** — the 6-method contract every backend must implement\n * (`get`, `put`, `delete`, `list`, `loadAll`, `saveAll`).\n * - **{@link EncryptedEnvelope}** — the wire format stored by backends:\n * `{ _noydb, _v, _ts, _iv, _data }`. Backends only ever see this shape.\n * - **{@link Role} / {@link Permission}** — the access-control vocabulary\n * (`owner`, `admin`, `operator`, `viewer`, `client`).\n * - **{@link NoydbOptions}** — the full configuration object passed to\n * {@link createNoydb}.\n *\n * ## Extending the store interface\n *\n * All optional store capabilities (`ping`, `listPage`, `listSince`,\n * `presencePublish`, `presenceSubscribe`, `listVaults`) are additive extensions\n * discovered via `'method' in store`. Implementing them unlocks features but\n * is never required — core always falls back to the 6-method baseline.\n *\n * @module\n */\n\nimport type { StandardSchemaV1 } from './schema.js'\nimport type { SyncPolicy } from './store/sync-policy.js'\nimport type { BlobStrategy } from './blobs/strategy.js'\nimport type { IndexStrategy } from './indexing/strategy.js'\nimport type { AggregateStrategy } from './aggregate/strategy.js'\nimport type { CrdtStrategy } from './crdt/strategy.js'\nimport type { ConsentStrategy } from './consent/strategy.js'\nimport type { PeriodsStrategy } from './periods/strategy.js'\nimport type { ShadowStrategy } from './shadow/strategy.js'\nimport type { TxStrategy } from './tx/strategy.js'\nimport type { HistoryStrategy } from './history/strategy.js'\nimport type { I18nStrategy } from './i18n/strategy.js'\nimport type { SessionStrategy } from './session/strategy.js'\nimport type { SyncStrategy } from './team/sync-strategy.js'\nimport type { GuardStrategyHandleAny } from './guards/types.js'\nimport type { DerivationStrategyHandle } from './derivations/types.js'\nimport type { UnlockedKeyring } from './team/keyring.js'\nimport type { VaultPolicy } from './policy/types.js'\nimport type { PublicEnvelopeSchema } from './meta/public-envelope/types.js'\nimport type { MaterializedViewStrategyHandle } from './materialized-views/types.js'\nimport type { OverlayedViewStrategyHandle } from './overlay-views/types.js'\nimport type { SealingKeyProvider } from './team/managed-passphrase.js'\nimport type { ShamirRecoveryProvider } from './team/shamir-recovery-provider.js'\n\n/** Format version for encrypted record envelopes. */\nexport const NOYDB_FORMAT_VERSION = 1 as const\n\n/** Format version for keyring files. */\nexport const NOYDB_KEYRING_VERSION = 1 as const\n\n/** Format version for backup files. */\nexport const NOYDB_BACKUP_VERSION = 1 as const\n\n/** Format version for sync metadata. */\nexport const NOYDB_SYNC_VERSION = 1 as const\n\n// ─── Roles & Permissions ───────────────────────────────────────────────\n\n/**\n * Access role assigned to a user within a vault.\n *\n * Roles control both the operations a user can perform and which DEKs\n * they receive in their keyring:\n *\n * | Role | Collections | Can grant/revoke | Can export |\n * |------------|-----------------|:----------------:|:----------:|\n * | `owner` | all (rw) | Yes (all roles) | Yes |\n * | `admin` | all (rw) | Yes (≤ admin) | Yes |\n * | `operator` | explicit (rw) | No | ACL-scoped |\n * | `viewer` | all (ro) | No | Yes |\n * | `client` | explicit (ro) | No | ACL-scoped |\n */\nexport type Role = 'owner' | 'admin' | 'operator' | 'viewer' | 'client'\n\n/**\n * Read-write or read-only access on a collection.\n * Stored per-collection in the user's keyring.\n */\nexport type Permission = 'rw' | 'ro'\n\n/**\n * Map of collection name → permission level for a user's keyring entry.\n * `'*'` is the wildcard collection matching all collections in the vault.\n */\nexport type Permissions = Record<string, Permission>\n\n// ─── Encrypted Envelope ────────────────────────────────────────────────\n\n/** The encrypted wrapper stored by stores. Stores only ever see this. */\nexport interface EncryptedEnvelope {\n readonly _noydb: typeof NOYDB_FORMAT_VERSION\n readonly _v: number\n readonly _ts: string\n readonly _iv: string\n readonly _data: string\n /** User who created this version (unencrypted metadata). */\n readonly _by?: string\n /**\n * Hierarchical access tier. Omitted → tier 0.\n *\n * Unencrypted on purpose — the store reads it to route the envelope\n * to the right DEK slot without having to try-decrypt against every\n * tier. Only leaks the tier of each record, not any value\n * equivalence.\n */\n readonly _tier?: number\n /**\n * User id who last elevated this record. Used by\n * `demote()` to gate the reverse operation: only the original\n * elevator or an owner can demote a record back down. Cleared on\n * every successful demote so a later re-elevate requires the new\n * actor to own the demotion right.\n */\n readonly _elevatedBy?: string\n /**\n * Deterministic-encryption index. Map of field name →\n * base64 deterministic ciphertext. Present only when the collection\n * declares `deterministicFields` and the feature is acknowledged. The\n * field names are unencrypted (they're the index keys); the values\n * are AES-GCM ciphertext with an HKDF-derived deterministic IV.\n *\n * Enables blind equality search (`collection.findByDet(field,\n * value)`) without decrypting every record. Leaks equality as a known\n * side channel.\n */\n readonly _det?: Record<string, string>\n}\n\n/**\n * Placeholder returned by `getAtTier()` in `'ghost'` mode when a\n * record is at a tier the caller cannot decrypt. Record existence is\n * advertised — the id and tier are visible — but contents are\n * withheld. `canElevateFrom` lists user ids authorized to elevate\n * access for this caller when known; absent when the workflow is\n * not configured.\n */\nexport interface GhostRecord {\n readonly _ghost: true\n readonly _tier: number\n readonly canElevateFrom?: readonly string[]\n}\n\n/** Control what lower-tier reads see above their clearance. */\nexport type TierMode = 'invisibility' | 'ghost'\n\n/**\n * Event emitted when a record at a tier above the caller's inherent\n * clearance is read or written successfully (via elevation or\n * delegation). Always written to the ledger; subscribers get a\n * real-time feed.\n */\nexport interface CrossTierAccessEvent {\n readonly actor: string\n readonly collection: string\n readonly id: string\n readonly tier: number\n /** How the caller gained tier access: they elevated it, or a delegation is active. */\n readonly authorization: 'elevation' | 'delegation' | 'inherent'\n readonly op: 'get' | 'put' | 'elevate' | 'demote'\n readonly ts: string\n /**\n * When `authorization === 'elevation'`, the audit reason string the\n * caller passed to `vault.elevate(...)`. Empty for inherent /\n * delegation paths.\n */\n readonly reason?: string\n /**\n * When `authorization === 'elevation'`, the tier the caller's\n * keyring effectively held BEFORE elevation. Useful for audit\n * dashboards distinguishing \"operator elevating to 2\" from\n * \"inherent tier-2 write.\"\n */\n readonly elevatedFrom?: number\n}\n\n/**\n * A single deterministic-ciphertext index slot on an envelope. Stored\n * as `iv:data` (both base64, colon-separated) so a single string per\n * field keeps the envelope compact.\n */\nexport type DeterministicCipher = string\n\n// ─── Vault Snapshot ──────────────────────────────────────────────\n\n/** All records across all collections for a compartment. */\nexport type VaultSnapshot = Record<string, Record<string, EncryptedEnvelope>>\n\n/**\n * Result of a single page fetch via the optional `listPage` adapter extension.\n *\n * `items` carries the actual encrypted envelopes (not just ids) so the\n * caller can decrypt and emit a single record without an extra `get()`\n * round-trip per id. `nextCursor` is `null` on the final page.\n */\nexport interface ListPageResult {\n /** Encrypted envelopes for this page, in adapter-defined order. */\n items: Array<{ id: string; envelope: EncryptedEnvelope }>\n /** Opaque cursor for the next page, or `null` if this was the last page. */\n nextCursor: string | null\n}\n\n// ─── Store Interface ───────────────────────────────────────────────────\n\nexport interface NoydbStore {\n /**\n * Optional human-readable store name (e.g. 'memory', 'file', 'dynamo').\n * Used in diagnostic messages and the listPage fallback warning. Stores\n * are encouraged to set this so logs are clearer about which backend is\n * involved when something goes wrong.\n */\n name?: string\n\n /** Get a single record. Returns null if not found. */\n get(vault: string, collection: string, id: string): Promise<EncryptedEnvelope | null>\n\n /** Put a record. Throws ConflictError if expectedVersion doesn't match. */\n put(\n vault: string,\n collection: string,\n id: string,\n envelope: EncryptedEnvelope,\n expectedVersion?: number,\n ): Promise<void>\n\n /** Delete a record. */\n delete(vault: string, collection: string, id: string): Promise<void>\n\n /** List all record IDs in a collection. */\n list(vault: string, collection: string): Promise<string[]>\n\n /** Load all records for a vault (initial hydration). */\n loadAll(vault: string): Promise<VaultSnapshot>\n\n /** Save all records for a vault (bulk write / restore). */\n saveAll(vault: string, data: VaultSnapshot): Promise<void>\n\n /** Optional connectivity check for sync engine. */\n ping?(): Promise<boolean>\n\n /**\n * Optional: list record IDs in a collection that have `_ts` after `since`.\n * Used by partial sync (`pull({ modifiedSince })`). Stores that omit this\n * fall back to a full `loadAll` + client-side timestamp filter.\n */\n listSince?(vault: string, collection: string, since: string): Promise<string[]>\n\n /**\n * Optional pagination extension. Stores that implement `listPage` get\n * the streaming `Collection.scan()` fast path; stores that don't are\n * silently fallen back to a full `loadAll()` + slice (with a one-time\n * console.warn).\n *\n * `cursor` is opaque to the core — each store encodes its own paging\n * state (DynamoDB: base64 LastEvaluatedKey JSON; S3: ContinuationToken;\n * memory/file/browser: numeric offset of a sorted id list). Pass\n * `undefined` to start from the beginning.\n *\n * `limit` is a soft upper bound on `items.length`. Stores MAY return\n * fewer items even when more exist (e.g. if the underlying store has\n * its own page size cap), and MUST signal \"no more pages\" by returning\n * `nextCursor: null`.\n *\n * The 6-method core contract is unchanged — this is an additive\n * extension discovered via `'listPage' in adapter`.\n */\n listPage?(\n vault: string,\n collection: string,\n cursor?: string,\n limit?: number,\n ): Promise<ListPageResult>\n\n /**\n * Optional pub/sub for real-time presence.\n * Publish an encrypted payload to a presence channel.\n * Falls back to storage-based polling when absent.\n */\n presencePublish?(channel: string, payload: string): Promise<void>\n\n /**\n * Optional pub/sub for real-time presence.\n * Subscribe to a presence channel. Returns an unsubscribe function.\n * Falls back to storage-based polling when absent.\n */\n presenceSubscribe?(channel: string, callback: (payload: string) => void): () => void\n\n /**\n * Optional cross-vault enumeration extension.\n *\n * Returns the names of every top-level vault the store\n * currently stores. Used by `Noydb.listAccessibleVaults()` to\n * enumerate the universe of vaults before filtering down to\n * the ones the calling principal can actually unwrap.\n *\n * **Why this is optional:** the storage shape of compartments\n * differs across backends. Memory and file stores store\n * vaults as top-level keys / directories and can enumerate\n * them in O(1) calls. DynamoDB stores everything in a single table\n * keyed by `(compartment#collection, id)` — enumerating compartments\n * requires either a Scan (expensive, eventually consistent, leaks\n * ciphertext metadata) or a dedicated GSI that the consumer\n * provisioned. S3 needs a prefix list (cheap if enabled, ACL-sensitive\n * otherwise). Browser localStorage can scan keys by prefix.\n *\n * Stores that cannot implement `listVaults` cheaply or\n * cleanly should omit it. Core surfaces a `StoreCapabilityError`\n * with a clear message when a caller invokes\n * `listAccessibleVaults()` against a store that doesn't\n * provide this method, so consumers know to either upgrade their\n * store, provide a candidate list explicitly to `queryAcross()`,\n * or fall back to maintaining the compartment index out of band.\n *\n * **Privacy note:** `listVaults` returns *every* compartment\n * the store has, not just the ones the caller can access. The\n * existence-leak filtering (returning only compartments whose\n * keyring the caller can unwrap) happens in core, not in the\n * store. The store is trusted to know its own contents — that\n * is not a leak in the threat model. The leak the API guards\n * against is the *return value* of `listAccessibleVaults()`\n * exposing existence to a downstream observer who only sees that\n * function's output.\n *\n * The 6-method core contract is unchanged — this is an additive\n * extension discovered via `'listVaults' in store`.\n */\n listVaults?(): Promise<string[]>\n\n /**\n * Optional: generate a presigned URL for direct client download.\n * Only meaningful for object stores (S3, GCS) that support URL signing.\n * Returns a time-limited URL that fetches the encrypted envelope directly.\n * The caller must decrypt client-side (the URL returns ciphertext).\n */\n presignUrl?(vault: string, collection: string, id: string, expiresInSeconds?: number): Promise<string>\n\n /**\n * Optional: estimate current storage usage.\n * Returns `{ usedBytes, quotaBytes }` or null if the store cannot estimate.\n * Used by quota-aware routing to detect overflow conditions.\n */\n estimateUsage?(): Promise<{ usedBytes: number; quotaBytes: number } | null>\n\n /**\n * Optional multi-record atomic write.\n *\n * When present, `db.transaction(async (tx) => { ... })` uses this to\n * commit every staged op in one storage-layer transaction — either\n * all ops land or none do, regardless of which records they touch.\n * Every `TxOp.expectedVersion` (when set) must be honored atomically\n * alongside the write; any violation throws `ConflictError` and the\n * whole batch fails.\n *\n * Stores that omit this fall through to the hub's per-record OCC\n * fallback: pre-flight CAS check, then sequential `put`/`delete`\n * with best-effort unwind on mid-batch failure (see\n * `runTransaction` for the exact semantics and crash window).\n *\n * Native implementations: `to-memory` (single Map mutation),\n * `to-dynamo` (`TransactWriteItems`), `to-browser-idb` (one\n * `readwrite` transaction). File / S3 cannot implement this\n * atomically and should omit the method.\n */\n tx?(ops: readonly TxOp[]): Promise<void>\n}\n\n/**\n * A single staged operation inside a `db.transaction(fn)` commit. The\n * hub assembles `TxOp[]` from the user's `tx.collection().put/delete`\n * calls, encrypts any `record` values into `envelope`, and hands the\n * array to `NoydbStore.tx()` when the store supports atomic batch\n * writes. Stores that implement `tx()` MUST honor every\n * `expectedVersion` atomically against the stored envelope version.\n */\nexport interface TxOp {\n readonly type: 'put' | 'delete'\n readonly vault: string\n readonly collection: string\n readonly id: string\n /** Populated for `type: 'put'` — the encrypted envelope to write. */\n readonly envelope?: EncryptedEnvelope\n /** Optional per-record CAS. Mismatch must throw `ConflictError`. */\n readonly expectedVersion?: number\n}\n\n// ─── Store Factory Helper ──────────────────────────────────────────────\n\n/** Type-safe helper for creating store factories. */\nexport function createStore<TOptions>(\n factory: (options: TOptions) => NoydbStore,\n): (options: TOptions) => NoydbStore {\n return factory\n}\n\n// ─── Keyring ───────────────────────────────────────────────────────────\n\n/**\n * Interchange formats `@noy-db/as-*` packages can produce. `'*'` is a\n * wildcard granting every current + future plaintext format.\n */\nexport type ExportFormat =\n | 'xlsx'\n | 'csv'\n | 'json'\n | 'ndjson'\n | 'xml'\n | 'sql'\n | 'pdf'\n | 'blob'\n | 'zip'\n | '*'\n\n/**\n * Owner-granted export capability on a keyring.\n *\n * Two independent dimensions:\n *\n * - `plaintext` — per-format allowlist for record formatters + blob\n * extractors that emit plaintext bytes (`as-xlsx`, `as-csv`,\n * `as-blob`, `as-zip`, …). **Defaults to empty** for every role;\n * the owner/admin must positively grant per-format (or `'*'`).\n * - `bundle` — boolean for `.noydb` encrypted container export\n * (`as-noydb`). **Default policy: on for owner/admin, off for\n * operator/viewer/client** — applied when the field is absent or\n * undefined (see `hasExportCapability`).\n */\nexport interface ExportCapability {\n readonly plaintext?: readonly ExportFormat[]\n readonly bundle?: boolean\n}\n\n/**\n * Owner-granted import capability on a keyring (sibling of\n * `ExportCapability`, issue ).\n *\n * Two independent dimensions:\n *\n * - `plaintext` — per-format allowlist for `as-*` readers that ingest\n * plaintext bytes (`as-csv`, `as-json`, `as-ndjson`, `as-zip`, …).\n * Defaults to empty for every role; the owner/admin must positively\n * grant per-format (or `'*'`).\n * - `bundle` — boolean gate for `.noydb` bundle import. **Defaults to\n * `false` for every role**, including owner/admin. Import is more\n * dangerous than export (corrupts vs leaks), so the policy is\n * default-closed across the board — the owner explicitly opts a\n * keyring in via `db.grant({ importCapability: { bundle: true } })`.\n */\nexport interface ImportCapability {\n readonly plaintext?: readonly ExportFormat[]\n readonly bundle?: boolean\n}\n\n/**\n * Forward-declared on-disk shape for `VaultPolicy` — the actual policy\n * model lives in `policy/types.ts` (#9). Declared here as `unknown`-typed\n * map so types.ts has no dependency on the policy module while the\n * `KeyringFile.policy` field can still round-trip foreign documents.\n *\n * @internal\n */\nexport type VaultPolicyOnDisk = Record<string, unknown>\n\n/**\n * Recovery profile enrolled at vault creation (issue #10).\n *\n * - `paper` — `on-recovery` codes (the only end-to-end profile in v0.1.0-pre.5).\n * - `shamir` / `multi-channel` / `admin-mediated` — API surface ships;\n * per-profile dispatch lands in follow-up issues. Calling\n * `db.recoverPassphrase` against these throws\n * {@link RecoveryProfileNotImplementedError}.\n */\nexport type RecoveryEnrollment =\n | {\n readonly profile: 'paper'\n /** Number of single-use codes to print at enrollment. */\n readonly codes: number\n }\n | {\n readonly profile: 'shamir'\n readonly k: number\n readonly n: number\n readonly trustees: ReadonlyArray<string>\n }\n | {\n readonly profile: 'multi-channel'\n readonly email?: string\n readonly pin?: boolean\n readonly paperCodes?: number\n }\n | {\n readonly profile: 'admin-mediated'\n readonly grantorUserId: string\n }\n\n/**\n * One tier-2 authenticator slot inside a keyring file. Each slot\n * independently wraps the SAME KEK under a method-specific derived key\n * (LUKS pattern). Adding or removing a slot is a constant-time keyring\n * write — no DEK re-keying required.\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — Authenticate (multi-slot)\n */\n/**\n * Shared fields across all authenticator slot variants. The variant\n * (`KeyringAuthenticatorWrappingKEK` vs `KeyringAuthenticatorWrappingDEKs`)\n * carries the actual wrapped material; everything below is identity +\n * metadata only.\n */\ninterface KeyringAuthenticatorBase {\n /** Caller-chosen identifier — e.g. `'webauthn-yubikey-blue'`, `'oidc-google'`, `'password'`. */\n readonly id: string\n /** Method family — selects which `@noy-db/on-*` package handles unlock. */\n readonly method: 'webauthn' | 'oidc' | 'password'\n /** ISO-8601 timestamp at which the slot was added. */\n readonly enrolled_at: string\n /**\n * Which session tier ENROLLED this slot. Tier 1 enrolls a fresh slot;\n * tier 2 may add a sibling slot when the active policy permits.\n */\n readonly enrolled_via_tier: 1 | 2\n /**\n * Method-specific metadata: WebAuthn cred id, OIDC issuer/sub, PBKDF2\n * salt for `on-password`, etc. The schema is open by design — the\n * `@noy-db/on-*` package owns the contents.\n */\n readonly meta: Record<string, unknown>\n}\n\n/**\n * Slot that wraps the KEK directly under a method-derived AES-KW key.\n * Used by ceremonies where the on-* package can produce/recover an\n * extractable KEK from its own credential — WebAuthn (PRF-derived\n * wrapping key) and split-key OIDC.\n *\n * `wrapKind` is optional/absent on slots written before pre.8 — those\n * legacy slots are treated as wrap-KEK by default at unlock time.\n */\nexport interface KeyringAuthenticatorWrappingKEK extends KeyringAuthenticatorBase {\n readonly wrapKind?: 'kek'\n /** Base64 wrapped-KEK ciphertext under the method-derived key. */\n readonly wrapped_kek: string\n /** XOR guard — wrap-KEK slots must NOT carry wrap-DEKs material. */\n readonly wrapped_deks?: never\n /** XOR guard — wrap-KEK slots must NOT carry wrap-DEKs material. */\n readonly iv?: never\n}\n\n/**\n * Slot that wraps the DEK set (not the KEK) under a method-derived\n * AES-GCM key — sidesteps the non-extractable-KEK constraint by\n * encrypting the serialized `{ deks: { collection: rawDekBase64 } }`\n * directly. Mirrors the format used by `mintPaperRecoveryEntry`\n * (`PaperRecoveryEntry`) and `@noy-db/on-pin`'s `PinResumeState` —\n * the unified wrap-DEKs primitive across tier-0 / tier-2 / tier-3.\n *\n * Trade-off: a slot of this kind reconstructs `UnlockedKeyring` with\n * `kek: null` after unlock. That is semantically correct for tier-2\n * (sensitive ops like `enrollAuthenticator` / `rotatePassphrase`\n * require a tier-1 unlock anyway) and matches how `@noy-db/on-pin`\n * already behaves at tier 3.\n *\n * @see `mintPaperRecoveryEntry` in `team/recovery.ts` — same shape on\n * a different on-disk path (`_meta/recovery-paper`).\n */\nexport interface KeyringAuthenticatorWrappingDEKs extends KeyringAuthenticatorBase {\n readonly wrapKind: 'deks'\n /** Base64 AES-GCM ciphertext of `{ deks: { collection: base64rawDek } }`. */\n readonly wrapped_deks: string\n /** Base64 AES-GCM IV used for the `wrapped_deks` ciphertext. */\n readonly iv: string\n /** XOR guard — wrap-DEKs slots must NOT carry wrap-KEK material. */\n readonly wrapped_kek?: never\n}\n\n/**\n * Discriminated union over the two wrap-format variants. Reads from\n * disk should always go through this type so the variant is preserved.\n *\n * Discriminator: `wrapKind`. Absent → wrap-KEK (legacy / WebAuthn /\n * OIDC). Present and `'deks'` → wrap-DEKs (password / future on-* that\n * want to sidestep extractable-KEK).\n *\n * The type-level XOR enforces \"exactly one of `wrapped_kek` /\n * `wrapped_deks` is present\" — a structural guarantee that the runtime\n * dispatch is safe.\n */\nexport type KeyringAuthenticator =\n | KeyringAuthenticatorWrappingKEK\n | KeyringAuthenticatorWrappingDEKs\n\nexport interface KeyringFile {\n readonly _noydb_keyring: typeof NOYDB_KEYRING_VERSION\n readonly user_id: string\n readonly display_name: string\n readonly role: Role\n readonly permissions: Permissions\n readonly deks: Record<string, string>\n readonly salt: string\n readonly created_at: string\n readonly granted_by: string\n /**\n * Passphrase canary — base64 AES-KW-wrapped form of a known constant\n * 256-bit value, wrapped under the keyring's KEK (#113).\n *\n * Optional: pre-#113 keyrings load with no canary and fall back to\n * the multi-DEK corruption heuristic from #82. Keyrings written after\n * #113 carry one and let `loadKeyring` distinguish wrong-passphrase\n * from corruption even when ALL DEKs (including a single-DEK keyring's\n * sole DEK) are corrupted.\n *\n * AES-KW is deterministic — every write site mints fresh on each\n * persist; same KEK + same constant input always produces the same\n * ciphertext, so this round-trips without state.\n */\n readonly canary?: string\n /**\n * Tier-2 authenticator slots (multi-slot keyring extension).\n * Optional / append-only: keyring files written before the\n * extension load with an empty list. Each slot independently wraps\n * the same KEK; any one of them unlocks.\n *\n * @see KeyringAuthenticator\n */\n readonly authenticators?: readonly KeyringAuthenticator[]\n /**\n * Per-keyring policy override (reserved). The on-disk format\n * accepts the field for forward compatibility with the Option C\n * merge engine deferred to a later release; v1.0 reads only the\n * vault-level `_meta/policy` document, so this field is parsed and\n * round-tripped but never enforced.\n */\n readonly policy?: VaultPolicyOnDisk\n /**\n * Optional — authorization spec capability bits. Absent on keyrings written\n * before the RFC implementation. Loading falls back to role-based\n * defaults (owner/admin get bundle-on, everyone else off).\n */\n readonly export_capability?: ExportCapability\n /**\n * Optional bundle-slot expiry. ISO-8601 timestamp; past\n * the cutoff `loadKeyring` throws `KeyringExpiredError` before any\n * DEK unwrap is attempted. Useful for time-boxed audit access:\n * \"this slot works for 30 days then becomes opaque to its holder.\"\n *\n * Absent on live keyrings written via `db.grant()` — the field is\n * meaningful for `BundleRecipient` slots produced by\n * `writeNoydbBundle({ recipients: [...] })`. Setting it on a live\n * keyring is allowed but unusual.\n */\n readonly expires_at?: string\n /**\n * Optional — issue import-capability bits. Absent on keyrings\n * written before landed. Loading falls back to default-closed\n * for every role and every format.\n */\n readonly import_capability?: ImportCapability\n /**\n * hierarchical access clearance. Absent → 0 (advisory;\n * the real check is whether the DEK map carries a `collection#tier`\n * entry for the requested tier). Owners and admins default to the\n * highest tier they have DEKs for at grant time.\n */\n readonly clearance?: number\n}\n\n// ─── Backup ────────────────────────────────────────────────────────────\n\nexport interface VaultBackup {\n readonly _noydb_backup: typeof NOYDB_BACKUP_VERSION\n readonly _compartment: string\n readonly _exported_at: string\n readonly _exported_by: string\n readonly keyrings: Record<string, KeyringFile>\n readonly collections: VaultSnapshot\n /**\n * Internal collections (`_ledger`, `_ledger_deltas`, `_history`, `_sync`, …)\n * captured alongside the data collections. Optional for backwards\n * compat with backups, which only stored data collections —\n * loading a backup leaves the ledger empty (and `verifyBackupIntegrity`\n * skips the chain check, surfacing only a console warning).\n */\n readonly _internal?: VaultSnapshot\n /**\n * Verifiable-backup metadata. Embeds the ledger head at\n * dump time so `load()` can cross-check that the loaded chain matches\n * exactly what was exported. A backup whose chain has been tampered\n * with — either by modifying ledger entries or by modifying data\n * envelopes that the chain references — fails this check.\n *\n * Optional for backwards compat with backups; missing means\n * \"legacy backup, load with a warning, no integrity check\".\n */\n readonly ledgerHead?: {\n /** Hex sha256 of the canonical JSON of the last ledger entry. */\n readonly hash: string\n /** Sequential index of the last ledger entry. */\n readonly index: number\n /** ISO timestamp captured at dump time. */\n readonly ts: string\n }\n}\n\n// ─── Export ────────────────────────────────────────────────────────────\n\n/**\n * Options for `Vault.exportStream()` and `Vault.exportJSON()`.\n *\n * The defaults match the most common consumer pattern: one chunk per\n * collection, no ledger metadata. Per-record streaming and ledger-head\n * inclusion are opt-in because both add structure most consumers don't\n * need.\n */\nexport interface ExportStreamOptions {\n /**\n * `'collection'` (default) yields one chunk per collection with all\n * records bundled in `chunk.records`. `'record'` yields one chunk per\n * record, useful for arbitrarily large collections that should never\n * be materialized as a single array.\n */\n readonly granularity?: 'collection' | 'record'\n\n /**\n * When `true`, every chunk includes the current compartment ledger\n * head under `chunk.ledgerHead`. The value is identical across every\n * chunk in a single export (one ledger per compartment). Forward-\n * compatible with future partition work where the head would become\n * per-partition. Default: `false`.\n */\n readonly withLedgerHead?: boolean\n /**\n * When set to a BCP 47 locale string (e.g. `'th'`), `exportJSON()`\n * resolves all `dictKey` labels to that locale and omits the raw\n * `dictionaries` snapshot from the output. Has no effect\n * on `exportStream()` — format packages use the `chunk.dictionaries`\n * snapshot directly and apply their own locale strategy.\n *\n * Default: `undefined` — embed the raw snapshot under `_dictionaries`.\n */\n readonly resolveLabels?: string\n}\n\n/**\n * One chunk yielded by `Vault.exportStream()`.\n *\n * `granularity: 'collection'` yields one chunk per collection with the\n * full record array in `records`. `granularity: 'record'` yields one\n * chunk per record with `records` containing exactly one element — the\n * `schema` and `refs` metadata is repeated on every chunk so consumers\n * doing per-record streaming don't have to thread state across yields.\n */\nexport interface ExportChunk<T = unknown> {\n /** Collection name (no leading underscore — internal collections are filtered out). */\n readonly collection: string\n\n /**\n * Standard Schema validator attached to the collection at `collection()`\n * construction time, or `null` if no schema was provided. Surfaced so\n * downstream serializers (`@noy-db/as-*` packages, custom\n * exporters) can produce schema-aware output (typed CSV headers, XSD\n * generation, etc.) without poking at collection internals.\n */\n readonly schema: StandardSchemaV1<unknown, T> | null\n\n /**\n * Foreign-key references declared on the collection via the `refs`\n * option, as the `{ field → { target, mode } }` map produced by\n * `RefRegistry.getOutbound`. Empty object when no refs were declared.\n */\n readonly refs: Record<string, { readonly target: string; readonly mode: 'strict' | 'warn' | 'cascade' }>\n\n /**\n * Decrypted, ACL-scoped, schema-validated records. Length 1 in\n * `granularity: 'record'` mode, full collection in `granularity: 'collection'`\n * mode. Records are returned by reference from the collection's eager\n * cache where applicable — consumers must treat them as immutable.\n */\n readonly records: T[]\n\n /**\n * Dictionary snapshots for every `dictKey` field declared on this\n * collection. Captured once at stream-start and held\n * constant across all chunks within the same export — a rename\n * mid-export does not change the snapshot. `undefined` when the\n * collection has no `dictKeyFields`.\n *\n * Shape: `{ [fieldName]: { [stableKey]: { [locale]: label } } }`\n *\n * @example\n * ```ts\n * chunk.dictionaries?.status?.paid?.th // → 'ชำระแล้ว'\n * ```\n */\n readonly dictionaries?: Record<\n string, // field name\n Record<string, Record<string, string>> // stable key → locale → label\n >\n\n /**\n * Vault ledger head at export time. Present only when\n * `exportStream({ withLedgerHead: true })` was called. Identical\n * across every chunk in the same export — included on every chunk\n * for forward-compatibility with future per-partition ledgers, where\n * the value will differ per chunk.\n */\n readonly ledgerHead?: {\n readonly hash: string\n readonly index: number\n readonly ts: string\n }\n}\n\n// ─── Sync ──────────────────────────────────────────────────────────────\n\nexport interface DirtyEntry {\n readonly vault: string\n readonly collection: string\n readonly id: string\n readonly action: 'put' | 'delete'\n readonly version: number\n readonly timestamp: string\n}\n\nexport interface SyncMetadata {\n readonly _noydb_sync: typeof NOYDB_SYNC_VERSION\n readonly last_push: string | null\n readonly last_pull: string | null\n readonly dirty: DirtyEntry[]\n}\n\nexport interface Conflict {\n readonly vault: string\n readonly collection: string\n readonly id: string\n readonly local: EncryptedEnvelope\n readonly remote: EncryptedEnvelope\n readonly localVersion: number\n readonly remoteVersion: number\n /**\n * Present only when the collection uses `conflictPolicy: 'manual'`.\n * Call `resolve(winner)` to commit the winning envelope, or\n * `resolve(null)` to defer (conflict stays queued for the next sync).\n * Called synchronously inside the `sync:conflict` event handler.\n */\n readonly resolve?: (winner: EncryptedEnvelope | null) => void\n}\n\nexport type ConflictStrategy =\n | 'local-wins'\n | 'remote-wins'\n | 'version'\n | ((conflict: Conflict) => 'local' | 'remote')\n\n/**\n * Collection-level conflict policy.\n * Overrides the db-level `conflict` option for the specific collection.\n *\n * - `'last-writer-wins'` — higher `_ts` wins (timestamp LWW).\n * - `'first-writer-wins'` — lower `_v` wins (earlier version is preserved).\n * - `'manual'` — emits `sync:conflict` with a `resolve` callback. Call\n * `resolve(winner)` synchronously to commit or `resolve(null)` to defer.\n * - Custom fn — synchronous `(local: T, remote: T) => T`. Must be pure.\n */\nexport type ConflictPolicy<T> =\n | 'last-writer-wins'\n | 'first-writer-wins'\n | 'manual'\n | ((local: T, remote: T) => T)\n\n/**\n * Envelope-level resolver registered per collection with the SyncEngine.\n * Receives the `id` of the conflicting record and both envelopes.\n * Returns the winning envelope, or `null` to defer resolution.\n * @internal\n */\nexport type CollectionConflictResolver = (\n id: string,\n local: EncryptedEnvelope,\n remote: EncryptedEnvelope,\n) => Promise<EncryptedEnvelope | null>\n\n/** Options for targeted push operations. */\nexport interface PushOptions {\n /** Only push records belonging to these collections. Omit to push all dirty. */\n collections?: string[]\n}\n\n/** Options for targeted pull operations. */\nexport interface PullOptions {\n /** Only pull these collections. Omit to pull all. */\n collections?: string[]\n /**\n * Only pull records with `_ts` strictly after this ISO timestamp.\n * Stores that implement `listSince` use it directly; others fall back\n * to a full scan with client-side filtering.\n */\n modifiedSince?: string\n}\n\nexport interface PushResult {\n readonly pushed: number\n readonly conflicts: Conflict[]\n readonly errors: Error[]\n}\n\nexport interface PullResult {\n readonly pulled: number\n readonly conflicts: Conflict[]\n readonly errors: Error[]\n}\n\n/** Result of a sync transaction commit. */\nexport interface SyncTransactionResult {\n readonly status: 'committed' | 'conflict'\n readonly pushed: number\n readonly conflicts: Conflict[]\n}\n\nexport interface SyncStatus {\n readonly dirty: number\n readonly lastPush: string | null\n readonly lastPull: string | null\n readonly online: boolean\n}\n\n// ─── Sync Target ─────────────────────────────────────────\n\nexport type SyncTargetRole = 'sync-peer' | 'backup' | 'archive'\n\n/**\n * A sync target with role and optional per-target policy.\n *\n * | Role | Direction | Conflict resolution | Typical use |\n * |-------------|---------------|---------------------|--------------------------|\n * | `sync-peer` | Bidirectional | ConflictStrategy | DynamoDB live sync |\n * | `backup` | Push-only | N/A (receives merged)| S3 dump, Google Drive |\n * | `archive` | Push-only | N/A | IPFS, Git tags, S3 Lock |\n */\nexport interface SyncTarget {\n /** The store to sync with. */\n readonly store: NoydbStore\n /** Role determines sync direction and conflict handling. */\n readonly role: SyncTargetRole\n /** Per-target sync policy. Inherits store-category default when absent. */\n readonly policy?: SyncPolicy\n /** Human-readable label for DevTools and audit logs. */\n readonly label?: string\n}\n\n// ─── Events ────────────────────────────────────────────────────────────\n\nexport interface ChangeEvent {\n readonly vault: string\n readonly collection: string\n readonly id: string\n readonly action: 'put' | 'delete'\n}\n\nexport interface NoydbEventMap {\n 'change': ChangeEvent\n 'error': Error\n 'sync:push': PushResult\n 'sync:pull': PullResult\n 'sync:conflict': Conflict\n 'sync:online': void\n 'sync:offline': void\n 'sync:backup-error': { vault: string; target: string; error: Error }\n 'history:save': { vault: string; collection: string; id: string; version: number }\n 'history:prune': { vault: string; collection: string; id: string; pruned: number }\n /**\n * Emitted when a persisted-index side-car put/delete fails after the\n * main record write already succeeded. The main record is durable; the\n * index mirror may have drifted. Operators reconcile via\n * `collection.reconcileIndex(field)`.\n */\n 'index:write-partial': {\n vault: string\n collection: string\n id: string\n action: 'put' | 'delete'\n error: Error\n }\n /**\n * emitted by `Collection.ensurePersistedIndexesLoaded()`\n * once per field on first lazy-mode query when\n * `reconcileOnOpen: 'auto' | 'dry-run'` is configured. `applied` is\n * `0` in `'dry-run'` mode. `skipped` is reserved for a future\n * drift-stamp optimization that short-circuits the reconcile when\n * the mirror version matches what's on disk — currently always\n * `false` (the full reconcile runs every session).\n */\n 'index:reconciled': {\n vault: string\n collection: string\n field: string\n missing: readonly string[]\n stale: readonly string[]\n applied: number\n skipped: boolean\n }\n}\n\n// ─── Grant / Revoke ────────────────────────────────────────────────────\n\nexport interface GrantOptions {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly passphrase: string\n readonly permissions?: Permissions\n /**\n * Optional `@noy-db/as-*` export capability. Omit or\n * leave undefined to apply role-based defaults (see\n * `hasExportCapability` and `ExportCapability`).\n */\n readonly exportCapability?: ExportCapability\n /**\n * Optional `@noy-db/as-*` import capability (issue ). Omit or\n * leave undefined for default-closed semantics — no plaintext format\n * is grantable until positively listed; bundle import is denied.\n */\n readonly importCapability?: ImportCapability\n /**\n * Skip phrase-format strength validation (issue #7). Defaults to\n * false — `grant()` rejects phrases that don't meet the configured\n * `PassphrasePolicy`. Test fixtures and CLI scripts pass `true`.\n */\n readonly allowWeakPassphrase?: boolean\n /**\n * Initial user-envelope payload for the new principal. Sealed under\n * the same vault DEK (the reserved `_users` collection's DEK) and\n * persisted alongside the keyring during grant.\n *\n * **Bootstrap-only.** Once the new user activates and writes their\n * own envelope, the own-only write rule kicks in — admins cannot\n * edit a teammate's envelope after activation. Use this field for\n * pre-fill at invite time (e.g. \"displayName: Bob, locale: en-US\")\n * and let the user take over from there.\n *\n * Hub does not introspect the payload; it is JSON-serialized and\n * encrypted opaquely. Apps own the schema.\n *\n * @see docs/superpowers/specs/2026-05-05-user-envelope-design.md → Lifecycle\n */\n readonly initialProfile?: unknown\n}\n\n/**\n * Caller payload for `db.updateUser` (#54). Mutate one or more\n * identity fields on an existing keyring without rotating any keys.\n *\n * `role`, `displayName`, and `permissions` live in the plaintext header\n * of `_keyring/<userId>` (the sync engine reads them without keys).\n * Mutating them is a JSON header swap — no DEK rewrap, no KEK\n * required, no authenticator slots touched. Tier-2 slots and recovery\n * enrollments survive unchanged. Last-write-wins through the existing\n * keyring put (same concurrency story as `db.grant` / `db.revoke`).\n *\n * Top-level fields are partial-merge: absent fields are not modified.\n * `null` on `displayName` clears the field (stored as the empty string;\n * UI consumers typically render the empty case by falling back to the\n * user id). `undefined` / absent leaves the field untouched. Mirrors\n * the `null`-as-clear convention `UserApi.updateMe` uses (#57).\n *\n * `permissions`, however, is a **full replacement** at the map level —\n * passing `{ invoices: 'rw' }` REPLACES the entire permissions map,\n * silently dropping any other entries. To partially update, read the\n * current keyring and merge: `permissions: { ...current, invoices: 'rw' }`.\n * To clear all permissions, pass `permissions: {}` explicitly.\n *\n * Role-elevation guard: the same hierarchy as `db.grant`. Admins can\n * change `admin` / `operator` / `viewer` / `client` to and from each\n * other; admins cannot promote to or demote from `owner`. Owners can\n * do anything. Non-admin callers (operator/viewer/client) cannot call\n * `db.updateUser` at all — for self-displayName changes, use\n * `vault.user.updateMe` (the user-envelope API).\n *\n * @see #54\n */\nexport interface UpdateUserOptions {\n readonly userId: string\n readonly role?: Role\n readonly displayName?: string | null\n readonly permissions?: Permissions\n}\n\nexport interface RevokeOptions {\n readonly userId: string\n readonly rotateKeys?: boolean\n\n /**\n * Cascade behavior when the revoked user is an admin who has granted\n * other admins.\n *\n * - `'strict'` (default) — recursively revoke every admin that the\n * target (transitively) granted. The cascade walks the\n * `granted_by` field on each keyring file and stops at non-admin\n * leaves. All affected collections are accumulated and rotated in\n * a single pass at the end, so cascade cost is O(records in\n * affected collections), not O(records × cascade depth).\n *\n * - `'warn'` — leave the descendant admins in place but emit a\n * `console.warn` listing them. Useful for diagnostic dry runs and\n * for environments where the operator wants to clean up the\n * delegation tree manually.\n *\n * No effect when the target is not an admin (operators, viewers, and\n * clients cannot grant other users, so they have no delegation\n * subtree to cascade through). Defaults to `'strict'`.\n */\n readonly cascade?: 'strict' | 'warn'\n}\n\n// ─── Cross-vault queries ──────────────────────────────\n\n/**\n * One entry returned by `Noydb.listAccessibleVaults()`. Carries\n * the compartment id and the role the calling principal holds in it,\n * so the consumer can decide how to fan out without re-checking\n * permissions per vault.\n */\nexport interface AccessibleVault {\n readonly id: string\n readonly role: Role\n}\n\n/**\n * Options for `Noydb.listAccessibleVaults()`.\n */\nexport interface ListAccessibleVaultsOptions {\n /**\n * Minimum role the caller must hold to include a vault in the\n * result. Vaults where the caller's role is strictly *below*\n * this threshold are silently excluded. Defaults to `'client'`,\n * which means \"every vault I can unwrap is returned.\" Set to\n * `'admin'` for \"vaults where I can grant/revoke,\" or\n * `'owner'` for \"vaults I own.\"\n *\n * The privilege ordering used:\n * `client (1) < viewer (2) < operator (3) < admin (4) < owner (5)`\n *\n * Note: `viewer` and `client` are conceptually peers in the ACL\n * (neither can grant), but `viewer` has read-all access while\n * `client` has only explicit-collection read. The numeric order\n * reflects \"how much can this principal see,\" not \"how much can\n * this principal modify.\"\n */\n readonly minRole?: Role\n}\n\n/**\n * Options for `Noydb.queryAcross()`.\n */\nexport interface QueryAcrossOptions {\n /**\n * Maximum number of compartments to process in parallel. Defaults\n * to `1` (sequential) — conservative because the per-compartment\n * callback typically does its own I/O and an unbounded fan-out can\n * exhaust adapter connections (DynamoDB throughput, S3 socket\n * limits, browser fetch concurrency).\n *\n * Set to `4` or `8` for cloud-backed compartments where parallelism\n * is the whole point of fanning out. Set to `1` (default) for local\n * adapters where the disk I/O serializes anyway.\n */\n readonly concurrency?: number\n}\n\n/**\n * One entry in the array returned by `Noydb.queryAcross()`. Either\n * `result` is set (callback succeeded for this compartment) or\n * `error` is set (callback threw, or compartment failed to open).\n *\n * Per-compartment errors do **not** abort the overall fan-out — every\n * compartment is given a chance to run its callback, and the\n * partition between success and failure is exposed in the return\n * value. Consumers that want fail-fast semantics can check\n * `r.error !== undefined` and short-circuit themselves.\n */\nexport type QueryAcrossResult<T> =\n | { readonly vault: string; readonly result: T; readonly error?: undefined }\n | { readonly vault: string; readonly result?: undefined; readonly error: Error }\n\n// ─── User Info ─────────────────────────────────────────────────────────\n\nexport interface UserInfo {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly permissions: Permissions\n readonly createdAt: string\n readonly grantedBy: string\n}\n\n// ─── Session ───────────────────────────────────────────────\n\n/**\n * Operations that a session policy can require re-authentication for.\n * Passed as the `requireReAuthFor` array in `SessionPolicy`.\n */\nexport type ReAuthOperation = 'export' | 'grant' | 'revoke' | 'rotate' | 'changeSecret'\n\n/**\n * Session policy controlling lifetime, re-auth requirements, and\n * background-lock behavior.\n *\n * All timeout values are in milliseconds. `undefined` means \"no limit.\"\n * The policy is evaluated lazily — it does not start timers itself;\n * enforcement happens at the Noydb call site.\n */\nexport interface SessionPolicy {\n /**\n * Idle timeout in ms. If no NOYDB operation is performed for this\n * duration, the session is revoked on the next operation attempt\n * (which will throw `SessionExpiredError`). The idle clock resets\n * on every successful operation.\n *\n * Default: `undefined` (no idle timeout).\n */\n readonly idleTimeoutMs?: number\n\n /**\n * Absolute timeout in ms from session creation. After this duration\n * the session is unconditionally revoked regardless of activity.\n *\n * Default: `undefined` (no absolute timeout).\n */\n readonly absoluteTimeoutMs?: number\n\n /**\n * Operations that require the user to re-authenticate (re-enter their\n * passphrase or perform a fresh WebAuthn assertion) before proceeding,\n * even if the session is still alive.\n *\n * Common pattern: `requireReAuthFor: ['export', 'grant']` — allow\n * read/write operations in the background but demand a fresh credential\n * for high-risk mutations.\n *\n * Default: `[]` (no extra re-auth requirements).\n */\n readonly requireReAuthFor?: readonly ReAuthOperation[]\n\n /**\n * If `true`, the session is revoked when the page goes to the background\n * (visibilitychange event, `document.hidden === true`). Useful for\n * high-sensitivity deployments where leaving the tab is treated as\n * a session boundary.\n *\n * No-op in non-browser environments (Node.js, workers without document).\n * Default: `false`.\n */\n readonly lockOnBackground?: boolean\n}\n\n// ─── i18n / Locale ─────────────────────────────────────\n\n/**\n * Locale-aware read options. Pass to `Collection.get()`, `list()`,\n * `query()`, and `scan()` to trigger per-record locale resolution for\n * `dictKey` and `i18nText` fields.\n *\n * - **`locale: 'raw'`** — skip resolution for `i18nText` fields and\n * return the full `{ [locale]: string }` map. Dict key fields still\n * return the stable key (no `<field>Label` added).\n * - **`fallback`** — single locale code or ordered list. Use `'any'` as\n * the last element to fall back to any present translation.\n *\n * When neither the call-level locale nor the compartment's default locale\n * is set, reading a record with `i18nText` fields throws\n * `LocaleNotSpecifiedError`.\n */\nexport interface LocaleReadOptions {\n /**\n * The target locale code (e.g. `'th'`), or `'raw'` to return the full\n * language map without resolution.\n */\n readonly locale?: string\n /**\n * Fallback locale or ordered fallback chain. Use `'any'` as the last\n * element to fall back to any present translation.\n */\n readonly fallback?: string | readonly string[]\n}\n\n// ─── plaintextTranslator hook ──────────────────────────────\n\n/**\n * Context passed to the consumer-supplied `plaintextTranslator` function.\n * The hook receives the source text plus enough metadata to route it to the\n * right translation service and record what it did.\n */\nexport interface PlaintextTranslatorContext {\n /** The plaintext string to translate. */\n readonly text: string\n /** BCP 47 source locale (the locale the text is written in). */\n readonly from: string\n /** BCP 47 target locale to translate into. */\n readonly to: string\n /** The schema field name that triggered the translation. */\n readonly field: string\n /** The collection the record is being put into. */\n readonly collection: string\n}\n\n/**\n * A consumer-supplied async function that translates a single string\n * from one locale to another. noy-db ships no built-in translator.\n *\n * **Security:** this function receives plaintext. The consumer is\n * responsible for the data policy of whatever service it calls. See\n * `NOYDB_SPEC.md § Zero-Knowledge Storage` and the `plaintextTranslator`\n * JSDoc on `NoydbOptions` for the full invariant statement.\n */\nexport type PlaintextTranslatorFn = (\n ctx: PlaintextTranslatorContext,\n) => Promise<string>\n\n/**\n * One entry in the in-process translator audit log. Cleared when\n * `db.close()` is called — same lifetime as the KEK and DEKs.\n *\n * Deliberately omits any content hash or translated-text fingerprint\n * to prevent correlation attacks on the audit trail.\n */\nexport interface TranslatorAuditEntry {\n readonly type: 'translator-invocation'\n /** Schema field name that was translated. */\n readonly field: string\n /** Collection the record belongs to. */\n readonly collection: string\n /** Source locale. */\n readonly fromLocale: string\n /** Target locale. */\n readonly toLocale: string\n /**\n * Consumer-provided translator name from\n * `NoydbOptions.plaintextTranslatorName`. Defaults to `'anonymous'`\n * when not supplied.\n */\n readonly translatorName: string\n /** ISO 8601 timestamp of the invocation. */\n readonly timestamp: string\n /**\n * `true` when the result was served from the in-process cache rather\n * than by calling the translator function. Present only on cache hits\n * so the absence of the field also communicates a cache miss.\n */\n readonly cached?: true\n}\n\n// ─── Presence ─────────────────────────────────────────────\n\n/**\n * A presence peer entry. `lastSeen` is an ISO timestamp set by core on each\n * `update()` call. Stale entries (lastSeen older than `staleMs`) are filtered\n * before delivering to the subscriber callback.\n */\nexport interface PresencePeer<P> {\n readonly userId: string\n readonly payload: P\n readonly lastSeen: string\n}\n\n// ─── CRDT ─────────────────────────────────────────────────\n\n// Re-exported from crdt.ts so consumers only need one import path.\nexport type { CrdtMode, CrdtState, LwwMapState, RgaState, YjsState } from './crdt/crdt.js'\n\n// ─── Blob / Attachment Store ────────────────────────\n\n/**\n * Second store shape for blob-store backends (Drive, WebDAV, Git, iCloud)\n * that operate on whole-vault bundles rather than per-record KV.\n *\n * Implement `readBundle` / `writeBundle` instead of the six-method KV\n * contract. Use `wrapBundleStore()` from `@noy-db/hub` to convert to a\n * `NoydbStore` that the rest of the API consumes transparently.\n *\n * Named `NoydbBundleStore` (not `NoydbBundleAdapter`) for consistency\n * with the hub / to-* / in-* rename. Concrete implementations ship\n * in `@noy-db/to-*` packages starting in.\n */\nexport interface NoydbBundleStore {\n /** Discriminant for engine auto-detection of store shape. */\n readonly kind: 'bundle'\n /** Human-readable name for diagnostics (e.g. `'drive'`, `'webdav'`). */\n readonly name?: string\n /**\n * Read the entire vault as raw bytes. Returns `null` if no bundle exists\n * yet (first open of a brand-new vault).\n */\n readBundle(vaultId: string): Promise<{ bytes: Uint8Array; version: string } | null>\n /**\n * Write the entire vault as raw bytes. `expectedVersion` is the version\n * token from the last `readBundle` (or `null` for a first write).\n * Implementations MUST reject the write if the stored version has advanced\n * past `expectedVersion` — throw `BundleVersionConflictError`.\n * Returns the new version token on success.\n */\n writeBundle(\n vaultId: string,\n bytes: Uint8Array,\n expectedVersion: string | null,\n ): Promise<{ version: string }>\n /** Delete a vault bundle. Idempotent — no-op if the bundle does not exist. */\n deleteBundle(vaultId: string): Promise<void>\n /** List all vault bundles managed by this store. */\n listBundles(): Promise<Array<{ vaultId: string; version: string; size: number }>>\n}\n\n/**\n * Content-addressed blob object stored in the vault-level blob index.\n * Identified by HMAC-SHA-256(blobDEK, plaintext) — opaque to the store.\n *\n * Shared across all collections within a vault for deduplication: two\n * records that attach identical byte content reference the same `eTag`\n * and share a single set of encrypted chunks in `_blob_chunks`.\n */\nexport interface BlobObject {\n /** HMAC-SHA-256 hex of the original plaintext bytes, keyed by `_blob` DEK. */\n readonly eTag: string\n /** Original uncompressed size in bytes. */\n readonly size: number\n /** Compressed size in bytes (the payload that is actually encrypted and chunked). */\n readonly compressedSize: number\n /** Compression algorithm applied before encryption. */\n readonly compression: 'gzip' | 'none'\n /** Raw chunk size in bytes used at write time. Readers MUST use this value. */\n readonly chunkSize: number\n /** Total number of chunks written. Reader expects exactly this many. */\n readonly chunkCount: number\n /** MIME type if provided or auto-detected at upload time. */\n readonly mimeType?: string\n /** ISO timestamp of first upload. */\n readonly createdAt: string\n /** Live reference count — slots + published versions pointing to this blob. */\n readonly refCount: number\n /**\n * Hint indicating which store holds the chunk data.\n * Used by `routeStore` size-tiered routing: `'default'` for small blobs\n * stored inline (e.g. DynamoDB), `'blobs'` for large blobs in the overflow\n * store (e.g. S3). Absent when no routing is configured.\n */\n readonly storeHint?: 'default' | 'blobs'\n}\n\n// ─── Attachment types ─────────────────────────────────────────\n\n/** Single attachment metadata entry stored inside a record's attachment envelope. */\nexport interface AttachmentEntry {\n /** Content-addressed identifier (HMAC-SHA-256 of plaintext). */\n readonly eTag: string\n /** User-visible filename for the slot. */\n readonly filename: string\n /** Original uncompressed size in bytes. */\n readonly size: number\n /** MIME type, if provided or auto-detected at upload time. */\n readonly mimeType?: string\n /** ISO timestamp of the upload. */\n readonly uploadedAt: string\n /** User ID of the uploader, if available. */\n readonly uploadedBy?: string\n}\n\n/** Attachment entry annotated with its slot name, as returned by `AttachmentHandle.list()`. */\nexport type AttachmentInfo = AttachmentEntry & { readonly name: string }\n\n/** Options for `AttachmentHandle.put()`. */\nexport interface AttachmentPutOptions {\n /** Compress the attachment with gzip before encryption. Default: `true`. */\n compress?: boolean\n /** Chunk size in bytes. Default: `DEFAULT_CHUNK_SIZE` (256 KB). */\n chunkSize?: number\n /** MIME type to store with the attachment. Auto-detected from magic bytes if omitted. */\n mimeType?: string\n /** User ID to record as the uploader. Falls back to the active user's ID. */\n uploadedBy?: string\n}\n\n/** Options for `AttachmentHandle.response()`. */\nexport interface AttachmentResponseOptions {\n /**\n * Set `Content-Disposition: inline` so the browser renders the file\n * instead of downloading it. Default: `false` (attachment disposition).\n */\n inline?: boolean\n}\n\n/**\n * Slot record — mutable metadata linking a named slot on a record\n * to a `BlobObject` via its eTag.\n *\n * Multiple slots (even across different records) may reference the same\n * `eTag` — the underlying chunks are shared. Updating metadata creates\n * a new envelope version (`_v++`) while the blob data is unchanged.\n */\nexport interface SlotRecord {\n /** Reference to the `BlobObject` in `_blob_index`. */\n readonly eTag: string\n /** User-visible filename for the slot. */\n readonly filename: string\n /** Original uncompressed size in bytes (denormalized from `BlobObject`). */\n readonly size: number\n /** MIME type. Takes precedence over the MIME type stored in `BlobObject`. */\n readonly mimeType?: string\n /** ISO timestamp of the upload that set this slot. */\n readonly uploadedAt: string\n /** User ID of the uploader, if available. */\n readonly uploadedBy?: string\n}\n\n/** Result of `BlobSet.list()` — slot record plus its named slot key. */\nexport interface SlotInfo extends SlotRecord {\n /** The slot name (key in the record's slot map). */\n readonly name: string\n}\n\n/**\n * Explicitly published version snapshot — an independent reference to a\n * blob at a specific point in time.\n */\nexport interface VersionRecord {\n /** User-defined label (e.g. `'issued-2025-01'`, `'amendment-2025-02'`). */\n readonly label: string\n /** eTag of the blob snapshot at publish time — independent of the current slot. */\n readonly eTag: string\n /** ISO timestamp when the version was published. */\n readonly publishedAt: string\n /** User ID of the publisher, if available. */\n readonly publishedBy?: string\n}\n\n/** Options for `BlobSet.put()`. */\nexport interface BlobPutOptions {\n /** MIME type hint. If omitted, auto-detected from magic bytes. */\n mimeType?: string\n /**\n * Raw chunk size in bytes. Priority: this value > store.maxBlobBytes > 256 KB.\n */\n chunkSize?: number\n /**\n * Whether to gzip-compress bytes before encrypting. Default: `true`.\n * Auto-set to `false` for pre-compressed MIME types (JPEG, PNG, ZIP, etc.).\n */\n compress?: boolean\n /** User ID to record as `uploadedBy`. Defaults to the Noydb session user. */\n uploadedBy?: string\n}\n\n/** Options for `BlobSet.response()` and `BlobSet.responseVersion()`. */\nexport interface BlobResponseOptions {\n /**\n * When `true`, sets `Content-Disposition: inline; filename=\"...\"` so\n * the browser renders the file in the tab. Default (`false`) sets\n * `attachment; filename=\"...\"` which triggers a download.\n */\n inline?: boolean\n /** Override the filename in the Content-Disposition header. */\n filename?: string\n}\n\n// ─── Store Capabilities ─────────────────────────────\n\nexport type StoreAuthKind =\n | 'none'\n | 'filesystem'\n | 'api-key'\n | 'iam'\n | 'oauth'\n | 'kerberos'\n | 'browser-origin'\n\nexport interface StoreAuth {\n kind: StoreAuthKind | StoreAuthKind[]\n required: boolean\n flow: 'static' | 'oauth' | 'kerberos' | 'implicit'\n}\n\nexport interface StoreCapabilities {\n /**\n * true — the store's expectedVersion check and write are atomic at the\n * storage layer. Two concurrent puts with the same expectedVersion will\n * produce exactly one success and one ConflictError.\n * false — check and write are separate operations with a race window.\n */\n casAtomic: boolean\n auth: StoreAuth\n /**\n * true — the store implements {@link NoydbStore.tx} and commits\n * every op atomically at the storage layer. The hub's\n * `db.transaction(fn)` will delegate to `tx(ops)` and surface a\n * single pass/fail outcome. false (or absent) — no native\n * multi-record atomicity; the hub falls back to per-record OCC\n * with best-effort unwind on partial failure.\n */\n txAtomic?: boolean\n /**\n * Maximum raw bytes per blob chunk record.\n * `undefined` — no limit (S3, file, IDB); blob stored as single chunk.\n * `256 * 1024` — DynamoDB (400 KB item limit minus envelope overhead).\n * `5 * 1024 * 1024` — localStorage quota safety.\n */\n maxBlobBytes?: number\n}\n\n// ─── Factory Options ───────────────────────────────────────────────────\n\nexport interface NoydbOptions {\n /** Primary store (local storage). */\n readonly store: NoydbStore\n /**\n * tree-shake seam — optional blob strategy. Pass `withBlobs()`\n * from `@noy-db/hub/blobs` to enable `collection.blob(id)` storage.\n * When omitted, hub's blob machinery stays out of the bundle (ESM\n * tree-shaking) and `collection.blob(id)` throws with a pointer at\n * the subpath. `BlobStrategy` is `@internal` — users only construct\n * it via the subpath factory.\n *\n * @internal\n */\n readonly blobStrategy?: BlobStrategy\n /**\n * tree-shake seam — optional indexing strategy. Pass\n * `withIndexing()` from `@noy-db/hub/indexing` to enable eager-mode\n * `==/in` fast-paths, lazy-mode `.lazyQuery()`, rebuild/reconcile,\n * and auto-reconcile. When omitted, indexing code never reaches the\n * bundle; `.lazyQuery()` throws with a pointer at the subpath, and\n * eager-mode collections fall back to linear scans regardless of\n * `indexes: [...]` declarations. `IndexStrategy` is `@internal` —\n * users only construct it via the subpath factory.\n *\n * @internal\n */\n readonly indexStrategy?: IndexStrategy\n /**\n * tree-shake seam — optional aggregate strategy. Pass\n * `withAggregate()` from `@noy-db/hub/aggregate` to enable\n * `.aggregate()` and `.groupBy()` on Query. When omitted, those\n * methods throw with a pointer at the subpath; the ~886 LOC of\n * Aggregation + GroupedQuery machinery never reaches the bundle.\n * Streaming `scan().aggregate()` works independently of this\n * strategy — it doesn't use the `Aggregation` class.\n *\n * @internal\n */\n readonly aggregateStrategy?: AggregateStrategy\n /**\n * tree-shake seam — optional CRDT strategy. Required when\n * any collection is declared with `crdt: 'lww-map' | 'rga' | 'yjs'`;\n * otherwise the first put/sync-merge hitting the CRDT path throws.\n * When omitted, ~221 LOC of LWW-Map / RGA / merge helpers never\n * reach the bundle.\n *\n * @internal\n */\n readonly crdtStrategy?: CrdtStrategy\n /**\n * tree-shake seam — optional consent-audit strategy. Pass\n * `withConsent()` from `@noy-db/hub/consent` to enable per-op audit\n * writes into `_consent_audit` when a consent scope is active.\n * When omitted, `vault.consentAudit()` returns `[]` and writes are\n * no-ops; the consent module's ~194 LOC never reaches the bundle.\n *\n * @internal\n */\n readonly consentStrategy?: ConsentStrategy\n /**\n * tree-shake seam — optional periods strategy. Pass\n * `withPeriods()` from `@noy-db/hub/periods` to enable\n * `vault.closePeriod()` / `.openPeriod()` / write-guard on closed\n * periods. When omitted, `vault.listPeriods()` returns `[]` and\n * the write-guard is a no-op; the ~363 LOC of period validation +\n * ledger appending stay out of the bundle.\n *\n * @internal\n */\n readonly periodsStrategy?: PeriodsStrategy\n /**\n * tree-shake seam — optional VaultFrame strategy. Pass\n * `withShadow()` from `@noy-db/hub/shadow` to enable\n * `vault.frame()`. Without it, calling `vault.frame()` throws.\n *\n * @internal\n */\n readonly shadowStrategy?: ShadowStrategy\n /**\n * tree-shake seam — optional multi-record transactions. Pass\n * `withTransactions()` from `@noy-db/hub/tx` to enable\n * `db.transaction(fn)`. Without it, calling the method throws.\n *\n * @internal\n */\n readonly txStrategy?: TxStrategy\n /**\n * tree-shake seam — optional history + ledger + time-machine.\n * Pass `withHistory()` from `@noy-db/hub/history` to enable\n * per-record version snapshots, the hash-chained audit ledger, JSON\n * Patch deltas, `vault.ledger()`, `vault.at()`, and the\n * `collection.history()` / `getVersion()` / `revert()` / `diff()` /\n * `clearHistory()` / `pruneRecordHistory()` read APIs. When omitted,\n * snapshots/prune/clear are silent no-ops, the read APIs throw with\n * a pointer at the subpath, and ~1,880 LOC stay out of the bundle.\n *\n * @internal\n */\n readonly historyStrategy?: HistoryStrategy\n /**\n * tree-shake seam — optional i18n strategy. Pass `withI18n()`\n * from `@noy-db/hub/i18n` to enable `i18nText`/`dictKey` field\n * resolution on reads, `i18nText` validation on writes, and\n * `vault.dictionary(name)`. When omitted, locale resolution is the\n * identity (raw values returned), the validators throw with a\n * pointer to the subpath, and ~854 LOC of dictionary + locale\n * machinery stay out of the bundle.\n *\n * @internal\n */\n readonly i18nStrategy?: I18nStrategy\n /**\n * tree-shake seam — optional session-policy strategy. Pass\n * `withSession()` from `@noy-db/hub/session` to enable\n * `sessionPolicy` validation, `PolicyEnforcer` lifecycle (idle /\n * absolute timeouts, lockOnBackground), and global session-token\n * revocation. When omitted, setting `sessionPolicy` throws at\n * `createNoydb()` time, and ~495 LOC of policy + token machinery\n * stay out of the bundle.\n *\n * @internal\n */\n readonly sessionStrategy?: SessionStrategy\n /**\n * tree-shake seam — optional sync engine + presence strategy.\n * Pass `withSync()` from `@noy-db/hub/sync` to enable\n * `db.push()` / `pull()` / replication, `db.transaction(vault)`\n * for sync-aware transactions, and `collection.presence()`. When\n * omitted, configuring `sync` / calling these surfaces throws with\n * a pointer at the subpath, and ~856 LOC of replication + presence\n * machinery stay out of the bundle. Keyring stays core; grant/\n * revoke/magic-link/delegation tree-shake via direct imports.\n *\n * @internal\n */\n readonly syncStrategy?: SyncStrategy\n /**\n * Optional guard strategies — collection-level write guards. Each\n * handle is the output of `withGuard()` from `@noy-db/hub/guards`.\n * Multiple guards per collection are allowed; they are dispatched\n * in registration order on `collection.put()`.\n */\n readonly guardStrategies?: ReadonlyArray<GuardStrategyHandleAny>\n /**\n * Optional derivation strategies — source-to-output projections that\n * fire on `collection.put()`. Each handle is the output of\n * `withDerivation()` from `@noy-db/hub/derivations`. The vault\n * validates the derivation graph for cycles on `openVault`; a cyclic\n * graph throws `DerivationCycleError`.\n */\n readonly derivationStrategies?: ReadonlyArray<DerivationStrategyHandle>\n /**\n * Optional materialized-view strategies (#143, foundation in #150).\n * Each handle returned by `withMaterializedView()` from\n * `@noy-db/hub/materialized-views`. The vault runs unified cycle\n * detection across the MV + derivation graphs at `openVault`; a\n * cyclic graph throws `MaterializedViewCycleError`.\n */\n readonly materializedViewStrategies?: ReadonlyArray<MaterializedViewStrategyHandle>\n /**\n * Optional overlay strategies (#154). Each handle returned by\n * `withOverlayedView()` from `@noy-db/hub/overlay-views`. The vault\n * validates name uniqueness + base concreteness + overlay\n * availability at `openVault`; a clash throws one of the\n * `Overlay*Error` family.\n */\n readonly overlayedViewStrategies?: ReadonlyArray<OverlayedViewStrategyHandle>\n /** Optional remote store(s) for sync. Accepts a single store, a SyncTarget, or an array. */\n readonly sync?: NoydbStore | SyncTarget | SyncTarget[]\n /** User identifier. */\n readonly user: string\n /** Passphrase for key derivation. Required unless encrypt is false or `getKeyring` is provided. */\n readonly secret?: string\n /**\n * Optional callback that returns an unlocked keyring for a given vault.\n * Use this to plug in WebAuthn / OIDC / Shamir / any unlock path that\n * produces an `UnlockedKeyring` outside the passphrase model.\n *\n * When set, `secret` MUST NOT also be set — `createNoydb` throws if both\n * are supplied. When neither is set (and `encrypt !== false`), `createNoydb`\n * also throws.\n *\n * The callback is called lazily, on the first operation that needs the\n * keyring for a given vault. Noydb caches the returned keyring per-vault\n * for the lifetime of the instance, so the callback is invoked at most\n * once per `(instance, vault)` pair (assuming the callback resolves\n * successfully). If the callback rejects, the rejection surfaces from the\n * first vault operation that triggered the unlock; subsequent operations\n * will retry the callback.\n *\n * @example\n * ```ts\n * import { createNoydb } from '@noy-db/hub'\n * import { unlockWebAuthn } from '@noy-db/on-webauthn'\n *\n * const enrollment = await loadEnrollment()\n * const db = await createNoydb({\n * store,\n * user: 'alice',\n * getKeyring: (vault) => unlockWebAuthn(enrollment),\n * })\n * ```\n *\n * Note: this callback is responsible for both the \"open existing vault\"\n * and the \"create new vault\" cases. Unlike the passphrase path, there is\n * no automatic `NoAccessError` → `createOwnerKeyring` fallback, because\n * the callback owner has the UI context to decide which path to run.\n * For first-time bootstrap, use a passphrase or recovery code, enroll\n * WebAuthn from the unlocked keyring, then swap to `getKeyring` on\n * subsequent sessions.\n */\n readonly getKeyring?: (vault: string) => Promise<UnlockedKeyring>\n /**\n * Passphrase mode (#14). Default `'standard'`.\n *\n * - `'standard'` — the legacy flow. `secret` supplies the\n * plaintext passphrase, the user knows it, and the policy gate\n * `rotate-passphrase` is enabled.\n * - `'managed'` — rubber-hose-resistant mode. Hub generates a\n * 256-bit random passphrase at first open and seals it under\n * the provided `sealingKey`. The user never sees or types the\n * passphrase, defeating the $5-wrench attack. Mutually\n * exclusive with `secret` and `getKeyring`.\n *\n * @see docs/subsystems/session-tiers.md → Managed-passphrase mode\n */\n readonly passphraseMode?: 'standard' | 'managed'\n /**\n * Provider that seals/unseals the auto-generated managed-mode\n * passphrase. Required when `passphraseMode === 'managed'`; ignored\n * otherwise. Implementations live in per-platform packages\n * (`@noy-db/seal-macos-keychain`, `@noy-db/seal-wincred`,\n * `@noy-db/seal-libsecret`, `@noy-db/seal-aws-kms`, …).\n */\n readonly sealingKey?: SealingKeyProvider\n /** Required to use `profile: 'shamir'` recovery. Pass\n * `shamirRecoveryProvider()` from `@noy-db/on-shamir`. */\n readonly shamirRecovery?: ShamirRecoveryProvider\n /** Auth method. Default: 'passphrase'. */\n readonly auth?: 'passphrase' | 'biometric'\n /** Enable encryption. Default: true. */\n readonly encrypt?: boolean\n /** Conflict resolution strategy. Default: 'version'. */\n readonly conflict?: ConflictStrategy\n /**\n * Sync scheduling policy. Controls when push/pull fire.\n * Default inferred from store category: per-record → `on-change`,\n * bundle → `debounce 30s`.\n */\n readonly syncPolicy?: SyncPolicy\n /**\n * @deprecated Use `syncPolicy` instead. Kept for backward compatibility.\n * When both are supplied, `syncPolicy` takes precedence.\n */\n readonly autoSync?: boolean\n /**\n * @deprecated Use `syncPolicy` instead. Kept for backward compatibility.\n */\n readonly syncInterval?: number\n /**\n * Session timeout in ms. Clears keys after inactivity. Default: none.\n * @deprecated Use `sessionPolicy.idleTimeoutMs` instead. This field is\n * still honored for backwards compatibility but `sessionPolicy` takes\n * precedence when both are supplied.\n */\n readonly sessionTimeout?: number\n /**\n * Session policy controlling lifetime, re-auth requirements, and\n * background-lock behavior. When supplied, replaces the\n * legacy `sessionTimeout` field.\n */\n readonly sessionPolicy?: SessionPolicy\n /**\n * Validate passphrase strength against the phrase format\n * (`@noy-db/hub` issue #7) on first-time keyring creation. When\n * `true`, weak phrases throw {@link WeakPassphraseError} from\n * `createNoydb()` / `db.rotatePassphrase()`. Default: `false` for\n * back-compat in v0.1.x; planned to flip to `true` at v1.0.\n */\n readonly validatePassphrase?: boolean\n /**\n * Vault-level policy gate document (issue #9). When present, the hub\n * persists the merged policy at `_meta/policy` on first-time vault\n * creation and gates sensitive operations (`db.rotatePassphrase`,\n * `db.export*`, …) against it. Omitted ⇒ the engine uses\n * {@link PERSONAL_POLICY}. Use {@link STRICT_POLICY} for regulated\n * deployments.\n *\n * The on-disk document is the source of truth — the policy field\n * is only honored at vault creation; subsequent runs read from\n * `_meta/policy`. Use `db.updatePolicy()` to change it deliberately.\n *\n * Imported from `@noy-db/hub` as a type-only reference; the runtime\n * import lives in `policy/index.ts`.\n */\n readonly policy?: VaultPolicy\n /**\n * Mandatory recovery profile enrollment (issue #10). Vaults with\n * `recover-passphrase` enabled MUST register at least one profile\n * before being production-ready, otherwise `createNoydb()` throws\n * {@link RecoveryNotEnrolledError}. Set\n * `policy.gates['recover-passphrase'].enabled = false` to\n * deliberately opt out of recovery (passphrase loss = data loss).\n *\n * v0.1.0-pre.5 supports the `'paper'` profile end-to-end. Other\n * profiles ship the API shape and throw\n * {@link RecoveryProfileNotImplementedError} during use.\n */\n readonly recovery?: ReadonlyArray<RecoveryEnrollment>\n /**\n * When `true`, `createNoydb` rejects vaults with no recovery\n * entries persisted (per the spec's mandatory-enrollment\n * requirement). Default `false` for v0.1.x back-compat; planned to\n * flip to `true` at v1.0. Apps in regulated environments should\n * turn this on now.\n */\n readonly requireRecovery?: boolean\n /**\n * What to do when `openVault` finds an existing keyring in the store that\n * cannot be decrypted with the supplied credentials (`InvalidKeyError`).\n *\n * - `'error'` (default) — propagate the error. The app must prompt the user\n * to supply the correct credentials or clear both the data and auth stores.\n * - `'reset'` — delete the stale keyring and re-initialise the vault from\n * scratch using the current credentials. Use this when the data store can\n * become detached from the auth store (e.g. the user cleared the IndexedDB\n * data records but not the keyring row, or a WebAuthn credential was rotated).\n * **All previously encrypted data is unrecoverable after a reset.**\n *\n * Only applies to the passphrase (`secret`) path. When `getKeyring` is used,\n * the callback is responsible for handling stale-keyring detection itself.\n */\n readonly onInvalidKey?: 'error' | 'reset'\n /**\n * Enable the public envelope subsystem (`docs/subsystems/public-envelope.md`).\n * Pass `true` for the default schema (every standard field, 256 KB\n * icon cap, 200-char text cap), or a `PublicEnvelopeSchema` to\n * narrow what the owner can set. Off by default — vaults written\n * by hubs without this option carry no envelope, full stop.\n */\n readonly publicEnvelope?: true | PublicEnvelopeSchema\n /** Audit history configuration. */\n readonly history?: HistoryConfig\n /**\n * Consumer-supplied translation function for `i18nText` fields with\n * `autoTranslate: true`.\n *\n * ⚠ **`plaintextTranslator` receives unencrypted text.** Configuring\n * this hook causes plaintext to leave noy-db's zero-knowledge boundary\n * over whatever channel the consumer's implementation uses. noy-db ships\n * no built-in translator and adds no translator SDKs as dependencies.\n * The consumer chooses and owns the data policy of the external service.\n *\n * Per-field opt-in via `autoTranslate: true` on `i18nText()`. Calling\n * `put()` on a collection with `autoTranslate: true` fields while this\n * option is absent throws `TranslatorNotConfiguredError`.\n *\n * See `NOYDB_SPEC.md § Zero-Knowledge Storage` for the invariant text.\n */\n readonly plaintextTranslator?: PlaintextTranslatorFn\n /**\n * Human-readable name for the translator, recorded in the in-process\n * audit log (e.g. `'deepl-pro-with-dpa'`, `'self-hosted-llama-7b'`).\n * Defaults to `'anonymous'` when not supplied.\n */\n readonly plaintextTranslatorName?: string\n}\n\n// ─── History / Audit Trail ─────────────────────────────────────────────\n\n/** History configuration. */\nexport interface HistoryConfig {\n /** Enable history tracking. Default: true. */\n readonly enabled?: boolean\n /** Maximum history entries per record. Oldest pruned on overflow. Default: unlimited. */\n readonly maxVersions?: number\n}\n\n/** Options for querying history. */\nexport interface HistoryOptions {\n /** Start date (inclusive), ISO 8601. */\n readonly from?: string\n /** End date (inclusive), ISO 8601. */\n readonly to?: string\n /** Maximum entries to return. */\n readonly limit?: number\n}\n\n/** Options for pruning history. */\nexport interface PruneOptions {\n /** Keep only the N most recent versions. */\n readonly keepVersions?: number\n /** Delete versions older than this date, ISO 8601. */\n readonly beforeDate?: string\n}\n\n/** A decrypted history entry. */\nexport interface HistoryEntry<T> {\n readonly version: number\n readonly timestamp: string\n readonly userId: string\n readonly record: T\n}\n\n// ─── Bulk operations ──────────────────────────────────────\n\n/** Per-item options for `Collection.putMany()`. */\nexport interface PutManyItemOptions {\n /**\n * Optimistic-concurrency check: fail this item if the stored version\n * is not `expectedVersion`. Honored only in `atomic: true` mode;\n * ignored in the default best-effort loop.\n */\n readonly expectedVersion?: number\n}\n\n/**\n * Batch-level options for `Collection.putMany()` and `deleteMany()`.\n *\n * `atomic: true` switches the call from best-effort loop\n * to all-or-nothing: a pre-flight CAS check runs first, then every op\n * is executed; any mid-batch failure triggers a best-effort revert.\n * On failure in atomic mode the whole call throws — you won't get a\n * partial `PutManyResult`. On success the result mirrors the default\n * loop's shape.\n */\nexport interface PutManyOptions {\n readonly atomic?: boolean\n}\n\n/** Result of `Collection.putMany()`. */\nexport interface PutManyResult {\n /** `true` iff every entry succeeded. */\n readonly ok: boolean\n /** IDs that were successfully written. */\n readonly success: readonly string[]\n /** Entries that failed, with the error that prevented each write. */\n readonly failures: ReadonlyArray<{ readonly id: string; readonly error: Error }>\n}\n\n/** Result of `Collection.deleteMany()`. Same shape as `PutManyResult`. */\nexport interface DeleteManyResult {\n readonly ok: boolean\n readonly success: readonly string[]\n readonly failures: ReadonlyArray<{ readonly id: string; readonly error: Error }>\n}\n","/**\n * All NOYDB error classes — a single import surface for `catch` blocks and\n * `instanceof` checks.\n *\n * ## Class hierarchy\n *\n * ```\n * Error\n * └─ NoydbError (code: string)\n * ├─ Crypto errors\n * │ ├─ DecryptionError — AES-GCM tag failure\n * │ ├─ TamperedError — ciphertext modified after write\n * │ └─ InvalidKeyError — wrong passphrase / corrupt keyring\n * ├─ Access errors\n * │ ├─ NoAccessError — no DEK for this collection\n * │ ├─ ReadOnlyError — ro permission, write attempted\n * │ ├─ PermissionDeniedError — role too low for operation\n * │ ├─ PrivilegeEscalationError — grant wider than grantor holds\n * │ └─ StoreCapabilityError — optional store method missing\n * ├─ Sync errors\n * │ ├─ ConflictError — optimistic-lock version mismatch\n * │ ├─ BundleVersionConflictError — bundle push rejected by remote\n * │ └─ NetworkError — push/pull network failure\n * ├─ Data errors\n * │ ├─ NotFoundError — get(id) on missing record\n * │ ├─ ValidationError — application-level guard failed\n * │ └─ SchemaValidationError — Standard Schema v1 rejection\n * ├─ Query errors\n * │ ├─ JoinTooLargeError — join row ceiling exceeded\n * │ ├─ DanglingReferenceError — strict ref() points at nothing\n * │ ├─ GroupCardinalityError — groupBy bucket cap exceeded\n * │ ├─ IndexRequiredError — lazy-mode query touches unindexed field\n * │ └─ IndexWriteFailureError — index side-car put/delete failed post-main\n * ├─ i18n / Dictionary errors\n * │ ├─ ReservedCollectionNameError\n * │ ├─ DictKeyMissingError\n * │ ├─ DictKeyInUseError\n * │ ├─ MissingTranslationError\n * │ ├─ LocaleNotSpecifiedError\n * │ └─ TranslatorNotConfiguredError\n * ├─ Backup errors\n * │ ├─ BackupLedgerError — hash-chain verification failed\n * │ └─ BackupCorruptedError — envelope hash mismatch in dump\n * ├─ Bundle errors\n * │ └─ BundleIntegrityError — .noydb body sha256 mismatch\n * └─ Session errors\n * ├─ SessionExpiredError\n * ├─ SessionNotFoundError\n * └─ SessionPolicyError\n * ```\n *\n * ## Catching all NOYDB errors\n *\n * ```ts\n * import { NoydbError, InvalidKeyError, ConflictError } from '@noy-db/hub'\n *\n * try {\n * await vault.unlock(passphrase)\n * } catch (e) {\n * if (e instanceof InvalidKeyError) { showBadPassphraseUI(); return }\n * if (e instanceof NoydbError) { logToSentry(e.code, e); return }\n * throw e // unexpected — re-throw\n * }\n * ```\n *\n * @module\n */\n\n/**\n * Base class for all NOYDB errors.\n *\n * Every error thrown by `@noy-db/hub` extends this class, so consumers can\n * catch all NOYDB errors in a single `catch (e) { if (e instanceof NoydbError) ... }`\n * block. The `code` field is a machine-readable string (e.g. `'DECRYPTION_FAILED'`)\n * suitable for `switch` statements and logging pipelines.\n */\nexport class NoydbError extends Error {\n /** Machine-readable error code. Stable across library versions. */\n readonly code: string\n\n constructor(code: string, message: string) {\n super(message)\n this.name = 'NoydbError'\n this.code = code\n }\n}\n\n// ─── Crypto Errors ─────────────────────────────────────────────────────\n\n/**\n * Thrown when AES-GCM decryption fails.\n *\n * The most common cause is a wrong passphrase or a corrupted ciphertext.\n * A `DecryptionError` at the wrong passphrase level is caught internally\n * and re-thrown as `InvalidKeyError` — so in practice this surfaces for\n * per-record corruption rather than authentication failures.\n */\nexport class DecryptionError extends NoydbError {\n constructor(message = 'Decryption failed') {\n super('DECRYPTION_FAILED', message)\n this.name = 'DecryptionError'\n }\n}\n\n/**\n * Thrown when GCM tag verification fails, indicating the ciphertext was\n * modified after encryption.\n *\n * AES-256-GCM is authenticated encryption — the tag over the ciphertext\n * is checked on every decrypt. If any byte was flipped (accidental\n * corruption or deliberate tampering), decryption throws this error.\n * Treat it as a security alert: the stored bytes are not what NOYDB wrote.\n */\nexport class TamperedError extends NoydbError {\n constructor(message = 'Data integrity check failed — record may have been tampered with') {\n super('TAMPERED', message)\n this.name = 'TamperedError'\n }\n}\n\n/**\n * Thrown when key unwrapping fails, typically because the passphrase is wrong\n * or the keyring file is corrupted.\n *\n * NOYDB uses AES-KW (RFC 3394) to wrap DEKs with the KEK. If AES-KW\n * unwrapping fails, it means either the KEK was derived from the wrong\n * passphrase (PBKDF2 with 600K iterations) or the keyring bytes are\n * corrupted. This is the error shown to the user on a failed unlock attempt.\n */\nexport class InvalidKeyError extends NoydbError {\n constructor(message = 'Invalid key — wrong passphrase or corrupted keyring') {\n super('INVALID_KEY', message)\n this.name = 'InvalidKeyError'\n }\n}\n\n/**\n * Thrown when a keyring's wrapped-DEK set unwraps partially — at least\n * one DEK succeeds (proving the KEK is correct) but at least one fails.\n * The passphrase is right; the failed entries are corrupted.\n *\n * This is distinct from {@link InvalidKeyError} so that\n * `NoydbOptions.onInvalidKey: 'reset'` does NOT fire — resetting on\n * partial corruption would destroy the still-valid DEKs and the data\n * they protect, which is silent data loss in response to a feature\n * designed for stale-credential recovery.\n */\nexport class KeyringCorruptError extends NoydbError {\n readonly failedCollections: readonly string[]\n readonly intactCount: number\n constructor(opts: { failedCollections: readonly string[]; intactCount: number; message?: string }) {\n super(\n 'KEYRING_CORRUPT',\n opts.message ??\n `Keyring has ${opts.failedCollections.length} corrupted wrapped DEK(s) ` +\n `(${opts.failedCollections.join(', ')}); ${opts.intactCount} other DEK(s) ` +\n `unwrapped successfully — the passphrase is correct, the entries are damaged. ` +\n `Do NOT use onInvalidKey: 'reset' here — that would destroy the intact DEKs.`,\n )\n this.name = 'KeyringCorruptError'\n this.failedCollections = opts.failedCollections\n this.intactCount = opts.intactCount\n }\n}\n\n// ─── Access Errors ─────────────────────────────────────────────────────\n\n/**\n * Thrown when the authenticated user does not have a DEK for the requested\n * collection — i.e. the collection is not in their keyring at all.\n *\n * This is the \"no key for this door\" error. It is different from\n * `ReadOnlyError` (user has a key but it only grants ro) and from\n * `PermissionDeniedError` (user's role doesn't allow the operation).\n */\nexport class NoAccessError extends NoydbError {\n constructor(message = 'No access — user does not have a key for this collection') {\n super('NO_ACCESS', message)\n this.name = 'NoAccessError'\n }\n}\n\n/**\n * Thrown when a user with read-only (`ro`) permission attempts a write\n * operation (`put` or `delete`) on a collection.\n *\n * The user has a DEK for the collection (they can decrypt and read), but\n * their keyring grants only `ro`. To fix: re-grant the user with `rw`\n * permission, or do not attempt writes as a viewer/client role.\n */\nexport class ReadOnlyError extends NoydbError {\n constructor(message = 'Read-only — user has ro permission on this collection') {\n super('READ_ONLY', message)\n this.name = 'ReadOnlyError'\n }\n}\n\n/**\n * Thrown when a write is attempted against a historical view produced\n * by `vault.at(timestamp)`. Time-machine views are read-only by\n * contract — mutating the past would require either the shadow-vault\n * mechanism or a ledger-history rewrite (which breaks\n * the tamper-evidence guarantee).\n *\n * Distinct from {@link ReadOnlyError} (keyring-level) and\n * {@link PermissionDeniedError} (role-level): this error is about the\n * *view* being historical, independent of the caller's permissions.\n */\nexport class ReadOnlyAtInstantError extends NoydbError {\n constructor(operation: string, timestamp: string) {\n super(\n 'READ_ONLY_AT_INSTANT',\n `Cannot ${operation}() on a vault view anchored at ${timestamp} — time-machine views are read-only`,\n )\n this.name = 'ReadOnlyAtInstantError'\n }\n}\n\n/**\n * Thrown when a write is attempted against a shadow-vault frame\n * produced by `vault.frame()`. Frames are read-only by contract —\n * the use case is screen-sharing / demos / compliance review where\n * the operator wants to prevent accidental edits.\n *\n * Behavioural enforcement only — the underlying keyring still holds\n * write-capable DEKs. See {@link VaultFrame} for the full caveat.\n */\nexport class ReadOnlyFrameError extends NoydbError {\n constructor(operation: string) {\n super(\n 'READ_ONLY_FRAME',\n `Cannot ${operation}() on a vault frame — frames are read-only presentations of the current vault`,\n )\n this.name = 'ReadOnlyFrameError'\n }\n}\n\n/**\n * Thrown when the authenticated user's role does not permit the requested\n * operation — e.g. a `viewer` calling `grantAccess()`, or an `operator`\n * calling `rotateKeys()`.\n *\n * This is a role-level check (what the user's role allows), distinct from\n * `NoAccessError` (collection not in keyring) and `ReadOnlyError` (in\n * keyring, but write not allowed).\n */\nexport class PermissionDeniedError extends NoydbError {\n constructor(message = 'Permission denied — insufficient role for this operation') {\n super('PERMISSION_DENIED', message)\n this.name = 'PermissionDeniedError'\n }\n}\n\n/**\n * Thrown when an `@noy-db/as-*` export is attempted without the\n * required capability bit on the invoking keyring.\n *\n * Two sub-cases discriminated by the `tier` field:\n *\n * - `tier: 'plaintext'` — a plaintext-tier export (`as-xlsx`,\n * `as-csv`, `as-blob`, `as-zip`, …) was attempted but the\n * keyring's `exportCapability.plaintext` does not include the\n * requested `format` (nor the `'*'` wildcard). Default for every\n * role is `plaintext: []` — the owner must positively grant.\n * - `tier: 'bundle'` — an encrypted `as-noydb` bundle export was\n * attempted but the keyring's `exportCapability.bundle` is\n * `false`. Default for `owner`/`admin` is `true`; for\n * `operator`/`viewer`/`client` it is `false`.\n *\n * Distinct from `PermissionDeniedError` (role-level check) and\n * `NoAccessError` (collection not readable). Surfaces separately so\n * UI layers can show a \"request the export capability from your\n * admin\" flow rather than a generic permission error.\n */\nexport class ExportCapabilityError extends NoydbError {\n readonly tier: 'plaintext' | 'bundle'\n readonly format?: string\n readonly userId: string\n\n constructor(opts: {\n tier: 'plaintext' | 'bundle'\n userId: string\n format?: string\n message?: string\n }) {\n const msg =\n opts.message ??\n (opts.tier === 'plaintext'\n ? `Export capability denied — keyring \"${opts.userId}\" is not granted plaintext-export capability for format \"${opts.format ?? '<unknown>'}\". Ask a vault owner or admin to grant it via vault.grant({ exportCapability: { plaintext: ['${opts.format ?? '<format>'}'] } }).`\n : `Export capability denied — keyring \"${opts.userId}\" is not granted encrypted-bundle export capability. Ask a vault owner or admin to grant it via vault.grant({ exportCapability: { bundle: true } }).`)\n super('EXPORT_CAPABILITY', msg)\n this.name = 'ExportCapabilityError'\n this.tier = opts.tier\n this.userId = opts.userId\n if (opts.format !== undefined) this.format = opts.format\n }\n}\n\n/**\n * Thrown when a keyring file's `expires_at` cutoff has passed.\n * Surfaced by `loadKeyring` before any DEK unwrap is attempted —\n * past the cutoff the slot refuses to open even with the right\n * passphrase. Distinct from PBKDF2 / unwrap errors so consumer code\n * can show a precise \"this bundle slot has expired\" message instead\n * of the generic decryption-failure UX.\n *\n * Used predominantly on `BundleRecipient` slots produced by\n * `writeNoydbBundle({ recipients: [...] })` to time-box audit access.\n */\nexport class KeyringExpiredError extends NoydbError {\n readonly userId: string\n readonly expiresAt: string\n constructor(opts: { userId: string; expiresAt: string }) {\n super(\n 'KEYRING_EXPIRED',\n `Keyring \"${opts.userId}\" expired at ${opts.expiresAt}. ` +\n 'The slot refuses to unlock past its expiry timestamp.',\n )\n this.name = 'KeyringExpiredError'\n this.userId = opts.userId\n this.expiresAt = opts.expiresAt\n }\n}\n\n/**\n * Thrown when an `@noy-db/as-*` import is attempted but the invoking\n * keyring lacks the required import-capability bit.\n *\n * - `tier: 'plaintext'` — a plaintext-tier import (`as-csv`, `as-json`,\n * `as-ndjson`, `as-zip`, …) was attempted but the keyring's\n * `importCapability.plaintext` does not include the requested\n * `format` (nor the `'*'` wildcard).\n * - `tier: 'bundle'` — a `.noydb` bundle import was attempted but the\n * keyring's `importCapability.bundle` is not `true`.\n *\n * Default for every role on every dimension is closed — owners and\n * admins must positively grant the capability. Distinct from\n * `PermissionDeniedError` and `NoAccessError` so UI layers can show a\n * specific \"request the import capability\" flow.\n */\nexport class ImportCapabilityError extends NoydbError {\n readonly tier: 'plaintext' | 'bundle'\n readonly format?: string\n readonly userId: string\n\n constructor(opts: {\n tier: 'plaintext' | 'bundle'\n userId: string\n format?: string\n message?: string\n }) {\n const msg =\n opts.message ??\n (opts.tier === 'plaintext'\n ? `Import capability denied — keyring \"${opts.userId}\" is not granted plaintext-import capability for format \"${opts.format ?? '<unknown>'}\". Ask a vault owner or admin to grant it via vault.grant({ importCapability: { plaintext: ['${opts.format ?? '<format>'}'] } }).`\n : `Import capability denied — keyring \"${opts.userId}\" is not granted encrypted-bundle import capability. Ask a vault owner or admin to grant it via vault.grant({ importCapability: { bundle: true } }).`)\n super('IMPORT_CAPABILITY', msg)\n this.name = 'ImportCapabilityError'\n this.tier = opts.tier\n this.userId = opts.userId\n if (opts.format !== undefined) this.format = opts.format\n }\n}\n\n/**\n * Thrown when a grant would give the grantee a permission the grantor\n * does not themselves hold — the \"admin cannot grant what admin cannot\n * do\" rule from the admin-delegation work.\n *\n * Distinct from `PermissionDeniedError` so callers can tell the two\n * cases apart in logs and tests:\n *\n * - `PermissionDeniedError` — \"you are not allowed to perform this\n * operation at all\" (wrong role).\n * - `PrivilegeEscalationError` — \"you are allowed to grant, but not\n * with these specific permissions\" (widening attempt).\n *\n * Under the admin model the grantee of an admin-grants-admin call\n * inherits the caller's entire DEK set by construction, so this error\n * is structurally unreachable in typical flows. The check and error\n * class exist so that future per-collection admin scoping cannot\n * accidentally bypass the subset rule — the guard is already wired in.\n *\n * `offendingCollection` carries the first collection name that failed\n * the subset check, to make the violation actionable in error output.\n */\n/**\n * Thrown when a caller invokes an API that requires an optional\n * store capability the active store does not implement.\n *\n * Today the only call site is `Noydb.listAccessibleVaults()`,\n * which depends on the optional `NoydbStore.listVaults()`\n * method. The error message names the missing method and the calling\n * API so consumers know exactly which combination is unsupported,\n * and the `capability` field is machine-readable so library code can\n * pattern-match in catch blocks (e.g. fall back to a candidate-list\n * shape).\n *\n * The class lives in `errors.ts` rather than as a generic\n * `ValidationError` because the diagnostic shape is different: a\n * `ValidationError` says \"the inputs you passed are wrong\"; this\n * error says \"the inputs are fine, but the store you wired up\n * doesn't support what you're asking for.\" Different fix, different\n * documentation.\n */\nexport class StoreCapabilityError extends NoydbError {\n /** The store method/capability that was missing. */\n readonly capability: string\n\n constructor(capability: string, callerApi: string, storeName?: string) {\n super(\n 'STORE_CAPABILITY',\n `${callerApi} requires the optional store capability \"${capability}\" ` +\n `but the active store${storeName ? ` (${storeName})` : ''} does not implement it. ` +\n `Use a store that supports \"${capability}\" (store-memory, store-file) or pass an explicit ` +\n `vault list to bypass enumeration.`,\n )\n this.name = 'StoreCapabilityError'\n this.capability = capability\n }\n}\n\nexport class PrivilegeEscalationError extends NoydbError {\n readonly offendingCollection: string\n\n constructor(offendingCollection: string, message?: string) {\n super(\n 'PRIVILEGE_ESCALATION',\n message ??\n `Privilege escalation: grantor has no DEK for collection \"${offendingCollection}\" and cannot grant access to it.`,\n )\n this.name = 'PrivilegeEscalationError'\n this.offendingCollection = offendingCollection\n }\n}\n\n/**\n * Thrown by `Collection.put` / `.delete` when the target record's\n * envelope `_ts` falls within a closed accounting period.\n *\n * Distinct from `ReadOnlyError` (keyring-level), `ReadOnlyAtInstantError`\n * (historical view), and `ReadOnlyFrameError` (shadow vault): this\n * error is about the STORED RECORD being sealed by an operator call\n * to `vault.closePeriod()`, independent of caller permissions or\n * view type. The `periodName` and `endDate` fields name the sealing\n * period so audit UIs can surface a \"this record is locked in\n * FY2026-Q1 (closed 2026-03-31)\" message without parsing the error\n * string.\n *\n * To apply a correction after close, book a compensating entry in a\n * new period rather than unlocking the old one. Re-opening a closed\n * period is deliberately unsupported.\n */\nexport class PeriodClosedError extends NoydbError {\n readonly periodName: string\n readonly endDate: string\n readonly recordTs: string\n\n constructor(periodName: string, endDate: string, recordTs: string) {\n super(\n 'PERIOD_CLOSED',\n `Cannot modify record (last written ${recordTs}) — sealed by closed period ` +\n `\"${periodName}\" (endDate: ${endDate}). Post a compensating entry in a ` +\n `new period instead.`,\n )\n this.name = 'PeriodClosedError'\n this.periodName = periodName\n this.endDate = endDate\n this.recordTs = recordTs\n }\n}\n\n/**\n * Thrown when a `put()` or `delete()` is rejected by a guard's `check`\n * function. The `reason` is the message the guard supplied — typically a\n * short business description (e.g. \"invoice is issued\"). The full\n * collection + id are surfaced so audit UIs can link back to the record.\n */\nexport class RecordLockedError extends NoydbError {\n readonly collection: string\n readonly id: string\n readonly reason: string\n\n constructor(collection: string, id: string, reason: string) {\n super(\n 'RECORD_LOCKED',\n `Cannot modify ${collection}/${id} — locked by guard: ${reason}. ` +\n `Use withTransactions({ amendment: true, reason }) with admin/owner role to override.`,\n )\n this.name = 'RecordLockedError'\n this.collection = collection\n this.id = id\n this.reason = reason\n }\n}\n\n/**\n * Thrown when a `put()` changes one or more fields that are frozen by a\n * `frozenFields` guard. The `fields` list contains the specific paths\n * that were detected as changed.\n */\nexport class FieldFrozenError extends NoydbError {\n readonly collection: string\n readonly id: string\n readonly fields: readonly string[]\n\n constructor(collection: string, id: string, fields: readonly string[]) {\n super(\n 'FIELD_FROZEN',\n `Cannot change frozen field(s) on ${collection}/${id}: ${fields.join(', ')}. ` +\n `Use withTransactions({ amendment: true, reason }) with admin/owner role to override.`,\n )\n this.name = 'FieldFrozenError'\n this.collection = collection\n this.id = id\n this.fields = fields\n }\n}\n\n/**\n * Thrown by an amendment invariant when the proposed change-set violates\n * the declared business rule (e.g. disbursement total not preserved).\n * Triggers a full transaction rollback via the existing revert pass.\n */\nexport class InvariantError extends NoydbError {\n constructor(message: string) {\n super('INVARIANT_VIOLATED', message)\n this.name = 'InvariantError'\n }\n}\n\n/**\n * Thrown at `withTransactions({ amendment: true })` open if the caller's\n * role is not in the guard's allowed amendment roles. Fail-fast: thrown\n * before any writes are attempted.\n */\nexport class AmendmentForbiddenError extends NoydbError {\n readonly userId: string\n readonly role: string\n\n constructor(userId: string, role: string) {\n super(\n 'AMENDMENT_FORBIDDEN',\n `User \"${userId}\" with role \"${role}\" cannot open an amendment transaction. ` +\n `Amendments require admin or owner role.`,\n )\n this.name = 'AmendmentForbiddenError'\n this.userId = userId\n this.role = role\n }\n}\n\n/**\n * Thrown by `listUsersWithEnvelopes` when the vault's user directory\n * has been disabled (via `db.setDirectoryEnabled(vault, false)`) and\n * the caller's role is neither `owner` nor `admin`. Owner/admin can\n * still enumerate users — the toggle is a UX privacy switch, not a\n * security boundary.\n *\n * Honest caveat: this is a UX flag, not a privacy guarantee. The\n * envelope ciphertext is still in the store, the keyring file is\n * still listed at `_keyring/*`, and anyone with direct store read\n * access can count keyrings without going through the hub. See\n * `docs/subsystems/user-envelope.md` → \"Directory visibility\".\n */\nexport class DirectoryDisabledError extends NoydbError {\n readonly vault: string\n\n constructor(vault: string) {\n super(\n 'DIRECTORY_DISABLED',\n `Vault \"${vault}\" has its user directory disabled. ` +\n `Only owners and admins can call listUsersWithEnvelopes() here. ` +\n `Use db.setDirectoryEnabled(vault, true) to re-enable.`,\n )\n this.name = 'DirectoryDisabledError'\n this.vault = vault\n }\n}\n\n// ─── Hierarchical Access Errors ─────────────────────\n\n/**\n * Thrown when a user tries to act at a tier they are not cleared for.\n *\n * This is the umbrella error for tier write refusals:\n * - `put({ tier: N })` when the user's keyring lacks tier-N DEK.\n * - `elevate(id, N)` when the caller cannot reach tier N.\n *\n * Distinct from `TierAccessDeniedError` which covers *read* refusals on\n * the invisibility/ghost path.\n */\nexport class TierNotGrantedError extends NoydbError {\n readonly tier: number\n readonly collection: string\n\n constructor(collection: string, tier: number) {\n super(\n 'TIER_NOT_GRANTED',\n `User has no DEK for tier ${tier} in collection \"${collection}\"`,\n )\n this.name = 'TierNotGrantedError'\n this.collection = collection\n this.tier = tier\n }\n}\n\n/**\n * Thrown when an elevated-handle operation runs after the elevation's\n * TTL expired. Reads continue at the original tier; only writes\n * through the scoped handle flip to throwing once expired.\n */\nexport class ElevationExpiredError extends NoydbError {\n readonly tier: number\n readonly expiresAt: number\n\n constructor(opts: { tier: number; expiresAt: number }) {\n super(\n 'ELEVATION_EXPIRED',\n `Elevation to tier ${opts.tier} expired at ${new Date(opts.expiresAt).toISOString()}`,\n )\n this.name = 'ElevationExpiredError'\n this.tier = opts.tier\n this.expiresAt = opts.expiresAt\n }\n}\n\n/**\n * Thrown by `vault.elevate(...)` when an elevation is already active\n * on the vault. Adopters must `release()` the existing handle before\n * starting a new elevation.\n */\nexport class AlreadyElevatedError extends NoydbError {\n readonly activeTier: number\n\n constructor(activeTier: number) {\n super(\n 'ALREADY_ELEVATED',\n `Vault is already elevated to tier ${activeTier}; release the existing handle first`,\n )\n this.name = 'AlreadyElevatedError'\n this.activeTier = activeTier\n }\n}\n\n/**\n * Thrown when `demote()` is called by someone who is not the original\n * elevator and not an owner.\n */\nexport class TierDemoteDeniedError extends NoydbError {\n constructor(id: string, tier: number) {\n super(\n 'TIER_DEMOTE_DENIED',\n `Only the original elevator or an owner can demote record \"${id}\" from tier ${tier}`,\n )\n this.name = 'TierDemoteDeniedError'\n }\n}\n\n/**\n * Thrown when `db.delegate()` is called against a user that has no\n * keyring in the target vault — the delegation token cannot be\n * constructed without the target user's KEK wrap.\n */\nexport class DelegationTargetMissingError extends NoydbError {\n readonly toUser: string\n\n constructor(toUser: string) {\n super(\n 'DELEGATION_TARGET_MISSING',\n `Delegation target user \"${toUser}\" has no keyring in this vault`,\n )\n this.name = 'DelegationTargetMissingError'\n this.toUser = toUser\n }\n}\n\n// ─── Sync Errors ───────────────────────────────────────────────────────\n\n/**\n * Thrown when a `put()` detects an optimistic concurrency conflict.\n *\n * NOYDB uses version numbers (`_v`) for optimistic locking. If a `put()`\n * is called with `expectedVersion: N` but the stored record is at version\n * `M ≠ N`, the write is rejected and the caller must re-read, re-apply their\n * change, and retry. The `version` field carries the actual stored version\n * so callers can decide whether to retry or surface the conflict to the user.\n */\nexport class ConflictError extends NoydbError {\n /** The actual stored version at the time of conflict. */\n readonly version: number\n\n constructor(version: number, message = 'Version conflict') {\n super('CONFLICT', message)\n this.name = 'ConflictError'\n this.version = version\n }\n}\n\n/**\n * Thrown by `LedgerStore.append()` after exhausting its CAS retry\n * budget under multi-writer contention. Two browser tabs, a\n * web app + an offline mobile peer, or a server worker pool all\n * producing ledger entries against the same vault can race on the\n * \"read head, write head+1\" cycle; the optimistic-CAS retry loop\n * resolves the race for `casAtomic: true` stores, but pathological\n * contention (or a buggy peer) can still exhaust the budget. When\n * that happens, the chain is intact — the failed writer simply\n * couldn't claim a slot. Caller's choice whether to retry, queue,\n * or surface the failure to the user.\n */\nexport class LedgerContentionError extends NoydbError {\n readonly attempts: number\n\n constructor(attempts: number) {\n super(\n 'LEDGER_CONTENTION',\n `LedgerStore.append: failed to claim a chain slot after ${attempts} optimistic-CAS retries`,\n )\n this.name = 'LedgerContentionError'\n this.attempts = attempts\n }\n}\n\n/**\n * Thrown when a bundle push is rejected because the remote has been updated\n * since the local bundle was last pulled.\n *\n * Unlike `ConflictError` (per-record), this is a whole-bundle conflict —\n * the remote's bundle handle has changed. The caller must pull the new\n * bundle, merge, and re-push. `remoteVersion` is the handle of the newer\n * remote bundle for use in diagnostics.\n */\nexport class BundleVersionConflictError extends NoydbError {\n /** The bundle handle of the newer remote version that rejected the push. */\n readonly remoteVersion: string\n\n constructor(remoteVersion: string, message = 'Bundle version conflict — remote has been updated') {\n super('BUNDLE_VERSION_CONFLICT', message)\n this.name = 'BundleVersionConflictError'\n this.remoteVersion = remoteVersion\n }\n}\n\n/**\n * Thrown when a sync operation (push or pull) fails due to a network error.\n *\n * NOYDB's offline-first design means network errors are expected during sync.\n * Callers should catch `NetworkError`, surface connectivity status in the UI,\n * and rely on the `SyncScheduler` to retry when connectivity is restored.\n */\nexport class NetworkError extends NoydbError {\n constructor(message = 'Network error') {\n super('NETWORK_ERROR', message)\n this.name = 'NetworkError'\n }\n}\n\n// ─── Data Errors ───────────────────────────────────────────────────────\n\n/**\n * Thrown when `collection.get(id)` is called with an ID that does not exist.\n *\n * NOYDB collections are memory-first, so this error is synchronous and cheap —\n * it does not make a network round-trip. Callers that expect the record to be\n * absent should use `collection.getOrNull(id)` instead.\n */\nexport class NotFoundError extends NoydbError {\n constructor(message = 'Record not found') {\n super('NOT_FOUND', message)\n this.name = 'NotFoundError'\n }\n}\n\n/**\n * Thrown when application-level validation fails before encryption.\n *\n * Distinct from `SchemaValidationError` (Standard Schema v1 validator)\n * and `MissingTranslationError` (i18nText). `ValidationError` is the\n * general-purpose validation base — use it for custom guards in `put()`\n * hooks or store middleware.\n */\nexport class ValidationError extends NoydbError {\n constructor(message = 'Validation error') {\n super('VALIDATION_ERROR', message)\n this.name = 'ValidationError'\n }\n}\n\n/**\n * Thrown when a Standard Schema v1 validator rejects a record on\n * `put()` (input validation) or on read (output validation). Carries\n * the raw issue list so callers can render field-level errors.\n *\n * `direction` distinguishes the two cases:\n * - `'input'`: the user passed bad data into `put()`. This is a\n * normal error case that application code should handle — typically\n * by showing validation messages in the UI.\n * - `'output'`: stored data does not match the current schema. This\n * indicates a schema drift (the schema was changed without\n * migrating the existing records) and should be treated as a bug\n * — the application should not swallow it silently.\n *\n * The `issues` type is deliberately `readonly unknown[]` on this class\n * so that `errors.ts` doesn't need to import from `schema.ts` (and\n * create a dependency cycle). Callers who know they're holding a\n * `SchemaValidationError` can cast to the more precise\n * `readonly StandardSchemaV1Issue[]` from `schema.ts`.\n */\nexport class SchemaValidationError extends NoydbError {\n readonly issues: readonly unknown[]\n readonly direction: 'input' | 'output'\n\n constructor(\n message: string,\n issues: readonly unknown[],\n direction: 'input' | 'output',\n ) {\n super('SCHEMA_VALIDATION_FAILED', message)\n this.name = 'SchemaValidationError'\n this.issues = issues\n this.direction = direction\n }\n}\n\n// ─── Query DSL Errors ─────────────────────────────────────────────────\n\n/**\n * Thrown when `.groupBy().aggregate()` produces more than the hard\n * cardinality cap (default 100_000 groups)..\n *\n * The cap exists because `.groupBy()` materializes one bucket per\n * distinct key value in memory, and runaway cardinality — a groupBy\n * on a high-uniqueness field like `id` or `createdAt` — is almost\n * always a query mistake rather than legitimate use. A hard error is\n * better than silent OOM: the consumer sees an actionable message\n * naming the field and the observed cardinality, with guidance to\n * either narrow the query with `.where()` or accept the ceiling\n * override.\n *\n * A separate one-shot warning fires at 10% of the cap (10_000\n * groups) so consumers get a heads-up before the hard error — same\n * pattern as `JoinTooLargeError` and the `.join()` row ceiling.\n *\n * **Not overridable in.** The 100k cap is a fixed constant so\n * the failure mode is consistent across the codebase; a\n * `{ maxGroups }` override can be added later without a break if a\n * real consumer asks.\n */\nexport class GroupCardinalityError extends NoydbError {\n /** The field being grouped on. */\n readonly field: string\n /** Observed number of distinct groups at the moment the cap tripped. */\n readonly cardinality: number\n /** The cap that was exceeded. */\n readonly maxGroups: number\n\n constructor(field: string, cardinality: number, maxGroups: number) {\n super(\n 'GROUP_CARDINALITY',\n `.groupBy(\"${field}\") produced ${cardinality} distinct groups, ` +\n `exceeding the ${maxGroups}-group ceiling. This is almost always a ` +\n `query mistake — grouping on a high-uniqueness field like \"id\" or ` +\n `\"createdAt\" produces one bucket per record. Narrow the query with ` +\n `.where() before grouping, or group on a lower-cardinality field ` +\n `(status, category, clientId). If you genuinely need high-cardinality ` +\n `grouping, file an issue with your use case.`,\n )\n this.name = 'GroupCardinalityError'\n this.field = field\n this.cardinality = cardinality\n this.maxGroups = maxGroups\n }\n}\n\n/**\n * Thrown in lazy mode when a `.query()` / `.where()` / `.orderBy()` clause\n * references a field that does not have a declared index.\n *\n * Lazy-mode queries only work when every touched field is indexed.\n * This is deliberate — silent scan-fallback would hide the performance\n * cliff that lazy-mode indexes exist to prevent.\n *\n * Payload:\n * - `collection` — name of the collection queried\n * - `touchedFields` — every field referenced by the query (filter + order)\n * - `missingFields` — subset of `touchedFields` that have no declared index\n */\nexport class IndexRequiredError extends NoydbError {\n readonly collection: string\n readonly touchedFields: readonly string[]\n readonly missingFields: readonly string[]\n\n constructor(args: { collection: string; touchedFields: readonly string[]; missingFields: readonly string[] }) {\n super(\n 'INDEX_REQUIRED',\n `Collection \"${args.collection}\": query references unindexed fields in lazy mode ` +\n `(missing: ${args.missingFields.join(', ')}). ` +\n `Declare an index on each field, or use collection.scan() for non-indexed iteration.`,\n )\n this.name = 'IndexRequiredError'\n this.collection = args.collection\n this.touchedFields = [...args.touchedFields]\n this.missingFields = [...args.missingFields]\n }\n}\n\n/**\n * Thrown (or surfaced via the `index:write-partial` event) when one or more\n * per-indexed-field side-car writes fail after the main record write has\n * already succeeded.\n *\n * Not thrown out of `.put()` / `.delete()` directly — those succeed when the\n * main record succeeds. Instead, `IndexWriteFailureError` instances are collected\n * into the session-scoped reconcile queue and emitted on the Collection\n * emitter as `index:write-partial`.\n *\n * Payload:\n * - `recordId` — the id of the main record whose side-car writes failed\n * - `field` — the indexed field whose side-car write failed\n * - `op` — `'put'` or `'delete'`, indicating which mutation was in flight\n * - `cause` — the underlying error from the store\n */\nexport class IndexWriteFailureError extends NoydbError {\n readonly recordId: string\n readonly field: string\n readonly op: 'put' | 'delete'\n override readonly cause: unknown\n\n constructor(args: { recordId: string; field: string; op: 'put' | 'delete'; cause: unknown }) {\n super(\n 'INDEX_WRITE_FAILURE',\n `Index side-car ${args.op} failed for field \"${args.field}\" on record \"${args.recordId}\"`,\n )\n this.name = 'IndexWriteFailureError'\n this.recordId = args.recordId\n this.field = args.field\n this.op = args.op\n this.cause = args.cause\n }\n}\n\n// ─── Bundle Format Errors ─────────────────────────────────\n\n/**\n * Thrown by `readNoydbBundle()` when the body bytes don't match\n * the integrity hash declared in the bundle header — i.e. someone\n * modified the bytes between write and read.\n *\n * Distinct from a generic `Error` (which would be thrown for\n * format violations like a missing magic prefix or malformed\n * header JSON) so consumers can pattern-match the corruption case\n * and handle it differently from a producer bug. A\n * `BundleIntegrityError` indicates \"the bytes you got are not\n * what was written\"; a plain `Error` from `parsePrefixAndHeader`\n * indicates \"what was written wasn't a valid bundle in the first\n * place.\"\n *\n * Also thrown when decompression fails after the integrity hash\n * passed — that's a producer bug (the wrong algorithm byte was\n * written) but it surfaces with the same error class because the\n * end result is \"the body cannot be turned back into a dump.\"\n */\nexport class BundleIntegrityError extends NoydbError {\n constructor(message: string) {\n super('BUNDLE_INTEGRITY', `.noydb bundle integrity check failed: ${message}`)\n this.name = 'BundleIntegrityError'\n }\n}\n\n/**\n * Thrown by `readNoydbBundle` (#197) when the bundle carries\n * sealed per-user passphrases but no supplied `SealingKeyProvider`\n * has a `.id` (= `pid`) matching the sealed entry's `pid`.\n *\n * Carries the failing pid + the user id so the recipient can\n * surface an actionable prompt:\n *\n * ```\n * BundleSealMismatchError: bundle carries sealed passphrase for user \"alice\"\n * under provider \"macos-keychain:com.acme.app/alice@acme.example\",\n * but no registered provider matches that pid.\n * ```\n *\n * Three resolution paths the message names (per foundation §11.9.4):\n *\n * 1. Configure a provider matching the pid and retry import.\n * 2. Pass `attemptUnsealAcrossProviders: true` to try each\n * registered provider regardless of pid.\n * 3. Inspect without unsealing — pass no `sealingProviders` to\n * receive the sealed entries unmodified for offline analysis.\n */\nexport class BundleSealMismatchError extends NoydbError {\n readonly userId: string\n readonly pid: string\n constructor(userId: string, pid: string) {\n super(\n 'BUNDLE_SEAL_MISMATCH',\n `bundle carries sealed passphrase for user \"${userId}\" under provider `\n + `\"${pid}\", but no registered provider matches that pid.\\n\\n`\n + 'Resolutions:\\n'\n + ' 1. Configure a provider matching the pid and retry import.\\n'\n + ' 2. Pass `attemptUnsealAcrossProviders: true` to try each registered\\n'\n + ' provider regardless of pid (extra credential prompts may surface).\\n'\n + ' 3. Inspect the bundle without unsealing — pass no `sealingProviders`\\n'\n + ' to receive the sealed entries unmodified for offline analysis.',\n )\n this.name = 'BundleSealMismatchError'\n this.userId = userId\n this.pid = pid\n }\n}\n\n// ─── i18n / Dictionary Errors ──────────────────────────\n\n/**\n * Thrown when `vault.collection()` is called with a name that is\n * reserved for NOYDB internal use (any name starting with `_dict_`).\n *\n * Dictionary collections are accessed exclusively via\n * `vault.dictionary(name)` — attempting to open one as a regular\n * collection would bypass the dictionary invariants (ACL, rename\n * tracking, reserved-name policy).\n */\nexport class ReservedCollectionNameError extends NoydbError {\n /** The rejected collection name. */\n readonly collectionName: string\n\n constructor(collectionName: string) {\n super(\n 'RESERVED_COLLECTION_NAME',\n `\"${collectionName}\" is a reserved collection name. ` +\n `Use vault.dictionary(\"${collectionName.replace(/^_dict_/, '')}\") ` +\n `to access dictionary collections.`,\n )\n this.name = 'ReservedCollectionNameError'\n this.collectionName = collectionName\n }\n}\n\n/**\n * Thrown by `DictionaryHandle.get()` and `DictionaryHandle.delete()` when\n * the requested key does not exist in the dictionary.\n *\n * Distinct from `NotFoundError` (which is for data records) so callers\n * can distinguish \"data record missing\" from \"dictionary key missing\"\n * without inspecting error messages.\n */\nexport class DictKeyMissingError extends NoydbError {\n /** The dictionary name. */\n readonly dictionaryName: string\n /** The key that was not found. */\n readonly key: string\n\n constructor(dictionaryName: string, key: string) {\n super(\n 'DICT_KEY_MISSING',\n `Dictionary \"${dictionaryName}\" has no entry for key \"${key}\".`,\n )\n this.name = 'DictKeyMissingError'\n this.dictionaryName = dictionaryName\n this.key = key\n }\n}\n\n/**\n * Thrown by `DictionaryHandle.delete()` in strict mode when the key to\n * be deleted is still referenced by one or more records.\n *\n * The caller must either rename the key first (the only sanctioned\n * mass-mutation path) or pass `{ mode: 'warn' }` to skip the check\n * (development only).\n */\nexport class DictKeyInUseError extends NoydbError {\n /** The dictionary name. */\n readonly dictionaryName: string\n /** The key that is still referenced. */\n readonly key: string\n /** Name of the first collection found to reference this key. */\n readonly usedBy: string\n /** Number of records in `usedBy` that reference this key. */\n readonly count: number\n\n constructor(\n dictionaryName: string,\n key: string,\n usedBy: string,\n count: number,\n ) {\n super(\n 'DICT_KEY_IN_USE',\n `Cannot delete key \"${key}\" from dictionary \"${dictionaryName}\": ` +\n `${count} record(s) in \"${usedBy}\" still reference it. ` +\n `Use dictionary.rename(\"${key}\", newKey) to rewrite references first.`,\n )\n this.name = 'DictKeyInUseError'\n this.dictionaryName = dictionaryName\n this.key = key\n this.usedBy = usedBy\n this.count = count\n }\n}\n\n/**\n * Thrown by `Collection.put()` when an `i18nText` field is missing one\n * or more required translations.\n *\n * The `missing` array names each locale code that was absent from the\n * field value. The `field` property names the field so callers can\n * render a field-level error message without parsing the string.\n */\nexport class MissingTranslationError extends NoydbError {\n /** The field name whose translation(s) are missing. */\n readonly field: string\n /** Locale codes that were required but absent. */\n readonly missing: readonly string[]\n\n constructor(field: string, missing: readonly string[], message?: string) {\n super(\n 'MISSING_TRANSLATION',\n message ??\n `Field \"${field}\": missing required translation(s): ${missing.join(', ')}.`,\n )\n this.name = 'MissingTranslationError'\n this.field = field\n this.missing = missing\n }\n}\n\n/**\n * Thrown when reading an `i18nText` field without specifying a locale —\n * either at the call site (`get(id, { locale })`) or on the vault\n * (`openVault(name, { locale })`).\n *\n * Also thrown when `resolveI18nText()` exhausts the fallback chain and\n * no translation is available for the requested locale.\n *\n * The `field` property names the field that triggered the error so the\n * caller can surface it in the UI.\n */\nexport class LocaleNotSpecifiedError extends NoydbError {\n /** The field name that required a locale. */\n readonly field: string\n\n constructor(field: string, message?: string) {\n super(\n 'LOCALE_NOT_SPECIFIED',\n message ??\n `Cannot read i18nText field \"${field}\" without a locale. ` +\n `Pass { locale } to get()/list()/query() or set a default via ` +\n `openVault(name, { locale }).`,\n )\n this.name = 'LocaleNotSpecifiedError'\n this.field = field\n }\n}\n\n// ─── Translator Errors ─────────────────────────────────────\n\n/**\n * Thrown when a collection has an `i18nText` field with\n * `autoTranslate: true` but no `plaintextTranslator` was configured\n * on `createNoydb()`.\n *\n * The error is raised at `put()` time (not at schema construction) so\n * the mis-configuration is surfaced by the first write rather than\n * silently at startup.\n */\nexport class TranslatorNotConfiguredError extends NoydbError {\n /** The field that requested auto-translation. */\n readonly field: string\n /** The collection the put was targeting. */\n readonly collection: string\n\n constructor(field: string, collection: string) {\n super(\n 'TRANSLATOR_NOT_CONFIGURED',\n `Field \"${field}\" in collection \"${collection}\" has autoTranslate: true, ` +\n `but no plaintextTranslator was configured on createNoydb(). ` +\n `Either configure a plaintextTranslator or remove autoTranslate from the schema.`,\n )\n this.name = 'TranslatorNotConfiguredError'\n this.field = field\n this.collection = collection\n }\n}\n\n// ─── Backup Errors ─────────────────────────────────────────\n\n/**\n * Thrown when `Vault.load()` finds that a backup's hash chain\n * doesn't verify, or that its embedded `ledgerHead.hash` doesn't\n * match the chain head reconstructed from the loaded entries.\n *\n * Distinct from `BackupCorruptedError` so callers can choose to\n * recover from one but not the other (e.g., a corrupted JSON file is\n * unrecoverable; a chain mismatch might mean the backup is from an\n * incompatible noy-db version).\n */\nexport class BackupLedgerError extends NoydbError {\n /** First-broken-entry index, if known. */\n readonly divergedAt?: number\n\n constructor(message: string, divergedAt?: number) {\n super('BACKUP_LEDGER', message)\n this.name = 'BackupLedgerError'\n if (divergedAt !== undefined) this.divergedAt = divergedAt\n }\n}\n\n/**\n * Thrown when `Vault.load()` finds that the backup's data\n * collection content doesn't match the ledger's recorded\n * `payloadHash`es. This is the \"envelope was tampered with after\n * dump\" detection — the chain itself can be intact, but if any\n * encrypted record bytes were swapped, this check catches it.\n */\nexport class BackupCorruptedError extends NoydbError {\n /** The (collection, id) pair whose envelope failed the hash check. */\n readonly collection: string\n readonly id: string\n\n constructor(collection: string, id: string, message: string) {\n super('BACKUP_CORRUPTED', message)\n this.name = 'BackupCorruptedError'\n this.collection = collection\n this.id = id\n }\n}\n\n// ─── Session Errors ───────────────────────────────────────\n\n/**\n * Thrown by `resolveSession()` when the session token's `expiresAt`\n * timestamp is in the past. The session key is also removed from the\n * in-memory store when this is thrown, so retrying with the same sessionId\n * will produce `SessionNotFoundError`.\n *\n * Separate from `SessionNotFoundError` so callers can distinguish between\n * \"session is gone\" (key store cleared, tab reloaded) and \"session is\n * still in the store but has exceeded its lifetime\" (idle timeout, absolute\n * timeout, policy-driven expiry). The remediation differs: expired sessions\n * should prompt a fresh unlock; not-found sessions may indicate a bug or a\n * cross-tab scenario where the session was never established.\n */\nexport class SessionExpiredError extends NoydbError {\n readonly sessionId: string\n\n constructor(sessionId: string) {\n super('SESSION_EXPIRED', `Session \"${sessionId}\" has expired. Re-unlock to continue.`)\n this.name = 'SessionExpiredError'\n this.sessionId = sessionId\n }\n}\n\n/**\n * Thrown by `resolveSession()` when the session key cannot be found in\n * the module-level store. This happens when:\n * - The session was explicitly revoked via `revokeSession()`.\n * - The JS context was reloaded (tab navigation, page refresh, worker restart).\n * - `Noydb.close()` was called (which calls `revokeAllSessions()`).\n * - The sessionId is wrong or was generated by a different JS context.\n *\n * The session token (if the caller holds it) is permanently useless after\n * this error — the key is gone and cannot be recovered.\n */\nexport class SessionNotFoundError extends NoydbError {\n readonly sessionId: string\n\n constructor(sessionId: string) {\n super('SESSION_NOT_FOUND', `Session key for \"${sessionId}\" not found. The session may have been revoked or the page reloaded.`)\n this.name = 'SessionNotFoundError'\n this.sessionId = sessionId\n }\n}\n\n/**\n * Thrown when a session policy blocks an operation — for example,\n * `requireReAuthFor: ['export']` is set and the caller attempts to\n * call `exportStream()` without re-authenticating for this session.\n *\n * The `operation` field names the specific operation that was blocked\n * (e.g. `'export'`, `'grant'`, `'rotate'`) so the caller can surface\n * a targeted prompt (\"Please re-enter your passphrase to export data\").\n */\nexport class SessionPolicyError extends NoydbError {\n readonly operation: string\n\n constructor(operation: string, message?: string) {\n super(\n 'SESSION_POLICY',\n message ?? `Operation \"${operation}\" requires re-authentication per the active session policy.`,\n )\n this.name = 'SessionPolicyError'\n this.operation = operation\n }\n}\n\n// ─── Query / Join Errors ────────────────────────────────────\n\n/**\n * Thrown when a `.join()` would exceed its configured row ceiling on\n * either side. The ceiling defaults to 50,000 per side and can be\n * overridden via the `{ maxRows }` option on `.join()`.\n *\n * Carries both row counts so the error message can show which side\n * tripped the limit (e.g. \"left had 60,000 rows, right had 1,200,\n * max was 50,000\"). The `side` field is machine-readable so test\n * code and devtools can match on it without regex-parsing the\n * message.\n *\n * The row ceiling exists because joins are bounded in-memory\n * operations over materialized record sets. Consumers whose\n * collections genuinely exceed the ceiling should track \n * (streaming joins over `scan()`) or filter the left side further\n * with `where()` / `limit()` before joining.\n */\nexport class JoinTooLargeError extends NoydbError {\n readonly leftRows: number\n readonly rightRows: number\n readonly maxRows: number\n readonly side: 'left' | 'right'\n\n constructor(opts: {\n leftRows: number\n rightRows: number\n maxRows: number\n side: 'left' | 'right'\n message: string\n }) {\n super('JOIN_TOO_LARGE', opts.message)\n this.name = 'JoinTooLargeError'\n this.leftRows = opts.leftRows\n this.rightRows = opts.rightRows\n this.maxRows = opts.maxRows\n this.side = opts.side\n }\n}\n\n/**\n * Thrown by `.join()` in strict `ref()` mode when a left-side record\n * points at a right-side id that does not exist in the target\n * collection.\n *\n * Distinct from `RefIntegrityError` so test code can pattern-match\n * on the *read-time* dangling case without catching *write-time*\n * integrity violations. Both indicate \"ref points at nothing\" but\n * happen at different lifecycle phases and deserve different\n * remediation in documentation: a RefIntegrityError on `put()`\n * means the input is invalid; a DanglingReferenceError on `.join()`\n * means stored data has drifted and `vault.checkIntegrity()`\n * is the right tool to find the full set of orphans.\n */\nexport class DanglingReferenceError extends NoydbError {\n readonly field: string\n readonly target: string\n readonly refId: string\n\n constructor(opts: {\n field: string\n target: string\n refId: string\n message: string\n }) {\n super('DANGLING_REFERENCE', opts.message)\n this.name = 'DanglingReferenceError'\n this.field = opts.field\n this.target = opts.target\n this.refId = opts.refId\n }\n}\n\n/**\n * Thrown by {@link sanitizeFilename} when an input filename cannot be\n * made safe — NUL byte, empty after normalization, missing\n * `opaqueId` for the opaque profile, `..` segment, or a `maxBytes`\n * cap too small to hold a single code point.\n */\nexport class FilenameSanitizationError extends NoydbError {\n constructor(message: string) {\n super('FILENAME_SANITIZATION', message)\n this.name = 'FilenameSanitizationError'\n }\n}\n\n/**\n * Thrown when a write target resolves OUTSIDE the requested\n * directory after sanitization — the canonical Zip-Slip class. The\n * sanitizer's job is to strip path-traversal segments; this error\n * is the defense-in-depth fallback at the FS write site.\n */\nexport class PathEscapeError extends NoydbError {\n readonly attempted: string\n readonly targetDir: string\n\n constructor(opts: { attempted: string; targetDir: string }) {\n super(\n 'PATH_ESCAPE',\n `Sanitized filename \"${opts.attempted}\" resolves outside target dir \"${opts.targetDir}\"`,\n )\n this.name = 'PathEscapeError'\n this.attempted = opts.attempted\n this.targetDir = opts.targetDir\n }\n}\n\n// ─── Derivation Errors ──────────────────────────────\n\n/**\n * Thrown at vault open if the derivation graph contains a cycle.\n * `path` is the offending chain (e.g. `['a', 'b', 'c', 'a']`).\n */\nexport class DerivationCycleError extends NoydbError {\n readonly path: readonly string[]\n\n constructor(path: readonly string[]) {\n super(\n 'DERIVATION_CYCLE',\n `Derivation graph contains a cycle: ${path.join(' → ')}. ` +\n `Refusing to open vault — break the cycle before retrying.`,\n )\n this.name = 'DerivationCycleError'\n this.path = path\n }\n}\n\n/**\n * Thrown when a cascade of source → output → source → … exceeds the\n * configured `maxDepth` (default 5).\n */\nexport class DerivationDepthError extends NoydbError {\n readonly limit: number\n readonly attempted: number\n\n constructor(limit: number, attempted: number) {\n super(\n 'DERIVATION_DEPTH',\n `Derivation cascade exceeded max depth ${limit} (attempted ${attempted}). ` +\n `Pass lifecycle: { maxDepth: N } to raise the limit if intentional.`,\n )\n this.name = 'DerivationDepthError'\n this.limit = limit\n this.attempted = attempted\n }\n}\n\n/**\n * Thrown at registration if a `withDerivation` strategy references an\n * output `collection` that isn't otherwise declared (no schema, no use\n * elsewhere). Surfacing this early catches typos in collection names.\n */\nexport class DerivationOutputUnknownError extends NoydbError {\n readonly collection: string\n\n constructor(collection: string) {\n super(\n 'DERIVATION_OUTPUT_UNKNOWN',\n `Derivation output collection \"${collection}\" is not declared on the vault. ` +\n `Register the collection (e.g. via schema) before registering a derivation that writes to it.`,\n )\n this.name = 'DerivationOutputUnknownError'\n this.collection = collection\n }\n}\n\n/**\n * Thrown when the user's `derive` function returns a value that doesn't\n * match the declared output spec (e.g. wrong shape, wrong key set).\n */\nexport class DerivationOutputShapeError extends NoydbError {\n readonly outputKey: string\n\n constructor(outputKey: string, detail: string) {\n super(\n 'DERIVATION_OUTPUT_SHAPE',\n `Derivation output \"${outputKey}\" has invalid shape: ${detail}.`,\n )\n this.name = 'DerivationOutputShapeError'\n this.outputKey = outputKey\n }\n}\n\n/**\n * Thrown by array-shape derivations (#200) when the `derive` function\n * returns more rows than the output's `maxFanout` cap. The cap exists\n * to keep dispatch cost bounded — without it a single source-row\n * update could fan out to thousands of derived rows, dominating the\n * write path.\n *\n * Defaults to `maxFanout: 64`. Raise on the output spec for\n * carry-forward expansion cases (e.g. monthly rows across multi-year\n * contracts).\n */\nexport class DerivationCapExceededError extends NoydbError {\n readonly outputKey: string\n readonly returned: number\n readonly maxFanout: number\n\n constructor(outputKey: string, returned: number, maxFanout: number) {\n super(\n 'DERIVATION_CAP_EXCEEDED',\n `Derivation array output \"${outputKey}\" returned ${returned} rows, exceeding `\n + `maxFanout=${maxFanout}. Raise \\`maxFanout\\` on the OutputSpec if this fanout `\n + 'is intended (the cap exists to keep dispatch cost bounded).',\n )\n this.name = 'DerivationCapExceededError'\n this.outputKey = outputKey\n this.returned = returned\n this.maxFanout = maxFanout\n }\n}\n\n/**\n * Thrown at vault open if the materialized-view graph contains a\n * cycle. `path` is the offending chain (e.g. `['a-mv', 'b-mv', 'a-mv']`).\n * Detected by the same shared DFS that catches `DerivationCycleError`;\n * surfaces with a distinct error type so consumers can disambiguate.\n */\nexport class MaterializedViewCycleError extends NoydbError {\n readonly path: readonly string[]\n\n constructor(path: readonly string[]) {\n super(\n 'MATERIALIZED_VIEW_CYCLE',\n `Materialized-view graph contains a cycle: ${path.join(' → ')}. ` +\n `Refusing to open vault — break the cycle before retrying.`,\n )\n this.name = 'MaterializedViewCycleError'\n this.path = path\n }\n}\n\n/**\n * Thrown at MV registration if the query references a source\n * collection that isn't declared on the vault. Surfacing this early\n * catches typos in collection names.\n */\nexport class MaterializedViewSourceUnknownError extends NoydbError {\n readonly mvName: string\n readonly collection: string\n\n constructor(mvName: string, collection: string) {\n super(\n 'MATERIALIZED_VIEW_SOURCE_UNKNOWN',\n `Materialized view \"${mvName}\" references unknown source collection \"${collection}\". ` +\n `Declare the collection (e.g. via schema or by writing to it once) before registering the MV.`,\n )\n this.name = 'MaterializedViewSourceUnknownError'\n this.mvName = mvName\n this.collection = collection\n }\n}\n\n/**\n * Thrown by the MV executor when a refresh produces more rows than\n * the configured ceiling. Default ceiling is 100k rows; override\n * per-MV via `maxRows`. Mirrors `JoinTooLargeError` /\n * `GroupCardinalityError` from the query DSL — the explosion is\n * detected BEFORE writes hit the store, so the source-write\n * transaction can roll back cleanly via strict-mode.\n */\nexport class MaterializedViewTooLargeError extends NoydbError {\n readonly mvName: string\n readonly expected: number\n readonly limit: number\n\n constructor(mvName: string, expected: number, limit: number) {\n super(\n 'MATERIALIZED_VIEW_TOO_LARGE',\n `Materialized view \"${mvName}\" would emit ${expected} rows, exceeding the configured limit of ${limit}. ` +\n `Override via { maxRows: N } on the MV strategy if intentional, or tighten the query's filter/groupBy.`,\n )\n this.name = 'MaterializedViewTooLargeError'\n this.mvName = mvName\n this.expected = expected\n this.limit = limit\n }\n}\n\n/**\n * Thrown by `withMaterializedView()` at registration time when the\n * strategy is structurally malformed. Distinct from\n * `MaterializedViewSourceUnknownError` (the source list is well-formed\n * but names a collection the vault doesn't know) and\n * `MaterializedViewCycleError` (the source graph has a cycle): this\n * error fires before either check, at the moment the spec is being\n * normalized.\n *\n * Today the trigger cases are all about the `query` / `unionSources`\n * dichotomy introduced by #165:\n * - both `query` and `unionSources` were set (mutually exclusive),\n * - neither `query` nor `unionSources` was set,\n * - `unionSources` has fewer than 2 arms,\n * - two arms in `unionSources` reference the same `collection`.\n *\n * The error message is prefixed with `[noy-db] withMaterializedView:`\n * so it's grep-friendly in logs and looks consistent with the existing\n * `ValidationError` messages from the same factory.\n */\nexport class MaterializedViewConfigError extends NoydbError {\n constructor(message: string) {\n super(\n 'MATERIALIZED_VIEW_CONFIG',\n `[noy-db] withMaterializedView: ${message}`,\n )\n this.name = 'MaterializedViewConfigError'\n }\n}\n\n/**\n * Thrown at vault open when a `withOverlayedView` declaration uses\n * another virtual-overlay name as its `base`. Multi-overlay stacking\n * is a v2 non-goal — the shallow expansion in\n * `QueryDependencyAnalyzer` would truncate at the inner overlay\n * name, leaving downstream MVs silently stale.\n */\nexport class OverlayBaseIsVirtualError extends NoydbError {\n readonly overlayName: string\n readonly base: string\n\n constructor(overlayName: string, base: string) {\n super(\n 'OVERLAY_BASE_IS_VIRTUAL',\n `withOverlayedView \"${overlayName}\": base \"${base}\" is another overlay's virtual name. ` +\n `Multi-overlay stacking is a v3 feature; base must reference a concrete collection (a real source or an MV output).`,\n )\n this.name = 'OverlayBaseIsVirtualError'\n this.overlayName = overlayName\n this.base = base\n }\n}\n\n/**\n * Thrown at vault open when a `withOverlayedView`'s `overlay`\n * references an unknown collection or an MV-owned collection. The\n * overlay collection is user-writable; MV-owned collections aren't.\n */\nexport class OverlayCollectionUnavailableError extends NoydbError {\n readonly overlayName: string\n readonly overlay: string\n\n constructor(overlayName: string, overlay: string) {\n super(\n 'OVERLAY_COLLECTION_UNAVAILABLE',\n `withOverlayedView \"${overlayName}\": overlay collection \"${overlay}\" is unavailable. ` +\n `It must be a real vault-known collection that is NOT itself an MV output collection.`,\n )\n this.name = 'OverlayCollectionUnavailableError'\n this.overlayName = overlayName\n this.overlay = overlay\n }\n}\n\n/**\n * Thrown at vault open when a `withOverlayedView`'s virtual `name`\n * collides with an MV output or a concrete source collection.\n */\nexport class OverlayNameCollisionError extends NoydbError {\n readonly overlayName: string\n\n constructor(overlayName: string) {\n super(\n 'OVERLAY_NAME_COLLISION',\n `withOverlayedView \"${overlayName}\": virtual name collides with an MV output or a concrete source collection. ` +\n `Pick a unique name for the virtual collection.`,\n )\n this.name = 'OverlayNameCollisionError'\n this.overlayName = overlayName\n }\n}\n\n/**\n * Thrown by the virtual overlay's `put(id, record)` when the\n * consumer-supplied `id` doesn't match `rowKey(record)`. Catches\n * fat-finger separator typos that would otherwise silently produce\n * orphaned overlay rows. Direct writes to the underlying overlay\n * collection (bypass the virtual layer) skip this validation.\n */\nexport class OverlayIdMismatchError extends NoydbError {\n readonly actual: string\n readonly expected: string\n\n constructor(actual: string, expected: string) {\n super(\n 'OVERLAY_ID_MISMATCH',\n `Overlay put(id, record): id \"${actual}\" does not match the base MV's rowKey(record) → \"${expected}\". ` +\n `Pass the row directly via .put(record) to derive the id, or fix the id to match the base MV's rowKey output.`,\n )\n this.name = 'OverlayIdMismatchError'\n this.actual = actual\n this.expected = expected\n }\n}\n","/**\n * Cryptographic primitives — thin wrappers around the Web Crypto API.\n *\n * ## Design principle\n *\n * **Zero npm crypto dependencies.** Every operation uses `globalThis.crypto.subtle`,\n * which is available natively in Node.js ≥ 18, all modern browsers, and\n * Deno/Bun. This avoids supply-chain risk from third-party crypto packages and\n * ensures the library stays auditable.\n *\n * ## Algorithms\n *\n * | Use case | Algorithm | Parameters |\n * |----------|-----------|------------|\n * | Key derivation | PBKDF2-SHA256 | 600,000 iterations, 32-byte salt |\n * | Record encryption | AES-256-GCM | 12-byte random IV per operation |\n * | DEK wrapping | AES-KW (RFC 3394) | 256-bit KEK |\n * | Binary encrypt | AES-256-GCM | same as record encryption |\n * | Integrity | HMAC-SHA256 | for presence channels |\n * | Content hash | SHA-256 | for ledger and bundle integrity |\n *\n * ## Key lifecycle\n *\n * ```\n * passphrase + salt\n * └─► deriveKey() → KEK (CryptoKey, extractable: false)\n * └─► wrapKey() → wrapped DEK bytes [stored in keyring]\n * └─► unwrapKey() → DEK (CryptoKey) [memory only during session]\n * └─► encrypt() / decrypt() → ciphertext / plaintext\n * ```\n *\n * IVs are generated fresh by {@link generateIV} on every encrypt call.\n * Reusing an IV with the same key would break GCM's authentication guarantee —\n * this function should be the only place IVs are produced.\n *\n * @module\n */\n\nimport { DecryptionError, InvalidKeyError, TamperedError } from './errors.js'\n\nconst PBKDF2_ITERATIONS = 600_000\nconst SALT_BYTES = 32\nconst IV_BYTES = 12\nconst KEY_BITS = 256\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Key Derivation ────────────────────────────────────────────────────\n\n/** Derive a KEK from a passphrase and salt using PBKDF2-SHA256. */\nexport async function deriveKey(\n passphrase: string,\n salt: Uint8Array,\n): Promise<CryptoKey> {\n const keyMaterial = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(passphrase),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n keyMaterial,\n { name: 'AES-KW', length: KEY_BITS },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// ─── DEK Generation ────────────────────────────────────────────────────\n\n/** Generate a random AES-256-GCM data encryption key. */\nexport async function generateDEK(): Promise<CryptoKey> {\n return subtle.generateKey(\n { name: 'AES-GCM', length: KEY_BITS },\n true, // extractable — needed for AES-KW wrapping\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Key Wrapping ──────────────────────────────────────────────────────\n\n/** Wrap (encrypt) a DEK with a KEK using AES-KW. Returns base64 string. */\nexport async function wrapKey(dek: CryptoKey, kek: CryptoKey): Promise<string> {\n const wrapped = await subtle.wrapKey('raw', dek, kek, 'AES-KW')\n return bufferToBase64(wrapped)\n}\n\n/** Unwrap (decrypt) a DEK from base64 string using a KEK. */\nexport async function unwrapKey(\n wrappedBase64: string,\n kek: CryptoKey,\n): Promise<CryptoKey> {\n try {\n return await subtle.unwrapKey(\n 'raw',\n base64ToBuffer(wrappedBase64) as BufferSource,\n kek,\n 'AES-KW',\n { name: 'AES-GCM', length: KEY_BITS },\n true,\n ['encrypt', 'decrypt'],\n )\n } catch {\n throw new InvalidKeyError()\n }\n}\n\n// ─── Encrypt / Decrypt ─────────────────────────────────────────────────\n\nexport interface EncryptResult {\n iv: string // base64\n data: string // base64\n}\n\n/** Encrypt plaintext JSON string with AES-256-GCM. Fresh IV per call. */\nexport async function encrypt(\n plaintext: string,\n dek: CryptoKey,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const encoded = new TextEncoder().encode(plaintext)\n\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n encoded,\n )\n\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/** Decrypt AES-256-GCM ciphertext. Throws on wrong key or tampered data. */\nexport async function decrypt(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<string> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n\n try {\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n ciphertext as BufferSource,\n )\n return new TextDecoder().decode(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n// ─── Binary Encrypt / Decrypt ────────\n\n/**\n * Encrypt raw bytes with AES-256-GCM using a fresh random IV.\n * Used by the attachment store so binary blobs avoid double base64 encoding\n * (the existing `encrypt()` function calls `TextEncoder` on a string — here\n * we pass the `Uint8Array` directly to `subtle.encrypt`).\n */\nexport async function encryptBytes(\n data: Uint8Array,\n dek: CryptoKey,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n data as unknown as BufferSource,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext back to raw bytes.\n * Counterpart to `encryptBytes`. Throws `TamperedError` on auth-tag failure.\n */\nexport async function decryptBytes(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<Uint8Array> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n try {\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n ciphertext as BufferSource,\n )\n return new Uint8Array(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n/**\n * SHA-256 hex digest of raw bytes. Used to derive content-addressed\n * eTags for blob deduplication. Computed on plaintext bytes\n * before compression and encryption so the eTag identifies content, not\n * ciphertext, and survives re-encryption (key rotation, re-upload).\n */\nexport async function sha256Hex(data: Uint8Array): Promise<string> {\n const hash = await subtle.digest('SHA-256', data as unknown as BufferSource)\n return Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\n// ─── HMAC-SHA-256 ─────────────────────────────\n\n/**\n * Compute HMAC-SHA-256(key, data) and return hex string.\n *\n * Used to derive content-addressed eTags that are opaque to the store:\n * ```\n * eTag = hmacSha256Hex(blobDEK, plaintext)\n * ```\n *\n * Unlike a plain SHA-256, the HMAC is keyed by the vault-shared `_blob` DEK,\n * so an attacker with store access cannot pre-compute eTags for known files.\n * Deduplication still works within a vault (same key + same content = same eTag).\n */\nexport async function hmacSha256Hex(key: CryptoKey, data: Uint8Array): Promise<string> {\n // Export AES-GCM DEK raw bytes → import as HMAC key\n const rawKey = await subtle.exportKey('raw', key)\n const hmacKey = await subtle.importKey(\n 'raw',\n rawKey,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const sig = await subtle.sign('HMAC', hmacKey, data as unknown as BufferSource)\n return Array.from(new Uint8Array(sig))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\n// ─── AAD-aware Binary Encrypt / Decrypt ──\n\n/**\n * Encrypt raw bytes with AES-256-GCM using Additional Authenticated Data.\n *\n * The AAD binds each chunk to its parent blob and position, preventing\n * chunk reorder, substitution, and truncation attacks:\n * ```\n * AAD = UTF-8(\"{eTag}:{chunkIndex}:{chunkCount}\")\n * ```\n *\n * The AAD is NOT stored — the reader reconstructs it from `BlobObject`\n * metadata and passes it to `decryptBytesWithAAD`.\n */\nexport async function encryptBytesWithAAD(\n data: Uint8Array,\n dek: CryptoKey,\n aad: Uint8Array,\n): Promise<EncryptResult> {\n const iv = generateIV()\n const ciphertext = await subtle.encrypt(\n {\n name: 'AES-GCM',\n iv: iv as BufferSource,\n additionalData: aad as BufferSource,\n },\n dek,\n data as unknown as BufferSource,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext with AAD verification.\n *\n * If the AAD does not match the one used at encryption time (e.g. because\n * a chunk was reordered or substituted from another blob), the GCM auth\n * tag fails and this throws `TamperedError`.\n */\nexport async function decryptBytesWithAAD(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n aad: Uint8Array,\n): Promise<Uint8Array> {\n const iv = base64ToBuffer(ivBase64)\n const ciphertext = base64ToBuffer(dataBase64)\n try {\n const plaintext = await subtle.decrypt(\n {\n name: 'AES-GCM',\n iv: iv as BufferSource,\n additionalData: aad as BufferSource,\n },\n dek,\n ciphertext as BufferSource,\n )\n return new Uint8Array(plaintext)\n } catch (err) {\n if (err instanceof Error && err.name === 'OperationError') {\n throw new TamperedError()\n }\n throw new DecryptionError(\n err instanceof Error ? err.message : 'Decryption failed',\n )\n }\n}\n\n// ─── Presence Key Derivation ──────────────────────────────\n\n/**\n * Derive an AES-256-GCM presence key from a collection DEK using HKDF-SHA256.\n *\n * The presence key is domain-separated from the data DEK by the fixed salt\n * `'noydb-presence'` and the `info` = collection name. This means:\n * - The adapter never sees the presence key.\n * - Presence payloads rotate automatically when the collection DEK is rotated.\n * - Revoked users cannot derive the new presence key after a DEK rotation.\n *\n * @param dek The collection's AES-256-GCM DEK (extractable).\n * @param collectionName Used as the HKDF `info` parameter for domain separation.\n * @returns A non-extractable AES-256-GCM key suitable for presence payload encryption.\n */\nexport async function derivePresenceKey(dek: CryptoKey, collectionName: string): Promise<CryptoKey> {\n // Step 1: export DEK raw bytes\n const rawDek = await subtle.exportKey('raw', dek)\n\n // Step 2: import as HKDF key material\n const hkdfKey = await subtle.importKey(\n 'raw',\n rawDek,\n 'HKDF',\n false,\n ['deriveBits'],\n )\n\n // Step 3: derive 256 bits with salt='noydb-presence' and info=collectionName\n const salt = new TextEncoder().encode('noydb-presence')\n const info = new TextEncoder().encode(collectionName)\n const bits = await subtle.deriveBits(\n { name: 'HKDF', hash: 'SHA-256', salt, info },\n hkdfKey,\n KEY_BITS,\n )\n\n // Step 4: import derived bits as AES-GCM key\n return subtle.importKey(\n 'raw',\n bits,\n { name: 'AES-GCM', length: KEY_BITS },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Deterministic Encryption ────────────────────────────\n\n/**\n * Derive a deterministic 12-byte IV from `{ DEK, context, plaintext }`\n * via HKDF-SHA256. Given the same three inputs, the IV is identical, so\n * `encryptDeterministic` produces the same ciphertext on every call —\n * which is precisely what enables blind equality search on encrypted\n * fields.\n *\n * **The side channel this opens.** Two records whose field value is the\n * same produce the same ciphertext. An observer with store access can\n * therefore tell which records share a value — not *what* the value is,\n * but the equivalence class. This is the well-known trade-off of\n * deterministic encryption and is why the feature is strictly opt-in\n * per field, guarded by `acknowledgeDeterministicRisk: true` at\n * collection creation.\n *\n * The context string MUST include the collection name and field name,\n * so:\n * - The same plaintext in two different fields encrypts differently\n * (no cross-field equality leak).\n * - The same plaintext in two different collections (different DEKs)\n * encrypts differently by virtue of the key, even before HKDF\n * domain separation kicks in.\n */\nasync function deriveDeterministicIV(\n dek: CryptoKey,\n context: string,\n plaintext: string,\n): Promise<Uint8Array> {\n const rawDek = await subtle.exportKey('raw', dek)\n const hkdfKey = await subtle.importKey('raw', rawDek, 'HKDF', false, ['deriveBits'])\n const salt = new TextEncoder().encode('noydb-deterministic-v1')\n const info = new TextEncoder().encode(`${context}\\x00${plaintext}`)\n const bits = await subtle.deriveBits(\n { name: 'HKDF', hash: 'SHA-256', salt, info },\n hkdfKey,\n IV_BYTES * 8,\n )\n return new Uint8Array(bits)\n}\n\n/**\n * Encrypt a plaintext string with AES-256-GCM and a deterministic,\n * HKDF-derived IV.\n *\n * The same `{ dek, context, plaintext }` triple always produces the\n * same `{ iv, data }` — call this twice and you can string-compare the\n * ciphertexts to check equality of the inputs without decrypting them.\n *\n * @param context Domain-separation string — by convention\n * `'<collection>/<field>'`. Different contexts encrypt\n * the same plaintext to different ciphertexts, so\n * `email` in collection `users` does not collide with\n * `email` in collection `customers`.\n */\nexport async function encryptDeterministic(\n plaintext: string,\n dek: CryptoKey,\n context: string,\n): Promise<EncryptResult> {\n const iv = await deriveDeterministicIV(dek, context, plaintext)\n const encoded = new TextEncoder().encode(plaintext)\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n dek,\n encoded,\n )\n return {\n iv: bufferToBase64(iv),\n data: bufferToBase64(ciphertext),\n }\n}\n\n/**\n * Counterpart to {@link encryptDeterministic}. The IV is stored\n * alongside the ciphertext (exactly like the randomized path), so\n * decrypt uses the stored IV and verifies the GCM auth tag — a tampered\n * ciphertext throws `TamperedError` just like randomized AES-GCM.\n */\nexport async function decryptDeterministic(\n ivBase64: string,\n dataBase64: string,\n dek: CryptoKey,\n): Promise<string> {\n return decrypt(ivBase64, dataBase64, dek)\n}\n\n// ─── Random Generation ─────────────────────────────────────────────────\n\n/** Generate a random 12-byte IV for AES-GCM. */\nexport function generateIV(): Uint8Array {\n return globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES))\n}\n\n/** Generate a random 32-byte salt for PBKDF2. */\nexport function generateSalt(): Uint8Array {\n return globalThis.crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n}\n\n// ─── Base64 Helpers ────────────────────────────────────────────────────\n\nexport function bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {\n const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)\n let binary = ''\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!)\n }\n return btoa(binary)\n}\n\nexport function base64ToBuffer(base64: string): Uint8Array<ArrayBuffer> {\n const binary = atob(base64)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i)\n }\n return bytes\n}\n","/**\n * Persistence helpers for the vault-level user-directory toggle\n * (`_meta/directory`). Mirrors the bypass-AES pattern used by\n * `_meta/policy` — the directory document is plain JSON, the\n * envelope's `_iv` field is left empty.\n *\n * @see docs/subsystems/user-envelope.md → Directory visibility\n * @see docs/subsystems/plaintext-bypass.md — every `_iv: ''` write site\n *\n * @module\n */\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../types.js'\nimport type { DirectoryConfig } from './types.js'\n\n/** Reserved collection name for vault-level metadata documents. */\nexport const META_COLLECTION = '_meta'\n/** Reserved id for the vault-level directory document. */\nexport const DIRECTORY_RECORD_ID = 'directory'\n\n/**\n * Read the directory toggle from `_meta/directory`. Returns `undefined`\n * when no document has been persisted — callers treat that as the\n * default-on case (`{ enabled: true }`).\n *\n * Tolerates corrupted documents the same way `_meta/policy` does: a\n * JSON parse failure surfaces as `undefined`, not a thrown error, so a\n * bad write never permanently breaks team enumeration.\n */\nexport async function readDirectoryConfig(\n store: NoydbStore,\n vault: string,\n): Promise<DirectoryConfig | undefined> {\n const envelope = await store.get(vault, META_COLLECTION, DIRECTORY_RECORD_ID)\n if (!envelope) return undefined\n try {\n const parsed = JSON.parse(envelope._data) as unknown\n if (!isDirectoryConfig(parsed)) return undefined\n return parsed\n } catch {\n return undefined\n }\n}\n\n/**\n * Persist the directory toggle at `_meta/directory`. Idempotent — call\n * on every `db.setDirectoryEnabled()` invocation. Owner-only at the\n * caller site; this primitive does not check roles.\n */\nexport async function persistDirectoryConfig(\n store: NoydbStore,\n vault: string,\n config: DirectoryConfig,\n): Promise<void> {\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify({ enabled: config.enabled }),\n }\n await store.put(vault, META_COLLECTION, DIRECTORY_RECORD_ID, envelope)\n}\n\nfunction isDirectoryConfig(x: unknown): x is DirectoryConfig {\n if (x === null || typeof x !== 'object') return false\n if (!('enabled' in x)) return false\n return typeof (x as { enabled: unknown }).enabled === 'boolean'\n}\n","/**\n * Persistence helpers for the per-user visibility flag\n * (`_meta/visibility/<keyringId>`). Mirrors the bypass-AES pattern used\n * by `_meta/policy` — the visibility document is plain JSON, the\n * envelope's `_iv` field is left empty.\n *\n * Stored alongside the keyring file rather than inside the encrypted\n * user envelope (`_users/<keyringId>`) because:\n *\n * - `UserEnvelope<T>.data` is opaque-to-hub by contract — hub does not\n * introspect or reserve any keys inside it. Adding `hidden` there\n * would violate that contract.\n * - `listUsersWithEnvelopes` filters by the flag, and the filter must\n * work even when decryption fails (legacy keyrings predating the\n * envelope feature, or a corrupted envelope).\n *\n * @see docs/subsystems/user-envelope.md → Directory visibility\n * @see docs/subsystems/plaintext-bypass.md — every `_iv: ''` write site\n *\n * @module\n */\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../types.js'\nimport type { UserVisibility } from './types.js'\nimport { META_COLLECTION } from './storage.js'\n\n/** Prefix for per-user visibility records inside `_meta`. */\nexport const VISIBILITY_RECORD_PREFIX = 'visibility/'\n\n/** Compose the `_meta` record id for a keyring's visibility doc. */\nexport function visibilityRecordId(keyringId: string): string {\n return VISIBILITY_RECORD_PREFIX + keyringId\n}\n\n/**\n * Read the visibility flag for `keyringId`. Returns `undefined` when no\n * document has been persisted — callers treat that as the default-visible\n * case (`{ hidden: false }`).\n */\nexport async function readUserVisibility(\n store: NoydbStore,\n vault: string,\n keyringId: string,\n): Promise<UserVisibility | undefined> {\n const envelope = await store.get(vault, META_COLLECTION, visibilityRecordId(keyringId))\n if (!envelope) return undefined\n try {\n const parsed = JSON.parse(envelope._data) as unknown\n if (!isUserVisibility(parsed)) return undefined\n return parsed\n } catch {\n return undefined\n }\n}\n\n/**\n * Persist the visibility flag for `keyringId` at\n * `_meta/visibility/<keyringId>`. Idempotent — call on every\n * `vault.user.setMyVisibility()` invocation. Own-only at the caller\n * site; this primitive does not enforce keyring ownership.\n */\nexport async function persistUserVisibility(\n store: NoydbStore,\n vault: string,\n keyringId: string,\n visibility: UserVisibility,\n): Promise<void> {\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify({ hidden: visibility.hidden }),\n }\n await store.put(vault, META_COLLECTION, visibilityRecordId(keyringId), envelope)\n}\n\n/**\n * Delete the visibility flag for `keyringId`. Called from `revoke()`\n * alongside `deleteUserEnvelope` so the sidecar does not leak to a\n * re-granted principal with the same `userId`. Idempotent — the store's\n * `delete()` is already a no-op when the record is absent.\n */\nexport async function deleteUserVisibility(\n store: NoydbStore,\n vault: string,\n keyringId: string,\n): Promise<void> {\n await store.delete(vault, META_COLLECTION, visibilityRecordId(keyringId))\n}\n\nfunction isUserVisibility(x: unknown): x is UserVisibility {\n if (x === null || typeof x !== 'object') return false\n if (!('hidden' in x)) return false\n return typeof (x as { hidden: unknown }).hidden === 'boolean'\n}\n","/**\n * Passphrase validation — phrase format (per the three-tier session-tiers\n * design, locked 2026-05-04).\n *\n * Passphrases are **phrases**: multiple simple words, easy to remember,\n * structurally constrained so a weak choice cannot silently collapse the\n * security floor. The format is intentionally narrow: lowercase letters\n * and single spaces only, no punctuation, no symbols, no digits.\n *\n * - Default minimum: 6 words (~77 bits with the 7,776-word EFF list).\n * - Strict minimum: 8 words (~103 bits).\n * - Per-word minimum: 3 characters (excludes \"a\", \"is\", \"of\").\n * - Adjacent repeats rejected (\"the the\").\n *\n * The hub runs validation default-on at every passphrase ingress\n * (`createOwnerKeyring`, `grant`, `rotatePassphrase`); test fixtures and\n * CLI scripts override via `{ allowWeakPassphrase: true }`.\n *\n * @module\n */\nimport { NoydbError, ValidationError } from './errors.js'\n\n/** All reasons a phrase can be rejected. */\nexport type WeakPassphraseReason =\n | 'empty'\n | 'invalid-chars'\n | 'leading-or-trailing-space'\n | 'double-space'\n | 'too-few-words'\n | 'word-too-short'\n | 'repeated-adjacent'\n\n/** Per-vault knobs. Aligns with `VaultPolicy.passphrase`. */\nexport interface PassphrasePolicy {\n /** Minimum number of words. Default 6. Strict policy uses 8. */\n readonly minWords?: number\n /** Minimum characters per word. Default 3. */\n readonly minWordLength?: number\n /** Reject adjacent identical words (\"the the\"). Default true. */\n readonly rejectRepeatedAdjacent?: boolean\n /**\n * Override the default character-class rule (`/^[a-z]+( [a-z]+)*$/`).\n *\n * The hub's strict default is lowercase-letters-and-single-spaces\n * because that's what the EFF wordlist generator emits and what\n * most attacker password lists are keyed on. Use this knob to allow\n * digits, uppercase, hyphens, or non-Latin scripts when the\n * consumer's audience needs them — e.g.:\n *\n * ```ts\n * // Thai + English mix with digits permitted\n * pattern: /^[\\p{L}0-9 ]+( [\\p{L}0-9 ]+)*$/u\n *\n * // Allow uppercase + hyphens (passphrase-with-hyphens style)\n * pattern: /^[A-Za-z]+([- ][A-Za-z]+)*$/\n * ```\n *\n * The OTHER structural rules still apply (min-words split by space,\n * min-word-length, repeated-adjacent, leading/trailing whitespace,\n * double-space). For non-space-delimited word semantics, use\n * {@link customValidator} instead.\n *\n * Added in pre.8 (#31).\n */\n readonly pattern?: RegExp\n /**\n * Replace ALL validation entirely with a custom function. When set,\n * none of the other PassphrasePolicy fields apply — the consumer\n * owns every rule (word splitting, character classes, entropy\n * thresholds, allowlist/denylist). Use sparingly; this is the\n * escape hatch for domain-specific phrase formats:\n *\n * - Localized wordlists with non-space word boundaries\n * - BIP-39 seed phrases (24 words, fixed wordlist, etc.)\n * - Organization-specific HR password policies\n *\n * The returned `PassphraseValidationResult` is what\n * {@link assertStrongPassphrase} dispatches on — `ok: true` accepts;\n * `ok: false` throws `WeakPassphraseError` with the supplied reason.\n *\n * Added in pre.8 (#31).\n */\n readonly customValidator?: (phrase: string) => PassphraseValidationResult\n}\n\n/** Result of a check. Discriminated union — compile-time exhaustive. */\nexport type PassphraseValidationResult =\n | { readonly ok: true; readonly words: number }\n | {\n readonly ok: false\n readonly reason: WeakPassphraseReason\n readonly minimum?: number\n readonly got?: number\n }\n\n/**\n * Thrown by `assertStrongPassphrase()` and by every hub ingress\n * point (`createOwnerKeyring`, `grant`, `rotatePassphrase`) when a\n * supplied phrase fails the structural rules above.\n */\nexport class WeakPassphraseError extends NoydbError {\n readonly reason: WeakPassphraseReason\n readonly suggestion: string\n constructor(reason: WeakPassphraseReason, suggestion: string) {\n super('WEAK_PASSPHRASE', `Weak passphrase (${reason}). ${suggestion}`)\n this.name = 'WeakPassphraseError'\n this.reason = reason\n this.suggestion = suggestion\n }\n}\n\nconst DEFAULT_MIN_WORDS = 6\nconst DEFAULT_MIN_WORD_LENGTH = 3\n\nconst SUGGESTIONS: Record<WeakPassphraseReason, string> = {\n empty: 'Provide a phrase of at least 6 lowercase words separated by single spaces.',\n 'invalid-chars':\n 'Use only lowercase letters [a-z] and single spaces. No punctuation, symbols, digits, or uppercase.',\n 'leading-or-trailing-space': 'Trim leading and trailing spaces.',\n 'double-space': 'Use exactly one space between words.',\n 'too-few-words':\n 'Use at least 6 words by default (8 under strict policy). Example: \"correct horse battery staple printer toaster\".',\n 'word-too-short': 'Each word must be at least 3 characters. Drop short fillers like \"a\", \"is\", \"of\".',\n 'repeated-adjacent': 'Avoid repeating the same word twice in a row.',\n}\n\n/**\n * Inspect a phrase against the format rules and return a structured\n * verdict. Never throws — callers either branch on `ok` or pass the\n * result to {@link assertStrongPassphrase} for the throwing flavour.\n */\nexport function validatePassphrase(\n s: string,\n opts?: PassphrasePolicy,\n): PassphraseValidationResult {\n // Escape hatch: customValidator owns the entire decision. None of\n // the structural rules below run when this is set — the consumer is\n // responsible for the full validation contract.\n if (opts?.customValidator) {\n return opts.customValidator(s)\n }\n\n const minWords = opts?.minWords ?? DEFAULT_MIN_WORDS\n const minWordLength = opts?.minWordLength ?? DEFAULT_MIN_WORD_LENGTH\n const rejectRepeated = opts?.rejectRepeatedAdjacent ?? true\n\n if (s.length === 0) {\n return { ok: false, reason: 'empty' }\n }\n\n if (s !== s.trim()) {\n return { ok: false, reason: 'leading-or-trailing-space' }\n }\n\n if (s.includes(' ')) {\n return { ok: false, reason: 'double-space' }\n }\n\n // The default character class is lowercase-letters-and-spaces;\n // consumers can override via PassphrasePolicy.pattern (e.g. to\n // allow digits, uppercase, or non-Latin scripts). Word splitting\n // below remains space-based — for non-space word semantics the\n // consumer should use customValidator instead.\n const charPattern = opts?.pattern ?? /^[a-z]+( [a-z]+)*$/\n if (!charPattern.test(s)) {\n return { ok: false, reason: 'invalid-chars' }\n }\n\n const words = s.split(' ')\n\n if (words.length < minWords) {\n return { ok: false, reason: 'too-few-words', minimum: minWords, got: words.length }\n }\n\n for (const w of words) {\n if (w.length < minWordLength) {\n return { ok: false, reason: 'word-too-short', minimum: minWordLength, got: w.length }\n }\n }\n\n if (rejectRepeated) {\n for (let i = 1; i < words.length; i++) {\n if (words[i] === words[i - 1]) {\n return { ok: false, reason: 'repeated-adjacent' }\n }\n }\n }\n\n return { ok: true, words: words.length }\n}\n\n/**\n * Throw {@link WeakPassphraseError} when the phrase fails. Used by\n * `createOwnerKeyring`, `grant`, and `rotatePassphrase` at ingress.\n *\n * Pass `{ allowWeakPassphrase: true }` to bypass — intended for test\n * fixtures, CLI scripts, and dev environments. The override never\n * loosens the cryptographic key derivation; it only relaxes the\n * structural-strength gate.\n */\nexport function assertStrongPassphrase(\n s: string,\n opts?: PassphrasePolicy & { allowWeakPassphrase?: boolean },\n): void {\n if (opts?.allowWeakPassphrase) return\n const result = validatePassphrase(s, opts)\n if (result.ok) return\n throw new WeakPassphraseError(result.reason, SUGGESTIONS[result.reason])\n}\n\n/**\n * Estimate the entropy of a phrase, given the EFF 7,776-word list as\n * the assumed wordlist. ~12.9 bits per word.\n *\n * Returns 0 for any input that fails the phrase format — character-class\n * estimates aren't comparable to phrase entropy, and surfacing 0 makes\n * weak inputs visible in any UI that displays an entropy meter.\n */\nexport function estimateEntropy(passphrase: string): number {\n const result = validatePassphrase(passphrase)\n if (!result.ok) return 0\n return Math.round(result.words * Math.log2(7776))\n}\n\n/**\n * Internal compatibility shim. Older code paths used the throwing\n * `validatePassphrase(s)` directly; some still do via re-exports. Routes\n * to the new `assertStrongPassphrase` so the contract holds for both\n * shapes during the transition. New code should call\n * {@link assertStrongPassphrase} directly.\n *\n * @internal\n */\nexport function legacyAssertPassphrase(s: string): void {\n try {\n assertStrongPassphrase(s)\n } catch (err) {\n if (err instanceof WeakPassphraseError) {\n throw new ValidationError(err.message)\n }\n throw err\n }\n}\n","/**\n * Type surface for the per-principal user envelope subsystem.\n *\n * @see docs/superpowers/specs/2026-05-05-user-envelope-design.md\n *\n * @module\n */\nimport { NoydbError } from '../../errors.js'\n\n/**\n * Thin reader view of a user envelope. The on-disk shape is the standard\n * {@link import('../../types.js').EncryptedEnvelope}; this is what callers\n * see after the storage layer has decrypted the payload.\n *\n * Hub commits to the `keyringId` ⇔ `userId` identity and the `_v` / `_ts`\n * envelope metadata. The `data` payload is fully app-defined — hub does\n * not introspect, validate, or reserve any keys inside it.\n */\nexport interface UserEnvelope<T> {\n /** The principal id this envelope belongs to. Equals the keyring `user_id`. */\n readonly keyringId: string\n /** App-owned payload. Opaque to hub. */\n readonly data: T\n /** Optimistic-concurrency version. Increments on every write. */\n readonly _v: number\n /** ISO timestamp of the last write. */\n readonly _ts: string\n}\n\n/**\n * Soft cap on the JSON-serialized payload size. Generous (a typical\n * profile + preferences + small app annex is ~1 KiB); rejects accidental\n * \"stuff app state in here\" anti-patterns.\n */\nexport const USER_ENVELOPE_MAX_BYTES = 64 * 1024\n\n/**\n * Reserved store collection name for user envelopes. Starts with `_` so the\n * keyring grant machinery propagates the DEK to every granted user via the\n * existing system-collection DEK propagation path in `team/keyring.ts`.\n */\nexport const USER_ENVELOPE_COLLECTION = '_users'\n\n/**\n * Thrown when a user-envelope payload exceeds {@link USER_ENVELOPE_MAX_BYTES}\n * after JSON-serialization. The error carries the actual size so callers\n * can decide whether to trim or split.\n */\nexport class UserEnvelopeOversizedError extends NoydbError {\n readonly bytes: number\n readonly limit: number\n constructor(bytes: number, limit: number = USER_ENVELOPE_MAX_BYTES) {\n super(\n 'USER_ENVELOPE_OVERSIZED',\n `User envelope payload is ${bytes} bytes; soft cap is ${limit} bytes. ` +\n `Move large data into the vault's regular collections.`,\n )\n this.name = 'UserEnvelopeOversizedError'\n this.bytes = bytes\n this.limit = limit\n }\n}\n","/**\n * Persistence helpers for per-principal user envelopes stored at\n * `_users/<keyringId>` (logically: `_meta/user/<keyringId>`).\n *\n * Unlike `_meta/policy` and `_meta/handle` which are plaintext, user\n * envelopes carry user data and are encrypted with a dedicated\n * {@link USER_ENVELOPE_COLLECTION} DEK (provisioned at vault open and\n * propagated to every keyring via the system-collection DEK path in\n * `team/keyring.ts`).\n *\n * This module is the **storage primitive** layer. The public API\n * (`vault.user.*`) sits on top of this; permission gates, own-only\n * write enforcement, and presence-channel propagation live there.\n *\n * @see docs/superpowers/specs/2026-05-05-user-envelope-design.md\n *\n * @module\n */\nimport type { NoydbStore, EncryptedEnvelope } from '../../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../../types.js'\nimport { encrypt, decrypt } from '../../crypto.js'\nimport { ConflictError } from '../../errors.js'\nimport {\n USER_ENVELOPE_COLLECTION,\n USER_ENVELOPE_MAX_BYTES,\n UserEnvelopeOversizedError,\n type UserEnvelope,\n} from './types.js'\n\n/**\n * Read and decrypt the user envelope for `keyringId`. Returns `null`\n * when no envelope has been persisted (either the principal has never\n * called `updateMe`, or the keyring predates this feature).\n *\n * Decryption errors propagate — a tampered or wrong-keyed envelope\n * surfaces as the underlying crypto error rather than masquerading as\n * \"not found\".\n */\nexport async function loadUserEnvelope<T = unknown>(\n store: NoydbStore,\n vault: string,\n keyringId: string,\n dek: CryptoKey,\n): Promise<UserEnvelope<T> | null> {\n const envelope = await store.get(vault, USER_ENVELOPE_COLLECTION, keyringId)\n if (!envelope) return null\n const plaintext = await decrypt(envelope._iv, envelope._data, dek)\n const data = JSON.parse(plaintext) as T\n return {\n keyringId,\n data,\n _v: envelope._v,\n _ts: envelope._ts,\n }\n}\n\n/**\n * Encrypt and persist the user envelope for `keyringId`. The new\n * version is `(prior._v ?? 0) + 1`. Pass `expectedVersion` to enable\n * optimistic-concurrency checks: a mismatch with the stored version\n * throws {@link ConflictError} with the actual stored version.\n *\n * `expectedVersion: 0` means \"expect no prior envelope\"; the write\n * succeeds only if no envelope exists yet.\n *\n * Soft-caps the JSON-serialized payload at {@link USER_ENVELOPE_MAX_BYTES};\n * larger payloads throw {@link UserEnvelopeOversizedError}.\n */\nexport async function saveUserEnvelope<T>(\n store: NoydbStore,\n vault: string,\n keyringId: string,\n payload: T,\n dek: CryptoKey,\n expectedVersion?: number,\n): Promise<UserEnvelope<T>> {\n const json = JSON.stringify(payload)\n // TextEncoder counts bytes correctly for multi-byte UTF-8 (Thai text,\n // emoji, etc.) — JSON.stringify().length would undercount.\n const bytes = new TextEncoder().encode(json).byteLength\n if (bytes > USER_ENVELOPE_MAX_BYTES) {\n throw new UserEnvelopeOversizedError(bytes)\n }\n\n const prior = await store.get(vault, USER_ENVELOPE_COLLECTION, keyringId)\n if (expectedVersion !== undefined) {\n const priorVersion = prior?._v ?? 0\n if (priorVersion !== expectedVersion) {\n throw new ConflictError(\n priorVersion,\n `User envelope for \"${keyringId}\" expected version ${expectedVersion}, ` +\n `actual ${priorVersion}`,\n )\n }\n }\n\n const nextVersion = (prior?._v ?? 0) + 1\n const ts = new Date().toISOString()\n const { iv, data } = await encrypt(json, dek)\n\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: nextVersion,\n _ts: ts,\n _iv: iv,\n _data: data,\n }\n await store.put(vault, USER_ENVELOPE_COLLECTION, keyringId, envelope)\n\n return {\n keyringId,\n data: payload,\n _v: nextVersion,\n _ts: ts,\n }\n}\n\n/**\n * Delete the user envelope for `keyringId`. Idempotent — no error if\n * the envelope is already absent. Called from the keyring revoke path\n * (cascade-delete) and is a no-op for keyrings that never wrote.\n */\nexport async function deleteUserEnvelope(\n store: NoydbStore,\n vault: string,\n keyringId: string,\n): Promise<void> {\n await store.delete(vault, USER_ENVELOPE_COLLECTION, keyringId)\n}\n\n/**\n * List the keyring ids that have a user envelope persisted in `vault`.\n * Order is store-defined — callers that need a stable order should sort.\n */\nexport async function listUserEnvelopeIds(\n store: NoydbStore,\n vault: string,\n): Promise<string[]> {\n return store.list(vault, USER_ENVELOPE_COLLECTION)\n}\n","import type { NoydbStore, KeyringFile, KeyringAuthenticator, Role, Permissions, GrantOptions, RevokeOptions, UpdateUserOptions, UserInfo, EncryptedEnvelope, ExportCapability, ExportFormat, ImportCapability, VaultPolicyOnDisk } from '../types.js'\nimport { NOYDB_KEYRING_VERSION, NOYDB_FORMAT_VERSION } from '../types.js'\nimport {\n deriveKey,\n generateDEK,\n generateSalt,\n wrapKey,\n unwrapKey,\n encrypt,\n decrypt,\n bufferToBase64,\n base64ToBuffer,\n} from '../crypto.js'\nimport { NoAccessError, PermissionDeniedError, PrivilegeEscalationError, KeyringExpiredError, KeyringCorruptError, InvalidKeyError, ValidationError, DirectoryDisabledError } from '../errors.js'\nimport { readDirectoryConfig } from '../directory/storage.js'\nimport { readUserVisibility, deleteUserVisibility } from '../directory/visibility.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../validation.js'\nimport {\n saveUserEnvelope,\n loadUserEnvelope as loadUserEnvelopeFn,\n deleteUserEnvelope,\n USER_ENVELOPE_COLLECTION,\n type UserEnvelope as UserEnvelopeReader,\n} from '../meta/user-envelope/index.js'\n\n// ─── Roles that can grant/revoke ───────────────────────────────────────\n\n/**\n * Roles that an `admin` is allowed to grant and revoke.\n *\n * Includes `'admin'` itself: the model bottlenecked all admin\n * onboarding through the single `owner` principal, which made lateral\n * delegation impossible and left a single-owner bus-factor risk\n * unresolved even when multiple trusted humans existed. opens up\n * admin↔admin lateral delegation, with two guardrails:\n *\n * 1. **No privilege escalation.** Enforced in `grant()`: every DEK\n * wrapped into the new admin's keyring must be present in the\n * grantor's own DEK set. Today this is structurally trivially\n * true (admin grants always inherit the full caller DEK set),\n * but the check is wired in so future per-collection admin scoping\n * cannot accidentally bypass it. See `PrivilegeEscalationError`.\n *\n * 2. **Cascade on revoke.** Enforced in `revoke()`: when an admin is\n * revoked, every admin they (transitively) granted is either\n * revoked too (`cascade: 'strict'`, default) or left in place with\n * a console warning (`cascade: 'warn'`). The walk uses the\n * `granted_by` field on each keyring file as the parent pointer.\n */\nconst ADMIN_GRANTABLE_TARGETS: readonly Role[] = ['operator', 'viewer', 'client', 'admin']\n\nfunction canGrant(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\nfunction canRevoke(callerRole: Role, targetRole: Role): boolean {\n if (targetRole === 'owner') return false // owner cannot be revoked\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\n/**\n * Whether `callerRole` can mutate a keyring whose role is (or becomes)\n * `targetRole`. Used by `updateKeyringIdentity` (#54).\n *\n * Mirrors `canGrant`'s hierarchy: admins manage admin/operator/viewer/\n * client laterally; admins cannot create or destroy `owner`-shaped\n * keyrings. Owner can do anything.\n *\n * Both the OLD role and the NEW role must satisfy this check —\n * otherwise admin could elevate themselves (`admin → owner`) or demote\n * an owner (`owner → admin`) under cover of \"update.\"\n */\nfunction canUpdateRole(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\n// ─── Unlocked Keyring ──────────────────────────────────────────────────\n\n/** In-memory representation of an unlocked keyring. */\nexport interface UnlockedKeyring {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly permissions: Permissions\n readonly deks: Map<string, CryptoKey>\n /**\n * The KEK, when this keyring was unlocked via tier 1 (passphrase) or\n * a wrap-KEK tier-2 method (WebAuthn / OIDC). `null` when the\n * keyring was opened via:\n *\n * - Unencrypted mode (no KEK exists)\n * - Tier-3 PIN quick-resume (`@noy-db/on-pin`)\n * - Wrap-DEKs tier-2 unlock (`@noy-db/on-password`'s\n * `verifyPasswordSlot` after #26 Path C)\n * - Session-state restore (`session/session.ts`)\n * - Dev-unlock fixture (`session/dev-unlock.ts`)\n *\n * Consumers performing tier-1 operations that need the KEK\n * (DEK rewrap, keyring persist, delegation issue/unwrap) must\n * null-check and throw a clear error if absent — re-authenticate\n * at tier 1 first to recover the KEK.\n *\n * Tightened from `CryptoKey` to `CryptoKey | null` in pre.8 (#41).\n * The runtime contract has always allowed null; the type now\n * matches reality.\n */\n readonly kek: CryptoKey | null\n readonly salt: Uint8Array\n /**\n * `@noy-db/as-*` export capability. Absent when the\n * keyring was written before this RFC landed — role-based defaults\n * apply via `hasExportCapability`.\n */\n readonly exportCapability?: ExportCapability\n /**\n * `@noy-db/as-*` import capability. Absent when the\n * keyring was written before the import-capability extension\n * landed — default-closed semantics\n * apply via `hasImportCapability` (no plaintext format granted, no\n * bundle import granted, regardless of role).\n */\n readonly importCapability?: ImportCapability\n /**\n * Tier-2 authenticator slots — readonly snapshot loaded from the\n * keyring file. Mutations go through `enrollAuthenticator` /\n * `removeAuthenticator` (issue #11), which write back via\n * `persistKeyring`. Always defined; loads with an empty array for\n * keyrings written before the multi-slot extension landed.\n */\n readonly authenticators: readonly KeyringAuthenticator[]\n /**\n * Reserved per-keyring policy override (forward-compat for Option C\n * — see {@link VaultPolicyOnDisk}). v1.0 round-trips this field but\n * never enforces it; the gate engine uses `_meta/policy` only.\n */\n readonly policy?: VaultPolicyOnDisk\n}\n\n// ─── Passphrase canary (#113) ──────────────────────────────────────────\n//\n// The canary is a fixed 256-bit AES-GCM key (32 zero bytes), wrapped\n// under the keyring's KEK with AES-KW. Because AES-KW is deterministic\n// (RFC 3394 fixed IV), wrapping the same constant under the same KEK\n// always yields the same ciphertext — so every write site can mint\n// fresh on each persist without round-tripping a `canary` field\n// through UnlockedKeyring.\n//\n// On load, the canary unwraps cleanly iff the KEK is correct AND the\n// canary bytes on disk are intact. Combined with each-DEK try/catch,\n// this distinguishes wrong-passphrase (canary fails AND every DEK fails)\n// from corruption (canary succeeds OR at least one DEK succeeds) —\n// closing the all-DEKs-corrupt and single-DEK ambiguities that the\n// pre-canary heuristic from #82 / #99 left open.\n\nconst CANARY_PLAINTEXT_BYTES = new Uint8Array(32)\nlet canaryKeyPromise: Promise<CryptoKey> | null = null\n\nfunction getCanaryKey(): Promise<CryptoKey> {\n if (canaryKeyPromise === null) {\n canaryKeyPromise = globalThis.crypto.subtle.importKey(\n 'raw',\n CANARY_PLAINTEXT_BYTES as BufferSource,\n { name: 'AES-GCM', length: 256 },\n true, // extractable so AES-KW can wrap it\n ['encrypt', 'decrypt'],\n )\n }\n return canaryKeyPromise\n}\n\n/** Mint a fresh wrapped-canary string. Deterministic for a given KEK. */\nexport async function mintKeyringCanary(kek: CryptoKey): Promise<string> {\n const canaryKey = await getCanaryKey()\n return wrapKey(canaryKey, kek)\n}\n\n/** Try to unwrap the canary. Returns true iff KEK + canary bytes are intact. */\nasync function verifyKeyringCanary(wrappedCanary: string, kek: CryptoKey): Promise<boolean> {\n try {\n await unwrapKey(wrappedCanary, kek)\n return true\n } catch {\n return false\n }\n}\n\n// ─── Load / Create ─────────────────────────────────────────────────────\n\n/** Load and unlock a user's keyring for a vault. */\nexport async function loadKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n): Promise<UnlockedKeyring> {\n const envelope = await adapter.get(vault, '_keyring', userId)\n\n if (!envelope) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\"`)\n }\n\n const keyringFile = JSON.parse(envelope._data) as KeyringFile\n\n // — refuse to unwrap an expired slot. Check happens before any\n // KEK derivation so an expired slot doesn't leak timing on the\n // passphrase. Comparison uses Date.parse → ms-since-epoch; an\n // unparseable expires_at is treated as \"no expiry\" so a malformed\n // value can't silently lock users out (it'll surface in tests).\n if (keyringFile.expires_at !== undefined) {\n const cutoff = Date.parse(keyringFile.expires_at)\n if (Number.isFinite(cutoff) && Date.now() >= cutoff) {\n throw new KeyringExpiredError({ userId: keyringFile.user_id, expiresAt: keyringFile.expires_at })\n }\n }\n\n const salt = base64ToBuffer(keyringFile.salt)\n const kek = await deriveKey(passphrase, salt)\n\n // Verify the canary first when present. A canary success proves the\n // KEK is correct independent of any DEK byte — so subsequent DEK\n // unwrap failures are unambiguously corruption, not wrong-pass. A\n // canary failure with at least one DEK success indicates the KEK\n // is correct but the canary itself is corrupt. (#113)\n // `null` sentinel = legacy keyring without canary; falls back to the\n // multi-DEK heuristic from #82 / #99.\n const canaryOk: boolean | null = keyringFile.canary !== undefined\n ? await verifyKeyringCanary(keyringFile.canary, kek)\n : null\n\n // Unwrap each DEK independently — collect successes and failures.\n const deks = new Map<string, CryptoKey>()\n const failedCollections: string[] = []\n let firstUnwrapError: unknown = null\n for (const [collName, wrappedDek] of Object.entries(keyringFile.deks)) {\n try {\n const dek = await unwrapKey(wrappedDek, kek)\n deks.set(collName, dek)\n } catch (err) {\n failedCollections.push(collName)\n if (firstUnwrapError === null) firstUnwrapError = err\n }\n }\n\n if (canaryOk === true) {\n // KEK proven correct by the canary. Any DEK failure is corruption.\n if (failedCollections.length > 0) {\n throw new KeyringCorruptError({ failedCollections, intactCount: deks.size })\n }\n } else if (canaryOk === false) {\n // Canary failed. If any DEK unwrapped, KEK is correct → canary bytes\n // are corrupted (rare; reported under the '_canary' sentinel).\n if (deks.size > 0) {\n throw new KeyringCorruptError({\n failedCollections: [...failedCollections, '_canary'],\n intactCount: deks.size,\n })\n }\n // Canary failed AND no DEK unwrapped — wrong KEK (or whole-file\n // corruption). Surface the original InvalidKeyError so\n // onInvalidKey: 'reset' can fire its documented recovery path.\n throw firstUnwrapError instanceof Error ? firstUnwrapError : new InvalidKeyError()\n } else {\n // Legacy keyring (no canary). Fall back to the multi-DEK heuristic.\n if (failedCollections.length > 0) {\n if (deks.size > 0) {\n throw new KeyringCorruptError({ failedCollections, intactCount: deks.size })\n }\n throw firstUnwrapError instanceof Error ? firstUnwrapError : new InvalidKeyError()\n }\n }\n\n return {\n userId: keyringFile.user_id,\n displayName: keyringFile.display_name,\n role: keyringFile.role,\n permissions: keyringFile.permissions,\n deks,\n kek,\n salt,\n authenticators: keyringFile.authenticators ?? [],\n ...(keyringFile.export_capability !== undefined && { exportCapability: keyringFile.export_capability }),\n ...(keyringFile.import_capability !== undefined && { importCapability: keyringFile.import_capability }),\n ...(keyringFile.policy !== undefined && { policy: keyringFile.policy }),\n }\n}\n\n/**\n * Create the initial owner keyring for a new vault.\n *\n * Pass `{ validate: true }` (or a `PassphrasePolicy`) to gate creation\n * on the phrase-format strength rules — `Noydb` threads this from\n * `NoydbOptions.validatePassphrase`. Direct callers (CLI, scripts,\n * test fixtures) opt in explicitly.\n */\nexport async function createOwnerKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n passphraseOpts?: PassphrasePolicy & { validate?: boolean; allowWeakPassphrase?: boolean },\n): Promise<UnlockedKeyring> {\n if (passphraseOpts?.validate && !passphraseOpts.allowWeakPassphrase) {\n assertStrongPassphrase(passphrase, passphraseOpts)\n }\n const salt = generateSalt()\n const kek = await deriveKey(passphrase, salt)\n\n // Eager-provision the _users DEK at owner creation. This guarantees\n // every subsequent grant inherits it via the existing\n // collName.startsWith('_') propagation in grant() — so multi-principal\n // user-envelope reads (alice reading bob's profile) work for new\n // vaults without any per-keyring DEK rotation. Pre-existing vaults\n // get the DEK lazily on first vault.user.* access (which only\n // materializes a single-principal DEK that won't propagate\n // retroactively — that's the documented \"lazy creation for\n // pre-existing keyrings\" rollout note in the spec).\n const userEnvelopeDek = await generateDEK()\n const wrappedUserEnvelopeDek = await wrapKey(userEnvelopeDek, kek)\n const canary = await mintKeyringCanary(kek)\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: userId,\n display_name: userId,\n role: 'owner',\n permissions: {},\n deks: { [USER_ENVELOPE_COLLECTION]: wrappedUserEnvelopeDek },\n salt: bufferToBase64(salt),\n created_at: new Date().toISOString(),\n granted_by: userId,\n canary,\n }\n\n await writeKeyringFile(adapter, vault, userId, keyringFile)\n\n return {\n userId,\n displayName: userId,\n role: 'owner',\n permissions: {},\n deks: new Map([[USER_ENVELOPE_COLLECTION, userEnvelopeDek]]),\n kek,\n salt,\n authenticators: [],\n }\n}\n\n// ─── Grant ─────────────────────────────────────────────────────────────\n\n/** Grant access to a new user. Caller must have grant privilege. */\nexport async function grant(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: GrantOptions,\n): Promise<void> {\n if (!callerKeyring.kek) {\n throw new ValidationError(\n 'grant: caller keyring has no KEK — tier-2 wrap-DEKs and tier-3 PIN-resume ' +\n 'sessions cannot grant access to other users. Re-authenticate at tier 1 ' +\n '(passphrase) before granting.',\n )\n }\n\n if (!canGrant(callerKeyring.role, options.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot grant role \"${options.role}\"`,\n )\n }\n\n // Optional strength validation — opt-in via grant({ validatePassphrase: true })\n // or via the calling Noydb's NoydbOptions.validatePassphrase flag.\n // The override `allowWeakPassphrase: true` skips even when validate is on.\n if (\n (options as { validatePassphrase?: boolean }).validatePassphrase &&\n !options.allowWeakPassphrase\n ) {\n assertStrongPassphrase(options.passphrase)\n }\n\n // Determine which collections the new user gets access to\n const permissions = resolvePermissions(options.role, options.permissions)\n\n // Derive the new user's KEK from their passphrase\n const newSalt = generateSalt()\n const newKek = await deriveKey(options.passphrase, newSalt)\n\n // Wrap the appropriate DEKs with the new user's KEK\n const wrappedDeks: Record<string, string> = {}\n for (const collName of Object.keys(permissions)) {\n const dek = callerKeyring.deks.get(collName)\n if (dek) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // For owner/admin/viewer roles, wrap ALL known DEKs\n if (options.role === 'owner' || options.role === 'admin' || options.role === 'viewer') {\n for (const [collName, dek] of callerKeyring.deks) {\n if (!(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n }\n\n // For ALL roles, propagate system-prefixed collection DEKs\n // (`_ledger`, `_history`, `_sync`, …). These are internal collections\n // that any user with access to the vault must be able to\n // read and write — for example, the hash-chained ledger writes\n // an entry on every put/delete, so operators and clients with write\n // access to a single data collection still need the `_ledger` DEK.\n //\n // Trade-off: a granted user can decrypt every system-collection\n // entry, including ones they would not otherwise have access to\n // (e.g., an operator on `invoices` can read ledger entries for\n // mutations in `salaries`). This is a metadata leak, not a\n // plaintext leak — the ledger entries record collection names,\n // record ids, and ciphertext hashes, but never plaintext records.\n // Per-collection ledger DEKs are tracked as a follow-up.\n for (const [collName, dek] of callerKeyring.deks) {\n if (collName.startsWith('_') && !(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // Anti-privilege-escalation check. Every DEK we just\n // wrapped into the new keyring must come from the caller's own DEK\n // set — the grantor cannot give the grantee access to a collection\n // they themselves can't read. Today this is structurally trivially\n // satisfied because every wrapped DEK was looked up in\n // `callerKeyring.deks` above, but the explicit check is wired in\n // so a future change (per-collection admin scoping, escrow-based\n // re-wrapping, etc.) cannot accidentally let a widening grant\n // through. See `PrivilegeEscalationError` for the rationale.\n for (const collName of Object.keys(wrappedDeks)) {\n if (!callerKeyring.deks.has(collName)) {\n throw new PrivilegeEscalationError(collName)\n }\n }\n\n const canary = await mintKeyringCanary(newKek)\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: options.userId,\n display_name: options.displayName,\n role: options.role,\n permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: callerKeyring.userId,\n canary,\n ...(options.exportCapability !== undefined && { export_capability: options.exportCapability }),\n ...(options.importCapability !== undefined && { import_capability: options.importCapability }),\n }\n\n await writeKeyringFile(adapter, vault, options.userId, keyringFile)\n\n // User envelope bootstrap. Seeded with `options.initialProfile` if\n // provided, otherwise an empty `{}`. Encrypted with the caller's\n // _users DEK — which is the same DEK that was wrapped into the new\n // keyring's `wrappedDeks[USER_ENVELOPE_COLLECTION]` above (system-\n // collection propagation), so the new user can decrypt it on first\n // open. Skipped silently if the caller has no _users DEK (pre-feature\n // vault upgrade path — documented \"lazy creation for pre-existing\n // keyrings\" in the spec).\n const userEnvelopeDek = callerKeyring.deks.get(USER_ENVELOPE_COLLECTION)\n if (userEnvelopeDek) {\n const initialPayload = options.initialProfile ?? {}\n await saveUserEnvelope(\n adapter,\n vault,\n options.userId,\n initialPayload,\n userEnvelopeDek,\n )\n }\n}\n\n// ─── Revoke ────────────────────────────────────────────────────────────\n\n/**\n * Walk every keyring in the vault to find admins that the given\n * `rootUserId` (transitively) granted, via the `granted_by` parent\n * pointer recorded on each keyring file.\n *\n * Returns the set of descendant admin user-ids in DFS order, NOT\n * including the root itself. Non-admin descendants are excluded\n * because operators/viewers/clients cannot grant other users — they\n * are leaves in the delegation tree and cleaning them up is the\n * caller's job (or the next rotate, since they'd lose key access\n * anyway when the cascading admin's collections rotate).\n *\n * The walk uses a visited set keyed by user-id so cycles introduced\n * by re-grants (admin-A revoked, then re-granted later by admin-B who\n * was originally granted by A) terminate cleanly.\n */\nasync function findAdminDescendants(\n adapter: NoydbStore,\n vault: string,\n rootUserId: string,\n): Promise<string[]> {\n const allUserIds = await adapter.list(vault, '_keyring')\n\n // Build a map: parentUserId → child KeyringFiles. We only ever\n // descend into admins, so non-admin children are skipped at the\n // edge level rather than after a recursive call.\n const childrenByParent = new Map<string, string[]>()\n for (const userId of allUserIds) {\n const env = await adapter.get(vault, '_keyring', userId)\n if (!env) continue\n const kf = JSON.parse(env._data) as KeyringFile\n if (kf.role !== 'admin') continue // only admins can grant — leaves are uninteresting\n if (kf.user_id === rootUserId) continue // self-edges are noise\n const list = childrenByParent.get(kf.granted_by) ?? []\n list.push(kf.user_id)\n childrenByParent.set(kf.granted_by, list)\n }\n\n const visited = new Set<string>()\n const order: string[] = []\n const stack: string[] = [...(childrenByParent.get(rootUserId) ?? [])]\n while (stack.length > 0) {\n const next = stack.pop()!\n if (visited.has(next)) continue\n visited.add(next)\n order.push(next)\n for (const grandchild of childrenByParent.get(next) ?? []) {\n if (!visited.has(grandchild)) stack.push(grandchild)\n }\n }\n return order\n}\n\n/** Revoke a user's access. Optionally rotate keys for affected collections. */\nexport async function revoke(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: RevokeOptions,\n): Promise<void> {\n // Load the target's keyring to check their role\n const targetEnvelope = await adapter.get(vault, '_keyring', options.userId)\n if (!targetEnvelope) {\n throw new NoAccessError(`User \"${options.userId}\" has no keyring in vault \"${vault}\"`)\n }\n\n const targetKeyring = JSON.parse(targetEnvelope._data) as KeyringFile\n\n if (!canRevoke(callerKeyring.role, targetKeyring.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot revoke role \"${targetKeyring.role}\"`,\n )\n }\n\n // Cascade-on-revoke. Only meaningful when the target is\n // an admin — operators/viewers/clients cannot grant other users so\n // they have no delegation subtree to walk.\n const cascadeMode = options.cascade ?? 'strict'\n const usersToRevoke: string[] = [options.userId]\n const affectedCollections = new Set(Object.keys(targetKeyring.deks))\n\n if (targetKeyring.role === 'admin') {\n const descendants = await findAdminDescendants(adapter, vault, options.userId)\n if (descendants.length > 0) {\n if (cascadeMode === 'warn') {\n // Diagnostic mode: leave the descendants in place but make\n // them visible. The owner / a different admin can clean up\n // manually. The single console.warn is intentionally noisy\n // (a list, not a count) so the operator sees exactly which\n // keyrings will become orphans.\n console.warn(\n `[noy-db] revoke(${options.userId}): cascade='warn' — leaving ` +\n `${descendants.length} descendant admin(s) in place: ` +\n `${descendants.join(', ')}. These admins were granted by the revoked user ` +\n `(transitively) and will become orphans in the delegation tree.`,\n )\n } else {\n // Strict mode (default): pull every descendant into the\n // revoke set. We collect their affected collections too so\n // the single rotation pass at the end covers everything.\n for (const userId of descendants) {\n const descEnv = await adapter.get(vault, '_keyring', userId)\n if (!descEnv) continue\n const descKf = JSON.parse(descEnv._data) as KeyringFile\n usersToRevoke.push(userId)\n for (const c of Object.keys(descKf.deks)) affectedCollections.add(c)\n }\n }\n }\n }\n\n // Delete every keyring in the revoke set. Order doesn't matter\n // because each keyring file is independent on disk; we don't have\n // referential integrity to maintain across deletes.\n for (const userId of usersToRevoke) {\n await adapter.delete(vault, '_keyring', userId)\n // Cascade-delete the principal's user envelope. Idempotent — no\n // error when the envelope was never written (e.g. the user was\n // granted but never authenticated to write their own profile).\n await deleteUserEnvelope(adapter, vault, userId)\n // Also drop the visibility sidecar at `_meta/visibility/<userId>`.\n // If the same `userId` is re-granted later (rare for humans,\n // possible for service accounts and test fixtures), the new\n // principal must start with a fresh visibility state instead of\n // silently inheriting the revoked user's `hidden` flag.\n await deleteUserVisibility(adapter, vault, userId)\n }\n\n // Single rotation pass at the end. The cost is O(records in\n // affected collections), NOT O(records × cascade depth) — every\n // descendant's collections were unioned into `affectedCollections`\n // before we got here, so the rotation re-encrypts each affected\n // record exactly once regardless of how deep the cascade went.\n if (options.rotateKeys !== false && affectedCollections.size > 0) {\n await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections])\n }\n}\n\n// ─── Update User (#54) ─────────────────────────────────────────────────\n\n/**\n * Mutate `role`, `displayName`, and/or `permissions` on an existing\n * keyring. Pure plaintext-header rewrite — no DEK rewrap, no KEK\n * required, no authenticator slots touched. Tier-2 enrollments and\n * recovery codes survive the operation.\n *\n * Role-elevation guard: BOTH the old role AND the new role must\n * satisfy `canUpdateRole(callerRole, _)`. This blocks the two\n * privilege-escalation shapes:\n * - admin elevates someone (or themselves) to owner\n * - admin demotes an owner to a role they then control\n *\n * Owner is always allowed. Admin manages admin / operator / viewer /\n * client laterally.\n *\n * Identity preserved: same userId, same DEK wrappings. Last-write-wins\n * through the standard keyring put (same concurrency story as `grant`\n * and `revoke`).\n *\n * @throws `NoAccessError` when no keyring exists for the target.\n * @throws `PermissionDeniedError` when the role hierarchy rejects.\n * @throws `ValidationError` when the diff is empty (nothing to update).\n *\n * @see #54\n */\nexport async function updateKeyringIdentity(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: UpdateUserOptions,\n): Promise<void> {\n if (\n options.role === undefined &&\n options.displayName === undefined &&\n options.permissions === undefined\n ) {\n throw new ValidationError(\n `updateUser: at least one of role / displayName / permissions must be provided ` +\n `(userId: \"${options.userId}\").`,\n )\n }\n\n const env = await adapter.get(vault, '_keyring', options.userId)\n if (!env) {\n throw new NoAccessError(\n `updateUser: user \"${options.userId}\" has no keyring in vault \"${vault}\".`,\n )\n }\n const target = JSON.parse(env._data) as KeyringFile\n\n // Role-elevation guard. The OLD role must be one this caller is\n // allowed to manage, AND the NEW role (if changing) must be too.\n // Two-sided check: blocks admin→owner promotion (new side) and\n // demoting an owner (old side).\n if (!canUpdateRole(callerKeyring.role, target.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot update a keyring with role \"${target.role}\"`,\n )\n }\n if (\n options.role !== undefined &&\n options.role !== target.role &&\n !canUpdateRole(callerKeyring.role, options.role)\n ) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot promote target to role \"${options.role}\"`,\n )\n }\n\n const next: KeyringFile = {\n ...target,\n ...(options.role !== undefined && { role: options.role }),\n ...(options.displayName !== undefined && {\n // null clears the field (stored as \"\"); a string sets it.\n display_name: options.displayName ?? '',\n }),\n ...(options.permissions !== undefined && { permissions: options.permissions }),\n }\n\n await writeKeyringFile(adapter, vault, options.userId, next)\n}\n\n// ─── Key Rotation ──────────────────────────────────────────────────────\n\n/**\n * Rotate DEKs for specified collections:\n * 1. Generate new DEKs\n * 2. Re-encrypt all records in affected collections\n * 3. Re-wrap new DEKs for all remaining users\n */\nexport async function rotateKeys(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n collections: string[],\n): Promise<void> {\n // Generate new DEKs for each affected collection\n const newDeks = new Map<string, CryptoKey>()\n for (const collName of collections) {\n newDeks.set(collName, await generateDEK())\n }\n\n // Re-encrypt all records in affected collections\n for (const collName of collections) {\n const oldDek = callerKeyring.deks.get(collName)\n const newDek = newDeks.get(collName)!\n if (!oldDek) continue\n\n const ids = await adapter.list(vault, collName)\n for (const id of ids) {\n const envelope = await adapter.get(vault, collName, id)\n if (!envelope || !envelope._iv) continue\n\n // Decrypt with old DEK\n const plaintext = await decrypt(envelope._iv, envelope._data, oldDek)\n\n // Re-encrypt with new DEK\n const { iv, data } = await encrypt(plaintext, newDek)\n const newEnvelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: envelope._v,\n _ts: new Date().toISOString(),\n _iv: iv,\n _data: data,\n }\n await adapter.put(vault, collName, id, newEnvelope)\n }\n }\n\n // Update caller's keyring with new DEKs\n for (const [collName, newDek] of newDeks) {\n callerKeyring.deks.set(collName, newDek)\n }\n await persistKeyring(adapter, vault, callerKeyring)\n\n // Update all remaining users' keyrings with re-wrapped new DEKs\n const userIds = await adapter.list(vault, '_keyring')\n for (const userId of userIds) {\n if (userId === callerKeyring.userId) continue\n\n const userEnvelope = await adapter.get(vault, '_keyring', userId)\n if (!userEnvelope) continue\n\n const userKeyringFile = JSON.parse(userEnvelope._data) as KeyringFile\n // Note: we can't derive other users' KEKs to re-wrap DEKs for them.\n // Rotation requires users to re-unlock and be re-granted after the caller\n // re-wraps with the raw DEKs held in memory. See rotation flow below.\n // The trick: import the user's KEK from their salt? No — we need their passphrase.\n //\n // Per the spec: the caller (owner/admin) wraps the new DEKs with each remaining\n // user's KEK. But we can't derive their KEK without their passphrase.\n //\n // Real solution from the spec: the caller wraps the DEK using the approach of\n // reading each user's existing wrapping. Since we can't derive their KEK,\n // we use a RE-KEYING approach: the new DEK is wrapped with a key-wrapping-key\n // that we CAN derive — we use the existing wrapped DEK as proof that the user\n // had access, and we replace it with the new wrapped DEK.\n //\n // Practical approach: Since the owner/admin has all raw DEKs in memory,\n // and each user's keyring contains their salt, we need the users to\n // re-authenticate to get the new wrapped keys. This is the standard approach.\n //\n // For NOYDB Phase 2: we'll update the keyring file to include a \"pending_rekey\"\n // flag. Users will get new DEKs on next login when the owner provides them.\n //\n // SIMPLER approach used here: Since the owner performed the rotation,\n // the owner has both old and new DEKs. We store a \"rekey token\" that the\n // user can use to unwrap: we wrap the new DEK with the OLD DEK (which the\n // user can still unwrap from their keyring, since their keyring has the old\n // wrapped DEK and their KEK can unwrap it).\n\n // Actually even simpler: we just need the user's KEK. We don't have it.\n // The spec says the owner wraps new DEKs for each remaining user.\n // This requires knowing each user's KEK (or having a shared secret).\n //\n // The CORRECT implementation from the spec: the owner/admin has all DEKs.\n // Each user's keyring stores DEKs wrapped with THAT USER's KEK.\n // To re-wrap, we need each user's KEK — which we can't get.\n //\n // Real-world solution: use a KEY ESCROW approach where the owner stores\n // each user's wrapping key (not their passphrase, but a key derived from\n // the grant process). During grant, the owner stores a copy of the new user's\n // KEK (wrapped with the owner's KEK) so they can re-wrap later.\n //\n // For now: mark the user's keyring as needing rekey. The user will need to\n // re-authenticate (owner provides new passphrase or re-grants).\n\n // Update: simplest correct approach — during grant, we store the user's KEK\n // wrapped with the owner's KEK in a separate escrow field. Then during rotation,\n // the owner unwraps the user's KEK from escrow and wraps the new DEKs.\n //\n // BUT: that means we need to change the KeyringFile format.\n // For Phase 2 MVP: just delete the user's old DEK entries and require re-grant.\n // This is secure (revoked keys are gone) but inconvenient (remaining users\n // need re-grant for rotated collections).\n\n // PHASE 2 APPROACH: Remove the affected collection DEKs from remaining users'\n // keyrings. The owner must re-grant access to those collections.\n // This is correct and secure — just requires the owner to re-run grant().\n\n const updatedDeks = { ...userKeyringFile.deks }\n for (const collName of collections) {\n delete updatedDeks[collName]\n }\n\n const updatedPermissions = { ...userKeyringFile.permissions }\n for (const collName of collections) {\n delete updatedPermissions[collName]\n }\n\n const updatedKeyring: KeyringFile = {\n ...userKeyringFile,\n deks: updatedDeks,\n permissions: updatedPermissions,\n }\n\n await writeKeyringFile(adapter, vault, userId, updatedKeyring)\n }\n}\n\n// ─── Change Secret ─────────────────────────────────────────────────────\n\n/**\n * Change the user's passphrase. Re-wraps every DEK under the new KEK.\n *\n * Validates the new passphrase against the strength rules unless\n * `allowWeakPassphrase: true` is passed. Mirrors `rotatePassphrase`'s\n * default-on validation contract.\n *\n * `db.rotatePassphrase()` adds a `checkGate('rotate-passphrase')` step\n * on top of this primitive and additionally requires the OLD passphrase\n * for re-derivation; `changeSecret` reuses the cached unlocked KEK so\n * the OLD passphrase is not retyped.\n */\nexport async function changeSecret(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n newPassphrase: string,\n passphraseOpts?: PassphrasePolicy & { allowWeakPassphrase?: boolean },\n): Promise<UnlockedKeyring> {\n if (!passphraseOpts?.allowWeakPassphrase) {\n assertStrongPassphrase(newPassphrase, passphraseOpts)\n }\n const newSalt = generateSalt()\n const newKek = await deriveKey(newPassphrase, newSalt)\n\n // Re-wrap all DEKs with the new KEK\n const wrappedDeks: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n\n const canary = await mintKeyringCanary(newKek)\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: keyring.userId,\n display_name: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: keyring.userId,\n canary,\n }\n\n await writeKeyringFile(adapter, vault, keyring.userId, keyringFile)\n\n return {\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: keyring.deks, // Same DEKs, different wrapping\n kek: newKek,\n salt: newSalt,\n // Tier-2 slots are NOT preserved through `changeSecret` —\n // each slot wraps the OLD KEK, so the new keyring has no\n // authenticator slots until the user re-enrolls. The higher-level\n // `db.rotatePassphrase()` (#10) preserves slots by rewrapping the\n // KEK reference, not the KEK itself.\n authenticators: [],\n ...(keyring.policy !== undefined && { policy: keyring.policy }),\n }\n}\n\n// ─── Bundle recipients ──────────────────────────────────────────\n\n/**\n * Recipient slot in a re-keyed `.noydb` bundle. Each slot becomes its\n * own keyring file inside the bundle, sealed with its own passphrase.\n * Same role/permission semantics as `db.grant()` but no adapter side\n * effect — the slot only exists inside the bundle bytes.\n *\n * @public\n */\nexport interface BundleRecipient {\n /** User id stamped onto the keyring file in the bundle. */\n readonly id: string\n /** Optional display name. Defaults to `id`. */\n readonly displayName?: string\n /** Passphrase the recipient will type to unlock. */\n readonly passphrase: string\n /** Role on the destination vault. Defaults to `'viewer'`. */\n readonly role?: Role\n /**\n * Per-collection permissions. When omitted, role defaults apply.\n * Restricting permissions here ALSO restricts which DEKs are wrapped\n * into the slot — a slot with `{ invoices: 'ro' }` cannot decrypt\n * other collections even though their ciphertext sits in the bundle.\n */\n readonly permissions?: Permissions\n /**\n * Optional `as-*` export grants on the destination vault.\n * Mirrors the `exportCapability` field on a live keyring.\n */\n readonly exportCapability?: ExportCapability\n /**\n * Optional `as-*` import grants on the destination vault.\n * Mirrors the `importCapability` field on a live keyring.\n * Default-closed: no plaintext format granted, no bundle import.\n */\n readonly importCapability?: ImportCapability\n /**\n * Optional bundle-slot expiry. ISO-8601 timestamp; past the\n * cutoff this slot's keyring refuses to load with\n * `KeyringExpiredError`. Time-boxed audit access pattern: \"this\n * slot works for 30 days then becomes opaque to its holder.\"\n */\n readonly expiresAt?: string\n}\n\n/**\n * Build a `KeyringFile` for one bundle recipient, given the source\n * vault's unwrapped DEKs. Mirrors `grant()` minus the adapter write —\n * the produced file is meant to be embedded in the bundle's\n * `keyrings` map, never persisted to the source vault.\n *\n * Privilege-escalation check still runs: every DEK wrapped into the\n * recipient's keyring must come from the source's own DEK set.\n *\n * @internal\n */\nexport async function buildRecipientKeyringFile(\n callerKeyring: UnlockedKeyring,\n recipient: BundleRecipient,\n): Promise<KeyringFile> {\n if (!callerKeyring.kek) {\n throw new ValidationError(\n 'buildRecipientKeyringFile: caller keyring has no KEK — tier-2 wrap-DEKs ' +\n 'and tier-3 PIN-resume sessions cannot create bundle recipients. ' +\n 'Re-authenticate at tier 1 (passphrase) before building a bundle.',\n )\n }\n\n const role: Role = recipient.role ?? 'viewer'\n const permissions = resolvePermissions(role, recipient.permissions)\n\n const newSalt = generateSalt()\n const newKek = await deriveKey(recipient.passphrase, newSalt)\n\n const wrappedDeks: Record<string, string> = {}\n\n // Collections the recipient was explicitly granted permission to.\n for (const collName of Object.keys(permissions)) {\n const dek = callerKeyring.deks.get(collName)\n if (dek) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // owner / admin / viewer: wrap every known DEK (matches grant).\n if (role === 'owner' || role === 'admin' || role === 'viewer') {\n for (const [collName, dek] of callerKeyring.deks) {\n if (!(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n }\n\n // Always propagate system-prefixed collection DEKs (`_ledger`, etc.) —\n // the recipient needs them to verify the bundle on import.\n for (const [collName, dek] of callerKeyring.deks) {\n if (collName.startsWith('_') && !(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // Anti-privilege-escalation: every wrapped DEK must come from the\n // caller's own DEK set. Belt-and-braces with the lookups above.\n for (const collName of Object.keys(wrappedDeks)) {\n if (!callerKeyring.deks.has(collName)) {\n throw new PrivilegeEscalationError(collName)\n }\n }\n\n const canary = await mintKeyringCanary(newKek)\n return {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: recipient.id,\n display_name: recipient.displayName ?? recipient.id,\n role,\n permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: callerKeyring.userId,\n canary,\n ...(recipient.exportCapability !== undefined\n ? { export_capability: recipient.exportCapability }\n : {}),\n ...(recipient.importCapability !== undefined\n ? { import_capability: recipient.importCapability }\n : {}),\n ...(recipient.expiresAt !== undefined\n ? { expires_at: recipient.expiresAt }\n : {}),\n }\n}\n\n// ─── List Users ────────────────────────────────────────────────────────\n\n/** List all users with access to a vault. */\nexport async function listUsers(\n adapter: NoydbStore,\n vault: string,\n): Promise<UserInfo[]> {\n const userIds = await adapter.list(vault, '_keyring')\n const users: UserInfo[] = []\n\n for (const userId of userIds) {\n const envelope = await adapter.get(vault, '_keyring', userId)\n if (!envelope) continue\n const kf = JSON.parse(envelope._data) as KeyringFile\n users.push({\n userId: kf.user_id,\n displayName: kf.display_name,\n role: kf.role,\n permissions: kf.permissions,\n createdAt: kf.created_at,\n grantedBy: kf.granted_by,\n })\n }\n\n return users\n}\n\n/**\n * Optional filter knobs for {@link listUsersWithEnvelopes}.\n *\n * - `includeHidden` — when true, principals with `_meta/visibility/<id>`\n * set to `{ hidden: true }` are returned alongside everyone else.\n * Requires `owner` or `admin` callerRole; lower roles get\n * {@link import('../errors.js').PermissionDeniedError}.\n */\nexport interface ListUsersOptions {\n readonly includeHidden?: boolean\n}\n\n/**\n * Joined enumeration: every keyring + its `_users/<keyringId>`\n * envelope side by side. Convenience for admin UIs that want to\n * render team-member lists with profile data (\"Bob — operator —\n * 'Bob the Auditor' avatar X locale fr-FR\") in a single pass.\n *\n * `userEnvelopeDek` is the vault's `_users` collection DEK\n * (`vault.getDEK('_users')`); used to decrypt every envelope.\n *\n * `callerRole` (#122) drives the directory-visibility checks:\n *\n * - When the vault's `_meta/directory` document has `enabled: false`,\n * only `owner` and `admin` callers may enumerate; anyone else gets\n * {@link import('../errors.js').DirectoryDisabledError}.\n * - Principals with `_meta/visibility/<id>` set to `{ hidden: true }`\n * are filtered out by default. `owner`/`admin` callers can pass\n * `{ includeHidden: true }` to see them; lower roles passing that\n * option get `PermissionDeniedError`.\n *\n * Honest caveat (#122): these filters are a UX hint, not a security\n * boundary. The keyring file is still listed at `_keyring/*` and the\n * envelope ciphertext at `_users/*`. A caller with direct store access\n * — or a caller that calls this function with `callerRole: 'owner'`\n * unconditionally — sees every principal. The protection is only as\n * strong as the role the calling layer passes in. The hub-level wrapper\n * on `Vault` sources `callerRole` from the unlocked keyring's `role`\n * field, which is signed-by-construction (it lives in the user's own\n * keyring file). See `docs/subsystems/user-envelope.md` →\n * \"Directory visibility\".\n *\n * Principals without a persisted envelope (legacy keyrings predating\n * the user-envelope feature) come back with `envelope: null`. The\n * caller chooses how to render — usually \"fall back to keyring's\n * `displayName`\".\n *\n * Order matches `listUsers()` (store-defined; sort if you need a\n * stable display order).\n */\nexport async function listUsersWithEnvelopes<T = unknown>(\n adapter: NoydbStore,\n vault: string,\n userEnvelopeDek: CryptoKey,\n callerRole: Role,\n options: ListUsersOptions = {},\n): Promise<Array<{ user: UserInfo; envelope: UserEnvelopeReader<T> | null }>> {\n const isPrivileged = callerRole === 'owner' || callerRole === 'admin'\n\n // 1. Vault-level directory toggle.\n const dirConfig = await readDirectoryConfig(adapter, vault)\n if (dirConfig?.enabled === false && !isPrivileged) {\n throw new DirectoryDisabledError(vault)\n }\n\n // 2. `includeHidden` requires admin/owner.\n if (options.includeHidden && !isPrivileged) {\n throw new PermissionDeniedError(\n 'Permission denied — listUsersWithEnvelopes({ includeHidden: true }) requires owner or admin role',\n )\n }\n\n const users = await listUsers(adapter, vault)\n const out: Array<{ user: UserInfo; envelope: UserEnvelopeReader<T> | null }> = []\n for (const user of users) {\n if (!options.includeHidden) {\n const visibility = await readUserVisibility(adapter, vault, user.userId)\n if (visibility?.hidden) continue\n }\n const envelope = await loadUserEnvelopeFn<T>(\n adapter,\n vault,\n user.userId,\n userEnvelopeDek,\n )\n out.push({ user, envelope })\n }\n return out\n}\n\n\n// ─── DEK Management ────────────────────────────────────────────────────\n\n/** Ensure a DEK exists for a collection. Generates one if new. */\nexport async function ensureCollectionDEK(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<(collectionName: string) => Promise<CryptoKey>> {\n // Dedupe concurrent first-time DEK creates per collection. Without\n // this, two concurrent `getDEK('foo')` calls both pass the `existing`\n // check (the Map is empty), both generate fresh DEKs, and the second\n // `set` overwrites the first — making any envelope encrypted with\n // the discarded DEK fail to decrypt later (TamperedError on read).\n // Pre-existing race exposed by the multi-writer ledger work in #296.\n const inFlight = new Map<string, Promise<CryptoKey>>()\n return async (collectionName: string): Promise<CryptoKey> => {\n const existing = keyring.deks.get(collectionName)\n if (existing) return existing\n const pending = inFlight.get(collectionName)\n if (pending) return pending\n\n const promise = (async () => {\n const dek = await generateDEK()\n keyring.deks.set(collectionName, dek)\n await persistKeyring(adapter, vault, keyring)\n return dek\n })()\n inFlight.set(collectionName, promise)\n try {\n return await promise\n } finally {\n inFlight.delete(collectionName)\n }\n }\n}\n\n// ─── Permission Checks ─────────────────────────────────────────────────\n\n/** Check if a user has write permission for a collection. */\nexport function hasWritePermission(keyring: UnlockedKeyring, collectionName: string): boolean {\n if (keyring.role === 'owner' || keyring.role === 'admin') return true\n if (keyring.role === 'viewer' || keyring.role === 'client') return false\n return keyring.permissions[collectionName] === 'rw'\n}\n\n/** Check if a user has any access to a collection. */\nexport function hasAccess(keyring: UnlockedKeyring, collectionName: string): boolean {\n if (keyring.role === 'owner' || keyring.role === 'admin' || keyring.role === 'viewer') return true\n return collectionName in keyring.permissions\n}\n\n// ─── Helpers ───────────────────────────────────────────────────────────\n\n/** Persist a keyring file to the adapter. */\nexport async function persistKeyring(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<void> {\n if (!keyring.kek) {\n throw new ValidationError(\n 'persistKeyring: keyring.kek is null — cannot wrap DEKs without the KEK. ' +\n 'This typically means the keyring was opened via tier-3 PIN resume, ' +\n 'session restore, or a wrap-DEKs tier-2 unlock. Re-authenticate at ' +\n 'tier 1 (passphrase) before persisting.',\n )\n }\n const wrappedDeks: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n wrappedDeks[collName] = await wrapKey(dek, keyring.kek)\n }\n const canary = await mintKeyringCanary(keyring.kek)\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: keyring.userId,\n display_name: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(keyring.salt),\n created_at: new Date().toISOString(),\n granted_by: keyring.userId,\n canary,\n ...(keyring.exportCapability !== undefined && { export_capability: keyring.exportCapability }),\n ...(keyring.importCapability !== undefined && { import_capability: keyring.importCapability }),\n ...(keyring.authenticators.length > 0 && { authenticators: keyring.authenticators }),\n ...(keyring.policy !== undefined && { policy: keyring.policy }),\n }\n\n await writeKeyringFile(adapter, vault, keyring.userId, keyringFile)\n}\n\n// ─── Export capability ──────────────────────────────────────\n\n/**\n * Role-based default policy for the encrypted-bundle capability.\n *\n * Applied when `keyring.exportCapability` is absent or\n * `exportCapability.bundle` is undefined:\n *\n * - `owner` / `admin` → `true` (happy-path backup without friction)\n * - `operator` / `viewer` / `client` → `false` (explicit grant required)\n *\n * Rationale: a bundle is inert without the KEK, so an owner backing up\n * their own vault doesn't need friction; a non-admin role producing a\n * bundle for an external party does, because the bundle outlives\n * keyring revocation.\n */\nfunction defaultBundleCapability(role: Role): boolean {\n return role === 'owner' || role === 'admin'\n}\n\n/**\n * Check whether a keyring is authorised for a given `@noy-db/as-*`\n * export tier.\n *\n * - `tier: 'plaintext'` — returns true iff `exportCapability.plaintext`\n * contains the requested `format` or the `'*'` wildcard. Default for\n * every role is empty — no grant, no plaintext export.\n * - `tier: 'bundle'` — returns `exportCapability.bundle` if present, or\n * the role-based default otherwise (owner/admin → true, else false).\n *\n * `@noy-db/as-*` packages MUST call this before invoking the underlying\n * export primitive. Rogue forks that skip the check are caught by code\n * review — the single-entry-point contract is a convention, not a\n * runtime invariant. Vault-level gated wrappers\n * (`vault.exportRecords` / `exportBlobs` / `writeBundle`) will land in a\n * follow-up PR to enforce at the primitive level.\n */\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'bundle',\n): boolean\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n const cap = keyring.exportCapability\n if (tier === 'plaintext') {\n const allowed = cap?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n // tier === 'bundle'\n return cap?.bundle ?? defaultBundleCapability(keyring.role)\n}\n\n/**\n * Same-shape inspector for an `ExportCapability` value that isn't yet\n * attached to a keyring (e.g. for previewing a grant before applying).\n * Role must be supplied separately so bundle defaults can be computed.\n */\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'bundle',\n): boolean\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n if (tier === 'plaintext') {\n const allowed = capability?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n return capability?.bundle ?? defaultBundleCapability(role)\n}\n\n// ─── Import capability (issue ) ────────────────────────────────────\n\n/**\n * Check whether a keyring is authorised for a given `@noy-db/as-*`\n * import tier (issue ).\n *\n * - `tier: 'plaintext'` — true iff `importCapability.plaintext`\n * contains the requested `format` or the `'*'` wildcard.\n * - `tier: 'bundle'` — true iff `importCapability.bundle === true`.\n *\n * **Default-closed for every role on every dimension** — including\n * owner. Import is more dangerous than export (corrupts vs leaks), so\n * the policy refuses to assume intent. Owners must positively grant\n * the capability via `vault.grant({ importCapability: ... })`.\n */\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'bundle',\n): boolean\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n const cap = keyring.importCapability\n if (tier === 'plaintext') {\n const allowed = cap?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n // tier === 'bundle' — closed default for every role\n return cap?.bundle === true\n}\n\n/**\n * Same-shape inspector for an `ImportCapability` value that isn't yet\n * attached to a keyring (e.g. previewing a grant before applying).\n * `role` is accepted for symmetry with `evaluateExportCapability` even\n * though the import policy ignores it — bundle defaults are\n * role-agnostic and closed.\n */\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n role: Role,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n role: Role,\n tier: 'bundle',\n): boolean\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n _role: Role,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n if (tier === 'plaintext') {\n const allowed = capability?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n return capability?.bundle === true\n}\n\nfunction resolvePermissions(role: Role, explicit?: Permissions): Permissions {\n if (role === 'owner' || role === 'admin' || role === 'viewer') return {}\n return explicit ?? {}\n}\n\nasync function writeKeyringFile(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n keyringFile: KeyringFile,\n): Promise<void> {\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(keyringFile),\n }\n await adapter.put(vault, '_keyring', userId, envelope)\n}\n","/**\n * Tier-2 authenticator slot management — issue #11.\n *\n * Each slot independently wraps the SAME KEK under a method-specific\n * derived key (LUKS pattern). Enrolling adds a slot; removing drops\n * one. Both are constant-time keyring writes — no DEK re-keying.\n *\n * The crypto for each method lives in its `@noy-db/on-*` package\n * (`on-webauthn`, `on-oidc`, `on-password`); this module accepts the\n * package's `wrapped_kek` ciphertext + `meta` payload and persists it.\n *\n * @see docs/subsystems/session-tiers.md → Tier 2 — Authenticate\n *\n * @module\n */\nimport type { NoydbStore, KeyringAuthenticator } from '../types.js'\nimport { NoAccessError, ValidationError } from '../errors.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { persistKeyring } from './keyring.js'\n\n/** Fields shared across both wrap-KEK and wrap-DEKs enroll inputs. */\ninterface EnrollAuthenticatorBase {\n readonly id: string\n readonly method: KeyringAuthenticator['method']\n /** Method-specific metadata (cred id, salt, …). */\n readonly meta: Record<string, unknown>\n /** Tier the active session held when enrolling. Defaults to 1. */\n readonly enrolled_via_tier?: 1 | 2\n}\n\n/** Wrap-KEK enroll input (WebAuthn, OIDC). */\nexport interface EnrollAuthenticatorWrappingKEKOptions extends EnrollAuthenticatorBase {\n /** Already-wrapped KEK ciphertext (base64) — produced by the on-* package. */\n readonly wrapped_kek: string\n readonly wrapKind?: 'kek'\n}\n\n/** Wrap-DEKs enroll input (password, future on-* using the unified wrap-DEKs primitive). */\nexport interface EnrollAuthenticatorWrappingDEKsOptions extends EnrollAuthenticatorBase {\n readonly wrapKind: 'deks'\n /** Base64 AES-GCM ciphertext of `{ deks: { collection: base64rawDek } }`. */\n readonly wrapped_deks: string\n /** Base64 AES-GCM IV used for the `wrapped_deks` ciphertext. */\n readonly iv: string\n}\n\n/** Discriminated union over the two enroll input shapes. */\nexport type EnrollAuthenticatorOptions =\n | EnrollAuthenticatorWrappingKEKOptions\n | EnrollAuthenticatorWrappingDEKsOptions\n\n/**\n * Append a new authenticator slot to the keyring file. Throws\n * `ValidationError` if a slot with the same id already exists — the\n * caller decides whether to remove + re-enroll.\n *\n * Accepts either wrap-KEK (WebAuthn, OIDC) or wrap-DEKs (password)\n * input. The variant is preserved verbatim into `KeyringAuthenticator`.\n */\nexport async function enrollAuthenticator(\n store: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n options: EnrollAuthenticatorOptions,\n): Promise<UnlockedKeyring> {\n const existing = keyring.authenticators.find((a) => a.id === options.id)\n if (existing) {\n throw new ValidationError(\n `enrollAuthenticator: slot id \"${options.id}\" already exists in vault \"${vault}\". ` +\n 'Remove the slot first or pick a unique id.',\n )\n }\n\n const base = {\n id: options.id,\n method: options.method,\n enrolled_at: new Date().toISOString(),\n enrolled_via_tier: options.enrolled_via_tier ?? 1,\n meta: options.meta,\n } as const\n\n const slot: KeyringAuthenticator = options.wrapKind === 'deks'\n ? {\n ...base,\n wrapKind: 'deks',\n wrapped_deks: options.wrapped_deks,\n iv: options.iv,\n }\n : {\n ...base,\n wrapped_kek: options.wrapped_kek,\n }\n\n const next = appendSlot(keyring, slot)\n await persistKeyring(store, vault, next)\n return next\n}\n\n/**\n * Caller payload for {@link updateAuthenticator} (#55). Mutates only\n * `meta` — the slot's id, method, and wrap material are immutable\n * through this primitive, preserving the anti-slot-swap guard.\n *\n * `meta` is **merged** at the top level: keys absent from the patch\n * are preserved, keys present overwrite. To clear a meta key, pass\n * `null` for that key explicitly. (Same semantics as #57's\n * `UserApi.updateMe`, scoped to this top-level merge — no recursion\n * into nested meta values.)\n */\nexport interface UpdateAuthenticatorOptions {\n readonly meta?: Record<string, unknown>\n}\n\n/**\n * Mutate a tier-2 authenticator slot's `meta` blob (slot rename,\n * label changes). The slot's `id`, `method`, and wrap material\n * (`wrapped_kek` for wrap-KEK; `wrapped_deks` + `iv` for wrap-DEKs)\n * are immutable through this entry point — the anti-slot-swap guard\n * is structural, not gate-driven, so even if the policy gate is\n * weakened a future caller cannot use this path to swap one slot's\n * crypto for another's.\n *\n * `meta` patch semantics:\n * - Top-level merge — absent keys preserved, present keys overwrite\n * - `null` value — delete that meta key\n * - Non-object values (string, number, boolean, array) — replace verbatim\n *\n * @throws `NoAccessError` when no slot with the given id exists.\n * @throws `ValidationError` when no patch field is provided.\n *\n * @see #55\n */\nexport async function updateAuthenticator(\n store: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n slotId: string,\n options: UpdateAuthenticatorOptions,\n): Promise<UnlockedKeyring> {\n if (options.meta === undefined) {\n throw new ValidationError(\n `updateAuthenticator: at least one of meta must be provided ` +\n `(slotId: \"${slotId}\").`,\n )\n }\n\n const idx = keyring.authenticators.findIndex((a) => a.id === slotId)\n if (idx === -1) {\n throw new NoAccessError(\n `updateAuthenticator: slot \"${slotId}\" not found in vault \"${vault}\".`,\n )\n }\n const existing = keyring.authenticators[idx]!\n\n // Merge at the top level. Absent keys preserved (same as #57's\n // updateMe semantics, but non-recursive — meta is a flat label\n // bag in practice, no consumer nests it).\n const mergedMeta: Record<string, unknown> = { ...existing.meta }\n for (const [k, v] of Object.entries(options.meta)) {\n if (v === undefined) continue // skip\n if (v === null) {\n delete mergedMeta[k]\n continue\n }\n mergedMeta[k] = v\n }\n\n // Reconstruct the slot preserving wrapKind discrimination. The\n // immutable fields (id, method, wrapped_kek / wrapped_deks + iv,\n // enrolled_at, enrolled_via_tier) all flow through ...existing.\n const next: KeyringAuthenticator = { ...existing, meta: mergedMeta }\n const nextSlots = [...keyring.authenticators]\n nextSlots[idx] = next\n\n const nextKeyring: UnlockedKeyring = {\n ...keyring,\n authenticators: nextSlots,\n }\n await persistKeyring(store, vault, nextKeyring)\n return nextKeyring\n}\n\n/**\n * Drop a slot by id. No-op if the slot doesn't exist (idempotent —\n * removing a non-existent slot is a recoverable retry, not an error).\n */\nexport async function removeAuthenticator(\n store: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n slotId: string,\n): Promise<UnlockedKeyring> {\n const filtered = keyring.authenticators.filter((a) => a.id !== slotId)\n if (filtered.length === keyring.authenticators.length) {\n return keyring // idempotent — nothing to do\n }\n const next: UnlockedKeyring = {\n ...keyring,\n authenticators: filtered,\n }\n await persistKeyring(store, vault, next)\n return next\n}\n\n/**\n * Look up a slot by id. Returns `undefined` when no slot matches.\n * Used by tier-2 unlock dispatchers to fetch the wrapped KEK + meta\n * before invoking the method-specific verifier.\n */\nexport function findAuthenticator(\n keyring: UnlockedKeyring,\n slotId: string,\n): KeyringAuthenticator | undefined {\n return keyring.authenticators.find((a) => a.id === slotId)\n}\n\nfunction appendSlot(\n keyring: UnlockedKeyring,\n slot: KeyringAuthenticator,\n): UnlockedKeyring {\n return {\n ...keyring,\n authenticators: [...keyring.authenticators, slot],\n }\n}\n","import { NoydbError } from '../errors.js'\nimport type { GateName, GatePolicy } from './types.js'\n\n/**\n * Why a gate denied a request. Stable across hub versions so consumers\n * can switch on the value in error UIs.\n */\nexport type PolicyDenyReason =\n | 'insufficient-tier'\n | 'missing-factor'\n | 'stale-proof'\n | 'disabled'\n | 'shared-device-blocked'\n\n/**\n * Thrown by {@link checkGate} when the active session does not meet\n * the gate's requirements. Carries the gate name, the reason, and the\n * full required {@link GatePolicy} so error UIs can prompt the user\n * for the missing factor without re-reading the policy document.\n */\nexport class PolicyDeniedError extends NoydbError {\n readonly gate: GateName\n readonly reason: PolicyDenyReason\n readonly required: GatePolicy\n constructor(gate: GateName, reason: PolicyDenyReason, required: GatePolicy, message?: string) {\n super(\n 'POLICY_DENIED',\n message ?? `Gate \"${gate}\" denied: ${reason}.`,\n )\n this.name = 'PolicyDeniedError'\n this.gate = gate\n this.reason = reason\n this.required = required\n }\n}\n\n/**\n * Raised by `createNoydb({ ... })` when the developer omits a recovery\n * profile and `recover-passphrase` is not explicitly disabled. Vaults\n * MUST have at least one recovery path enrolled before being\n * production-ready (paper, shamir, multi-channel, or admin-mediated).\n *\n * The error references issue #10 in its message so a developer hitting\n * it gets a one-line pointer to the design.\n */\nexport class RecoveryNotEnrolledError extends NoydbError {\n constructor(\n message =\n 'Recovery profile not enrolled. Pass `recovery: [{ profile: \"paper\", codes: 10 }]` ' +\n 'to `createNoydb()`, or set `policy.gates[\"recover-passphrase\"].enabled = false` to ' +\n 'opt out of recovery (passphrase loss = data loss). See docs/subsystems/session-tiers.md.',\n ) {\n super('RECOVERY_NOT_ENROLLED', message)\n this.name = 'RecoveryNotEnrolledError'\n }\n}\n\n/**\n * Raised by `openVault` when a managed-passphrase-mode vault has no\n * STRONG recovery profile enrolled (#195).\n *\n * Managed mode means the user never types a passphrase — the unlock\n * material lives in a `SealingKeyProvider` (`at-*` package). If that\n * provider's key is lost AND no strong recovery is enrolled, the\n * vault is irrecoverable. To prevent that footgun, managed-mode vaults\n * require at least one strong recovery profile (Shamir today;\n * multi-channel / admin-mediated when those ship).\n *\n * Paper recovery alone is NOT strong under managed mode: the user has\n * no memorized passphrase to fall back on, so losing the paper sheet =\n * losing every record permanently.\n *\n * Bootstrap with `db.openVaultAndEnrollRecovery(vault, { recovery: [{ profile: \"shamir\", k, n }] })`\n * to atomically create-and-enroll, or call `db.enrollRecovery(vault, { profile: \"shamir\", ... })`\n * separately before re-attempting `openVault`.\n */\nexport class ManagedRecoveryNotEnrolledError extends NoydbError {\n readonly vault: string\n constructor(vault: string) {\n super(\n 'MANAGED_RECOVERY_NOT_ENROLLED',\n `Managed-mode vault \"${vault}\" requires at least one strong recovery profile `\n + '(Shamir today; multi-channel / admin-mediated when they ship). Paper alone is '\n + 'NOT strong under managed mode — losing the paper sheet would mean losing every '\n + 'record permanently. '\n + `Bootstrap with \\`db.openVaultAndEnrollRecovery(\"${vault}\", { recovery: [{ profile: \"shamir\", k: 2, n: 3 }] })\\`, `\n + 'or call `db.enrollRecovery(vault, { profile: \"shamir\", k, n })` separately, '\n + 'then re-attempt `openVault`.',\n )\n this.name = 'ManagedRecoveryNotEnrolledError'\n this.vault = vault\n }\n}\n\n/**\n * Raised by `db.recoverPassphrase` / `db.enrollRecovery` /\n * `db.rotateRecovery` when the developer requests a recovery profile\n * not yet wired in this hub release.\n *\n * Implemented: `paper` (#10, pre.5) and `shamir` (#196 slice 1, pre.16).\n * Pending: `multi-channel` and `admin-mediated` (tracked under #196\n * follow-up slices).\n *\n * The carried `profile` and `tracking` fields let consumers steer the\n * UI (\"multi-channel recovery is not yet wired up — open issue #N to follow\").\n */\nexport class RecoveryProfileNotImplementedError extends NoydbError {\n readonly profile: string\n readonly tracking: string\n constructor(profile: string, tracking: string) {\n super(\n 'RECOVERY_PROFILE_NOT_IMPLEMENTED',\n `Recovery profile \"${profile}\" is not yet implemented in this hub release. ` +\n `Tracking: ${tracking}. Use the \"paper\" profile via @noy-db/on-recovery in the meantime.`,\n )\n this.name = 'RecoveryProfileNotImplementedError'\n this.profile = profile\n this.tracking = tracking\n }\n}\n","/**\n * **Wrap-DEKs primitive (#44)** — a single canonical shape for the\n * pattern of \"serialize a DEK set, encrypt it under a credential-derived\n * AES-GCM key.\" Used by:\n *\n * - **tier-0** — paper recovery entries (`_meta/recovery-paper`),\n * credential = the printed code.\n * - **tier-2** — password authenticator slots (`KeyringFile.authenticators`,\n * `wrapKind: 'deks'`), credential = the user's password.\n *\n * **Not** used by `@noy-db/on-pin` — tier-3 wraps the DEK set under\n * the same conceptual pattern but at **100,000 PBKDF2 iterations**\n * (vs the 600,000 here), because the protection window for a PIN\n * slot is short (idle-timeout-bounded, typically 15 min) and 600k\n * iterations would make every PIN-resume noticeably slow. The wire\n * formats are deliberately incompatible. See `@noy-db/on-pin`'s\n * `PIN_PBKDF2_ITERATIONS` and the threat-model rationale in its\n * module docstring.\n *\n * Before #44, the same crypto lived in two places: `mintPaperRecoveryEntry`\n * (in `team/recovery.ts`) and `enrollPasswordAuthenticator` (in\n * `@noy-db/on-password`). Both functions did identical work — PBKDF2\n * the credential, AES-GCM-encrypt the JSON-serialized DEK set — but\n * their implementations had drifted apart enough that fixing a bug\n * in one wouldn't fix the other.\n *\n * This module owns the canonical implementation. Consumers compose:\n *\n * - `mintPaperRecoveryEntry` is now a thin wrapper that calls\n * `mintWrappedDeksBlob` and adds `{ codeId, enrolledAt }`.\n * - `enrollPasswordAuthenticator` calls `mintWrappedDeksBlob` and\n * wraps the result in the slot envelope.\n *\n * @module\n */\n\nconst PBKDF2_ITERATIONS = 600_000\nconst SALT_BYTES = 32\nconst IV_BYTES = 12\n\nconst subtle = globalThis.crypto.subtle\n\n// ─── Type ──────────────────────────────────────────────────────────────\n\n/**\n * The wrap-DEKs primitive — a serialized + AES-GCM-encrypted DEK set\n * keyed under a credential-derived key.\n *\n * All three fields are base64-encoded so the blob is JSON-safe and\n * round-trips through `_meta/*` envelopes (which carry plaintext\n * JSON in `_data`).\n *\n * Composition: `PaperRecoveryEntry extends WrappedDeksBlob` plus\n * `{ codeId, enrolledAt }`. `KeyringAuthenticatorWrappingDEKs`\n * carries the same three fields with `salt` stored in `meta` for\n * slot-format back-compat (#44 defers moving it to top-level).\n */\nexport interface WrappedDeksBlob {\n /** Base64 PBKDF2 salt for the credential-derived wrapping key. */\n readonly salt: string\n /** Base64 AES-GCM IV used for the `wrappedDeks` ciphertext. */\n readonly iv: string\n /** Base64 AES-GCM ciphertext of `{ deks: { collection: base64rawDek } }`. */\n readonly wrappedDeks: string\n}\n\n// ─── Mint ──────────────────────────────────────────────────────────────\n\n/**\n * Mint a fresh `WrappedDeksBlob` from a DEK set + a string credential.\n *\n * Generates a random salt + IV, derives a 256-bit AES-GCM key via\n * PBKDF2-SHA256(credential, salt, 600K), serializes the DEK set as\n * `{ deks: { coll: rawBase64 } }`, and AES-GCM-encrypts.\n *\n * The `credential` is the user-typed string (recovery code, password,\n * PIN). Caller normalization rules apply (e.g. paper\n * recovery uppercase-strips the code before reaching this function).\n *\n * @param deks - DEK set to wrap. Each DEK must be exportable via\n * `subtle.exportKey('raw', dek)` (the hub mints DEKs\n * this way; consumers feeding non-extractable keys\n * will get `InvalidAccessError` from WebCrypto).\n * @param credential - String input the consumer minted (paper code,\n * password, PIN). Treated as opaque bytes by PBKDF2.\n */\nexport async function mintWrappedDeksBlob(\n deks: Map<string, CryptoKey>,\n credential: string,\n): Promise<WrappedDeksBlob> {\n const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))\n const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))\n const wrappingKey = await deriveWrappingKey(credential, salt)\n\n // Serialize the DEK set as JSON `{ deks: { collection: base64 } }`.\n const exported: Record<string, string> = {}\n for (const [coll, dek] of deks) {\n const raw = await subtle.exportKey('raw', dek)\n exported[coll] = bytesToBase64(new Uint8Array(raw))\n }\n const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }))\n const ciphertext = await subtle.encrypt(\n { name: 'AES-GCM', iv: iv as BufferSource },\n wrappingKey,\n plaintext as BufferSource,\n )\n\n return {\n salt: bytesToBase64(salt),\n iv: bytesToBase64(iv),\n wrappedDeks: bytesToBase64(new Uint8Array(ciphertext)),\n }\n}\n\n// ─── Unwrap ────────────────────────────────────────────────────────────\n\n/**\n * Reverse of {@link mintWrappedDeksBlob}. Re-derives the wrapping key\n * from the credential + stored salt, AES-GCM-decrypts the wrapped DEK\n * set, and re-imports each DEK as an extractable AES-GCM CryptoKey.\n *\n * Throws (AES-GCM auth tag failure) when the credential doesn't\n * match the blob. Callers iterating over multiple blobs (e.g. paper\n * recovery's \"try every entry until one matches\") should catch.\n */\nexport async function unwrapDeksFromBlob(\n blob: WrappedDeksBlob,\n credential: string,\n): Promise<Map<string, CryptoKey>> {\n const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt))\n const plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBytes(blob.iv) as BufferSource },\n wrappingKey,\n base64ToBytes(blob.wrappedDeks) as BufferSource,\n )\n const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as { deks: Record<string, string> }\n const deks = new Map<string, CryptoKey>()\n for (const [coll, b64] of Object.entries(parsed.deks)) {\n const raw = base64ToBytes(b64)\n const key = await subtle.importKey(\n 'raw',\n raw as BufferSource,\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(coll, key)\n }\n return deks\n}\n\n// ─── Internals ─────────────────────────────────────────────────────────\n\nasync function deriveWrappingKey(credential: string, salt: Uint8Array): Promise<CryptoKey> {\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(credential),\n 'PBKDF2',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt as BufferSource,\n iterations: PBKDF2_ITERATIONS,\n hash: 'SHA-256',\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\nfunction bytesToBase64(b: Uint8Array): string {\n let s = ''\n for (const x of b) s += String.fromCharCode(x)\n return btoa(s)\n}\n\nfunction base64ToBytes(b64: string): Uint8Array {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n","/**\n * Recovery profile persistence + dispatch — issue #10.\n *\n * v0.1.0-pre.5 wires the **paper** profile end-to-end through\n * `@noy-db/on-recovery`. The other three profiles (Shamir,\n * multi-channel, admin-mediated) ship the API surface and throw\n * {@link RecoveryProfileNotImplementedError} during use; per-profile\n * dispatch lands in follow-up issues.\n *\n * Storage layout:\n *\n * ```\n * _meta/recovery-paper — JSON { entries: RecoveryCodeEntry[] } produced by `on-recovery`.\n * _meta/recovery-shamir — reserved\n * _meta/recovery-multi — reserved\n * _meta/recovery-admin — reserved\n * ```\n *\n * Like `_meta/policy` and `_meta/handle`, the documents are plain JSON\n * with empty `_iv` — the recovery-code wrapping is what protects the\n * KEK; the entries themselves are inert without the user's code.\n *\n * @module\n */\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../types.js'\nimport {\n mintWrappedDeksBlob,\n unwrapDeksFromBlob,\n type WrappedDeksBlob,\n} from './wrapped-deks.js'\nimport type { ShamirRecoveryProvider } from './shamir-recovery-provider.js'\n\n/**\n * One paper recovery code as persisted in `_meta/recovery-paper`.\n *\n * The hub's KEK is intentionally non-extractable (see `crypto.ts`),\n * so the recovery entry can't AES-KW-wrap the KEK directly. Instead\n * we wrap a serialized DEK set: the entry holds the AES-GCM\n * ciphertext of `{ deks: { collection: rawDekBase64 } }`. Recovery\n * deserializes the DEK set, then mints a fresh KEK from the new\n * passphrase and rewraps the DEKs under it.\n *\n * This is the same pattern `@noy-db/on-pin` uses for tier-3 quick\n * resume — the cryptographic guarantee is identical (AES-GCM with a\n * PBKDF2-derived key), and it sidesteps the non-extractable-KEK\n * constraint cleanly.\n *\n * Type-level composition (#44): `PaperRecoveryEntry extends\n * WrappedDeksBlob` — the three crypto fields (`salt`, `iv`,\n * `wrappedDeks`) come from the shared primitive; `codeId` and\n * `enrolledAt` are paper-recovery's own metadata. Wire format\n * unchanged.\n */\nexport interface PaperRecoveryEntry extends WrappedDeksBlob {\n readonly codeId: string\n readonly enrolledAt: string\n}\n\nexport interface PaperRecoveryDoc {\n readonly _noydb_recovery: 1\n readonly profile: 'paper'\n readonly entries: ReadonlyArray<PaperRecoveryEntry>\n}\n\nconst PAPER_DOC_ID = 'recovery-paper'\n\n/** Read the paper-recovery entries. Returns empty array when absent. */\nexport async function loadPaperRecoveryEntries(\n store: NoydbStore,\n vault: string,\n): Promise<ReadonlyArray<PaperRecoveryEntry>> {\n const env = await store.get(vault, '_meta', PAPER_DOC_ID)\n if (!env) return []\n try {\n const doc = JSON.parse(env._data) as PaperRecoveryDoc\n if (doc.profile !== 'paper' || !Array.isArray(doc.entries)) return []\n return doc.entries\n } catch {\n return []\n }\n}\n\n/** Replace the paper-recovery entries (used after burn-on-recovery). */\nexport async function savePaperRecoveryEntries(\n store: NoydbStore,\n vault: string,\n entries: ReadonlyArray<PaperRecoveryEntry>,\n): Promise<void> {\n const doc: PaperRecoveryDoc = {\n _noydb_recovery: 1,\n profile: 'paper',\n entries,\n }\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(doc),\n }\n await store.put(vault, '_meta', PAPER_DOC_ID, envelope)\n}\n\n/** Drop a single paper-recovery entry (burn-on-use). */\nexport async function burnPaperRecoveryEntry(\n store: NoydbStore,\n vault: string,\n codeId: string,\n): Promise<void> {\n const entries = await loadPaperRecoveryEntries(store, vault)\n const remaining = entries.filter((e) => e.codeId !== codeId)\n await savePaperRecoveryEntries(store, vault, remaining)\n}\n\n/** Whether at least one recovery profile has any enrolled entries. */\nexport async function hasRecoveryEnrolled(\n store: NoydbStore,\n vault: string,\n): Promise<boolean> {\n const paper = await loadPaperRecoveryEntries(store, vault)\n if (paper.length > 0) return true\n const shamir = await loadShamirRecoveryEntries(store, vault)\n return shamir.length > 0\n}\n\n/**\n * Whether at least one **strong** recovery profile is enrolled (#195).\n *\n * \"Strong\" excludes paper-alone — under managed-passphrase mode the\n * user has no memorized passphrase, so a stolen/lost paper sheet\n * would be a single point of total loss. Strong profiles today:\n *\n * - `shamir` (k-of-n threshold; survives loss of up to n-k shares)\n * - `multi-channel` (when shipped — #196 follow-up slice)\n * - `admin-mediated` (when shipped — #196 follow-up slice)\n *\n * Managed mode requires this check to pass before `openVault` returns.\n */\nexport async function hasStrongRecoveryEnrolled(\n store: NoydbStore,\n vault: string,\n): Promise<boolean> {\n const shamir = await loadShamirRecoveryEntries(store, vault)\n return shamir.length > 0\n // When multi-channel / admin-mediated land, extend this check.\n}\n\n// ─── Shamir recovery (#196 slice 1) ──────────────────────────────────────\n\n/**\n * One Shamir-recovery entry as persisted in `_meta/recovery-shamir`.\n *\n * Like {@link PaperRecoveryEntry}, the entry composes\n * {@link WrappedDeksBlob} (DEKs wrapped under a fresh ephemeral\n * recovery secret) with profile-specific metadata. Unlike paper, the\n * \"credential\" was never visible to the user — it was 32 random\n * bytes split into N Shamir shares at enrollment. The shares ARE\n * the credential; the user holds them, the hub never sees them\n * again after `enrollRecovery` returns.\n *\n * Per the spec §5: the recovery secret is base64-encoded and\n * passed as the `credential` arg to\n * {@link mintWrappedDeksBlob} / {@link unwrapDeksFromBlob}. The\n * PBKDF2 round over high-entropy input is harmless overhead — it\n * keeps the shared primitive unchanged while letting Shamir reuse\n * the same wrapping pipeline as paper.\n */\nexport interface ShamirRecoveryEntry extends WrappedDeksBlob {\n /** Stable id for this entry. Allows multiple Shamir splits to coexist. */\n readonly entryId: string\n /** Threshold — minimum shares to reconstruct. */\n readonly k: number\n /** Total shares minted at enrollment. */\n readonly n: number\n /** x-coordinates of the n minted shares. Informational. Omitted as of 0.2\n * (string-level provider doesn't expose share x-coords); kept optional so\n * pre-0.2 entries still read. */\n readonly xCoords?: ReadonlyArray<number>\n /** ISO timestamp. */\n readonly enrolledAt: string\n /** Optional caller-supplied label (e.g., \"2-of-3 board escrow\"). */\n readonly label?: string\n}\n\nexport interface ShamirRecoveryDoc {\n readonly _noydb_recovery: 1\n readonly profile: 'shamir'\n readonly entries: ReadonlyArray<ShamirRecoveryEntry>\n}\n\nconst SHAMIR_DOC_ID = 'recovery-shamir'\n\n/** Read the Shamir-recovery entries. Returns empty array when absent. */\nexport async function loadShamirRecoveryEntries(\n store: NoydbStore,\n vault: string,\n): Promise<ReadonlyArray<ShamirRecoveryEntry>> {\n const env = await store.get(vault, '_meta', SHAMIR_DOC_ID)\n if (!env) return []\n try {\n const doc = JSON.parse(env._data) as ShamirRecoveryDoc\n if (doc.profile !== 'shamir' || !Array.isArray(doc.entries)) return []\n return doc.entries\n } catch {\n return []\n }\n}\n\n/** Replace the Shamir-recovery entries (used by enrollment and rotation). */\nexport async function saveShamirRecoveryEntries(\n store: NoydbStore,\n vault: string,\n entries: ReadonlyArray<ShamirRecoveryEntry>,\n): Promise<void> {\n const doc: ShamirRecoveryDoc = {\n _noydb_recovery: 1,\n profile: 'shamir',\n entries,\n }\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(doc),\n }\n await store.put(vault, '_meta', SHAMIR_DOC_ID, envelope)\n}\n\n/**\n * Mint a fresh Shamir recovery entry from a DEK set.\n *\n * 1. Generates a 32-byte recovery secret.\n * 2. Wraps the DEK set under that secret via\n * {@link mintWrappedDeksBlob} (the recovery secret is base64-\n * encoded as the credential string — PBKDF2 over high-entropy\n * input is harmless overhead).\n * 3. Splits the recovery secret via Shamir into `n` shares with\n * threshold `k`.\n * 4. Zeros the in-memory recovery secret after wrapping + splitting.\n *\n * Returns:\n * - `entry` — the {@link ShamirRecoveryEntry} to persist.\n * - `shareStrings` — the `n` Base32-encoded share strings to\n * return to the caller. The HUB MUST NOT PERSIST THESE; once\n * returned they are the user's responsibility.\n *\n * @param deks - DEK set to wrap.\n * @param entryId - Stable id for this entry (caller-supplied or\n * hub-generated).\n * @param k - Threshold (>= 2).\n * @param n - Total shares (k <= n <= 255).\n * @param label - Optional caller label.\n */\nexport async function mintShamirRecoveryEntry(\n provider: ShamirRecoveryProvider,\n deks: Map<string, CryptoKey>,\n entryId: string,\n k: number,\n n: number,\n label?: string,\n): Promise<{ entry: ShamirRecoveryEntry; shareStrings: string[] }> {\n const recoverySecret = crypto.getRandomValues(new Uint8Array(32))\n try {\n const credential = bytesToBase64(recoverySecret)\n const blob = await mintWrappedDeksBlob(deks, credential)\n const shareStrings = provider.splitToShares(recoverySecret, k, n)\n const entry: ShamirRecoveryEntry = {\n ...blob, entryId, k, n,\n enrolledAt: new Date().toISOString(),\n ...(label !== undefined && { label }),\n }\n return { entry, shareStrings }\n } finally {\n recoverySecret.fill(0)\n }\n}\n\n/**\n * Decrypt a Shamir recovery entry to recover the raw DEK set.\n *\n * Combines K or more `shares`, reconstructs the recovery secret,\n * unwraps the DEKs via {@link unwrapDeksFromBlob}.\n *\n * Throws (AES-GCM auth-tag mismatch) when the shares don't combine\n * to the secret originally used to mint the entry — typically\n * because they came from a different enrollment or were tampered\n * with. Callers iterating multiple entries should catch.\n */\nexport async function unwrapDeksFromShamirEntry(\n provider: ShamirRecoveryProvider,\n entry: ShamirRecoveryEntry,\n shareStrings: readonly string[],\n): Promise<Map<string, CryptoKey>> {\n if (shareStrings.length < entry.k) {\n throw new Error(\n `Insufficient shares: this Shamir entry needs ${entry.k} of ${entry.n}, `\n + `but ${shareStrings.length} were provided.`,\n )\n }\n const secret = provider.combineShares(shareStrings)\n try {\n return await unwrapDeksFromBlob(entry, bytesToBase64(secret))\n } finally {\n secret.fill(0)\n }\n}\n\nfunction bytesToBase64(b: Uint8Array): string {\n let s = ''\n for (const x of b) s += String.fromCharCode(x)\n return btoa(s)\n}\n\n/**\n * Generate one paper-recovery entry from an unlocked DEK set.\n *\n * Returns the serializable entry (persisted via\n * {@link savePaperRecoveryEntries}). The recovery flow unwraps the\n * DEK set, then mints a fresh KEK from the user's new passphrase.\n *\n * Thin wrapper over {@link mintWrappedDeksBlob} (#44) — the crypto\n * lives in the shared primitive; this function just adds paper-\n * recovery's own metadata (`codeId`, `enrolledAt`).\n *\n * @param deks Map of collection-name → DEK (extractable).\n * @param code The plaintext recovery code (caller-supplied;\n * pair this with `@noy-db/on-recovery`'s code\n * generator/parser if available).\n * @param codeId Stable id used by `burnPaperRecoveryEntry`.\n */\nexport async function mintPaperRecoveryEntry(\n deks: Map<string, CryptoKey>,\n code: string,\n codeId: string,\n): Promise<PaperRecoveryEntry> {\n const blob = await mintWrappedDeksBlob(deks, code)\n return {\n ...blob,\n codeId,\n enrolledAt: new Date().toISOString(),\n }\n}\n\n/**\n * Decrypt a recovery entry to recover the raw DEK set. Used by the\n * `recoverPassphrase` flow after the user's code has been parsed.\n *\n * Thin wrapper over {@link unwrapDeksFromBlob} (#44).\n *\n * @throws when the code does not match the entry (AES-GCM auth tag fail).\n */\nexport async function unwrapDeksFromPaperEntry(\n entry: PaperRecoveryEntry,\n code: string,\n): Promise<Map<string, CryptoKey>> {\n return unwrapDeksFromBlob(entry, code)\n}\n\n// Legacy crypto helpers (deriveRecoveryWrappingKey, bytesToBase64,\n// base64ToBytes) were inlined here pre-#44. They now live in the\n// canonical wrap-DEKs primitive at `./wrapped-deks.ts` and are\n// reached via `mintWrappedDeksBlob` / `unwrapDeksFromBlob`.\n","/**\n * Tier-1 change flows — `rotatePassphrase` (user remembers old) and\n * `recoverPassphrase` (user supplies a recovery proof). Issue #10.\n *\n * The two flows share the post-verification half — fresh salt, fresh\n * KEK, rewrap every DEK — and differ only in how they re-derive the\n * old KEK:\n *\n * - **Rotate**: derive from the supplied `oldPassphrase`.\n * - **Recover (paper)**: unwrap from a `RecoveryCodeEntry` using a\n * user-supplied recovery code. The entry is burned on success.\n *\n * The non-paper recovery profiles (Shamir, multi-channel,\n * admin-mediated) are not yet wired — calling them throws\n * {@link RecoveryProfileNotImplementedError} with a tracking link.\n *\n * @module\n */\nimport type { NoydbStore, KeyringFile } from '../types.js'\nimport { NOYDB_KEYRING_VERSION } from '../types.js'\nimport {\n deriveKey,\n generateSalt,\n wrapKey,\n unwrapKey,\n bufferToBase64,\n base64ToBuffer,\n} from '../crypto.js'\nimport { InvalidKeyError, NoAccessError } from '../errors.js'\nimport {\n RecoveryProfileNotImplementedError,\n} from '../policy/errors.js'\nimport {\n loadPaperRecoveryEntries,\n burnPaperRecoveryEntry,\n unwrapDeksFromPaperEntry,\n loadShamirRecoveryEntries,\n unwrapDeksFromShamirEntry,\n type PaperRecoveryEntry,\n type ShamirRecoveryEntry,\n} from './recovery.js'\nimport type { ShamirRecoveryProvider } from './shamir-recovery-provider.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../validation.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { mintKeyringCanary } from './keyring.js'\nimport type { KeyringAuthenticator } from '../types.js'\nimport type { EnrollAuthenticatorOptions } from './authenticators.js'\nimport { ValidationError } from '../errors.js'\n\n/**\n * Context handed to a {@link SlotRewrapCeremony} when `rotatePassphrase`\n * preserves a tier-2 slot. The ceremony's job is to re-derive its\n * method-specific wrapping material (PRF assertion, PBKDF2 of the\n * password, etc.) and wrap the freshly rewrapped DEK set under\n * the new wrapping key.\n *\n * Two surfaces are exposed:\n *\n * - `newDeks` — the rewrapped (extractable) DEK set the slot will\n * wrap. This is what `mintPaperRecoveryEntry` / `enrollPassword-\n * Authenticator` / `wrapKeyringSummary` (in `@noy-db/on-webauthn`)\n * all consume; effectively the canonical input for every\n * post-Path C tier-2 ceremony.\n *\n * - `newKek` — the freshly-derived KEK (extractable for the\n * ceremony scope only). Only relevant for forward-compatibility\n * with a hypothetical future on-* package that wants to wrap the\n * KEK itself under a method-derived key. None of the shipped\n * on-* packages need this; they all operate on `newDeks`.\n *\n * The ceremony MUST preserve `oldSlot.id` and `oldSlot.method` in the\n * returned `EnrollAuthenticatorOptions`. Hub validates these — a\n * mismatch throws `ValidationError` (prevents slot-type swap mid-\n * rotation, e.g. converting a webauthn slot to a password slot under\n * cover of preservation).\n */\nexport interface SlotRewrapContext {\n readonly newKek: CryptoKey\n readonly newDeks: Map<string, CryptoKey>\n readonly oldSlot: KeyringAuthenticator\n}\n\n/**\n * Callback that re-enrolls one tier-2 slot during `rotatePassphrase`.\n * Returns the new slot's `EnrollAuthenticatorOptions` — same shape\n * the consumer would pass to `db.enrollAuthenticator` for a fresh\n * enrollment. Hub persists the result atomically with the rotation.\n */\nexport type SlotRewrapCeremony = (\n ctx: SlotRewrapContext,\n) => Promise<EnrollAuthenticatorOptions>\n\n/** Caller payload for {@link rotatePassphrase}. */\nexport interface RotatePassphraseInput {\n readonly oldPassphrase: string\n readonly newPassphrase: string\n readonly passphrasePolicy?: PassphrasePolicy\n readonly allowWeakPassphrase?: boolean\n /**\n * Map of slot id → re-enrolment ceremony. Slots whose id appears\n * here are PRESERVED across rotation (the ceremony re-derives the\n * method-specific wrapping under the new keyring); slots whose id\n * is absent are DROPPED (the pre-#29 behavior).\n *\n * Without this map, `rotatePassphrase` retains the pre-pre.8\n * behavior of wiping every tier-2 slot. Consumers building a\n * \"rotate without losing my biometric\" flow supply ceremonies for\n * each slot they want to keep.\n *\n * If a ceremony throws, the entire rotation throws — no partial\n * state. Callers wrap individual ceremonies in try/catch + return\n * a sentinel if they want graceful degradation per slot.\n *\n * Added in pre.8 (#29).\n */\n readonly slotCeremonies?: { readonly [slotId: string]: SlotRewrapCeremony }\n}\n\n/**\n * Re-derive the user's KEK from `oldPassphrase`, rewrap every DEK\n * under a freshly-derived KEK from `newPassphrase`, and persist.\n *\n * Tier-2 authenticator slots are dropped UNLESS the caller supplies\n * a `slotCeremonies` map (#29) — each ceremony re-derives its\n * method-specific wrapping under the new keyring, and hub persists\n * the rewrapped slots atomically with the rotation. Slots whose id\n * isn't in the map are still dropped (pre-pre.8 behavior).\n *\n * @throws `InvalidKeyError` if `oldPassphrase` does not unwrap the keyring.\n * @throws `WeakPassphraseError` if `newPassphrase` fails the strength rule.\n * @throws `ValidationError` if a ceremony's result mismatches the\n * slot's id or method (anti-slot-swap guard).\n */\nexport async function rotatePassphrase(\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RotatePassphraseInput,\n): Promise<UnlockedKeyring> {\n if (!input.allowWeakPassphrase) {\n assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy)\n }\n\n const env = await store.get(vault, '_keyring', userId)\n if (!env) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\".`)\n }\n const file = JSON.parse(env._data) as KeyringFile\n const oldSalt = base64ToBuffer(file.salt)\n const oldKek = await deriveKey(input.oldPassphrase, oldSalt)\n\n // Unwrap every DEK with the OLD KEK first — this also validates the\n // passphrase (a bad KEK throws InvalidKeyError on the first unwrap).\n const deks = new Map<string, CryptoKey>()\n for (const [coll, wrapped] of Object.entries(file.deks)) {\n deks.set(coll, await unwrapKey(wrapped, oldKek))\n }\n\n const newSalt = generateSalt()\n const newKek = await deriveKey(input.newPassphrase, newSalt)\n\n // Rewrap with the new KEK.\n const wrappedDeks: Record<string, string> = {}\n for (const [coll, dek] of deks) {\n wrappedDeks[coll] = await wrapKey(dek, newKek)\n }\n\n // Slot rewrap (#29). Without slotCeremonies, we drop every existing\n // slot — the pre-pre.8 behavior. With a ceremony map, slots whose\n // id appears in the map are preserved; the rest are dropped.\n const oldSlots = file.authenticators ?? []\n const newSlots: KeyringAuthenticator[] = []\n if (input.slotCeremonies && oldSlots.length > 0) {\n for (const oldSlot of oldSlots) {\n const ceremony = input.slotCeremonies[oldSlot.id]\n if (!ceremony) continue // drop — same as pre-#29 behavior\n\n const result = await ceremony({ newKek, newDeks: deks, oldSlot })\n\n // Anti-slot-swap guard. The ceremony MUST preserve identity —\n // a mismatch would let the consumer convert a webauthn slot to\n // a password slot mid-rotation, which would silently change\n // the security profile of the slot under cover of \"rotation.\"\n if (result.id !== oldSlot.id) {\n throw new ValidationError(\n `slotCeremonies['${oldSlot.id}'] returned id=\"${result.id}\". ` +\n 'The id must match the rotated slot — a ceremony cannot ' +\n 'change a slot\\'s identity.',\n )\n }\n if (result.method !== oldSlot.method) {\n throw new ValidationError(\n `slotCeremonies['${oldSlot.id}'] returned method=\"${result.method}\", ` +\n `expected \"${oldSlot.method}\". The method must match the rotated ` +\n 'slot — a ceremony cannot change the auth method (e.g. webauthn ' +\n '→ password) under cover of rotation.',\n )\n }\n // wrapKind absent on legacy slots / wrap-KEK enroll inputs; treat as 'kek'.\n const oldWrapKind = oldSlot.wrapKind ?? 'kek'\n const newWrapKind = result.wrapKind ?? 'kek'\n if (oldWrapKind !== newWrapKind) {\n throw new ValidationError(\n `slotCeremonies['${oldSlot.id}'] returned wrapKind=\"${newWrapKind}\", ` +\n `expected \"${oldWrapKind}\". The wrap format must match the rotated ` +\n 'slot — a ceremony cannot change the wrap shape (e.g. wrap-KEK → ' +\n 'wrap-DEKs) under cover of rotation, since that would silently ' +\n 'change the session tier produced at unlock.',\n )\n }\n\n // Build the persisted slot from the ceremony result. Mirrors\n // the same construction `enrollAuthenticator` does — wrap-DEKs\n // variants carry { wrapped_deks, iv }; wrap-KEK variants\n // carry { wrapped_kek }.\n const baseFields = {\n id: result.id,\n method: result.method,\n // Preserve original enrolled_at — rotation is rewrapping, not\n // re-enrollment. The slot's enrolment timestamp tracks when\n // the user originally added the slot, not when it was last\n // rewrapped. Forensics consumers reading enrolled_at are\n // tracking the slot's ORIGIN, not its CURRENT wrapping.\n enrolled_at: oldSlot.enrolled_at,\n enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,\n meta: result.meta,\n } as const\n const newSlot: KeyringAuthenticator = result.wrapKind === 'deks'\n ? {\n ...baseFields,\n wrapKind: 'deks',\n wrapped_deks: result.wrapped_deks,\n iv: result.iv,\n }\n : {\n ...baseFields,\n wrapped_kek: result.wrapped_kek,\n }\n newSlots.push(newSlot)\n }\n }\n\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...file,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n authenticators: newSlots,\n canary,\n }\n\n await writeKeyringFile(store, vault, userId, next)\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n deks,\n kek: newKek,\n salt: newSalt,\n authenticators: newSlots,\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n }\n}\n\n/**\n * Caller payload for {@link recoverPassphrase}.\n *\n * As of #196 slice 1, `paper` and `shamir` are wired end-to-end.\n * The remaining two profiles (`multi-channel`, `admin-mediated`)\n * stay outside the union and throw\n * {@link RecoveryProfileNotImplementedError} at the runtime guard\n * when bypassed via `as unknown as RecoveryProof`.\n */\nexport type RecoveryProof =\n | { readonly profile: 'paper'; readonly payload: { readonly code: string } }\n | { readonly profile: 'shamir'; readonly payload: {\n /** Optional disambiguator when multiple Shamir entries are enrolled.\n * When omitted, hub tries each entry until one combines. */\n readonly entryId?: string\n /** K or more opaque share strings, as returned by `ShamirRecoveryProvider.splitToShares`. */\n readonly shares: ReadonlyArray<string>\n } }\n\nexport interface RecoverPassphraseInput {\n readonly newPassphrase: string\n readonly recoveryProof: RecoveryProof\n readonly passphrasePolicy?: PassphrasePolicy\n readonly allowWeakPassphrase?: boolean\n /**\n * After a successful paper-recovery, replace ALL remaining recovery\n * entries with freshly-minted ones. Defaults to `true` (defensive).\n *\n * Rationale (issue #36): the user just demonstrated they had access\n * to AT LEAST one code. The remaining codes from the same printed\n * sheet may also be compromised — photographed, leaked via a\n * screen-share slip, or in the hands of whoever stole the sheet.\n * Auto-rotation closes the window without requiring consumer action.\n *\n * Set to `false` to preserve the original behavior (only the matched\n * code is burned; the rest stay valid).\n *\n * Hub-side orchestration is non-atomic with the recovery itself:\n * if the rotation step fails after a successful burn, the user\n * falls back to the pre-rotation state (remaining codes still\n * valid). Strictly safer than the previous default — a failed\n * rotation degrades gracefully rather than leaving the vault\n * locked or codes dual-existing.\n */\n readonly rotateRemainingCodes?: boolean\n /**\n * Number of fresh codes to mint when `rotateRemainingCodes` is on.\n * Defaults to the count of remaining entries POST-burn (e.g. if\n * the user enrolled 8 originally and just consumed 1, defaults to\n * 7). Pass an explicit number to mint a different count — useful\n * when the consumer wants to refresh to a target N regardless of\n * how many were left.\n */\n readonly newCodeCount?: number\n /**\n * Override the default raw-code generator. The default is hub's\n * {@link generateULID} — uppercase Crockford-Base32, 26 chars,\n * passes through `normalizePaperCode` untouched.\n *\n * Pass `() => generateRawCode()` from `@noy-db/on-recovery` when\n * the consumer prefers the Base32 + checksum format with hyphenated\n * display. The `mintPaperRecoveryEntry` helper accepts any string —\n * the generator just needs to produce a high-entropy unique value.\n */\n readonly codeGenerator?: () => string\n}\n\n/**\n * Return shape of `db.recoverPassphrase`. `newCodes` is populated when\n * `rotateRemainingCodes` was enabled and at least one entry was\n * rotated; an empty array means no rotation happened (rotation\n * disabled, or no remaining codes after burn). Show the codes to the\n * user once — they are the canonical credential for future recovery\n * and CANNOT be retrieved again.\n */\nexport interface RecoverPassphraseResult {\n readonly newCodes: readonly string[]\n}\n\n/**\n * Input for {@link Noydb.rotateRecovery} (#121) — deliberate\n * recovery-credential regeneration when the user knows their\n * passphrase but wants a fresh sheet (paper) or fresh shares\n * (shamir). Symmetric to {@link RotatePassphraseInput}.\n */\nexport type RotateRecoveryOptions =\n | {\n readonly profile: 'paper'\n /** How many fresh codes to mint. Default: existing sheet size. */\n readonly count?: number\n /** Optional code generator — see {@link RecoverPassphraseInput.codeGenerator}. */\n readonly codeGenerator?: () => string\n }\n | {\n readonly profile: 'shamir'\n /** New threshold. */\n readonly k: number\n /** New total share count. */\n readonly n: number\n /** Disambiguator when multiple Shamir entries exist; required if there are 2+. */\n readonly entryId?: string\n /** Optional updated label. */\n readonly label?: string\n }\n\n/**\n * Result of {@link Noydb.rotateRecovery}. Shape varies by profile:\n *\n * - `paper` → `{ newCodes: string[] }` (and `entryId === 'paper-batch'`)\n * - `shamir` → `{ newShares: string[], entryId }`\n *\n * `newCodes` is populated for paper rotations; `newShares` for\n * Shamir rotations. Both are show-once — the hub does not\n * retain them.\n */\nexport interface RotateRecoveryResult {\n readonly newCodes?: readonly string[]\n readonly newShares?: readonly string[]\n readonly entryId?: string\n}\n\n/**\n * Result of {@link Noydb.enrollRecovery}. Shape varies by profile:\n *\n * - `paper` → `{ entryId: 'paper-batch' }` (caller minted the\n * entries; this is a sentinel since paper enrollments are batch-shaped).\n * - `shamir` → `{ entryId, shares: string[] }` — shares are\n * show-once; the hub does not retain them.\n */\nexport interface EnrollRecoveryResult {\n readonly entryId: string\n readonly shares?: readonly string[]\n}\n\n/**\n * Input shape for {@link Noydb.enrollRecovery} and\n * {@link Noydb.openVaultAndEnrollRecovery} (#195). Discriminated\n * union over recovery profiles.\n *\n * - `paper`: caller pre-mints entries (typically via\n * `mintPaperRecoveryEntry` or `@noy-db/on-recovery`'s\n * `generateRecoveryCodeSet`) and passes them in. The hub stores\n * them and surfaces an opaque batch id.\n * - `shamir`: hub mints the recovery secret + the shares at\n * enrollment time. The shares are returned in\n * {@link EnrollRecoveryResult.shares} (show-once); the hub never\n * retains them.\n *\n * Multi-channel and admin-mediated will be added when the respective\n * dispatch slices ship.\n */\nexport type RecoveryEnrollmentInput =\n | { readonly profile: 'paper'; readonly entries: ReadonlyArray<PaperRecoveryEntry> }\n | {\n readonly profile: 'shamir'\n readonly k: number\n readonly n: number\n readonly label?: string\n readonly entryId?: string\n }\n\n/**\n * Reset the user's passphrase using a recovery proof. v0.1.0-pre.5\n * supports the `'paper'` profile via `@noy-db/on-recovery` entries\n * persisted in `_meta/recovery-paper`. The other three profiles throw\n * {@link RecoveryProfileNotImplementedError}.\n *\n * On success, the used recovery entry is burned (deleted from the\n * stored set).\n */\nexport async function recoverPassphrase(\n provider: ShamirRecoveryProvider | undefined,\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RecoverPassphraseInput,\n): Promise<UnlockedKeyring> {\n if (!input.allowWeakPassphrase) {\n assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy)\n }\n\n // Runtime defense-in-depth: the type narrows to 'paper' | 'shamir'\n // (#86 + #196), but a consumer bypassing TS via\n // `as unknown as RecoveryProof` should still hit a clear error\n // rather than silently fall into a handler with a malformed payload.\n const profile = (input.recoveryProof as { profile: string }).profile\n if (profile === 'paper') {\n return recoverViaPaperCode(store, vault, userId, input)\n }\n if (profile === 'shamir') {\n return recoverViaShamir(provider, store, vault, userId, input)\n }\n throw new RecoveryProfileNotImplementedError(\n profile,\n 'https://github.com/vLannaAi/noy-db/issues/196',\n )\n}\n\nasync function recoverViaPaperCode(\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RecoverPassphraseInput,\n): Promise<UnlockedKeyring> {\n if (input.recoveryProof.profile !== 'paper') throw new Error('unreachable')\n const { code } = input.recoveryProof.payload\n\n const env = await store.get(vault, '_keyring', userId)\n if (!env) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\".`)\n }\n const file = JSON.parse(env._data) as KeyringFile\n\n const entries = await loadPaperRecoveryEntries(store, vault)\n if (entries.length === 0) {\n throw new NoAccessError(\n `No paper-recovery entries enrolled for vault \"${vault}\". ` +\n 'Enroll via `db.enrollRecovery({ profile: \"paper\", entries })` before relying on recovery.',\n )\n }\n\n const normalized = normalizePaperCode(code)\n let recovered: { deks: Map<string, CryptoKey>; entry: PaperRecoveryEntry } | undefined\n for (const entry of entries) {\n try {\n const deks = await unwrapDeksFromPaperEntry(entry, normalized)\n recovered = { deks, entry }\n break\n } catch {\n // wrong code for this entry — try the next one\n }\n }\n if (!recovered) {\n throw new InvalidKeyError(\n 'Recovery code does not match any enrolled paper entry. The code may have been ' +\n 'previously used (single-use) or typed incorrectly.',\n )\n }\n\n const deks = recovered.deks\n\n // Fresh salt + KEK from the new passphrase, rewrap.\n const newSalt = generateSalt()\n const newKek = await deriveKey(input.newPassphrase, newSalt)\n const wrappedDeks: Record<string, string> = {}\n for (const [coll, dek] of deks) {\n wrappedDeks[coll] = await wrapKey(dek, newKek)\n }\n\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...file,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n authenticators: [], // tier-2 slots wrap old KEK, drop them\n canary,\n }\n\n // Burn first, then rewrite the keyring. The two writes are not\n // atomic — if the second fails (#84), the safer ordering is:\n //\n // 1. Code burned, keyring untouched: user keeps their old passphrase\n // and loses one recovery code (recoverable: contact admin / use\n // another code).\n //\n // 2. Keyring rewritten, code unburned: user has rotated, but the\n // consumed code REMAINS VALID. Anyone with access to the paper\n // sheet can use it again. Security regression.\n //\n // Burning first picks (1) over (2).\n await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId)\n await writeKeyringFile(store, vault, userId, next)\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n deks,\n kek: newKek,\n salt: newSalt,\n authenticators: [],\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n }\n}\n\n/**\n * Mirror of `@noy-db/on-recovery/parseRecoveryCode`. Inlined so the\n * hub does not gain a peer dep on on-recovery — both implementations\n * follow the same RFC 4648 Base32 + checksum format and round-trip\n * through the same KDF.\n *\n * Accepts hyphenated, lowercase, or whitespace-padded input.\n */\nfunction normalizePaperCode(input: string): string {\n return input.toUpperCase().replace(/[\\s\\-_]/g, '')\n}\n\n/**\n * Recover the user's keyring via the Shamir profile.\n *\n * 1. Decode each supplied share string into a {@link RawShare}.\n * 2. Load `_meta/recovery-shamir` entries.\n * 3. If `payload.entryId` is supplied, restrict to that entry; else\n * iterate over all entries and try each until one combines.\n * 4. For each candidate: filter shares to those whose `(k, n)`\n * match the entry's parameters, then attempt\n * `unwrapDeksFromShamirEntry`. AES-GCM auth-tag failure means\n * the combined secret doesn't match — try the next entry.\n * 5. With unwrapped DEKs: derive fresh KEK from `newPassphrase` +\n * fresh salt, rewrap, write the keyring.\n * 6. Shamir entries are NOT burned on recovery (shares reusable);\n * explicit {@link Noydb.rotateRecovery} is the refresh ceremony.\n */\nasync function recoverViaShamir(\n provider: ShamirRecoveryProvider | undefined,\n store: NoydbStore,\n vault: string,\n userId: string,\n input: RecoverPassphraseInput,\n): Promise<UnlockedKeyring> {\n if (input.recoveryProof.profile !== 'shamir') throw new Error('unreachable')\n const { entryId: requestedEntryId, shares: shareStrings } = input.recoveryProof.payload\n\n if (shareStrings.length === 0) {\n throw new ValidationError(\n 'Shamir recovery requires at least one share; received an empty array.',\n )\n }\n\n const env = await store.get(vault, '_keyring', userId)\n if (!env) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\".`)\n }\n const file = JSON.parse(env._data) as KeyringFile\n\n const allEntries = await loadShamirRecoveryEntries(store, vault)\n if (allEntries.length === 0) {\n throw new NoAccessError(\n `No Shamir-recovery entries enrolled for vault \"${vault}\". `\n + 'Enroll via `db.enrollRecovery({ profile: \"shamir\", k, n })` before relying on recovery.',\n )\n }\n\n if (!provider) {\n throw new Error(\n \"shamir recovery requires a ShamirRecoveryProvider — pass \"\n + \"shamirRecovery: shamirRecoveryProvider() from '@noy-db/on-shamir' to createNoydb()\",\n )\n }\n\n // Restrict to a specific entry when entryId supplied.\n let candidates: ReadonlyArray<ShamirRecoveryEntry>\n if (requestedEntryId !== undefined) {\n candidates = allEntries.filter(e => e.entryId === requestedEntryId)\n if (candidates.length === 0) {\n throw new NoAccessError(\n `No Shamir-recovery entry with entryId=\"${requestedEntryId}\" found `\n + `in vault \"${vault}\". Available entries: `\n + allEntries.map(e => `\"${e.entryId}\"`).join(', '),\n )\n }\n } else {\n candidates = allEntries\n }\n\n // Try each candidate entry. Pass all share strings to the provider;\n // provider.combineShares validates and throws on mismatch — the\n // AES-GCM auth-tag is an additional guard.\n let recoveredDeks: Map<string, CryptoKey> | undefined\n for (const entry of candidates) {\n if (shareStrings.length < entry.k) {\n // Not enough shares for this entry — could still match another.\n continue\n }\n try {\n const deks = await unwrapDeksFromShamirEntry(provider, entry, shareStrings)\n recoveredDeks = deks\n break\n } catch {\n // provider.combineShares threw (malformed/mismatched shares) or\n // AES-GCM auth-tag failure → try the next entry.\n }\n }\n\n if (!recoveredDeks) {\n // Distinguish \"below-threshold\" from \"no entry matches\" so the\n // error message is actionable.\n const minK = Math.min(...candidates.map(e => e.k))\n if (shareStrings.length < minK) {\n throw new InvalidKeyError(\n `Insufficient Shamir shares to combine: the smallest enrolled threshold is ${minK}, `\n + `but only ${shareStrings.length} share${shareStrings.length === 1 ? ' was' : 's were'} provided.`,\n )\n }\n throw new InvalidKeyError(\n 'Shamir shares do not match any enrolled entry. Possible causes: '\n + 'shares were tampered with, came from a different enrollment, '\n + 'or the entry was rotated after these shares were distributed.',\n )\n }\n\n // Mint fresh KEK from new passphrase, rewrap DEKs (mirrors paper).\n const newSalt = generateSalt()\n const newKek = await deriveKey(input.newPassphrase, newSalt)\n const wrappedDeks: Record<string, string> = {}\n for (const [coll, dek] of recoveredDeks) {\n wrappedDeks[coll] = await wrapKey(dek, newKek)\n }\n\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...file,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n authenticators: [], // tier-2 slots wrap old KEK, drop them on recovery\n canary,\n }\n\n // No burn: Shamir entries persist across recoveries. Explicit\n // rotateRecovery is the refresh ceremony.\n await writeKeyringFile(store, vault, userId, next)\n\n return {\n userId: file.user_id,\n displayName: file.display_name,\n role: file.role,\n permissions: file.permissions,\n deks: recoveredDeks,\n kek: newKek,\n salt: newSalt,\n authenticators: [],\n ...(file.export_capability !== undefined && { exportCapability: file.export_capability }),\n ...(file.import_capability !== undefined && { importCapability: file.import_capability }),\n }\n}\n\nasync function writeKeyringFile(\n store: NoydbStore,\n vault: string,\n userId: string,\n file: KeyringFile,\n): Promise<void> {\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(file),\n }\n await store.put(vault, '_keyring', userId, envelope)\n}\n","/**\n * Atomic peer-recovery primitive — issues #33 + #34.\n *\n * `recoverUser` is a SEPARATE operation from `revoke + grant`. It\n * exists because peer-recovery has different semantics than account\n * removal-then-reissue:\n *\n * 1. **Same identity preserved.** `userId`, `role`, `permissions`,\n * capability bits, user envelope (if any), policy override (if\n * any) all survive. Only the wrapping changes.\n * 2. **No key rotation.** The existing DEKs stay valid — every\n * OTHER principal in the vault keeps their access. Rotating\n * keys would invalidate every co-user's wrapping.\n * 3. **Atomic by construction.** A single `store.put` overwrites\n * `_keyring/<userId>` with the recovered file. No revoke step\n * means no partial-failure window.\n * 4. **Owner→owner natively allowed.** Two co-owners recovering\n * each other is the explicitly-intentional case (a partner\n * forgot the master phrase). The existing `canRevoke` rule that\n * blocks owner→owner is correct for `revoke` (which is account\n * *removal*) and intentionally NOT replicated here. The policy\n * gate `peer-recover-user` carries the freshness requirement.\n * 5. **Tier-2 slots dropped.** The slots wrap the OLD KEK under\n * method-derived keys; after recovery the KEK is re-derived\n * from the new temp passphrase. Match `rotatePassphrase`'s\n * precedent — the recovered user re-enrols slots after picking\n * their own phrase.\n *\n * Caller must be at least as privileged as the target. The hub\n * `db.recoverUser` method gates this with the `peer-recover-user`\n * policy gate (#33's factor-proof requirement); the function below\n * enforces only the role + anti-privilege-escalation invariants.\n *\n * @module\n */\nimport type { NoydbStore, KeyringFile, Role } from '../types.js'\nimport { NOYDB_KEYRING_VERSION } from '../types.js'\nimport { deriveKey, generateSalt, wrapKey, bufferToBase64 } from '../crypto.js'\nimport { NoAccessError, PermissionDeniedError, PrivilegeEscalationError } from '../errors.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../validation.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { mintKeyringCanary } from './keyring.js'\n\nconst ADMIN_RECOVERABLE_TARGETS: readonly Role[] = ['operator', 'viewer', 'client', 'admin']\n\n/**\n * Whether `callerRole` may recover `targetRole`.\n *\n * Differs from `canRevoke` (in `keyring.ts`) in one critical place:\n * **owner→owner IS allowed**. Peer recovery is the explicitly\n * intentional case (a co-owner forgot their phrase); the freshness\n * binding lives in the `peer-recover-user` policy gate, not in the\n * permission predicate.\n *\n * Admins can recover everyone they could grant (operator / viewer /\n * client / admin) but NOT owners — that boundary stays as a hard\n * structural rule even under recovery.\n */\nfunction canRecover(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_RECOVERABLE_TARGETS.includes(targetRole)\n return false\n}\n\n/** Input shape for {@link recoverUser}. */\nexport interface RecoverUserOptions {\n /** Target user id whose keyring is being recovered. */\n readonly userId: string\n /**\n * Temporary passphrase under which the new keyring is wrapped.\n * The recipient should call `db.rotatePassphrase` immediately on\n * acceptance to choose their own phrase — this temp acts as a\n * single-use bridge in invite / peer-recovery flows.\n */\n readonly passphrase: string\n /** Override the target's role. Defaults to the existing target's role. */\n readonly role?: Role\n /** Override the target's display name. Defaults to existing. */\n readonly displayName?: string\n /** Validate phrase strength against the configured policy. */\n readonly validatePassphrase?: boolean\n /**\n * Skip phrase strength validation even when `validatePassphrase` is\n * set. The escape hatch matches `grant`'s shape — used when the\n * temp phrase is a high-entropy one-shot string that doesn't need\n * to satisfy the human-typeable rules.\n */\n readonly allowWeakPassphrase?: boolean\n /**\n * Optional explicit phrase policy override (passed through to\n * `assertStrongPassphrase`). Mirrors how `grant` accepts a custom\n * `PassphrasePolicy` for app-specific tightening.\n */\n readonly passphrasePolicy?: PassphrasePolicy\n}\n\n/**\n * Atomically rewrap the target user's keyring under a fresh temp\n * passphrase. Single store write; no revoke step; no key rotation.\n *\n * Caller's responsibilities (NOT enforced here):\n * - Run the `peer-recover-user` policy gate first via\n * `Noydb.checkGate` to enforce the freshness factor proof.\n * - Communicate the temp passphrase to the recipient via a secure\n * channel (URL fragment, in-person, etc.) — the hub does not\n * transport secrets.\n */\nexport async function recoverUser(\n store: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: RecoverUserOptions,\n): Promise<void> {\n // 1. Load the target's existing keyring file (plaintext header).\n const env = await store.get(vault, '_keyring', options.userId)\n if (!env) {\n throw new NoAccessError(\n `recoverUser: user \"${options.userId}\" has no keyring in vault \"${vault}\".`,\n )\n }\n const target = JSON.parse(env._data) as KeyringFile\n const targetRole = options.role ?? target.role\n\n // 2. Permission check — caller must be allowed to recover this role.\n // Owner→owner natively allowed; admin→admin allowed; admin→owner blocked.\n if (!canRecover(callerKeyring.role, targetRole)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot recover role \"${targetRole}\"`,\n )\n }\n // Also guard against role-uplift via the override — admin cannot\n // promote a target to owner under cover of recovery.\n if (!canRecover(callerKeyring.role, target.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot recover role \"${target.role}\"`,\n )\n }\n\n // 3. Anti-privilege-escalation. Every collection the target had\n // access to must be in the caller's DEK set — the recoverer\n // cannot give the recovered user access to a collection the\n // recoverer themselves can't read. Mirrors `grant()`'s check.\n for (const coll of Object.keys(target.deks)) {\n if (!callerKeyring.deks.has(coll)) {\n throw new PrivilegeEscalationError(coll)\n }\n }\n\n // 4. Optional phrase strength validation (mirrors `grant` opt-in).\n if (options.validatePassphrase && !options.allowWeakPassphrase) {\n assertStrongPassphrase(options.passphrase, options.passphrasePolicy)\n }\n\n // 5. Mint a fresh salt + KEK from the temp passphrase. The DEKs\n // themselves are unchanged — only the wrapping is replaced.\n const newSalt = generateSalt()\n const newKek = await deriveKey(options.passphrase, newSalt)\n\n const wrappedDeks: Record<string, string> = {}\n for (const coll of Object.keys(target.deks)) {\n const callerDek = callerKeyring.deks.get(coll)\n if (!callerDek) {\n // Already caught by the anti-privilege-escalation loop above.\n // This branch is defensive belt-and-braces; if it ever fires,\n // the target had a collection the caller's deks Map disagrees\n // with — fail loud rather than silently dropping access.\n throw new PrivilegeEscalationError(coll)\n }\n wrappedDeks[coll] = await wrapKey(callerDek, newKek)\n }\n\n // 6. Build the recovered keyring file. Identity preserved; wrapping\n // refreshed; tier-2 slots dropped (they wrap the OLD KEK and\n // can't survive a tier-1 phrase change — same precedent as\n // rotatePassphrase). Mint a fresh canary under newKek (#113); the\n // OLD canary on the spread `...target` would fail to verify against\n // the new KEK and trip KeyringCorruptError on next load.\n const canary = await mintKeyringCanary(newKek)\n const next: KeyringFile = {\n ...target,\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n role: targetRole,\n display_name: options.displayName ?? target.display_name,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n granted_by: callerKeyring.userId,\n authenticators: [],\n canary,\n }\n\n // 7. Single atomic write — overwrites the existing envelope.\n // Backend `put` is the canonical write primitive across every\n // `to-*` store; no partial-failure window between revoke + grant.\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(next),\n }\n await store.put(vault, '_keyring', options.userId, envelope)\n}\n","/**\n * Hierarchical access — tier-aware keyring helpers.\n *\n * The keyring's existing `deks: Map<string, CryptoKey>` is keyed by\n * collection name. extends the key space:\n *\n * `'invoices'` — tier-0 DEK (unchanged from v0.x)\n * `'invoices#1'` — tier-1 DEK\n * `'invoices#2'` — tier-2 DEK\n *\n * Tier 0 keeps the bare collection name so any keyring written\n * before tiers existed loads without migration. Tiers ≥ 1 use `#N`\n * suffixes that\n * would be invalid as user-supplied collection names (see\n * `ReservedCollectionNameError` — `#` is reserved).\n *\n * @module\n */\n\nimport type { UnlockedKeyring } from './keyring.js'\nimport { TierNotGrantedError } from '../errors.js'\n\n/** Canonical DEK key for a given collection + tier. Tier 0 → bare name. */\nexport function dekKey(collection: string, tier: number): string {\n if (tier <= 0) return collection\n return `${collection}#${tier}`\n}\n\n/**\n * Returns the user's effective clearance for a given collection: the\n * maximum tier for which their keyring holds a DEK. Falls back to 0\n * when the user has only the tier-0 DEK (or none — the getDEK caller\n * will raise separately).\n */\nexport function effectiveClearance(keyring: UnlockedKeyring, collection: string): number {\n let max = 0\n const prefix = `${collection}#`\n for (const key of keyring.deks.keys()) {\n if (!key.startsWith(prefix)) continue\n const suffix = key.slice(prefix.length)\n const n = Number.parseInt(suffix, 10)\n if (Number.isFinite(n) && n > max) max = n\n }\n return max\n}\n\n/**\n * Assert the caller is cleared for the requested tier. Owners and\n * admins always pass (they can mint any new tier DEK on demand);\n * other roles must already hold the tier DEK — via a prior grant or\n * an active delegation — otherwise this throws `TierNotGrantedError`.\n *\n * This gate runs BEFORE `getDEK()` on the mutation path so a\n * non-cleared operator never has the opportunity to silently\n * auto-create a tier DEK they shouldn't have.\n */\nexport function assertTierAccess(\n keyring: UnlockedKeyring,\n collection: string,\n tier: number,\n): void {\n if (tier <= 0) return\n if (keyring.role === 'owner' || keyring.role === 'admin') return\n if (!keyring.deks.has(dekKey(collection, tier))) {\n throw new TierNotGrantedError(collection, tier)\n }\n}\n","/**\n * Magic-link-bound cross-user delegation grants.\n *\n * This module is the **core storage + encryption layer** that lets a\n * grantor issue a tier-DEK to a user whose KEK they do not know. The\n * trust bridge is provided by the `@noy-db/on-magic-link` package:\n *\n * 1. Grantor picks a grantee identity (user id + email handle).\n * 2. Grantor mints a magic-link token (ULID) via `createMagicLinkToken`.\n * 3. Grantor derives a **content key** + a **KEK** from\n * `(serverSecret, token, vault)` using HKDF-SHA256 with separate\n * `info` tags — both callers (grantor and grantee) can derive the\n * same keys given the same inputs.\n * 4. Grantor persists a record in `_magic_link_grants/<token>`:\n * - envelope `_data` is AES-GCM encrypted under the content key\n * - the inner `wrappedDek` is AES-KW wrapped under the KEK\n * 5. Grantee receives the URL, derives the same content key + KEK,\n * loads the grant, decrypts the envelope, unwraps the tier DEK.\n *\n * ## Why a separate collection from `_delegations`\n *\n * `_delegations` envelopes are encrypted under a DEK shared across\n * every vault user (audit-visibility). External auditors / client\n * portal users have NO pre-existing keyring, so they cannot read that\n * DEK. Magic-link grants live in their own collection whose envelope\n * encryption is derived purely from the magic-link URL + server secret\n * — nothing else is required to decrypt.\n *\n * ## Batch grants\n *\n * One magic-link token may point to MULTIPLE grants (e.g. the client\n * portal case: invoices + payments + etax all share one link). Each\n * grant is persisted under a distinct record id:\n *\n * `<token>` for the single-grant / primary entry\n * `<token>:<index>` for subsequent entries\n *\n * `listMagicLinkGrants(store, vault, token)` enumerates every record\n * whose id begins with `<token>` so the claimant can materialize all\n * DEKs in one pass.\n *\n * ## Revocation\n *\n * `store.delete(vault, _magic_link_grants, <token>)` immediately\n * invalidates the link — even if the URL was captured and the server\n * secret leaked, no payload remains to decrypt.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { encrypt, decrypt, wrapKey, unwrapKey } from '../crypto.js'\nimport { dekKey } from './tiers.js'\nimport { DelegationTargetMissingError } from '../errors.js'\n\n/** Reserved collection holding magic-link grant envelopes. */\nexport const MAGIC_LINK_GRANTS_COLLECTION = '_magic_link_grants'\n\n/** HKDF `info` for the AES-GCM content key. Version-namespaced. */\nexport const MAGIC_LINK_CONTENT_INFO_PREFIX = 'noydb-magic-link-content-v1:'\n\n/** HKDF `info` for the AES-KW KEK. Matches `@noy-db/on-magic-link`. */\nexport const MAGIC_LINK_KEK_INFO_PREFIX = 'noydb-magic-link-v1:'\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * Decrypted payload of a magic-link grant record. Mirrors\n * `DelegationToken` in `team/delegation.ts` but tracked separately\n * because the two flows persist under different collections + envelope\n * encryption schemes.\n */\nexport interface MagicLinkGrantPayload {\n readonly id: string\n readonly toUser: string\n readonly fromUser: string\n readonly tier: number\n /** Collection name or `null` for the vault-wide tier DEK. */\n readonly collection: string | null\n /** Optional specific record id scope. */\n readonly record?: string\n /** ISO timestamp — grant expires at this instant. */\n readonly until: string\n /** AES-KW-wrapped tier DEK, unwrap with the magic-link KEK. */\n readonly wrappedDek: string\n /** ISO timestamp the grant was issued. */\n readonly createdAt: string\n /** Optional caller-provided label (surfaced in audit UIs). */\n readonly note?: string\n}\n\nexport interface IssueMagicLinkGrantOptions {\n readonly toUser: string\n readonly tier: number\n readonly collection?: string\n readonly record?: string\n readonly until: Date | string\n readonly note?: string\n}\n\nexport interface MagicLinkGrantRecord {\n /** Store record id — `<token>` or `<token>:<index>` for batch entries. */\n readonly recordId: string\n readonly payload: MagicLinkGrantPayload\n}\n\n// ─── Key derivation ─────────────────────────────────────────────────────\n\n/**\n * Derive the AES-GCM content key from the same HKDF inputs used for\n * the magic-link KEK. Different `info` suffix → domain-separated key.\n *\n * Exported so the `@noy-db/on-magic-link` package can share the exact\n * derivation path without cross-dependency between the two modules.\n */\nexport async function deriveMagicLinkContentKey(\n serverSecret: string | Uint8Array<ArrayBuffer>,\n token: string,\n vault: string,\n): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikmBytes =\n serverSecret instanceof Uint8Array\n ? serverSecret\n : new TextEncoder().encode(serverSecret)\n const tokenBytes = new TextEncoder().encode(token)\n const saltBuffer = await subtle.digest('SHA-256', tokenBytes)\n const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault)\n const ikm = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveKey'])\n return subtle.deriveKey(\n { name: 'HKDF', hash: 'SHA-256', salt: saltBuffer, info },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\n// ─── Issue ──────────────────────────────────────────────────────────────\n\n/**\n * Persist a magic-link grant record. Caller derives + provides both\n * the content key and the KEK; this function performs the wrap/encrypt\n * and writes the envelope.\n *\n * `recordId` lets the caller use either the bare token (primary grant)\n * or a suffixed id (batch entry). The writer is responsible for\n * collision-avoidance across batch entries.\n */\nexport async function writeMagicLinkGrant(\n store: NoydbStore,\n vault: string,\n grantor: UnlockedKeyring,\n contentKey: CryptoKey,\n grantKek: CryptoKey,\n recordId: string,\n opts: IssueMagicLinkGrantOptions,\n): Promise<MagicLinkGrantRecord> {\n const collectionName = opts.collection ?? null\n const sourceKey = collectionName\n ? dekKey(collectionName, opts.tier)\n : `__any#${opts.tier}`\n const sourceDek = grantor.deks.get(sourceKey)\n if (!sourceDek) {\n throw new DelegationTargetMissingError(\n `grantor cannot find tier ${opts.tier} DEK for ${collectionName ?? '(any)'}`,\n )\n }\n const wrappedDek = await wrapKey(sourceDek, grantKek)\n\n const until = typeof opts.until === 'string' ? opts.until : opts.until.toISOString()\n const createdAt = new Date().toISOString()\n const payload: MagicLinkGrantPayload = {\n id: recordId,\n toUser: opts.toUser,\n fromUser: grantor.userId,\n tier: opts.tier,\n collection: collectionName,\n ...(opts.record && { record: opts.record }),\n until,\n wrappedDek,\n createdAt,\n ...(opts.note && { note: opts.note }),\n }\n\n const { iv, data } = await encrypt(JSON.stringify(payload), contentKey)\n const envelope: EncryptedEnvelope = {\n _noydb: 1,\n _v: 1,\n _ts: createdAt,\n _iv: iv,\n _data: data,\n _by: grantor.userId,\n }\n await store.put(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId, envelope)\n return { recordId, payload }\n}\n\n// ─── Claim ──────────────────────────────────────────────────────────────\n\n/**\n * Fetch + decrypt a single magic-link grant record by id. Returns null\n * when the record is absent OR when decryption fails (wrong server\n * secret, wrong vault, tampered envelope) — callers treat a null as\n * \"this URL is not valid for this server\".\n *\n * The returned payload's `wrappedDek` is still AES-KW-wrapped; the\n * caller unwraps it with the magic-link KEK to obtain the tier DEK.\n */\nexport async function readMagicLinkGrantRecord(\n store: NoydbStore,\n vault: string,\n contentKey: CryptoKey,\n recordId: string,\n): Promise<MagicLinkGrantPayload | null> {\n const env = await store.get(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId)\n if (!env) return null\n try {\n const json = await decrypt(env._iv, env._data, contentKey)\n return JSON.parse(json) as MagicLinkGrantPayload\n } catch {\n return null\n }\n}\n\n/**\n * Enumerate every grant record sharing the magic-link `token` prefix\n * (i.e. the primary `<token>` entry plus any `<token>:*` batch entries).\n * Expired grants are still returned — the caller filters on `until`.\n */\nexport async function listMagicLinkGrants(\n store: NoydbStore,\n vault: string,\n contentKey: CryptoKey,\n token: string,\n): Promise<MagicLinkGrantPayload[]> {\n const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION)\n const matching = ids.filter(id => id === token || id.startsWith(`${token}:`))\n const out: MagicLinkGrantPayload[] = []\n for (const id of matching) {\n const payload = await readMagicLinkGrantRecord(store, vault, contentKey, id)\n if (payload) out.push(payload)\n }\n return out\n}\n\n/**\n * Unwrap the tier DEK from a grant payload using the magic-link KEK.\n * Thin wrapper around `unwrapKey` — provided so the claimant can avoid\n * importing `crypto.js` directly.\n */\nexport async function unwrapMagicLinkGrant(\n payload: MagicLinkGrantPayload,\n grantKek: CryptoKey,\n): Promise<CryptoKey> {\n return unwrapKey(payload.wrappedDek, grantKek)\n}\n\n/**\n * Delete a magic-link grant (primary + every batch entry sharing the\n * token). Safe to call when nothing exists.\n */\nexport async function revokeMagicLinkGrant(\n store: NoydbStore,\n vault: string,\n token: string,\n): Promise<number> {\n const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION)\n const matching = ids.filter(id => id === token || id.startsWith(`${token}:`))\n for (const id of matching) {\n await store.delete(vault, MAGIC_LINK_GRANTS_COLLECTION, id)\n }\n return matching.length\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────\n\n/**\n * Compose the batch-entry record id. `index === 0` → bare token.\n * Subsequent entries use `<token>:<index>` so `store.list()` can\n * enumerate them all by common prefix.\n */\nexport function magicLinkGrantRecordId(token: string, index: number): string {\n return index === 0 ? token : `${token}:${index}`\n}\n\n/**\n * True when the payload's `until` is in the past relative to `now`.\n * Kept here (rather than inlined) so the semantics stay aligned with\n * the canonical `DelegationToken` expiry check.\n */\nexport function isMagicLinkGrantExpired(\n payload: MagicLinkGrantPayload,\n now: Date = new Date(),\n): boolean {\n return payload.until <= now.toISOString()\n}\n","/**\n * Sync scheduling policy.\n *\n * ## What it controls\n *\n * A {@link SyncPolicy} has two halves:\n * - **push** ({@link PushPolicy}) — when dirty local writes are sent to the remote.\n * - **pull** ({@link PullPolicy}) — when the remote is polled for new data.\n *\n * ## Choosing a policy\n *\n * The right policy depends on the backend's operational characteristics:\n *\n * | Backend type | Recommended policy |\n * |---|---|\n * | Per-record (DynamoDB, S3, IDB) | {@link INDEXED_STORE_POLICY} — `on-change` push, `manual` pull |\n * | Bundle (Drive, WebDAV, Git) | {@link BUNDLE_STORE_POLICY} — `debounce` push, `interval` pull |\n *\n * Consumers can override via `createNoydb({ syncPolicy: { ... } })`:\n *\n * ```ts\n * const db = await createNoydb({\n * store: jsonFile({ dir: './data' }),\n * syncPolicy: {\n * push: { mode: 'debounce', debounceMs: 5_000 },\n * pull: { mode: 'on-focus' },\n * },\n * })\n * ```\n *\n * ## Scheduler lifecycle\n *\n * {@link SyncScheduler} owns all timers, debounce logic, and browser lifecycle\n * hooks (`visibilitychange`, `pagehide`, `beforeExit`). Call `scheduler.start()`\n * after opening a vault and `scheduler.stop()` when closing it. The scheduler\n * delegates actual push/pull work to {@link SyncSchedulerCallbacks} provided\n * by the {@link SyncEngine}.\n *\n * @module\n */\n\n// ─── Policy types ───────────────────────────────────────────────────────\n\n/**\n * When push operations are triggered automatically.\n *\n * - `'manual'` — only on explicit `sync.push()` calls.\n * - `'on-change'` — immediately after every local write (respecting `minIntervalMs`).\n * - `'debounce'` — after `debounceMs` of inactivity following a write.\n * - `'interval'` — on a fixed timer regardless of writes.\n */\nexport type PushMode = 'manual' | 'on-change' | 'debounce' | 'interval'\n\n/**\n * When pull operations are triggered automatically.\n *\n * - `'manual'` — only on explicit `sync.pull()` calls.\n * - `'interval'` — on a fixed `intervalMs` timer.\n * - `'on-focus'` — when the browser tab regains visibility.\n */\nexport type PullMode = 'manual' | 'interval' | 'on-focus'\n\n/**\n * Push half of a sync policy. Controls the trigger mode and timing guards\n * for outbound sync operations.\n */\nexport interface PushPolicy {\n /** Push trigger mode. */\n readonly mode: PushMode\n /** Debounce delay in ms. Only used when `mode: 'debounce'`. Default: 30_000. */\n readonly debounceMs?: number\n /** Interval in ms between automatic pushes. Used by `'interval'` and as floor for `'debounce'`. */\n readonly intervalMs?: number\n /**\n * Hard floor between pushes regardless of mode. Prevents burst writes\n * from hammering the remote. Default: 0 (no floor).\n */\n readonly minIntervalMs?: number\n /**\n * Force a push on page unload (`pagehide` / `visibilitychange → hidden`)\n * in browsers, `beforeExit` in Node. Default: true for non-manual modes.\n */\n readonly onUnload?: boolean\n}\n\n/**\n * Pull half of a sync policy. Controls when and how often inbound sync\n * operations are triggered.\n */\nexport interface PullPolicy {\n /** Pull trigger mode. */\n readonly mode: PullMode\n /** Interval in ms between automatic pulls. Used by `'interval'` mode. Default: 60_000. */\n readonly intervalMs?: number\n}\n\n/**\n * Combined push + pull sync scheduling policy for a vault.\n *\n * Pass via `createNoydb({ syncPolicy })` to override the default policy\n * derived from the active store type. Pre-built defaults are available\n * as `INDEXED_STORE_POLICY` and `BUNDLE_STORE_POLICY`.\n */\nexport interface SyncPolicy {\n readonly push: PushPolicy\n readonly pull: PullPolicy\n}\n\n// ─── Default policies by store category ─────────────────────────────────\n\n/** Default for per-record stores (DynamoDB, S3, file, IDB). */\nexport const INDEXED_STORE_POLICY: SyncPolicy = {\n push: { mode: 'on-change', minIntervalMs: 0, onUnload: true },\n pull: { mode: 'manual' },\n}\n\n/** Default for bundle stores (Drive, WebDAV, Git). */\nexport const BUNDLE_STORE_POLICY: SyncPolicy = {\n push: { mode: 'debounce', debounceMs: 30_000, minIntervalMs: 120_000, onUnload: true },\n pull: { mode: 'interval', intervalMs: 60_000 },\n}\n\n// ─── Sync scheduler ─────────────────────────────────────────────────────\n\n/**\n * Current operational state of the `SyncScheduler`.\n *\n * - `'idle'` — no pending or active sync operations.\n * - `'pending'` — local writes are queued, waiting for debounce/interval to fire.\n * - `'pushing'` — push in progress.\n * - `'pulling'` — pull in progress.\n * - `'error'` — last sync operation failed; `lastError` holds the cause.\n */\nexport type SyncSchedulerState = 'idle' | 'pending' | 'pushing' | 'pulling' | 'error'\n\n/**\n * Snapshot of the sync scheduler's state, returned by `SyncScheduler.status`.\n * Safe to expose in a reactive UI status indicator.\n */\nexport interface SyncSchedulerStatus {\n readonly state: SyncSchedulerState\n readonly lastPushAt: string | null\n readonly lastPullAt: string | null\n readonly lastError: Error | null\n readonly pendingWrites: number\n}\n\n/**\n * Callbacks injected into `SyncScheduler` by the SyncEngine.\n *\n * The scheduler owns timers and lifecycle hooks; it delegates actual push/pull\n * work to these callbacks to stay decoupled from the sync implementation.\n */\nexport interface SyncSchedulerCallbacks {\n push(): Promise<void>\n pull(): Promise<void>\n getDirtyCount(): number\n}\n\n/**\n * Manages sync timing according to a `SyncPolicy`.\n *\n * The scheduler owns all timers and lifecycle hooks. It delegates actual\n * push/pull work to callbacks provided by the SyncEngine.\n */\nexport class SyncScheduler {\n private readonly policy: SyncPolicy\n private readonly callbacks: SyncSchedulerCallbacks\n\n private _state: SyncSchedulerState = 'idle'\n private _lastPushAt: string | null = null\n private _lastPullAt: string | null = null\n private _lastError: Error | null = null\n private _lastPushTime = 0 // monotonic ms for minIntervalMs enforcement\n\n // Timers\n private debounceTimer: ReturnType<typeof setTimeout> | null = null\n private pushIntervalTimer: ReturnType<typeof setInterval> | null = null\n private pullIntervalTimer: ReturnType<typeof setInterval> | null = null\n\n // Bound handlers for cleanup\n private readonly boundOnVisibilityChange: (() => void) | null = null\n private readonly boundOnBeforeExit: (() => void) | null = null\n private readonly boundOnPageHide: (() => void) | null = null\n\n private started = false\n\n constructor(policy: SyncPolicy, callbacks: SyncSchedulerCallbacks) {\n this.policy = policy\n this.callbacks = callbacks\n\n // Pre-bind handlers\n if (this.shouldRegisterUnload()) {\n this.boundOnVisibilityChange = this.handleVisibilityChange.bind(this)\n this.boundOnPageHide = this.handlePageHide.bind(this)\n this.boundOnBeforeExit = this.handleBeforeExit.bind(this)\n }\n }\n\n /** Current scheduler status snapshot. */\n get status(): SyncSchedulerStatus {\n return {\n state: this._state,\n lastPushAt: this._lastPushAt,\n lastPullAt: this._lastPullAt,\n lastError: this._lastError,\n pendingWrites: this.callbacks.getDirtyCount(),\n }\n }\n\n /** Start the scheduler — registers timers, event listeners. */\n start(): void {\n if (this.started) return\n this.started = true\n\n // Push: interval mode\n if (this.policy.push.mode === 'interval' && this.policy.push.intervalMs) {\n this.pushIntervalTimer = setInterval(() => {\n void this.executePush()\n }, this.policy.push.intervalMs)\n }\n\n // Pull: interval mode\n if (this.policy.pull.mode === 'interval' && this.policy.pull.intervalMs) {\n this.pullIntervalTimer = setInterval(() => {\n void this.executePull()\n }, this.policy.pull.intervalMs)\n }\n\n // Pull: on-focus mode\n if (this.policy.pull.mode === 'on-focus' && typeof document !== 'undefined') {\n document.addEventListener('visibilitychange', this.handleFocusPull)\n }\n\n // Unload hooks\n if (this.shouldRegisterUnload()) {\n if (typeof document !== 'undefined' && this.boundOnVisibilityChange) {\n document.addEventListener('visibilitychange', this.boundOnVisibilityChange)\n }\n if (typeof globalThis.addEventListener === 'function' && this.boundOnPageHide) {\n globalThis.addEventListener('pagehide', this.boundOnPageHide)\n }\n if (typeof process !== 'undefined' && this.boundOnBeforeExit) {\n process.on('beforeExit', this.boundOnBeforeExit)\n }\n }\n }\n\n /** Stop the scheduler — clears timers, removes event listeners. */\n stop(): void {\n if (!this.started) return\n this.started = false\n\n if (this.debounceTimer) {\n clearTimeout(this.debounceTimer)\n this.debounceTimer = null\n }\n if (this.pushIntervalTimer) {\n clearInterval(this.pushIntervalTimer)\n this.pushIntervalTimer = null\n }\n if (this.pullIntervalTimer) {\n clearInterval(this.pullIntervalTimer)\n this.pullIntervalTimer = null\n }\n\n // Focus pull\n if (this.policy.pull.mode === 'on-focus' && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.handleFocusPull)\n }\n\n // Unload hooks\n if (typeof document !== 'undefined' && this.boundOnVisibilityChange) {\n document.removeEventListener('visibilitychange', this.boundOnVisibilityChange)\n }\n if (typeof globalThis.removeEventListener === 'function' && this.boundOnPageHide) {\n globalThis.removeEventListener('pagehide', this.boundOnPageHide)\n }\n if (typeof process !== 'undefined' && this.boundOnBeforeExit) {\n process.removeListener('beforeExit', this.boundOnBeforeExit)\n }\n }\n\n /**\n * Notify the scheduler that a local write occurred.\n * For `on-change` mode: triggers immediate push (respecting minIntervalMs).\n * For `debounce` mode: resets the debounce timer.\n * For `manual` / `interval`: no-op.\n */\n notifyChange(): void {\n if (!this.started) return\n\n if (this.policy.push.mode === 'on-change') {\n void this.executePush()\n } else if (this.policy.push.mode === 'debounce') {\n this.resetDebounce()\n }\n }\n\n /** Force an immediate push, bypassing the scheduler. */\n async forcePush(): Promise<void> {\n await this.executePush()\n }\n\n /** Force an immediate pull, bypassing the scheduler. */\n async forcePull(): Promise<void> {\n await this.executePull()\n }\n\n // ─── Internal ─────────────────────────────────────────────────────\n\n private async executePush(): Promise<void> {\n if (this._state === 'pushing') return // already in progress\n\n // minIntervalMs enforcement\n const minInterval = this.policy.push.minIntervalMs ?? 0\n if (minInterval > 0) {\n const elapsed = Date.now() - this._lastPushTime\n if (elapsed < minInterval) {\n // Schedule for later if debounce mode\n if (this.policy.push.mode === 'debounce') {\n this.scheduleDebounce(minInterval - elapsed)\n }\n return\n }\n }\n\n // Nothing to push\n if (this.callbacks.getDirtyCount() === 0) {\n this._state = 'idle'\n return\n }\n\n this._state = 'pushing'\n try {\n await this.callbacks.push()\n this._lastPushAt = new Date().toISOString()\n this._lastPushTime = Date.now()\n this._lastError = null\n this._state = this.callbacks.getDirtyCount() > 0 ? 'pending' : 'idle'\n } catch (err) {\n this._lastError = err instanceof Error ? err : new Error(String(err))\n this._state = 'error'\n }\n }\n\n private async executePull(): Promise<void> {\n if (this._state === 'pulling') return\n\n const previousState = this._state\n this._state = 'pulling'\n try {\n await this.callbacks.pull()\n this._lastPullAt = new Date().toISOString()\n this._lastError = null\n this._state = previousState === 'pending' ? 'pending' : 'idle'\n } catch (err) {\n this._lastError = err instanceof Error ? err : new Error(String(err))\n this._state = 'error'\n }\n }\n\n private resetDebounce(): void {\n if (this.debounceTimer) clearTimeout(this.debounceTimer)\n const ms = this.policy.push.debounceMs ?? 30_000\n this._state = 'pending'\n this.scheduleDebounce(ms)\n }\n\n private scheduleDebounce(ms: number): void {\n if (this.debounceTimer) clearTimeout(this.debounceTimer)\n this.debounceTimer = setTimeout(() => {\n this.debounceTimer = null\n void this.executePush()\n }, ms)\n }\n\n private shouldRegisterUnload(): boolean {\n const onUnload = this.policy.push.onUnload\n if (onUnload !== undefined) return onUnload\n return this.policy.push.mode !== 'manual'\n }\n\n // ─── Event handlers ───────────────────────────────────────────────\n\n private handleVisibilityChange(): void {\n if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {\n this.fireUnloadPush()\n }\n }\n\n private handlePageHide(): void {\n this.fireUnloadPush()\n }\n\n private handleBeforeExit(): void {\n this.fireUnloadPush()\n }\n\n private handleFocusPull = (): void => {\n if (typeof document !== 'undefined' && document.visibilityState === 'visible') {\n void this.executePull()\n }\n }\n\n private fireUnloadPush(): void {\n if (this.callbacks.getDirtyCount() === 0) return\n // Best-effort synchronous-ish push on unload\n void this.callbacks.push().catch(() => {})\n }\n}\n","import type {\n NoydbStore,\n DirtyEntry,\n Conflict,\n ConflictStrategy,\n CollectionConflictResolver,\n PushOptions,\n PullOptions,\n PushResult,\n PullResult,\n SyncStatus,\n EncryptedEnvelope,\n SyncMetadata,\n SyncTargetRole,\n} from '../types.js'\nimport { NOYDB_SYNC_VERSION } from '../types.js'\nimport { ConflictError } from '../errors.js'\nimport type { NoydbEventEmitter } from '../events.js'\nimport type { SyncPolicy } from '../store/sync-policy.js'\nimport { SyncScheduler } from '../store/sync-policy.js'\n\n/** Sync engine: dirty tracking, push, pull, conflict resolution, scheduling. */\nexport class SyncEngine {\n private readonly local: NoydbStore\n private readonly remote: NoydbStore\n private readonly strategy: ConflictStrategy\n private readonly emitter: NoydbEventEmitter\n private readonly vault: string\n readonly role: SyncTargetRole\n readonly label: string | undefined\n\n private dirty: DirtyEntry[] = []\n private lastPush: string | null = null\n private lastPull: string | null = null\n private loaded = false\n private autoSyncInterval: ReturnType<typeof setInterval> | null = null\n private isOnline = true\n\n /** Sync scheduler. Manages push/pull timing. */\n readonly scheduler: SyncScheduler | null\n\n /** Per-collection conflict resolvers registered by Collection instances. */\n private readonly conflictResolvers = new Map<string, CollectionConflictResolver>()\n\n constructor(opts: {\n local: NoydbStore\n remote: NoydbStore\n vault: string\n strategy: ConflictStrategy\n emitter: NoydbEventEmitter\n syncPolicy?: SyncPolicy\n role?: SyncTargetRole\n label?: string\n }) {\n this.local = opts.local\n this.remote = opts.remote\n this.vault = opts.vault\n this.strategy = opts.strategy\n this.emitter = opts.emitter\n this.role = opts.role ?? 'sync-peer'\n this.label = opts.label\n\n // Create scheduler if a policy is provided\n const policy = opts.syncPolicy\n if (policy && policy.push.mode !== 'manual') {\n this.scheduler = new SyncScheduler(policy, {\n push: () => this.push().then(() => {}),\n pull: () => this.pull().then(() => {}),\n getDirtyCount: () => this.dirty.length,\n })\n } else {\n this.scheduler = null\n }\n }\n\n /** Start the sync scheduler. Called after vault is fully opened. */\n startScheduler(): void {\n this.scheduler?.start()\n }\n\n /** Stop the sync scheduler. Called on close. */\n stopScheduler(): void {\n this.scheduler?.stop()\n }\n\n /**\n * Register a per-collection conflict resolver.\n * Called by Collection when `conflictPolicy` is set.\n */\n registerConflictResolver(collection: string, resolver: CollectionConflictResolver): void {\n this.conflictResolvers.set(collection, resolver)\n }\n\n /** Record a local change for later push. */\n async trackChange(collection: string, id: string, action: 'put' | 'delete', version: number): Promise<void> {\n await this.ensureLoaded()\n\n // Deduplicate: if same collection+id already in dirty, update it\n const idx = this.dirty.findIndex(d => d.collection === collection && d.id === id)\n const entry: DirtyEntry = {\n vault: this.vault,\n collection,\n id,\n action,\n version,\n timestamp: new Date().toISOString(),\n }\n\n if (idx >= 0) {\n this.dirty[idx] = entry\n } else {\n this.dirty.push(entry)\n }\n\n await this.persistMeta()\n\n // Notify scheduler of the write (triggers on-change or debounce)\n this.scheduler?.notifyChange()\n }\n\n /** Push dirty records to remote adapter. Accepts optional `PushOptions` for partial sync. */\n async push(options?: PushOptions): Promise<PushResult> {\n await this.ensureLoaded()\n\n let pushed = 0\n const conflicts: Conflict[] = []\n const errors: Error[] = []\n const completed: number[] = []\n\n for (let i = 0; i < this.dirty.length; i++) {\n const entry = this.dirty[i]!\n\n // Partial sync: skip collections not in the filter\n if (options?.collections && !options.collections.includes(entry.collection)) {\n continue\n }\n\n try {\n if (entry.action === 'delete') {\n await this.remote.delete(this.vault, entry.collection, entry.id)\n completed.push(i)\n pushed++\n } else {\n const envelope = await this.local.get(this.vault, entry.collection, entry.id)\n if (!envelope) {\n // Record was deleted locally after being marked dirty\n completed.push(i)\n continue\n }\n\n try {\n await this.remote.put(\n this.vault,\n entry.collection,\n entry.id,\n envelope,\n entry.version - 1,\n )\n completed.push(i)\n pushed++\n } catch (err) {\n if (err instanceof ConflictError) {\n const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id)\n if (remoteEnvelope) {\n const { handled, conflict } = await this.handleConflict(\n entry.collection,\n entry.id,\n envelope,\n remoteEnvelope,\n 'push',\n )\n conflicts.push(conflict)\n if (handled === 'local') {\n await this.remote.put(this.vault, entry.collection, entry.id, conflict.local)\n completed.push(i)\n pushed++\n } else if (handled === 'remote') {\n await this.local.put(this.vault, entry.collection, entry.id, conflict.remote)\n completed.push(i)\n } else if (handled === 'merged' && conflict.local !== envelope) {\n // Merged envelope is stored in conflict.local (the winner)\n const merged = conflict.local\n await this.remote.put(this.vault, entry.collection, entry.id, merged)\n await this.local.put(this.vault, entry.collection, entry.id, merged)\n completed.push(i)\n pushed++\n }\n // handled === 'deferred': leave in dirty log\n }\n } else {\n throw err\n }\n }\n }\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n }\n\n // Remove completed entries from dirty log (reverse order to preserve indices)\n for (const i of completed.sort((a, b) => b - a)) {\n this.dirty.splice(i, 1)\n }\n\n this.lastPush = new Date().toISOString()\n await this.persistMeta()\n\n const result: PushResult = { pushed, conflicts, errors }\n this.emitter.emit('sync:push', result)\n return result\n }\n\n /** Pull remote records to local adapter. Accepts optional `PullOptions` for partial sync. */\n async pull(options?: PullOptions): Promise<PullResult> {\n await this.ensureLoaded()\n\n let pulled = 0\n const conflicts: Conflict[] = []\n const errors: Error[] = []\n\n try {\n const remoteSnapshot = await this.remote.loadAll(this.vault)\n\n for (const [collName, records] of Object.entries(remoteSnapshot)) {\n // Partial sync: skip collections not in the filter\n if (options?.collections && !options.collections.includes(collName)) {\n continue\n }\n\n for (const [id, remoteEnvelope] of Object.entries(records)) {\n // Partial sync: modifiedSince filter\n if (options?.modifiedSince && remoteEnvelope._ts <= options.modifiedSince) {\n continue\n }\n\n try {\n const localEnvelope = await this.local.get(this.vault, collName, id)\n\n if (!localEnvelope) {\n // New record from remote\n await this.local.put(this.vault, collName, id, remoteEnvelope)\n pulled++\n } else if (remoteEnvelope._v > localEnvelope._v) {\n // Remote is newer — check if we have a dirty entry for this\n const isDirty = this.dirty.some(d => d.collection === collName && d.id === id)\n if (isDirty) {\n // Both changed — conflict\n const { handled, conflict } = await this.handleConflict(\n collName,\n id,\n localEnvelope,\n remoteEnvelope,\n 'pull',\n )\n conflicts.push(conflict)\n if (handled === 'remote') {\n await this.local.put(this.vault, collName, id, conflict.remote)\n this.dirty = this.dirty.filter(d => !(d.collection === collName && d.id === id))\n pulled++\n } else if (handled === 'merged' && conflict.local !== localEnvelope) {\n const merged = conflict.local\n await this.local.put(this.vault, collName, id, merged)\n this.dirty = this.dirty.filter(d => !(d.collection === collName && d.id === id))\n pulled++\n }\n // 'local' or 'deferred': push handles it\n } else {\n // Remote is newer, no local changes — update\n await this.local.put(this.vault, collName, id, remoteEnvelope)\n pulled++\n }\n }\n // Same version or local is newer — skip (push will handle)\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n }\n }\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n\n this.lastPull = new Date().toISOString()\n await this.persistMeta()\n\n const result: PullResult = { pulled, conflicts, errors }\n this.emitter.emit('sync:pull', result)\n return result\n }\n\n /** Bidirectional sync: pull then push. */\n async sync(options?: { push?: PushOptions; pull?: PullOptions }): Promise<{ pull: PullResult; push: PushResult }> {\n const pullResult = await this.pull(options?.pull)\n const pushResult = await this.push(options?.push)\n return { pull: pullResult, push: pushResult }\n }\n\n /**\n * Push a specific subset of dirty entries (for sync transactions, ).\n * Entries are matched by collection+id from the dirty log; matched entries\n * are removed from the dirty log on success.\n */\n async pushFiltered(predicate: (entry: DirtyEntry) => boolean): Promise<PushResult> {\n await this.ensureLoaded()\n\n let pushed = 0\n const conflicts: Conflict[] = []\n const errors: Error[] = []\n const completed: number[] = []\n\n for (let i = 0; i < this.dirty.length; i++) {\n const entry = this.dirty[i]!\n if (!predicate(entry)) continue\n\n try {\n if (entry.action === 'delete') {\n await this.remote.delete(this.vault, entry.collection, entry.id)\n completed.push(i)\n pushed++\n } else {\n const envelope = await this.local.get(this.vault, entry.collection, entry.id)\n if (!envelope) {\n completed.push(i)\n continue\n }\n\n try {\n await this.remote.put(\n this.vault,\n entry.collection,\n entry.id,\n envelope,\n entry.version - 1,\n )\n completed.push(i)\n pushed++\n } catch (err) {\n if (err instanceof ConflictError) {\n const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id)\n if (remoteEnvelope) {\n const { handled, conflict } = await this.handleConflict(\n entry.collection,\n entry.id,\n envelope,\n remoteEnvelope,\n 'push',\n )\n conflicts.push(conflict)\n if (handled === 'local') {\n await this.remote.put(this.vault, entry.collection, entry.id, conflict.local)\n completed.push(i)\n pushed++\n } else if (handled === 'remote') {\n await this.local.put(this.vault, entry.collection, entry.id, conflict.remote)\n completed.push(i)\n } else if (handled === 'merged' && conflict.local !== envelope) {\n const merged = conflict.local\n await this.remote.put(this.vault, entry.collection, entry.id, merged)\n await this.local.put(this.vault, entry.collection, entry.id, merged)\n completed.push(i)\n pushed++\n }\n }\n } else {\n throw err\n }\n }\n }\n } catch (err) {\n errors.push(err instanceof Error ? err : new Error(String(err)))\n }\n }\n\n for (const i of completed.sort((a, b) => b - a)) {\n this.dirty.splice(i, 1)\n }\n\n this.lastPush = new Date().toISOString()\n await this.persistMeta()\n\n const result: PushResult = { pushed, conflicts, errors }\n this.emitter.emit('sync:push', result)\n return result\n }\n\n /** Get current sync status. */\n status(): SyncStatus {\n return {\n dirty: this.dirty.length,\n lastPush: this.lastPush,\n lastPull: this.lastPull,\n online: this.isOnline,\n }\n }\n\n // ─── Auto-Sync ───────────────────────────────────────────────────\n\n /** Start auto-sync: listen for online/offline events, optional periodic sync. */\n startAutoSync(intervalMs?: number): void {\n // Online/offline detection\n if (typeof globalThis.addEventListener === 'function') {\n globalThis.addEventListener('online', this.handleOnline)\n globalThis.addEventListener('offline', this.handleOffline)\n }\n\n // Periodic sync\n if (intervalMs && intervalMs > 0) {\n this.autoSyncInterval = setInterval(() => {\n if (this.isOnline) {\n void this.sync()\n }\n }, intervalMs)\n }\n }\n\n /** Stop auto-sync and scheduler. */\n stopAutoSync(): void {\n this.stopScheduler()\n if (typeof globalThis.removeEventListener === 'function') {\n globalThis.removeEventListener('online', this.handleOnline)\n globalThis.removeEventListener('offline', this.handleOffline)\n }\n if (this.autoSyncInterval) {\n clearInterval(this.autoSyncInterval)\n this.autoSyncInterval = null\n }\n }\n\n private handleOnline = (): void => {\n this.isOnline = true\n this.emitter.emit('sync:online', undefined as never)\n void this.sync()\n }\n\n private handleOffline = (): void => {\n this.isOnline = false\n this.emitter.emit('sync:offline', undefined as never)\n }\n\n /**\n * Resolve a conflict, checking per-collection resolvers first,\n * then falling back to the db-level `ConflictStrategy`.\n *\n * Returns the resolved `Conflict` object (possibly with `resolve` set for\n * manual mode) and a `handled` discriminant:\n * - `'local'` — keep the local envelope; push it to remote.\n * - `'remote'` — keep the remote envelope; update local.\n * - `'merged'` — a custom merge fn produced a new envelope stored as `conflict.local`.\n * - `'deferred'` — manual mode, resolve was not called synchronously.\n */\n private async handleConflict(\n collection: string,\n id: string,\n local: EncryptedEnvelope,\n remote: EncryptedEnvelope,\n _phase: 'push' | 'pull',\n ): Promise<{ handled: 'local' | 'remote' | 'merged' | 'deferred'; conflict: Conflict }> {\n const resolver = this.conflictResolvers.get(collection)\n\n if (resolver) {\n // Per-collection resolver is responsible for emitting sync:conflict\n // (manual policy emits with a resolve callback; LWW/FWW/custom are silent).\n const winner = await resolver(id, local, remote)\n const base: Conflict = {\n vault: this.vault,\n collection,\n id,\n local,\n remote,\n localVersion: local._v,\n remoteVersion: remote._v,\n }\n if (winner === null) return { handled: 'deferred', conflict: base }\n if (winner === local) return { handled: 'local', conflict: base }\n if (winner === remote) return { handled: 'remote', conflict: base }\n // Custom merge fn produced a new envelope — store as conflict.local for the caller\n return {\n handled: 'merged',\n conflict: { ...base, local: winner, localVersion: winner._v },\n }\n }\n\n // Fall back to db-level strategy — emit once\n const baseConflict: Conflict = {\n vault: this.vault,\n collection,\n id,\n local,\n remote,\n localVersion: local._v,\n remoteVersion: remote._v,\n }\n this.emitter.emit('sync:conflict', baseConflict)\n const side = this.legacyResolve(baseConflict)\n return { handled: side, conflict: baseConflict }\n }\n\n /** DB-level ConflictStrategy resolution (legacy, kept for backward compat). */\n private legacyResolve(conflict: Conflict): 'local' | 'remote' {\n if (typeof this.strategy === 'function') {\n return this.strategy(conflict)\n }\n switch (this.strategy) {\n case 'local-wins': return 'local'\n case 'remote-wins': return 'remote'\n case 'version':\n default:\n return conflict.localVersion >= conflict.remoteVersion ? 'local' : 'remote'\n }\n }\n\n // ─── Persistence ─────────────────────────────────────────────────\n\n private async ensureLoaded(): Promise<void> {\n if (this.loaded) return\n\n const envelope = await this.local.get(this.vault, '_sync', 'meta')\n if (envelope) {\n const meta = JSON.parse(envelope._data) as SyncMetadata\n this.dirty = [...meta.dirty]\n this.lastPush = meta.last_push\n this.lastPull = meta.last_pull\n }\n\n this.loaded = true\n }\n\n private async persistMeta(): Promise<void> {\n const meta: SyncMetadata = {\n _noydb_sync: NOYDB_SYNC_VERSION,\n last_push: this.lastPush,\n last_pull: this.lastPull,\n dirty: this.dirty,\n }\n\n const envelope: EncryptedEnvelope = {\n _noydb: 1,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(meta),\n }\n\n await this.local.put(this.vault, '_sync', 'meta', envelope)\n }\n}\n","import type { SyncTransactionResult } from '../types.js'\nimport type { SyncEngine } from './sync.js'\nimport type { Vault } from '../vault.js'\n\ninterface TxOp {\n readonly type: 'put' | 'delete'\n readonly collection: string\n readonly id: string\n readonly record?: unknown\n}\n\n/**\n * Sync transaction.\n *\n * Stages local writes and then pushes only those records to remote in a\n * single batch. If any record conflicts during the push, the result\n * carries `status: 'conflict'` — no automatic rollback is performed;\n * the caller handles conflict resolution.\n *\n * Obtain via `db.transaction(compartmentName)`.\n */\nexport class SyncTransaction {\n private readonly comp: Vault\n private readonly engine: SyncEngine\n private readonly ops: TxOp[] = []\n\n /** @internal — constructed by `Noydb.transaction()` */\n constructor(comp: Vault, engine: SyncEngine) {\n this.comp = comp\n this.engine = engine\n }\n\n /** Stage a record write. Does not write to any adapter until `commit()`. */\n put(collection: string, id: string, record: unknown): this {\n this.ops.push({ type: 'put', collection, id, record })\n return this\n }\n\n /** Stage a record delete. Does not write to any adapter until `commit()`. */\n delete(collection: string, id: string): this {\n this.ops.push({ type: 'delete', collection, id })\n return this\n }\n\n /**\n * Commit the transaction.\n *\n * Phase 1 — writes all staged operations to the local adapter via the\n * collection layer (encryption + dirty-log tracking).\n *\n * Phase 2 — pushes only the records that were written in this\n * transaction to the remote adapter. Existing dirty entries from\n * outside this transaction are not affected.\n *\n * If any record conflicts during the push, `status` is `'conflict'`\n * and `conflicts` lists the affected records. No automatic rollback is\n * performed.\n */\n async commit(): Promise<SyncTransactionResult> {\n // Phase 1: write all staged ops to local via collection layer\n for (const op of this.ops) {\n if (op.type === 'put') {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n await (this.comp.collection<any>(op.collection)).put(op.id, op.record as any)\n } else {\n await this.comp.collection(op.collection).delete(op.id)\n }\n }\n\n // Phase 2: push only the records from this transaction\n const opSet = new Set<string>()\n for (const op of this.ops) {\n opSet.add(`${op.collection}::${op.id}`)\n }\n\n const pushResult = await this.engine.pushFiltered(\n (entry) => opSet.has(`${entry.collection}::${entry.id}`),\n )\n\n return {\n status: pushResult.conflicts.length > 0 ? 'conflict' : 'committed',\n pushed: pushResult.pushed,\n conflicts: pushResult.conflicts,\n }\n }\n}\n","/**\n * Presence handle — real-time awareness of who is viewing/editing a collection.\n * encrypted ephemeral channel keyed by collection DEK via HKDF.\n *\n * The presence key is derived from the collection DEK so:\n * - The adapter never learns user identities from presence payloads.\n * - Presence rotates automatically when the DEK rotates (revoked users\n * can no longer participate after a DEK rotation).\n *\n * Two transport strategies:\n * 1. **Pub/sub** (real-time): used when the adapter implements\n * `presencePublish` and `presenceSubscribe`.\n * 2. **Storage-poll** (fallback): presence records are written to a\n * reserved `_presence_<collection>` collection on the sync adapter\n * (if available) or local adapter, and polled periodically.\n */\n\nimport type { NoydbStore, PresencePeer } from '../types.js'\nimport { encrypt, decrypt, generateIV, bufferToBase64, derivePresenceKey } from '../crypto.js'\n\n/** Options for constructing a PresenceHandle. @internal */\nexport interface PresenceHandleOpts {\n /** Local adapter for storage-poll fallback. */\n adapter: NoydbStore\n /** Remote (sync) adapter — preferred for broadcasting presence if available. */\n syncAdapter?: NoydbStore\n /** Vault name — used as part of the channel and storage key. */\n vault: string\n /** Collection name — used as HKDF `info` and channel suffix. */\n collectionName: string\n /** Calling user's ID, embedded unencrypted in storage records. */\n userId: string\n /** Whether encryption is active. When false, presence payloads are stored as JSON. */\n encrypted: boolean\n /** Callback that resolves the collection DEK (used to derive the presence key). */\n getDEK: (collectionName: string) => Promise<CryptoKey>\n /** How long (ms) before a peer's presence is considered stale. Default: 30_000. */\n staleMs?: number\n /** Poll interval (ms) for the storage-poll fallback. Default: 5_000. */\n pollIntervalMs?: number\n}\n\n/**\n * Internal storage envelope for the storage-poll fallback.\n * Written to `_presence_<collection>` as `{ userId, lastSeen, iv, data }`.\n */\ninterface StoragePresenceRecord {\n userId: string\n lastSeen: string\n iv: string // base64 AES-GCM IV (empty when not encrypted)\n data: string // base64 ciphertext or JSON string when not encrypted\n}\n\n/** Presence handle for a single collection. */\nexport class PresenceHandle<P> {\n private readonly adapter: NoydbStore\n private readonly syncAdapter: NoydbStore | undefined\n private readonly vault: string\n private readonly collectionName: string\n private readonly userId: string\n private readonly encrypted: boolean\n private readonly getDEK: (collectionName: string) => Promise<CryptoKey>\n private readonly staleMs: number\n private readonly pollIntervalMs: number\n private readonly channel: string\n private readonly storageCollection: string\n\n private presenceKey: CryptoKey | null = null\n private subscribers: Array<(peers: PresencePeer<P>[]) => void> = []\n private unsubscribePubSub: (() => void) | null = null\n private pollTimer: ReturnType<typeof setInterval> | null = null\n private stopped = false\n\n constructor(opts: PresenceHandleOpts) {\n this.adapter = opts.adapter\n this.syncAdapter = opts.syncAdapter\n this.vault = opts.vault\n this.collectionName = opts.collectionName\n this.userId = opts.userId\n this.encrypted = opts.encrypted\n this.getDEK = opts.getDEK\n this.staleMs = opts.staleMs ?? 30_000\n this.pollIntervalMs = opts.pollIntervalMs ?? 5_000\n // Channel used by pub/sub adapters — vault-scoped so two collections\n // in the same vault don't bleed into each other's presence channels.\n this.channel = `${opts.vault}:${opts.collectionName}:presence`\n // Reserved collection name for the storage-poll fallback.\n this.storageCollection = `_presence_${opts.collectionName}`\n }\n\n /**\n * Announce yourself (or update your cursor/status).\n * Encrypts `payload` with the presence key and publishes it.\n */\n async update(payload: P): Promise<void> {\n if (this.stopped) return\n\n const key = await this.getPresenceKey()\n const now = new Date().toISOString()\n const plaintext = JSON.stringify({ userId: this.userId, lastSeen: now, payload })\n let encryptedPayload: string\n\n if (this.encrypted && key) {\n const iv = generateIV()\n const ivB64 = bufferToBase64(iv)\n const { data } = await encrypt(plaintext, key)\n encryptedPayload = JSON.stringify({ iv: ivB64, data })\n } else {\n encryptedPayload = plaintext\n }\n\n // Pub/sub path — publish to any adapter that supports it\n const pubAdapter = this.getPubSubAdapter()\n if (pubAdapter?.presencePublish) {\n await pubAdapter.presencePublish(this.channel, encryptedPayload)\n }\n\n // Storage-poll path — write a record to the storage adapter\n await this.writeStorageRecord(payload, now)\n }\n\n /**\n * Subscribe to presence updates. The callback receives a filtered, decrypted\n * list of all currently-active peers (excluding yourself, excluding stale).\n *\n * Returns an unsubscribe function. Also call `stop()` to release all resources.\n */\n subscribe(cb: (peers: PresencePeer<P>[]) => void): () => void {\n if (this.stopped) return () => {}\n\n this.subscribers.push(cb)\n\n // Start pub/sub listener on first subscriber\n if (this.subscribers.length === 1) {\n this.startListening()\n }\n\n return () => {\n this.subscribers = this.subscribers.filter(s => s !== cb)\n if (this.subscribers.length === 0) this.stopListening()\n }\n }\n\n /** Stop all listening and clear resources. */\n stop(): void {\n this.stopped = true\n this.stopListening()\n this.subscribers = []\n }\n\n // ─── Private ────────────────────────────────────────────────────────\n\n private async getPresenceKey(): Promise<CryptoKey | null> {\n if (!this.encrypted) return null\n if (!this.presenceKey) {\n try {\n const dek = await this.getDEK(this.collectionName)\n this.presenceKey = await derivePresenceKey(dek, this.collectionName)\n } catch {\n // no-op — presence degrades gracefully if crypto fails\n }\n }\n return this.presenceKey\n }\n\n private getPubSubAdapter(): NoydbStore | undefined {\n // Prefer the sync adapter (it broadcasts to other devices)\n if (this.syncAdapter?.presencePublish) return this.syncAdapter\n if (this.adapter.presencePublish) return this.adapter\n return undefined\n }\n\n private startListening(): void {\n const pubAdapter = this.getPubSubAdapter()\n\n if (pubAdapter?.presenceSubscribe) {\n // Real-time pub/sub path\n this.unsubscribePubSub = pubAdapter.presenceSubscribe(\n this.channel,\n (encryptedPayload) => { void this.handlePubSubMessage(encryptedPayload) },\n )\n } else {\n // Storage-poll fallback\n this.pollTimer = setInterval(\n () => { void this.pollStoragePresence() },\n this.pollIntervalMs,\n )\n // Kick off an immediate poll\n void this.pollStoragePresence()\n }\n }\n\n private stopListening(): void {\n if (this.unsubscribePubSub) {\n this.unsubscribePubSub()\n this.unsubscribePubSub = null\n }\n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = null\n }\n }\n\n private async handlePubSubMessage(encryptedPayload: string): Promise<void> {\n try {\n const peer = await this.decryptPresencePayload(encryptedPayload)\n if (!peer || peer.userId === this.userId) return\n\n const cutoff = new Date(Date.now() - this.staleMs).toISOString()\n if (peer.lastSeen < cutoff) return\n\n // Deliver only this new peer to subscribers; a full snapshot poll follows\n // on next interval. For pub/sub, we could maintain a map of active peers,\n // but for simplicity: emit a snapshot read from storage.\n await this.pollStoragePresence()\n } catch {\n // Decrypt failure — stale key or tampered payload, ignore\n }\n }\n\n private async decryptPresencePayload(\n encryptedPayload: string,\n ): Promise<{ userId: string; lastSeen: string; payload: P } | null> {\n const key = await this.getPresenceKey()\n\n if (!this.encrypted || !key) {\n return JSON.parse(encryptedPayload) as { userId: string; lastSeen: string; payload: P }\n }\n\n const { iv: ivB64, data } = JSON.parse(encryptedPayload) as { iv: string; data: string }\n const plaintext = await decrypt(ivB64, data, key)\n return JSON.parse(plaintext) as { userId: string; lastSeen: string; payload: P }\n }\n\n private async writeStorageRecord(payload: P, now: string): Promise<void> {\n const key = await this.getPresenceKey()\n const plaintext = JSON.stringify(payload)\n let iv = ''\n let data: string\n\n if (this.encrypted && key) {\n const ivBytes = generateIV()\n iv = bufferToBase64(ivBytes)\n const result = await encrypt(plaintext, key)\n data = result.data\n } else {\n data = plaintext\n }\n\n const record: StoragePresenceRecord = { userId: this.userId, lastSeen: now, iv, data }\n const json = JSON.stringify(record)\n\n // Use the sync adapter if available (so other devices can read it);\n // fall back to local adapter.\n const storeAdapter = this.syncAdapter ?? this.adapter\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: now,\n _iv: '',\n _data: json,\n }\n try {\n await storeAdapter.put(\n this.vault,\n this.storageCollection,\n this.userId,\n envelope,\n )\n } catch {\n // Presence write failure is non-fatal — the user is still present locally\n }\n }\n\n private async pollStoragePresence(): Promise<void> {\n if (this.stopped || this.subscribers.length === 0) return\n\n try {\n const storeAdapter = this.syncAdapter ?? this.adapter\n const ids = await storeAdapter.list(this.vault, this.storageCollection)\n const cutoff = new Date(Date.now() - this.staleMs).toISOString()\n const peers: PresencePeer<P>[] = []\n\n for (const id of ids) {\n if (id === this.userId) continue // skip ourselves\n const envelope = await storeAdapter.get(this.vault, this.storageCollection, id)\n if (!envelope) continue\n\n const record = JSON.parse(envelope._data) as StoragePresenceRecord\n if (record.lastSeen < cutoff) continue\n\n let peerPayload: P\n if (this.encrypted && this.presenceKey && record.iv) {\n const plaintext = await decrypt(record.iv, record.data, this.presenceKey)\n peerPayload = JSON.parse(plaintext) as P\n } else {\n peerPayload = JSON.parse(record.data) as P\n }\n\n peers.push({ userId: record.userId, payload: peerPayload, lastSeen: record.lastSeen })\n }\n\n for (const cb of this.subscribers) {\n cb(peers)\n }\n } catch {\n // Poll failure is non-fatal\n }\n }\n}\n","/**\n * _sync_credentials reserved collection —\n *\n * Stores per-adapter OAuth tokens (and any other long-lived sync secrets) as\n * encrypted records inside the vault itself. Tokens are wrapped with the\n * compartment's own DEK, live on disk as ciphertext like any other record, and\n * are accessed only through the dedicated API in this module — never via\n * `vault.collection('_sync_credentials')`.\n *\n * Design decisions\n * ────────────────\n *\n * **Why a reserved collection, not a separate store?**\n * The compartment's existing encryption stack (AES-256-GCM + collection DEK)\n * is exactly the right primitive for protecting OAuth tokens at rest. Using a\n * separate store would require a new encryption surface, new adapter calls,\n * and a new backup/restore path — all of which already exist for collections.\n *\n * **Why not exposed as a regular collection?**\n * The same reason `_keyring` and `_ledger` aren't: they have invariants that\n * must be enforced (naming scheme, no cross-user leakage, no schema\n * validation, no history/ledger writes for privacy). Routing through a\n * dedicated API enforces those invariants.\n *\n * **Token lifecycle:**\n * - `putCredential(vault, adapterId, token)` — store or overwrite\n * - `getCredential(vault, adapterId)` — load and decrypt\n * - `deleteCredential(vault, adapterId)` — remove\n * - `listCredentials(vault)` — enumerate adapter IDs (not tokens)\n *\n * The `adapterId` is the record ID within the `_sync_credentials` collection.\n * It should be a stable, human-readable identifier for the adapter instance\n * (e.g. `'google-drive'`, `'dropbox'`, `'s3-prod'`).\n *\n * **ACL:** only `owner` and `admin` roles can read/write sync credentials.\n * Operators, viewers, and clients cannot call this API. The check is made\n * against the caller's keyring role at call time.\n */\n\nimport type { NoydbStore, EncryptedEnvelope } from '../types.js'\nimport { NOYDB_FORMAT_VERSION } from '../types.js'\nimport type { UnlockedKeyring } from './keyring.js'\nimport { encrypt, decrypt } from '../crypto.js'\nimport { ensureCollectionDEK } from './keyring.js'\nimport { PermissionDeniedError } from '../errors.js'\n\n/** The reserved collection name. Never collides with user collections. */\nexport const SYNC_CREDENTIALS_COLLECTION = '_sync_credentials'\n\n// ─── Token types ──────────────────────────────────────────────────────\n\n/**\n * An OAuth/auth token stored in `_sync_credentials`.\n *\n * Fields mirror the OAuth2 token response shape. `customData` is an escape\n * hatch for adapter-specific secrets (API keys, connection strings, etc.)\n * that don't fit the OAuth2 shape.\n */\nexport interface SyncCredential {\n /** Stable identifier for the adapter instance (e.g. 'google-drive'). */\n readonly adapterId: string\n /** OAuth token type, usually 'Bearer'. */\n readonly tokenType: string\n /** The access token. Expires at `expiresAt` if set. */\n readonly accessToken: string\n /** Long-lived refresh token for renewing the access token. */\n readonly refreshToken?: string\n /** ISO timestamp when `accessToken` expires. Absent means \"no expiry\". */\n readonly expiresAt?: string\n /** Space-separated OAuth scopes. */\n readonly scopes?: string\n /** Adapter-specific opaque data (API keys, endpoints, etc.). */\n readonly customData?: Record<string, string>\n}\n\n// ─── Access check ─────────────────────────────────────────────────────\n\nfunction requireAdminAccess(keyring: UnlockedKeyring): void {\n if (keyring.role !== 'owner' && keyring.role !== 'admin') {\n throw new PermissionDeniedError(\n `Sync credentials require owner or admin role. Current role: \"${keyring.role}\"`,\n )\n }\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/**\n * Store or overwrite a sync credential for the given adapter.\n *\n * The credential is encrypted with the `_sync_credentials` collection DEK\n * (auto-generated on first use). The record ID is the `adapterId`.\n *\n * Requires owner or admin role.\n */\nexport async function putCredential(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n credential: SyncCredential,\n): Promise<void> {\n requireAdminAccess(keyring)\n\n const getDek = await ensureCollectionDEK(adapter, vault, keyring)\n const dek = await getDek(SYNC_CREDENTIALS_COLLECTION)\n\n const { iv, data } = await encrypt(JSON.stringify(credential), dek)\n\n const existing = await adapter.get(vault, SYNC_CREDENTIALS_COLLECTION, credential.adapterId)\n const version = existing ? existing._v + 1 : 1\n\n const envelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: version,\n _ts: new Date().toISOString(),\n _iv: iv,\n _data: data,\n _by: keyring.userId,\n }\n\n await adapter.put(\n vault,\n SYNC_CREDENTIALS_COLLECTION,\n credential.adapterId,\n envelope,\n existing ? existing._v : undefined,\n )\n}\n\n/**\n * Load and decrypt a sync credential for the given adapter ID.\n *\n * Returns `null` if no credential exists for this adapter.\n * Requires owner or admin role.\n */\nexport async function getCredential(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n adapterId: string,\n): Promise<SyncCredential | null> {\n requireAdminAccess(keyring)\n\n const getDek = await ensureCollectionDEK(adapter, vault, keyring)\n const dek = await getDek(SYNC_CREDENTIALS_COLLECTION)\n\n const envelope = await adapter.get(vault, SYNC_CREDENTIALS_COLLECTION, adapterId)\n if (!envelope) return null\n\n const plaintext = await decrypt(envelope._iv, envelope._data, dek)\n return JSON.parse(plaintext) as SyncCredential\n}\n\n/**\n * Delete a sync credential by adapter ID.\n *\n * No-op if the credential doesn't exist. Requires owner or admin role.\n */\nexport async function deleteCredential(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n adapterId: string,\n): Promise<void> {\n requireAdminAccess(keyring)\n await adapter.delete(vault, SYNC_CREDENTIALS_COLLECTION, adapterId)\n}\n\n/**\n * List all adapter IDs that have stored credentials.\n *\n * Returns only the IDs, never the credential payloads. Useful for\n * displaying \"connected adapters\" in UI without decrypting tokens.\n * Requires owner or admin role.\n */\nexport async function listCredentials(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<string[]> {\n requireAdminAccess(keyring)\n return adapter.list(vault, SYNC_CREDENTIALS_COLLECTION)\n}\n\n/**\n * Check whether a credential exists and whether its access token has expired.\n *\n * Returns `{ exists: false }` if no credential is stored, or\n * `{ exists: true, expired: boolean }` based on the `expiresAt` field.\n * Requires owner or admin role.\n */\nexport async function credentialStatus(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n adapterId: string,\n): Promise<{ exists: false } | { exists: true; expired: boolean }> {\n const credential = await getCredential(adapter, vault, keyring, adapterId)\n if (!credential) return { exists: false }\n\n const expired = credential.expiresAt\n ? Date.now() > new Date(credential.expiresAt).getTime()\n : false\n\n return { exists: true, expired }\n}\n"],"mappings":"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","names":["PBKDF2_ITERATIONS","SALT_BYTES","IV_BYTES","subtle","bytesToBase64","writeKeyringFile","deks","subtle"]}
|