@noy-db/hub 0.1.0-pre.4 → 0.1.0-pre.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blobs/index.cjs.map +1 -1
- package/dist/blobs/index.d.cts +3 -3
- package/dist/blobs/index.d.ts +3 -3
- package/dist/blobs/index.js +2 -2
- package/dist/bundle/index.cjs +26 -3
- package/dist/bundle/index.cjs.map +1 -1
- package/dist/bundle/index.d.cts +3 -3
- package/dist/bundle/index.d.ts +3 -3
- package/dist/bundle/index.js +3 -1
- package/dist/{chunk-LSZHBNDG.js → chunk-3WCRU7TI.js} +2 -2
- package/dist/{chunk-PSHTHSIX.js → chunk-6IJQ27XN.js} +213 -10
- package/dist/chunk-6IJQ27XN.js.map +1 -0
- package/dist/{chunk-O5GK62FJ.js → chunk-B6HF6NTZ.js} +1 -1
- package/dist/chunk-B6HF6NTZ.js.map +1 -0
- package/dist/{chunk-AVWFLPNR.js → chunk-CL37QSND.js} +2 -2
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/{chunk-GJILMRPO.js → chunk-FAAWLVTF.js} +42 -4
- package/dist/chunk-FAAWLVTF.js.map +1 -0
- package/dist/chunk-GILMPJXB.js +155 -0
- package/dist/chunk-GILMPJXB.js.map +1 -0
- package/dist/{chunk-L77MEFCH.js → chunk-INSJBB5W.js} +3 -3
- package/dist/{chunk-QZIACZZU.js → chunk-KPF2HHPI.js} +2 -2
- package/dist/{chunk-NK2NSXXK.js → chunk-N2LMZKLR.js} +2 -2
- package/dist/{chunk-EARQCIL7.js → chunk-NZ4XCIKS.js} +3 -3
- package/dist/{chunk-E445ICYI.js → chunk-UFL4DUEV.js} +5 -3
- package/dist/chunk-UFL4DUEV.js.map +1 -0
- package/dist/consent/index.d.cts +3 -3
- package/dist/consent/index.d.ts +3 -3
- package/dist/{dev-unlock-XOUecfQ9.d.ts → dev-unlock-CcJ1qIi7.d.ts} +1 -1
- package/dist/{dev-unlock-5SmCVGyx.d.cts → dev-unlock-Dk14V6lX.d.cts} +1 -1
- package/dist/{hash-Bxud16vM.d.ts → hash-1Xsqx1jl.d.ts} +1 -1
- package/dist/{hash-CvuKN2gH.d.cts → hash-h_2U3TFb.d.cts} +1 -1
- package/dist/history/index.cjs.map +1 -1
- package/dist/history/index.d.cts +4 -4
- package/dist/history/index.d.ts +4 -4
- package/dist/history/index.js +2 -2
- package/dist/i18n/index.cjs +3 -1
- package/dist/i18n/index.cjs.map +1 -1
- package/dist/i18n/index.d.cts +3 -3
- package/dist/i18n/index.d.ts +3 -3
- package/dist/i18n/index.js +3 -3
- package/dist/{index-DN-J-5wT.d.cts → index-6xNpPsxR.d.cts} +1 -1
- package/dist/{index-Cy-MKrdK.d.ts → index-Cvb0efA_.d.cts} +39 -5
- package/dist/{index-BRHBCmLt.d.ts → index-DJTf9yxn.d.ts} +1 -1
- package/dist/{index-BvUiM47h.d.cts → index-DZn6Yick.d.ts} +39 -5
- package/dist/index.cjs +2001 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +315 -19
- package/dist/index.d.ts +315 -19
- package/dist/index.js +1503 -41
- package/dist/index.js.map +1 -1
- package/dist/{ledger-HWXYGUIQ.js → ledger-5V67MAIL.js} +3 -3
- package/dist/periods/index.cjs.map +1 -1
- package/dist/periods/index.d.cts +3 -3
- package/dist/periods/index.d.ts +3 -3
- package/dist/periods/index.js +3 -3
- package/dist/public-envelope-DFJZHXVH.js +31 -0
- package/dist/public-envelope-DFJZHXVH.js.map +1 -0
- package/dist/query/index.d.cts +1 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/session/index.cjs +4 -2
- package/dist/session/index.cjs.map +1 -1
- package/dist/session/index.d.cts +4 -4
- package/dist/session/index.d.ts +4 -4
- package/dist/session/index.js +1 -1
- package/dist/shadow/index.d.cts +3 -3
- package/dist/shadow/index.d.ts +3 -3
- package/dist/store/index.d.cts +3 -3
- package/dist/store/index.d.ts +3 -3
- package/dist/sync/index.cjs.map +1 -1
- package/dist/sync/index.d.cts +2 -2
- package/dist/sync/index.d.ts +2 -2
- package/dist/sync/index.js +2 -2
- package/dist/team/index.cjs +3 -1
- package/dist/team/index.cjs.map +1 -1
- package/dist/team/index.d.cts +3 -3
- package/dist/team/index.d.ts +3 -3
- package/dist/team/index.js +4 -4
- package/dist/tx/index.d.cts +3 -3
- package/dist/tx/index.d.ts +3 -3
- package/dist/{types-Dmi7nrC9.d.ts → types-D-6bmD2c.d.ts} +1271 -3
- package/dist/{types-BVSfkYg6.d.cts → types-D3QLmhlk.d.cts} +1271 -3
- package/package.json +1 -1
- package/dist/chunk-E445ICYI.js.map +0 -1
- package/dist/chunk-GJILMRPO.js.map +0 -1
- package/dist/chunk-O5GK62FJ.js.map +0 -1
- package/dist/chunk-PSHTHSIX.js.map +0 -1
- /package/dist/{chunk-LSZHBNDG.js.map → chunk-3WCRU7TI.js.map} +0 -0
- /package/dist/{chunk-AVWFLPNR.js.map → chunk-CL37QSND.js.map} +0 -0
- /package/dist/{chunk-L77MEFCH.js.map → chunk-INSJBB5W.js.map} +0 -0
- /package/dist/{chunk-QZIACZZU.js.map → chunk-KPF2HHPI.js.map} +0 -0
- /package/dist/{chunk-NK2NSXXK.js.map → chunk-N2LMZKLR.js.map} +0 -0
- /package/dist/{chunk-EARQCIL7.js.map → chunk-NZ4XCIKS.js.map} +0 -0
- /package/dist/{ledger-HWXYGUIQ.js.map → ledger-5V67MAIL.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/hub",
|
|
3
|
-
"version": "0.1.0-pre.
|
|
3
|
+
"version": "0.1.0-pre.7",
|
|
4
4
|
"description": "Zero-knowledge, offline-first, encrypted document store — core library with AES-256-GCM, PBKDF2, multi-user keyring, and sync engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/session/session.ts","../src/session/session-policy.ts","../src/session/dev-unlock.ts"],"sourcesContent":["/**\n * Session tokens —\n *\n * After a vault is unlocked (via passphrase, WebAuthn, OIDC, or magic-\n * link), the caller can call `createSession()` to get a session token that\n * allows re-establishing the KEK for the session lifetime without re-running\n * PBKDF2 or any interactive auth challenge.\n *\n * Security model\n * ──────────────\n * A session consists of two pieces that must both be present to recover the\n * KEK:\n *\n * 1. The **session key** — a non-extractable AES-256-GCM CryptoKey that\n * exists only in memory. \"Non-extractable\" is enforced by the WebCrypto\n * API: the key object cannot be serialized, exported, or sent over\n * postMessage. When the JS context is GC'd (tab close, navigation away,\n * worker termination) the key becomes unrecoverable.\n *\n * 2. The **session token** — a JSON object that carries the KEK wrapped\n * with the session key (AES-256-GCM, fresh IV per session), plus\n * unencrypted session metadata (sessionId, userId, vault, role,\n * expiresAt). The token can be serialized to JSON and stored in\n * sessionStorage or passed across callsites within the same tab, but\n * it is useless without the session key.\n *\n * The session key is kept in a module-level Map indexed by sessionId. Callers\n * that need to re-use a session must hold on to the sessionId returned from\n * `createSession()`; the key is looked up automatically by `resolveSession()`.\n *\n * Revocation: `revokeSession()` removes the entry from the Map. Because the\n * key is non-extractable, removal is sufficient — no one holds a serializable\n * copy of the key.\n *\n * Tab-scoped lifetime: the module-level Map lives only as long as the JS\n * module. Tab close → module unloaded → Map GC'd → all session keys gone.\n * This is the zero-effort logout: closing the tab is always a secure logout.\n *\n * Expiry: `createSession()` accepts a `ttlMs` option. `resolveSession()`\n * checks `expiresAt` and throws `SessionExpiredError` if the token is stale,\n * even if the session key is still in the Map.\n */\n\nimport { bufferToBase64, base64ToBuffer } from '../crypto.js'\nimport { generateULID } from '../bundle/ulid.js'\nimport type { Role } from '../types.js'\nimport type { UnlockedKeyring } from '../team/keyring.js'\nimport { SessionExpiredError, SessionNotFoundError } from '../errors.js'\n\nconst subtle = globalThis.crypto.subtle\n\n// Default session TTL: 60 minutes\nconst DEFAULT_TTL_MS = 60 * 60 * 1000\n\n// Module-level session key store. Tab-scoped by construction.\nconst sessionKeyStore = new Map<string, CryptoKey>()\n\n// ─── Public types ──────────────────────────────────────────────────────\n\n/** The serializable part of a session token. Safe to store in sessionStorage. */\nexport interface SessionToken {\n readonly _noydb_session: 1\n /** Unique session identifier (ULID). Use this as the handle for resolve/revoke. */\n readonly sessionId: string\n readonly userId: string\n readonly vault: string\n readonly role: Role\n /** ISO timestamp — resolveSession() rejects this token after this time. */\n readonly expiresAt: string\n /** KEK wrapped with the session key (AES-256-GCM). Base64. */\n readonly wrappedKek: string\n /** IV used for the wrapping operation. Base64. */\n readonly kekIv: string\n}\n\n/** Result returned from `createSession()`. */\nexport interface CreateSessionResult {\n /** Serializable token — store in sessionStorage or pass to `resolveSession()`. */\n token: SessionToken\n /** The sessionId — use this handle for `resolveSession()` and `revokeSession()`. */\n sessionId: string\n}\n\n/** Options for `createSession()`. */\nexport interface CreateSessionOptions {\n /**\n * Session lifetime in milliseconds. Defaults to 60 minutes.\n * After this duration, `resolveSession()` throws `SessionExpiredError`.\n */\n ttlMs?: number\n}\n\n// ─── Core session operations ───────────────────────────────────────────\n\n/**\n * Create a session for an already-unlocked keyring.\n *\n * Call this after any successful unlock (passphrase, WebAuthn, OIDC,\n * magic-link). The returned `sessionId` is the handle for later\n * `resolveSession()` and `revokeSession()` calls.\n *\n * The session key is generated fresh (non-extractable) and stored in the\n * module-level Map. The KEK from `keyring.kek` is exported (it must be\n * extractable — it was derived by `deriveKey()` which sets extractable: false,\n * but it's unwrapped from the keyring which sets extractable: true) and then\n * re-wrapped with the session key.\n *\n * @param keyring - An already-unlocked keyring whose `kek` is available.\n * @param vault - The vault name this session is scoped to.\n * @param options - Optional session configuration.\n */\nexport async function createSession(\n keyring: UnlockedKeyring,\n vault: string,\n options: CreateSessionOptions = {},\n): Promise<CreateSessionResult> {\n const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS\n const sessionId = generateULID()\n const expiresAt = new Date(Date.now() + ttlMs).toISOString()\n\n // Generate a fresh non-extractable session key.\n // AES-256-GCM is used here (rather than AES-KW) because the session key\n // wraps raw key bytes (the exported KEK) rather than a CryptoKey object.\n const sessionKey = await subtle.generateKey(\n { name: 'AES-GCM', length: 256 },\n false, // non-extractable — this is the tab-scope security invariant\n ['encrypt', 'decrypt'],\n )\n\n // Export the KEK as raw bytes so we can wrap it.\n // The KEK is AES-256-KW, which must have been importable (extractable: true)\n // to allow wrapKey — it is, because unwrapKey sets extractable: true for\n // DEKs, but the KEK itself is derived with extractable: false (see\n // crypto.ts deriveKey). We use a separate raw export + encrypt path.\n //\n // Wait — the KEK is AES-KW with extractable:false. We cannot export it.\n // Instead, we wrap the DEKs (which ARE extractable) and the salt+role+userId\n // metadata together. This means resolveSession() reconstructs an\n // UnlockedKeyring by re-wrapping the DEKs list from the token.\n //\n // Simpler approach: export each DEK (they're extractable) and encrypt\n // the serialized DEK map with the session key. The keyring is reconstructed\n // from the session token without the original KEK — only DEKs matter for\n // record operations.\n //\n // This is the right design: sessions don't need the KEK (no re-grant,\n // no re-derive during session lifetime). They need the DEK set.\n\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n\n const payload = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encrypted = await subtle.encrypt(\n { name: 'AES-GCM', iv },\n sessionKey,\n new TextEncoder().encode(payload),\n )\n\n const token: SessionToken = {\n _noydb_session: 1,\n sessionId,\n userId: keyring.userId,\n vault,\n role: keyring.role,\n expiresAt,\n wrappedKek: bufferToBase64(encrypted),\n kekIv: bufferToBase64(iv),\n }\n\n sessionKeyStore.set(sessionId, sessionKey)\n return { token, sessionId }\n}\n\n/**\n * Resolve a session token back into an UnlockedKeyring.\n *\n * Looks up the session key by `sessionId`, checks the token is not expired,\n * then decrypts the payload to reconstruct the keyring's DEK set.\n *\n * Throws `SessionExpiredError` if the token's `expiresAt` is in the past.\n * Throws `SessionNotFoundError` if the session key is not in the store\n * (tab was reloaded, session was revoked, or the sessionId is wrong).\n *\n * @param token - The SessionToken from `createSession()`.\n */\nexport async function resolveSession(token: SessionToken): Promise<UnlockedKeyring> {\n // Expiry check first — fast path without touching crypto\n if (Date.now() > new Date(token.expiresAt).getTime()) {\n sessionKeyStore.delete(token.sessionId)\n throw new SessionExpiredError(token.sessionId)\n }\n\n const sessionKey = sessionKeyStore.get(token.sessionId)\n if (!sessionKey) {\n throw new SessionNotFoundError(token.sessionId)\n }\n\n const iv = base64ToBuffer(token.kekIv)\n const ciphertext = base64ToBuffer(token.wrappedKek)\n\n let plaintext: ArrayBuffer\n try {\n plaintext = await subtle.decrypt(\n { name: 'AES-GCM', iv },\n sessionKey,\n ciphertext,\n )\n } catch {\n throw new SessionNotFoundError(token.sessionId)\n }\n\n const payload = JSON.parse(new TextDecoder().decode(plaintext)) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(payload.deks)) {\n const dek = await subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: payload.userId,\n displayName: payload.displayName,\n role: payload.role,\n permissions: payload.permissions,\n deks,\n kek: null as unknown as CryptoKey, // KEK not available in session context\n salt: base64ToBuffer(payload.salt),\n }\n}\n\n/**\n * Revoke a session by removing its key from the store.\n *\n * After revocation, `resolveSession()` will throw `SessionNotFoundError`\n * for this sessionId. The session token (if held by the caller) becomes\n * permanently useless. This is the explicit logout path.\n *\n * No-op if the session was already expired or does not exist.\n */\nexport function revokeSession(sessionId: string): void {\n sessionKeyStore.delete(sessionId)\n}\n\n/**\n * Check if a session is still alive (key in store + not expired).\n * Does not decrypt anything — purely a metadata check.\n */\nexport function isSessionAlive(token: SessionToken): boolean {\n if (Date.now() > new Date(token.expiresAt).getTime()) return false\n return sessionKeyStore.has(token.sessionId)\n}\n\n/**\n * Revoke all active sessions. Used by `Noydb.close()` to ensure that\n * closing the instance destroys all session state, not just the keyring\n * cache.\n */\nexport function revokeAllSessions(): void {\n sessionKeyStore.clear()\n}\n\n/**\n * Return the number of active sessions currently in the store.\n * Useful for diagnostics and tests.\n */\nexport function activeSessionCount(): number {\n return sessionKeyStore.size\n}\n","/**\n * Session policies —\n *\n * A `SessionPolicy` is a small declarative object that controls how long a\n * session lives and which operations require re-authentication. It is\n * evaluated by the `PolicyEnforcer` class, which the Noydb instance\n * integrates to replace the bare `sessionTimeout` timer from.\n *\n * Design decisions\n * ────────────────\n * Policies are stateless value objects — no timers, no event listeners.\n * The Noydb instance is the stateful coordinator: it holds the enforcer,\n * calls `enforcer.touch()` on every operation, and calls\n * `enforcer.checkOperation()` before high-risk operations.\n *\n * This keeps the policy module easy to unit-test (no global timers to mock)\n * and avoids the \"who owns cleanup\" problem that comes with timer-based\n * callbacks embedded in a value object.\n *\n * `lockOnBackground` registers a `visibilitychange` listener on the document\n * at enforcer creation time and removes it on `destroy()`. It is a no-op in\n * non-browser environments (no `document`).\n */\n\nimport type { SessionPolicy, ReAuthOperation } from '../types.js'\nimport { SessionExpiredError, SessionPolicyError } from '../errors.js'\nimport { revokeSession } from './session.js'\n\n// ─── PolicyEnforcer ────────────────────────────────────────────────────\n\nexport interface PolicyEnforcerOptions {\n /** The policy to enforce. */\n policy: SessionPolicy\n /** The session ID to revoke when idle/absolute timeouts fire. */\n sessionId: string\n /**\n * Called when the policy decides the session should end (idle timeout,\n * absolute timeout, or lockOnBackground). Use this to trigger the\n * same cleanup that `Noydb.close()` would perform.\n */\n onRevoke: (reason: 'idle' | 'absolute' | 'background') => void\n}\n\n/**\n * Stateful enforcer for a single session policy.\n *\n * Create one per open session, call `touch()` on every operation,\n * call `checkOperation(op)` before export/grant/revoke/rotate/changeSecret,\n * and call `destroy()` when the session ends.\n */\nexport class PolicyEnforcer {\n private readonly policy: SessionPolicy\n private readonly sessionId: string\n private readonly onRevoke: PolicyEnforcerOptions['onRevoke']\n private readonly createdAt: number\n private lastActivityAt: number\n private idleTimer: ReturnType<typeof setTimeout> | null = null\n private absoluteTimer: ReturnType<typeof setTimeout> | null = null\n private visibilityHandler: (() => void) | null = null\n\n constructor(opts: PolicyEnforcerOptions) {\n this.policy = opts.policy\n this.sessionId = opts.sessionId\n this.onRevoke = opts.onRevoke\n this.createdAt = Date.now()\n this.lastActivityAt = Date.now()\n\n this.scheduleIdleTimer()\n this.scheduleAbsoluteTimer()\n this.registerBackgroundLock()\n }\n\n /**\n * Record an activity timestamp and reset the idle timer.\n * Call this at the top of every Noydb public method.\n */\n touch(): void {\n this.lastActivityAt = Date.now()\n this.scheduleIdleTimer()\n }\n\n /**\n * Check whether the given operation is allowed under the active policy.\n * Throws `SessionPolicyError` if the operation requires re-authentication.\n * Throws `SessionExpiredError` if the absolute timeout has been exceeded\n * (defensive check in case the timer fired before the call arrived).\n *\n * This is a synchronous check — callers don't await it.\n */\n checkOperation(op: ReAuthOperation): void {\n // Defensive absolute-timeout check (timer may have fired late)\n const { absoluteTimeoutMs } = this.policy\n if (absoluteTimeoutMs !== undefined && Date.now() - this.createdAt >= absoluteTimeoutMs) {\n this.expire('absolute')\n throw new SessionExpiredError(this.sessionId)\n }\n\n const required = this.policy.requireReAuthFor ?? []\n if (required.includes(op)) {\n throw new SessionPolicyError(op)\n }\n }\n\n /**\n * Tear down timers and background-lock listener. Call from `Noydb.close()`\n * and whenever the session is revoked externally.\n */\n destroy(): void {\n if (this.idleTimer) {\n clearTimeout(this.idleTimer)\n this.idleTimer = null\n }\n if (this.absoluteTimer) {\n clearTimeout(this.absoluteTimer)\n this.absoluteTimer = null\n }\n if (this.visibilityHandler && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.visibilityHandler)\n this.visibilityHandler = null\n }\n }\n\n /** How long since the last activity, in ms. */\n get idleMs(): number {\n return Date.now() - this.lastActivityAt\n }\n\n /** How long since session creation, in ms. */\n get ageMs(): number {\n return Date.now() - this.createdAt\n }\n\n // ── Private ──────────────────────────────────────────────────────────\n\n private scheduleIdleTimer(): void {\n const { idleTimeoutMs } = this.policy\n if (!idleTimeoutMs) return\n\n if (this.idleTimer) clearTimeout(this.idleTimer)\n this.idleTimer = setTimeout(() => {\n this.expire('idle')\n }, idleTimeoutMs)\n }\n\n private scheduleAbsoluteTimer(): void {\n const { absoluteTimeoutMs } = this.policy\n if (!absoluteTimeoutMs) return\n\n if (this.absoluteTimer) clearTimeout(this.absoluteTimer)\n this.absoluteTimer = setTimeout(() => {\n this.expire('absolute')\n }, absoluteTimeoutMs)\n }\n\n private registerBackgroundLock(): void {\n if (!this.policy.lockOnBackground) return\n if (typeof document === 'undefined') return\n\n this.visibilityHandler = () => {\n if (document.hidden) {\n this.expire('background')\n }\n }\n document.addEventListener('visibilitychange', this.visibilityHandler)\n }\n\n private expire(reason: 'idle' | 'absolute' | 'background'): void {\n this.destroy()\n revokeSession(this.sessionId)\n this.onRevoke(reason)\n }\n}\n\n// ─── Helpers ───────────────────────────────────────────────────────────\n\n/**\n * Build a `PolicyEnforcer` from a policy + session token, and return it\n * alongside a cleanup function. Convenience wrapper for Noydb.\n */\nexport function createEnforcer(opts: PolicyEnforcerOptions): PolicyEnforcer {\n return new PolicyEnforcer(opts)\n}\n\n/**\n * Validate that a `SessionPolicy` is well-formed.\n * Throws a plain `Error` (not `NoydbError`) because this is a developer\n * error — invalid policies passed at construction time, not at runtime.\n */\nexport function validateSessionPolicy(policy: SessionPolicy): void {\n const { idleTimeoutMs, absoluteTimeoutMs } = policy\n if (idleTimeoutMs !== undefined && (typeof idleTimeoutMs !== 'number' || idleTimeoutMs <= 0)) {\n throw new Error(`SessionPolicy.idleTimeoutMs must be a positive number, got ${idleTimeoutMs}`)\n }\n if (absoluteTimeoutMs !== undefined && (typeof absoluteTimeoutMs !== 'number' || absoluteTimeoutMs <= 0)) {\n throw new Error(`SessionPolicy.absoluteTimeoutMs must be a positive number, got ${absoluteTimeoutMs}`)\n }\n if (idleTimeoutMs !== undefined && absoluteTimeoutMs !== undefined && idleTimeoutMs >= absoluteTimeoutMs) {\n throw new Error(\n `SessionPolicy.idleTimeoutMs (${idleTimeoutMs}ms) must be less than absoluteTimeoutMs (${absoluteTimeoutMs}ms)`,\n )\n }\n}\n","/**\n * Dev-mode persistent unlock —\n *\n * Solves the developer inner-loop friction: hot-reload destroys the session\n * (page navigation semantics), forcing a passphrase re-entry every refresh.\n *\n * This module provides an opt-in, deliberately-named escape hatch that lets\n * developers store the keyring payload in sessionStorage or localStorage so\n * the vault auto-unlocks on every page load — without a passphrase,\n * without a biometric prompt, without any OIDC flow.\n *\n * ⚠️ WARNING — this is a loaded footgun ⚠️\n * ─────────────────────────────────────────\n * The keyring payload stored by this module contains the DEKs. Whoever has\n * access to sessionStorage/localStorage has access to the DEKs. On a shared\n * development machine, a compromised browser extension, or a mis-configured\n * origin, this is a complete key exposure.\n *\n * This module is ONLY safe for local development. It must NEVER be active\n * in production builds.\n *\n * Guardrails (all enforced by the module, not by the caller)\n * ──────────────────────────────────────────────────────────\n * 1. **Production guard:** `enableDevUnlock()` throws immediately if\n * `process.env.NODE_ENV === 'production'` or if `import.meta.env?.PROD === true`\n * (Vite convention). Also throws if the hostname is NOT localhost or 127.0.0.1.\n *\n * 2. **Explicit acknowledgement string:** the caller must pass\n * `acknowledge: 'I-UNDERSTAND-THIS-DISABLES-UNLOCK-SECURITY'` or the call\n * throws. This string appears in every grep for `devUnlock` in the codebase,\n * making it impossible to enable this feature accidentally.\n *\n * 3. **Scope is vault + userId:** the storage key includes both the\n * vault name and the userId, so dev-unlock for vault-A does\n * NOT auto-unlock vault-B.\n *\n * 4. **Storage scope:** default is `sessionStorage` (cleared on tab close).\n * `localStorage` is opt-in and requires an additional\n * `persistAcrossTabs: true` flag in the options.\n *\n * 5. **Clear method:** `clearDevUnlock()` removes the stored payload. Wire\n * this to a dev toolbar button or `Ctrl+Shift+L` so clearing is one action.\n *\n * 6. **Console banner:** on first enable, a highly visible console warning\n * fires. Cannot be suppressed.\n *\n * Usage\n * ─────\n * ```ts\n * // In your dev entry point only (guarded by import.meta.env.DEV):\n * if (import.meta.env.DEV) {\n * const { enableDevUnlock, loadDevUnlock } = await import('@noy-db/hub')\n * enableDevUnlock('my-compartment', 'alice', keyring, {\n * acknowledge: 'I-UNDERSTAND-THIS-DISABLES-UNLOCK-SECURITY',\n * })\n * }\n *\n * // On page load:\n * if (import.meta.env.DEV) {\n * const keyring = await loadDevUnlock('my-compartment', 'alice')\n * if (keyring) {\n * // Skip unlock prompt, use keyring directly\n * }\n * }\n * ```\n */\n\nimport { bufferToBase64, base64ToBuffer } from '../crypto.js'\nimport { ValidationError } from '../errors.js'\nimport type { UnlockedKeyring } from '../team/keyring.js'\nimport type { Role } from '../types.js'\n\n// The exact acknowledgement string callers must pass\nconst REQUIRED_ACKNOWLEDGE = 'I-UNDERSTAND-THIS-DISABLES-UNLOCK-SECURITY'\n\nconst STORAGE_PREFIX = 'noydb:dev-unlock:'\n\n// ─── Options ──────────────────────────────────────────────────────────\n\nexport interface DevUnlockOptions {\n /**\n * Required: the exact string 'I-UNDERSTAND-THIS-DISABLES-UNLOCK-SECURITY'.\n * Any other value causes `enableDevUnlock()` to throw.\n */\n acknowledge: string\n /**\n * If `true`, stores in localStorage (persists across tabs and browser restarts).\n * If `false` (default), stores in sessionStorage (cleared on tab close).\n */\n persistAcrossTabs?: boolean\n}\n\n// ─── Production guard ─────────────────────────────────────────────────\n\nfunction assertDevEnvironment(): void {\n // Node.js: check NODE_ENV\n if (\n typeof process !== 'undefined' &&\n process.env.NODE_ENV === 'production'\n ) {\n throw new ValidationError(\n 'devUnlock is not available in production builds. ' +\n 'process.env.NODE_ENV is \"production\".',\n )\n }\n\n // Vite / build tool convention\n if (\n typeof globalThis !== 'undefined' &&\n (globalThis as Record<string, unknown>).__vite_is_production__ === true\n ) {\n throw new ValidationError('devUnlock is not available in production builds.')\n }\n\n // Browser: only allow on localhost\n if (\n typeof window !== 'undefined' &&\n typeof window.location !== 'undefined'\n ) {\n const host = window.location.hostname\n if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && !host.endsWith('.local')) {\n throw new ValidationError(\n `devUnlock is only available on localhost. Current hostname: \"${host}\". ` +\n 'Set NODE_ENV=development and run on localhost to use dev unlock.',\n )\n }\n }\n}\n\n// ─── Storage key ──────────────────────────────────────────────────────\n\nfunction storageKey(vault: string, userId: string): string {\n return `${STORAGE_PREFIX}${vault}:${userId}`\n}\n\nfunction resolveStorage(persistAcrossTabs?: boolean): Storage {\n if (typeof window === 'undefined') {\n throw new ValidationError('devUnlock requires a browser environment (window.sessionStorage / window.localStorage).')\n }\n return persistAcrossTabs ? window.localStorage : window.sessionStorage\n}\n\n// ─── Public API ────────────────────────────────────────────────────────\n\n/**\n * Serialize and store a keyring to browser storage for dev-mode auto-unlock.\n *\n * Throws immediately if:\n * - The acknowledge string is wrong.\n * - Running in a production environment (NODE_ENV=production).\n * - Running on a non-localhost hostname.\n *\n * Emits a highly visible console warning that cannot be suppressed.\n *\n * @param vault - The vault name.\n * @param userId - The user ID.\n * @param keyring - The unlocked keyring to persist.\n * @param options - Options including the required acknowledge string.\n */\nexport async function enableDevUnlock(\n vault: string,\n userId: string,\n keyring: UnlockedKeyring,\n options: DevUnlockOptions,\n): Promise<void> {\n if (options.acknowledge !== REQUIRED_ACKNOWLEDGE) {\n throw new ValidationError(\n `devUnlock requires acknowledge: '${REQUIRED_ACKNOWLEDGE}'. ` +\n `Got: '${options.acknowledge}'. This is intentional — the full string must appear in your source.`,\n )\n }\n\n assertDevEnvironment()\n\n const storage = resolveStorage(options.persistAcrossTabs)\n\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await globalThis.crypto.subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n\n const payload = JSON.stringify({\n _noydb_dev_unlock: 1,\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n\n storage.setItem(storageKey(vault, userId), payload)\n\n // Visible, unsuppressable warning\n console.warn(\n '%c⚠️ NOYDB DEV UNLOCK ACTIVE ⚠️',\n 'color: red; font-size: 16px; font-weight: bold',\n `\\n\\nCompartment \"${vault}\" user \"${userId}\" is stored in ` +\n `${options.persistAcrossTabs ? 'localStorage' : 'sessionStorage'} in PLAINTEXT DEKs.\\n` +\n 'This is ONLY safe for local development. Never use in production.\\n' +\n 'Call clearDevUnlock() to remove.',\n )\n}\n\n/**\n * Load a dev-mode keyring from browser storage.\n *\n * Returns `null` if no dev-unlock state is stored for this vault + user,\n * or if the stored payload is malformed.\n *\n * Does NOT perform the production environment check — it's safe to CALL\n * `loadDevUnlock` in production (it will simply return `null` because no\n * dev-unlock state was ever written). The guard only fires on `enableDevUnlock`.\n *\n * @param vault - The vault name.\n * @param userId - The user ID.\n * @param options - Optional storage override.\n */\nexport async function loadDevUnlock(\n vault: string,\n userId: string,\n options: { persistAcrossTabs?: boolean } = {},\n): Promise<UnlockedKeyring | null> {\n if (typeof window === 'undefined') return null\n\n const storage = resolveStorage(options.persistAcrossTabs)\n const raw = storage.getItem(storageKey(vault, userId))\n if (!raw) return null\n\n let parsed: {\n _noydb_dev_unlock?: number\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n try {\n parsed = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (parsed._noydb_dev_unlock !== 1) return null\n\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await globalThis.crypto.subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null as unknown as CryptoKey,\n salt: base64ToBuffer(parsed.salt),\n }\n}\n\n/**\n * Remove dev-unlock state from browser storage.\n *\n * Safe to call in production (no-op if no dev state exists).\n */\nexport function clearDevUnlock(\n vault: string,\n userId: string,\n options: { persistAcrossTabs?: boolean } = {},\n): void {\n if (typeof window === 'undefined') return\n const storage = resolveStorage(options.persistAcrossTabs)\n storage.removeItem(storageKey(vault, userId))\n}\n\n/**\n * Check if dev-unlock state exists for this vault + user.\n *\n * Safe to call in production (returns false if nothing is stored).\n */\nexport function isDevUnlockActive(\n vault: string,\n userId: string,\n options: { persistAcrossTabs?: boolean } = {},\n): boolean {\n if (typeof window === 'undefined') return false\n const storage = resolveStorage(options.persistAcrossTabs)\n return storage.getItem(storageKey(vault, userId)) !== null\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/bundle/format.ts","../src/bundle/bundle.ts"],"sourcesContent":["/**\n * `.noydb` container format — byte layout, header schema, validators.\n *\n *. Wraps a `vault.dump()` JSON string in a thin\n * binary container with a magic-byte prefix, a minimum-disclosure\n * unencrypted header, and a compressed body.\n *\n * **Byte layout** (read in order from offset 0):\n *\n * ```\n * +--------+--------+--------+--------+\n * | N=78 | D=68 | B=66 | 1=49 | Magic 'NDB1' (4 bytes)\n * +--------+--------+--------+--------+\n * | flags | compr | header_length (uint32 BE) |\n * +--------+--------+--------+--------+--------+--------+--------+\n * | header_length bytes of UTF-8 JSON header ...\n * +--------+--------+\n * | compressed body bytes ...\n * ```\n *\n * Total fixed prefix before the header JSON is **10 bytes**:\n * - 4 bytes magic\n * - 1 byte flags\n * - 1 byte compression algorithm\n * - 4 bytes header length (uint32 big-endian)\n *\n * **Why a binary container** at all? `vault.dump()` already\n * produces a JSON string with encrypted records inside. Wrapping it\n * again seems redundant — but the wrap is what makes the file safe\n * to drop into cloud storage (Drive, Dropbox, iCloud) without\n * leaking the vault name and exporter identity through the\n * cloud's metadata API. The minimum-disclosure header is the only\n * thing visible without downloading and decompressing the body.\n * The dump JSON inside the body still contains the original\n * metadata, but that's only readable by someone who already has the\n * file bytes — the same person who could read the encrypted records\n * with the right passphrase.\n *\n * **Why minimum disclosure** in the header? Because consumers will\n * inevitably store these in services where the filename, file size,\n * and any unencrypted metadata are indexed for search. A field like\n * `vault: \"Acme Corp\"` would let an attacker (or a curious\n * cloud admin) enumerate which compartments exist and who exported\n * them, even with zero access to the encrypted body. The header\n * carries only what's needed to identify the file as a NOYDB\n * bundle and verify its integrity — nothing about the contents.\n */\n\n/** Magic bytes 'NDB1' (ASCII), identifying a NOYDB bundle. */\nexport const NOYDB_BUNDLE_MAGIC = new Uint8Array([0x4e, 0x44, 0x42, 0x31])\n\n/** Total fixed prefix before the header JSON: 4+1+1+4 bytes. */\nexport const NOYDB_BUNDLE_PREFIX_BYTES = 10\n\n/** Current bundle format version. Bumped on layout changes. */\nexport const NOYDB_BUNDLE_FORMAT_VERSION = 1\n\n/**\n * Bitfield interpretation of the flags byte.\n *\n * Bit 0 — body is compressed (0 = raw, 1 = compressed)\n * Bit 1 — header carries an integrity hash over the body bytes\n * Bits 2-7 — reserved, must be 0 in\n */\nexport const FLAG_COMPRESSED = 0b0000_0001\nexport const FLAG_HAS_INTEGRITY_HASH = 0b0000_0010\n\n/**\n * Compression algorithm encoding for the byte at offset 5.\n *\n * `none` is admitted for round-trip testing and for callers that\n * want to bundle without compression (e.g. when piping into a\n * separately compressed transport). `gzip` is the universally\n * available baseline (Node 18+, all modern browsers). `brotli` is\n * preferred when the runtime supports it — typically 30-50% smaller\n * for JSON payloads — but Node 22+ / Chrome 124+ / Firefox 122+\n * are required, so the writer feature-detects at runtime and falls\n * back to gzip. The reader must handle all three.\n */\nexport const COMPRESSION_NONE = 0\nexport const COMPRESSION_GZIP = 1\nexport const COMPRESSION_BROTLI = 2\n\nexport type CompressionAlgo = 0 | 1 | 2\n\n/**\n * The unencrypted header carried in every `.noydb` bundle.\n *\n * **Minimum-disclosure rules:** these are the ONLY allowed keys.\n * Any other key in a parsed header causes\n * `validateBundleHeader` to throw. The set is kept short to\n * minimize attack surface from cloud-storage metadata indexing —\n * see the file-level doc comment for the rationale.\n *\n * Forbidden in particular:\n * - `vault` / `_compartment` — would leak the tenant name\n * - `exporter` / `_exported_by` — would leak user identity\n * - `timestamp` / `_exported_at` — would leak activity timing\n * - `kdfParams` / salt fields — would leak crypto config that\n * could narrow brute-force search space\n * - any field starting with `_` (reserved by the dump format)\n */\nexport interface NoydbBundleHeader {\n /** Bundle format version — bumped on layout changes. */\n readonly formatVersion: number\n /**\n * Opaque ULID identifier — generated once per vault and\n * stable across re-exports of the same vault. Does not\n * leak any information about contents (the timestamp prefix is\n * just monotonicity for sortability, not exporter activity —\n * see `bundle/ulid.ts` for the design notes).\n */\n readonly handle: string\n /** Compressed body length in bytes. Lets readers verify completeness without decompressing. */\n readonly bodyBytes: number\n /** SHA-256 of the compressed body bytes (lowercase hex). Lets readers verify integrity without decompressing. */\n readonly bodySha256: string\n}\n\n/**\n * Allowlist of header keys. Any key not in this set is forbidden\n * and causes `validateBundleHeader` to throw. Kept as a Set for\n * O(1) lookup; the validator iterates over the parsed header and\n * checks each key against this set.\n */\nconst ALLOWED_HEADER_KEYS: ReadonlySet<string> = new Set([\n 'formatVersion',\n 'handle',\n 'bodyBytes',\n 'bodySha256',\n])\n\n/**\n * Validate a parsed bundle header. Throws on any deviation from\n * the minimum-disclosure schema:\n *\n * - Missing required field\n * - Wrong type for any field\n * - Any extra key not in `ALLOWED_HEADER_KEYS`\n * - Unsupported `formatVersion`\n * - Negative or non-integer `bodyBytes`\n * - Malformed `handle` (must be 26-char Crockford base32)\n * - Malformed `bodySha256` (must be 64-char lowercase hex)\n *\n * The error messages name the offending field so consumers can\n * fix the producer rather than the reader.\n */\nexport function validateBundleHeader(\n parsed: unknown,\n): asserts parsed is NoydbBundleHeader {\n if (parsed === null || typeof parsed !== 'object') {\n throw new Error(\n `.noydb bundle header must be a JSON object, got ${parsed === null ? 'null' : typeof parsed}`,\n )\n }\n // Disallow any unknown key — minimum disclosure means we reject\n // forward-compat extension keys at the format layer; new fields\n // require a format version bump and a new validator.\n for (const key of Object.keys(parsed)) {\n if (!ALLOWED_HEADER_KEYS.has(key)) {\n throw new Error(\n `.noydb bundle header contains forbidden key \"${key}\". ` +\n `Only minimum-disclosure fields are allowed: ` +\n `${[...ALLOWED_HEADER_KEYS].join(', ')}.`,\n )\n }\n }\n const h = parsed as Record<string, unknown>\n if (typeof h['formatVersion'] !== 'number' || h['formatVersion'] !== NOYDB_BUNDLE_FORMAT_VERSION) {\n throw new Error(\n `.noydb bundle header.formatVersion must be ${NOYDB_BUNDLE_FORMAT_VERSION}, ` +\n `got ${String(h['formatVersion'])}. The reader does not support ` +\n `forward-compat versions; upgrade the reader to handle newer bundles.`,\n )\n }\n if (typeof h['handle'] !== 'string' || !/^[0-9A-HJKMNP-TV-Z]{26}$/.test(h['handle'])) {\n throw new Error(\n `.noydb bundle header.handle must be a 26-character Crockford base32 ULID, ` +\n `got ${typeof h['handle'] === 'string' ? `\"${h['handle']}\"` : String(h['handle'])}.`,\n )\n }\n if (typeof h['bodyBytes'] !== 'number' || !Number.isInteger(h['bodyBytes']) || h['bodyBytes'] < 0) {\n throw new Error(\n `.noydb bundle header.bodyBytes must be a non-negative integer, ` +\n `got ${String(h['bodyBytes'])}.`,\n )\n }\n if (typeof h['bodySha256'] !== 'string' || !/^[0-9a-f]{64}$/.test(h['bodySha256'])) {\n throw new Error(\n `.noydb bundle header.bodySha256 must be a 64-character lowercase hex string, ` +\n `got ${typeof h['bodySha256'] === 'string' ? `\"${h['bodySha256']}\"` : String(h['bodySha256'])}.`,\n )\n }\n}\n\n/**\n * Encode a header object to UTF-8 JSON bytes after validating\n * minimum disclosure. Used by the writer to serialize the header\n * region of the container.\n */\nexport function encodeBundleHeader(header: NoydbBundleHeader): Uint8Array {\n validateBundleHeader(header)\n // Stable key ordering — JSON.stringify with no replacer uses\n // insertion order, which is fine here because we control the\n // object construction. Stable ordering means two bundles with\n // identical contents produce byte-identical headers.\n const json = JSON.stringify({\n formatVersion: header.formatVersion,\n handle: header.handle,\n bodyBytes: header.bodyBytes,\n bodySha256: header.bodySha256,\n })\n return new TextEncoder().encode(json)\n}\n\n/**\n * Parse a bundle header from its UTF-8 JSON bytes. Throws on\n * invalid JSON or any minimum-disclosure violation.\n */\nexport function decodeBundleHeader(bytes: Uint8Array): NoydbBundleHeader {\n const json = new TextDecoder('utf-8', { fatal: true }).decode(bytes)\n let parsed: unknown\n try {\n parsed = JSON.parse(json)\n } catch (err) {\n throw new Error(\n `.noydb bundle header is not valid JSON: ${(err as Error).message}`,\n )\n }\n validateBundleHeader(parsed)\n return parsed\n}\n\n/**\n * Read a uint32 from `bytes` at `offset` in big-endian byte order.\n * No bounds check — callers must guarantee `offset + 4 <= bytes.length`.\n * Used to decode the header length field; kept inline so the parser\n * doesn't depend on DataView allocation per call.\n */\nexport function readUint32BE(bytes: Uint8Array, offset: number): number {\n return (\n (bytes[offset]! << 24 >>> 0) +\n (bytes[offset + 1]! << 16) +\n (bytes[offset + 2]! << 8) +\n bytes[offset + 3]!\n )\n}\n\n/**\n * Write a uint32 to `bytes` at `offset` in big-endian byte order.\n * No bounds check — callers must guarantee `offset + 4 <= bytes.length`.\n */\nexport function writeUint32BE(bytes: Uint8Array, offset: number, value: number): void {\n bytes[offset] = (value >>> 24) & 0xff\n bytes[offset + 1] = (value >>> 16) & 0xff\n bytes[offset + 2] = (value >>> 8) & 0xff\n bytes[offset + 3] = value & 0xff\n}\n\n/**\n * Verify the magic prefix of a bundle. Returns true if the first\n * 4 bytes match `NDB1`. Used by readers as a fast file-type check\n * before any further parsing.\n */\nexport function hasNoydbBundleMagic(bytes: Uint8Array): boolean {\n if (bytes.length < NOYDB_BUNDLE_MAGIC.length) return false\n for (let i = 0; i < NOYDB_BUNDLE_MAGIC.length; i++) {\n if (bytes[i] !== NOYDB_BUNDLE_MAGIC[i]) return false\n }\n return true\n}\n","/**\n * `.noydb` container primitives — write, read, header-only read.\n *\n *. Wraps a `vault.dump()` JSON string in the\n * binary container described in `format.ts`.\n *\n * **Three primitives:**\n *\n * - `writeNoydbBundle(vault, opts?)` — produces the\n * full container bytes ready to write to disk or upload\n * - `readNoydbBundleHeader(bytes)` — parses just the header\n * without decompressing the body, fast file-type and\n * metadata read for cloud listing UIs\n * - `readNoydbBundle(bytes)` — full read: validates magic,\n * header, integrity hash, and decompresses the body to\n * return the original `dump()` JSON string for use with\n * `vault.load()`\n *\n * **Compression strategy:** brotli when available (Node 22+,\n * Chrome 124+, Firefox 122+), gzip fallback elsewhere. The\n * algorithm choice is encoded in the format byte at offset 5,\n * so readers handle either transparently. Brotli wins ~30-50%\n * on JSON payloads with repeated keys (which vault dumps\n * are).\n *\n * **Why split read/load?** `readNoydbBundle` returns the\n * *unwrapped JSON string*, not a Vault object. The caller\n * is responsible for piping that JSON into\n * `vault.load(json, passphrase)`. Splitting the layers\n * keeps the bundle module free of any crypto/passphrase\n * concerns — it's purely a format layer. The same `readNoydbBundle`\n * call can also feed verification tools, format inspectors, or\n * archive utilities that don't care about decryption.\n */\n\nimport {\n COMPRESSION_BROTLI,\n COMPRESSION_GZIP,\n COMPRESSION_NONE,\n FLAG_COMPRESSED,\n FLAG_HAS_INTEGRITY_HASH,\n NOYDB_BUNDLE_FORMAT_VERSION,\n NOYDB_BUNDLE_MAGIC,\n NOYDB_BUNDLE_PREFIX_BYTES,\n decodeBundleHeader,\n encodeBundleHeader,\n hasNoydbBundleMagic,\n readUint32BE,\n writeUint32BE,\n type CompressionAlgo,\n type NoydbBundleHeader,\n} from './format.js'\nimport { BundleIntegrityError } from '../errors.js'\nimport type { Vault } from '../vault.js'\nimport type { BundleRecipient } from '../team/keyring.js'\n\n/**\n * Options accepted by `writeNoydbBundle`.\n *\n * - `compression: 'auto'` (default) — try brotli, fall back to gzip\n * - `compression: 'brotli'` — force brotli, throw if unsupported\n * - `compression: 'gzip'` — force gzip\n * - `compression: 'none'` — no compression (round-trip testing only)\n *\n * **Slice filtering** (added in ):\n * - `collections` — allowlist of collection names to include. Internal\n * collections (keyrings, ledger) and excluded user collections are\n * dropped from the bundle. Records inside included collections are\n * carried through verbatim.\n * - `since` — only records whose envelope `_ts` is on/after the given\n * instant survive. Operates on the unencrypted envelope timestamp,\n * so plaintext access to records is not required.\n *\n * Both filters intersect (AND). When neither is provided the bundle is\n * a whole-vault snapshot, identical to today's behaviour.\n */\nexport interface WriteNoydbBundleOptions {\n readonly compression?: 'auto' | 'brotli' | 'gzip' | 'none'\n /** Allowlist of user-collection names to include. */\n readonly collections?: readonly string[]\n /**\n * Drop records whose envelope `_ts` is strictly older than this\n * instant. Accepts a `Date` or any ISO-8601 string parseable by\n * `new Date()`.\n */\n readonly since?: Date | string\n /**\n * Plaintext-pipeline record predicate. Decrypts each record\n * with the vault's per-collection DEK, runs the predicate, and\n * keeps the original ciphertext for survivors (no re-encrypt —\n * preserves zero-knowledge cleanly). Records the predicate returns\n * `false` for are dropped from the bundle.\n *\n * Async predicates are supported. Mutating the record from inside\n * the predicate is undefined behaviour.\n */\n readonly where?: (\n record: unknown,\n ctx: { collection: string; id: string },\n ) => boolean | Promise<boolean>\n /**\n * Hierarchical-tier ceiling. Records whose envelope `_tier`\n * is strictly greater than this number are dropped. Operates on the\n * envelope `_tier` (no decryption needed) — vault.exportStream is\n * referenced in the issue body for symmetry, but the tier value\n * lives on the unencrypted envelope. Vault without tiers is a no-op.\n */\n readonly tierAtMost?: number\n /**\n * Single-recipient re-keying shorthand. When set, the\n * bundle's keyring is replaced with one freshly-derived entry sealed\n * with this passphrase. The recipient inherits the source keyring's\n * userId, role, and permissions. Mutually exclusive with `recipients`.\n */\n readonly exportPassphrase?: string\n /**\n * Multi-recipient re-keying. Replaces the bundle's keyring\n * map with one slot per recipient, each sealed with its own\n * passphrase. DEKs are unwrapped from the source keyring once and\n * re-wrapped per recipient — record ciphertext is unchanged.\n *\n * Mutually exclusive with `exportPassphrase`. When neither is set,\n * the bundle inherits the source keyring as-is (today's behaviour,\n * suited to personal backup-and-restore).\n */\n readonly recipients?: readonly BundleRecipient[]\n}\n\n/**\n * Result returned by `readNoydbBundle`. The caller is expected to\n * pass `dumpJson` into `vault.load(json, passphrase)` to\n * actually restore a vault. Splitting the layers keeps the\n * bundle module free of crypto concerns — see file-level docs.\n */\nexport interface NoydbBundleReadResult {\n readonly header: NoydbBundleHeader\n readonly dumpJson: string\n}\n\n/**\n * Detect whether the runtime's `CompressionStream` supports brotli.\n *\n * Brotli requires Node 22+ / Chrome 124+ / Firefox 122+. The\n * detection runs the `CompressionStream` constructor in a\n * try/catch — unsupported formats throw `TypeError` synchronously,\n * making this a safe one-shot check that we cache for the\n * lifetime of the process.\n */\nlet cachedBrotliSupport: boolean | null = null\nfunction supportsBrotliCompression(): boolean {\n if (cachedBrotliSupport !== null) return cachedBrotliSupport\n try {\n new CompressionStream('br' as CompressionFormat)\n cachedBrotliSupport = true\n } catch {\n cachedBrotliSupport = false\n }\n return cachedBrotliSupport\n}\n\n/** Test-only: reset the brotli detection cache between tests. */\nexport function resetBrotliSupportCache(): void {\n cachedBrotliSupport = null\n}\n\n/**\n * Pick the compression algorithm and the corresponding format byte\n * from a user option. Throws if the user explicitly requests brotli\n * on a runtime that doesn't support it — a silent fallback would\n * make the produced bundle smaller-than-expected and confuse\n * size-bound tests.\n */\nfunction selectCompression(option: WriteNoydbBundleOptions['compression']): {\n format: CompressionAlgo\n streamFormat: CompressionFormat | null\n} {\n const choice = option ?? 'auto'\n if (choice === 'none') return { format: COMPRESSION_NONE, streamFormat: null }\n if (choice === 'gzip') return { format: COMPRESSION_GZIP, streamFormat: 'gzip' }\n if (choice === 'brotli') {\n if (!supportsBrotliCompression()) {\n throw new Error(\n `writeNoydbBundle({ compression: 'brotli' }) is not supported on this ` +\n `runtime. Brotli requires Node 22+, Chrome 124+, or Firefox 122+. ` +\n `Use { compression: 'auto' } to fall back to gzip silently, or ` +\n `{ compression: 'gzip' } to be explicit.`,\n )\n }\n return { format: COMPRESSION_BROTLI, streamFormat: 'br' as CompressionFormat }\n }\n // 'auto' — prefer brotli, fall back to gzip\n if (supportsBrotliCompression()) {\n return { format: COMPRESSION_BROTLI, streamFormat: 'br' as CompressionFormat }\n }\n return { format: COMPRESSION_GZIP, streamFormat: 'gzip' }\n}\n\n/**\n * Pump a Uint8Array through a CompressionStream / DecompressionStream\n * and collect the output. Both APIs are universally available in\n * Node 18+ and modern browsers; the only variance is which\n * formats they support, handled by `selectCompression` above.\n *\n * Implementation: build a single-chunk ReadableStream from the\n * input, pipe through the transform, then drain the resulting\n * ReadableStream into a single concatenated Uint8Array. This is\n * O(N) memory in the input + output sizes, which is fine for the\n * dump-sized payloads (typically <50MB) targets.\n */\nasync function pumpThroughStream(\n input: Uint8Array,\n stream: CompressionStream | DecompressionStream,\n): Promise<Uint8Array> {\n const readable = new Blob([input as BlobPart]).stream().pipeThrough(stream)\n const reader = readable.getReader()\n const chunks: Uint8Array[] = []\n let total = 0\n for (;;) {\n const { value, done } = await reader.read()\n if (done) break\n if (value) {\n chunks.push(value as Uint8Array)\n total += value.length\n }\n }\n const out = new Uint8Array(total)\n let offset = 0\n for (const chunk of chunks) {\n out.set(chunk, offset)\n offset += chunk.length\n }\n return out\n}\n\n/**\n * SHA-256 hex digest of `bytes`. Used for the bundle integrity\n * hash carried in the header. Web Crypto API only — no Node\n * crypto module, no third-party hash library.\n *\n * The output format is lowercase hex (64 chars for SHA-256). The\n * format validator pins this — uppercase or mixed-case digests\n * are rejected, so the writer and reader agree on canonicalization.\n */\nasync function sha256Hex(bytes: Uint8Array): Promise<string> {\n // Copy into a fresh ArrayBuffer-backed Uint8Array. The\n // underlying buffer of `bytes` may be SharedArrayBuffer (e.g.\n // from a worker), which `subtle.digest` rejects via TypeScript's\n // BufferSource type. Allocating a fresh ArrayBuffer-backed view\n // sidesteps the type narrowing and is portable across all\n // runtimes — the copy cost is O(N) but bundle bodies are\n // typically <50MB, well below the threshold where the copy\n // matters.\n const copy = new Uint8Array(bytes.length)\n copy.set(bytes)\n const digest = await crypto.subtle.digest('SHA-256', copy)\n const view = new Uint8Array(digest)\n let hex = ''\n for (let i = 0; i < view.length; i++) {\n hex += view[i]!.toString(16).padStart(2, '0')\n }\n return hex\n}\n\n/**\n * Concatenate any number of Uint8Arrays into a single new buffer.\n * Used to assemble the final bundle from its prefix + header +\n * body parts.\n */\nfunction concatBytes(parts: readonly Uint8Array[]): Uint8Array {\n let total = 0\n for (const p of parts) total += p.length\n const out = new Uint8Array(total)\n let offset = 0\n for (const p of parts) {\n out.set(p, offset)\n offset += p.length\n }\n return out\n}\n\n/**\n * Replace the bundle's keyrings with freshly built recipient slots,\n * one per supplied recipient. No-op when neither `exportPassphrase`\n * nor `recipients` is set — the source keyring is inherited as-is.\n *\n * The single-passphrase shorthand creates a one-recipient list whose\n * id, role, and permissions inherit from the source vault — useful\n * for \"back up to a different passphrase\" without changing role\n * semantics. The multi-recipient form wraps each slot independently\n * with its declared role + permissions.\n *\n * @internal\n */\nasync function applyRecipientRewrap(\n vault: Vault,\n dumpJson: string,\n opts: WriteNoydbBundleOptions,\n): Promise<string> {\n if (opts.exportPassphrase === undefined && opts.recipients === undefined) {\n return dumpJson\n }\n\n const recipients: readonly BundleRecipient[] =\n opts.recipients ?? [\n {\n id: vault.userId,\n passphrase: opts.exportPassphrase as string,\n role: vault.role,\n },\n ]\n\n const recipientKeyrings = await vault.buildBundleRecipientKeyrings(recipients)\n\n const backup = JSON.parse(dumpJson) as { keyrings: unknown; [k: string]: unknown }\n backup.keyrings = recipientKeyrings\n return JSON.stringify(backup)\n}\n\n/**\n * Apply opt-in slice filters to a vault dump JSON string. Filters that\n * narrow the bundle without crossing the encryption boundary — both\n * operate on metadata (collection name, envelope `_ts`) and never need\n * to decrypt records. When neither filter is set, the dump is returned\n * unchanged so the no-arg path stays a pure passthrough.\n *\n * Internal-collection filtering: when a `collections` allowlist is\n * provided, the bundle still carries `_internal` (ledger entries) and\n * the keyrings — they're necessary for the receiver to verify and\n * unlock the bundle. The allowlist applies to the user-collection\n * map only.\n *\n * @internal\n */\nfunction applySliceFilters(\n dumpJson: string,\n opts: WriteNoydbBundleOptions,\n): string {\n const collectionsFilter = opts.collections\n ? new Set(opts.collections)\n : null\n const sinceMs =\n opts.since !== undefined ? new Date(opts.since).getTime() : null\n if (collectionsFilter === null && sinceMs === null) return dumpJson\n\n // Parse, prune, re-serialize. The dump shape is stable\n // (VaultBackup) so this is a one-off allocation; for vaults beyond\n // the documented 1K–50K target a streaming variant would be a\n // follow-up, but the simple parse path keeps the slice path\n // type-safe and trivially auditable.\n const backup = JSON.parse(dumpJson) as {\n collections?: Record<string, Record<string, { _ts?: string }>>\n [k: string]: unknown\n }\n\n if (backup.collections && typeof backup.collections === 'object') {\n const next: Record<string, Record<string, unknown>> = {}\n for (const [name, records] of Object.entries(backup.collections)) {\n if (collectionsFilter && !collectionsFilter.has(name)) continue\n if (sinceMs === null) {\n next[name] = records\n continue\n }\n const kept: Record<string, unknown> = {}\n for (const [id, env] of Object.entries(records)) {\n const envTs = env._ts ? new Date(env._ts).getTime() : NaN\n if (Number.isFinite(envTs) && envTs >= sinceMs) {\n kept[id] = env\n }\n }\n next[name] = kept\n }\n backup.collections = next as typeof backup.collections\n }\n\n return JSON.stringify(backup)\n}\n\n/**\n * Apply opt-in plaintext-tier filters\n * to a vault dump. Operates BEFORE `applySliceFilters` so the metadata\n * pass sees the trimmed record set.\n *\n * The filter never re-encrypts: surviving records carry their original\n * envelope unchanged. Failing records are dropped from the\n * `collections` map. Internal collections (ledger, deltas) and the\n * keyrings map are untouched.\n *\n * @internal\n */\nasync function applyPlaintextFilters(\n vault: Vault,\n dumpJson: string,\n opts: WriteNoydbBundleOptions,\n): Promise<string> {\n if (opts.where === undefined && opts.tierAtMost === undefined) {\n return dumpJson\n }\n\n type Env = { _ts?: string; _tier?: number; _iv: string; _data: string }\n const backup = JSON.parse(dumpJson) as {\n collections?: Record<string, Record<string, Env>>\n [k: string]: unknown\n }\n if (!backup.collections || typeof backup.collections !== 'object') {\n return dumpJson\n }\n\n const tierCeiling = opts.tierAtMost\n const where = opts.where\n\n const next: Record<string, Record<string, Env>> = {}\n for (const [collName, records] of Object.entries(backup.collections)) {\n const kept: Record<string, Env> = {}\n for (const [id, env] of Object.entries(records)) {\n // Tier ceiling — runs FIRST so we don't waste a decrypt on\n // records about to be dropped anyway. Envelope tier defaults to\n // 0 when absent (matches Vault's tier-0 conventions).\n if (tierCeiling !== undefined) {\n const tier = env._tier ?? 0\n if (tier > tierCeiling) continue\n }\n // Plaintext predicate — decrypt, run, keep on truthy. Errors\n // from inside the predicate propagate (callers want to see why\n // their filter blew up rather than getting a silent passthrough).\n if (where !== undefined) {\n const record = await vault._decryptEnvelopeForBundleFilter(\n env as never,\n collName,\n )\n const ok = await where(record, { collection: collName, id })\n if (!ok) continue\n }\n kept[id] = env\n }\n next[collName] = kept\n }\n backup.collections = next\n return JSON.stringify(backup)\n}\n\n/**\n * Write a `.noydb` bundle for the given vault.\n *\n * Pipeline:\n * 1. Resolve or create the compartment's stable bundle handle\n * via `vault.getBundleHandle()` — same handle on\n * every export from the same vault instance, so cloud\n * adapters can use it as a primary key.\n * 2. `vault.dump()` → JSON string with encrypted records\n * inside.\n * 3. UTF-8 encode the dump string.\n * 4. Compress (brotli if available, gzip fallback by default).\n * 5. Compute SHA-256 of the compressed body for integrity.\n * 6. Build the minimum-disclosure header from format version,\n * handle, body length, body sha.\n * 7. Serialize: magic (4) + flags (1) + algo (1) + headerLen (4)\n * + header JSON (N) + compressed body (M).\n *\n * The output is a single `Uint8Array`. Consumers writing to disk\n * pass it to `fs.writeFile`; consumers uploading to cloud storage\n * pass it as the request body. The `@noy-db/file` adapter wraps\n * this with a `saveBundle(path, vault)` helper.\n */\nexport async function writeNoydbBundle(\n vault: Vault,\n opts: WriteNoydbBundleOptions = {},\n): Promise<Uint8Array> {\n if (opts.exportPassphrase !== undefined && opts.recipients !== undefined) {\n throw new Error(\n 'writeNoydbBundle: pass either exportPassphrase or recipients, not both',\n )\n }\n\n const handle = await vault.getBundleHandle()\n const dumpJson = await vault.dump()\n\n // Re-keying: when caller supplied recipients (or the single-recipient\n // shorthand), substitute the bundle's `keyrings` map with freshly\n // built recipient slots before slice filters run.\n const rekeyed = await applyRecipientRewrap(vault, dumpJson, opts)\n // Plaintext-tier filters run BEFORE\n // the metadata-only slice — that way the metadata pass sees the\n // already-trimmed record set and the two filter chains compose\n // cleanly.\n const plainFiltered = await applyPlaintextFilters(vault, rekeyed, opts)\n const filtered = applySliceFilters(plainFiltered, opts)\n const dumpBytes = new TextEncoder().encode(filtered)\n\n const { format, streamFormat } = selectCompression(opts.compression)\n const body = streamFormat === null\n ? dumpBytes\n : await pumpThroughStream(dumpBytes, new CompressionStream(streamFormat))\n\n const bodySha256 = await sha256Hex(body)\n const header: NoydbBundleHeader = {\n formatVersion: NOYDB_BUNDLE_FORMAT_VERSION,\n handle,\n bodyBytes: body.length,\n bodySha256,\n }\n const headerBytes = encodeBundleHeader(header)\n\n // Assemble the fixed prefix in a 10-byte buffer.\n const prefix = new Uint8Array(NOYDB_BUNDLE_PREFIX_BYTES)\n prefix.set(NOYDB_BUNDLE_MAGIC, 0)\n prefix[4] =\n (streamFormat === null ? 0 : FLAG_COMPRESSED) | FLAG_HAS_INTEGRITY_HASH\n prefix[5] = format\n writeUint32BE(prefix, 6, headerBytes.length)\n\n return concatBytes([prefix, headerBytes, body])\n}\n\n/**\n * Internal helper shared by both readers — parses just the prefix\n * + header region of a bundle without touching the body. Returns\n * the parsed header plus the offset where the body starts and the\n * compression algorithm needed to decompress it.\n *\n * Throws on any format violation: missing/invalid magic, truncated\n * prefix, header length larger than the file, or unknown\n * compression algorithm.\n */\nfunction parsePrefixAndHeader(bytes: Uint8Array): {\n header: NoydbBundleHeader\n bodyOffset: number\n algo: CompressionAlgo\n flags: number\n} {\n if (!hasNoydbBundleMagic(bytes)) {\n throw new Error(\n `Not a .noydb bundle: missing 'NDB1' magic prefix. The first 4 bytes ` +\n `are ${[...bytes.slice(0, 4)].map((b) => b.toString(16).padStart(2, '0')).join(' ')}.`,\n )\n }\n if (bytes.length < NOYDB_BUNDLE_PREFIX_BYTES) {\n throw new Error(\n `Truncated .noydb bundle: file is only ${bytes.length} bytes, ` +\n `which is less than the ${NOYDB_BUNDLE_PREFIX_BYTES}-byte fixed prefix.`,\n )\n }\n const flags = bytes[4]!\n const algo = bytes[5]!\n if (algo !== COMPRESSION_NONE && algo !== COMPRESSION_GZIP && algo !== COMPRESSION_BROTLI) {\n throw new Error(\n `.noydb bundle declares unknown compression algorithm ${algo}. ` +\n `Known values: 0 (none), 1 (gzip), 2 (brotli).`,\n )\n }\n const headerLength = readUint32BE(bytes, 6)\n const bodyOffset = NOYDB_BUNDLE_PREFIX_BYTES + headerLength\n if (bodyOffset > bytes.length) {\n throw new Error(\n `Truncated .noydb bundle: declared header length ${headerLength} ` +\n `would extend past end of file (${bytes.length} bytes).`,\n )\n }\n const headerBytes = bytes.slice(NOYDB_BUNDLE_PREFIX_BYTES, bodyOffset)\n const header = decodeBundleHeader(headerBytes)\n return { header, bodyOffset, algo: algo as CompressionAlgo, flags }\n}\n\n/**\n * Read just the bundle header — no body decompression, no\n * integrity verification. Fast (O(prefix + header bytes)) and\n * intended for cloud-listing UIs that want to show the handle and\n * size before downloading the full body.\n *\n * Returns the same `NoydbBundleHeader` shape as the writer, with\n * minimum-disclosure validation already applied.\n */\nexport function readNoydbBundleHeader(bytes: Uint8Array): NoydbBundleHeader {\n return parsePrefixAndHeader(bytes).header\n}\n\n/**\n * Read a full `.noydb` bundle: validate magic + header, verify\n * integrity hash over the body bytes, decompress, and return the\n * original `vault.dump()` JSON string ready to pass to\n * `vault.load()`.\n *\n * Throws `BundleIntegrityError` if the body's actual SHA-256 does\n * not match the value declared in the header. Distinct from a\n * format error so consumers can pattern-match in catch blocks\n * (corrupted-in-transit vs malformed-by-producer).\n *\n * Note: this function does NOT take a passphrase. The dump JSON\n * inside the body still contains encrypted records — restoring\n * the vault requires `vault.load(dumpJson, passphrase)`\n * after this call. Splitting the layers keeps the bundle module\n * free of crypto concerns and lets the same code feed format\n * inspectors that never decrypt anything.\n */\nexport async function readNoydbBundle(\n bytes: Uint8Array,\n): Promise<NoydbBundleReadResult> {\n const { header, bodyOffset, algo } = parsePrefixAndHeader(bytes)\n const body = bytes.slice(bodyOffset)\n\n // Length check before hash check — a length mismatch is the\n // cheapest tamper signal and produces a more actionable error.\n if (body.length !== header.bodyBytes) {\n throw new BundleIntegrityError(\n `body length ${body.length} does not match header.bodyBytes ` +\n `${header.bodyBytes}. The bundle was truncated or padded ` +\n `between write and read.`,\n )\n }\n\n const actualSha = await sha256Hex(body)\n if (actualSha !== header.bodySha256) {\n throw new BundleIntegrityError(\n `body sha256 ${actualSha} does not match header.bodySha256 ` +\n `${header.bodySha256}. The bundle bytes were modified between ` +\n `write and read — refuse to decompress.`,\n )\n }\n\n let dumpBytes: Uint8Array\n if (algo === COMPRESSION_NONE) {\n dumpBytes = body\n } else {\n const streamFormat: CompressionFormat =\n algo === COMPRESSION_BROTLI ? ('br' as CompressionFormat) : 'gzip'\n try {\n dumpBytes = await pumpThroughStream(body, new DecompressionStream(streamFormat))\n } catch (err) {\n throw new BundleIntegrityError(\n `decompression failed: ${(err as Error).message}. The bundle ` +\n `passed the integrity hash but the body is not valid ` +\n `${streamFormat} data — likely a producer bug.`,\n )\n }\n }\n\n const dumpJson = new TextDecoder('utf-8', { fatal: true }).decode(dumpBytes)\n return { header, dumpJson }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\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'\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\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 * 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\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 /** Validate passphrase strength on creation. Default: true. */\n readonly validatePassphrase?: boolean\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"],"mappings":";AA0CO,IAAM,uBAAuB;AAG7B,IAAM,wBAAwB;AAG9B,IAAM,uBAAuB;AAG7B,IAAM,qBAAqB;AA6U3B,SAAS,YACd,SACmC;AACnC,SAAO;AACT;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/team/keyring.ts"],"sourcesContent":["import type { NoydbStore, KeyringFile, Role, Permissions, GrantOptions, RevokeOptions, UserInfo, EncryptedEnvelope, ExportCapability, ExportFormat, ImportCapability } 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 } from '../errors.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// ─── 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 readonly kek: CryptoKey\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\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 ...(keyringFile.export_capability !== undefined && { exportCapability: keyringFile.export_capability }),\n ...(keyringFile.import_capability !== undefined && { importCapability: keyringFile.import_capability }),\n }\n}\n\n/** Create the initial owner keyring for a new vault. */\nexport async function createOwnerKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n): Promise<UnlockedKeyring> {\n const salt = generateSalt()\n const kek = await deriveKey(passphrase, salt)\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: {},\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(),\n kek,\n salt,\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 // 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\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 }\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// ─── 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/** Change the user's passphrase. Re-wraps all DEKs with the new KEK. */\nexport async function changeSecret(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n newPassphrase: string,\n): Promise<UnlockedKeyring> {\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 }\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// ─── 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 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 }\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"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|