import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; import { LIMITS } from '../config/limits'; import { ensureStorageSchema } from './storage-schema'; import { getConfigValue as getStoredConfigValue, isRegistered as getRegisteredFlag, setConfigValue as saveConfigValue, setRegistered as saveRegisteredFlag, } from './storage-config-repo'; import { createFirstUser as createFirstStoredUser, createUser as createStoredUser, deleteUserById as deleteStoredUserById, getAllUsers as listStoredUsers, getUser as findStoredUserByEmail, getUserById as findStoredUserById, getUserCount as countStoredUsers, saveUser as saveStoredUser, } from './storage-user-repo'; import { createAuditLog as createStoredAuditLog, createInvite as createStoredInvite, deleteAllInvites as deleteStoredInvites, getInvite as findStoredInvite, listInvites as listStoredInvites, markInviteUsed as markStoredInviteUsed, revokeInvite as revokeStoredInvite, } from './storage-admin-repo'; import { bulkDeleteFolders as deleteStoredFolders, clearFolderFromCiphers as clearStoredFolderFromCiphers, deleteFolder as deleteStoredFolder, getAllFolders as listStoredFolders, getFolder as findStoredFolder, getFoldersPage as listStoredFoldersPage, saveFolder as saveStoredFolder, } from './storage-folder-repo'; import { bulkArchiveCiphers as archiveStoredCiphers, bulkDeleteCiphers as deleteStoredCiphers, bulkMoveCiphers as moveStoredCiphers, bulkRestoreCiphers as restoreStoredCiphers, bulkSoftDeleteCiphers as softDeleteStoredCiphers, bulkUnarchiveCiphers as unarchiveStoredCiphers, getAllCiphers as listStoredCiphers, getCipher as findStoredCipher, getCiphersByIds as listStoredCiphersByIds, getCiphersPage as listStoredCiphersPage, saveCipher as saveStoredCipher, deleteCipher as deleteStoredCipher, } from './storage-cipher-repo'; import { addAttachmentToCipher as attachStoredAttachmentToCipher, bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAttachment as deleteStoredAttachment, getAttachment as findStoredAttachment, getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByUserId as listStoredAttachmentsByUserId, saveAttachment as saveStoredAttachment, updateCipherRevisionDate as updateStoredCipherRevisionDate, } from './storage-attachment-repo'; import { bulkDeleteSends as deleteStoredSends, deleteSend as deleteStoredSend, getAllSends as listStoredSends, getSend as findStoredSend, getSendsByIds as listStoredSendsByIds, getSendsPage as listStoredSendsPage, incrementSendAccessCount as incrementStoredSendAccessCount, saveSend as saveStoredSend, } from './storage-send-repo'; import { constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, deleteRefreshToken as deleteStoredRefreshToken, deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, getRefreshTokenRecord as findStoredRefreshTokenRecord, saveRefreshToken as saveStoredRefreshToken, } from './storage-refresh-token-repo'; import { deleteDevice as deleteStoredDevice, deleteDevicesByUserId as deleteStoredDevicesByUserId, clearDeviceKeys as clearStoredDeviceKeys, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, getDevice as findStoredDevice, getDevicesByUserId as listStoredDevicesByUserId, getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, isKnownDevice as getKnownStoredDevice, isKnownDeviceByEmail as getKnownStoredDeviceByEmail, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, touchDeviceLastSeen as touchStoredDeviceLastSeen, upsertDevice as saveStoredDevice, updateDeviceName as updateStoredDeviceName, updateDeviceKeys as updateStoredDeviceKeys, } from './storage-device-repo'; import { ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, } from './storage-attachment-token-repo'; import { getRevisionDate as getStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate, } from './storage-revision-repo'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; const STORAGE_SCHEMA_VERSION = '2026-04-28'; // D1-backed storage. // Contract: // - All methods are scoped by userId where applicable. // - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions. // - Revision date is maintained per user for Bitwarden sync. export class StorageService { private static attachmentTokenTableReady = false; private static schemaVerified = false; private static lastRefreshTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0; private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; constructor(private db: D1Database) {} /** * D1 .bind() throws on `undefined` values. This helper converts every * `undefined` in the argument list to `null` so we never hit that runtime * error - especially important after the opaque-passthrough change where * client-supplied JSON may omit fields we later reference as columns. */ private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement { return stmt.bind(...values.map(v => v === undefined ? null : v)); } private sqlChunkSize(fixedBindCount: number): number { return Math.max( 1, Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount) ); } private async sha256Hex(input: string): Promise { const bytes = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join(''); } private async refreshTokenKey(token: string): Promise { const digest = await this.sha256Hex(token); return `sha256:${digest}`; } private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean { const now = Date.now(); if (now - lastRunAt < intervalMs) return false; return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY; } private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise { if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) { return; } await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run(); StorageService.lastRefreshTokenCleanupAt = nowMs; } // --- Database initialization --- // Strategy: // - Run only once per isolate. // - Execute idempotent schema SQL on first request in each isolate. // - Keep statements idempotent so updates are safe. async initializeDatabase(): Promise { if (StorageService.schemaVerified) return; await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY); if (schemaVersion !== STORAGE_SCHEMA_VERSION) { await ensureStorageSchema(this.db); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); } StorageService.schemaVerified = true; } // --- Config / setup --- async isRegistered(): Promise { return getRegisteredFlag(this.db); } async getConfigValue(key: string): Promise { return getStoredConfigValue(this.db, key); } async setConfigValue(key: string, value: string): Promise { await saveConfigValue(this.db, key, value); } async setRegistered(): Promise { await saveRegisteredFlag(this.db); } // --- Users --- async getUser(email: string): Promise { return findStoredUserByEmail(this.db, email); } async getUserById(id: string): Promise { return findStoredUserById(this.db, id); } async getUserCount(): Promise { return countStoredUsers(this.db); } async getAllUsers(): Promise { return listStoredUsers(this.db); } async saveUser(user: User): Promise { await saveStoredUser(this.db, this.safeBind.bind(this), user); } async createUser(user: User): Promise { await createStoredUser(this.db, this.safeBind.bind(this), user); } async createFirstUser(user: User): Promise { return createFirstStoredUser(this.db, this.safeBind.bind(this), user); } async deleteUserById(id: string): Promise { return deleteStoredUserById(this.db, id); } async createInvite(invite: Invite): Promise { await createStoredInvite(this.db, invite); } async getInvite(code: string): Promise { return findStoredInvite(this.db, code); } async listInvites(includeInactive: boolean = false): Promise { return listStoredInvites(this.db, includeInactive); } async markInviteUsed(code: string, userId: string): Promise { return markStoredInviteUsed(this.db, code, userId); } async revokeInvite(code: string): Promise { return revokeStoredInvite(this.db, code); } async deleteAllInvites(): Promise { return deleteStoredInvites(this.db); } async createAuditLog(log: AuditLog): Promise { await createStoredAuditLog(this.db, log); } // --- Ciphers --- async getCipher(id: string): Promise { return findStoredCipher(this.db, id); } async saveCipher(cipher: Cipher): Promise { await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); } async deleteCipher(id: string, userId: string): Promise { await deleteStoredCipher(this.db, id, userId); } async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise { return softDeleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async bulkRestoreCiphers(ids: string[], userId: string): Promise { return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async bulkArchiveCiphers(ids: string[], userId: string): Promise { return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async bulkUnarchiveCiphers(ids: string[], userId: string): Promise { return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async bulkDeleteCiphers(ids: string[], userId: string): Promise { return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async getAllCiphers(userId: string): Promise { return listStoredCiphers(this.db, userId); } async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise { return listStoredCiphersPage(this.db, userId, includeDeleted, limit, offset); } async getCiphersByIds(ids: string[], userId: string): Promise { return listStoredCiphersByIds(this.db, this.sqlChunkSize.bind(this), ids, userId); } async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { return moveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, folderId, userId); } // --- Folders --- async getFolder(id: string): Promise { return findStoredFolder(this.db, id); } async saveFolder(folder: Folder): Promise { await saveStoredFolder(this.db, folder); } async deleteFolder(id: string, userId: string): Promise { await deleteStoredFolder(this.db, id, userId); } async bulkDeleteFolders(ids: string[], userId: string): Promise { return deleteStoredFolders( this.db, userId, ids, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this) ); } // Clear folder references from all ciphers owned by the user. // Without this, deleting a folder leaves stale folderId values in cipher JSON. async clearFolderFromCiphers(userId: string, folderId: string): Promise { await clearStoredFolderFromCiphers(this.db, userId, folderId); } async getAllFolders(userId: string): Promise { return listStoredFolders(this.db, userId); } async getFoldersPage(userId: string, limit: number, offset: number): Promise { return listStoredFoldersPage(this.db, userId, limit, offset); } // --- Attachments --- async getAttachment(id: string): Promise { return findStoredAttachment(this.db, id); } async saveAttachment(attachment: Attachment): Promise { await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment); } async deleteAttachment(id: string): Promise { await deleteStoredAttachment(this.db, id); } async bulkDeleteAttachmentsByIds(ids: string[]): Promise { await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids); } async getAttachmentsByCipher(cipherId: string): Promise { return listStoredAttachmentsByCipher(this.db, cipherId); } async getAttachmentsByCipherIds(cipherIds: string[]): Promise> { return listStoredAttachmentsByCipherIds(this.db, this.sqlChunkSize.bind(this), cipherIds); } async getAttachmentsByUserId(userId: string): Promise> { return listStoredAttachmentsByUserId(this.db, userId); } async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise { await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); } async deleteAllAttachmentsByCipher(cipherId: string): Promise { await deleteStoredAttachmentsByCipher(this.db, cipherId); } async updateCipherRevisionDate(cipherId: string): Promise<{ userId: string; revisionDate: string } | null> { return updateStoredCipherRevisionDate( this.getCipher.bind(this), this.saveCipher.bind(this), this.updateRevisionDate.bind(this), cipherId ); } // --- Refresh tokens --- async saveRefreshToken( token: string, userId: string, expiresAtMs?: number, deviceIdentifier?: string | null, deviceSessionStamp?: string | null ): Promise { const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); await saveStoredRefreshToken( this.db, this.refreshTokenKey.bind(this), this.maybeCleanupExpiredRefreshTokens.bind(this), token, userId, expiresAt, deviceIdentifier, deviceSessionStamp ); } async getRefreshTokenRecord(token: string): Promise { return findStoredRefreshTokenRecord( this.db, this.refreshTokenKey.bind(this), this.maybeCleanupExpiredRefreshTokens.bind(this), this.saveRefreshToken.bind(this), this.deleteRefreshToken.bind(this), token ); } async getRefreshTokenUserId(token: string): Promise { const record = await this.getRefreshTokenRecord(token); return record?.userId ?? null; } async deleteRefreshToken(token: string): Promise { await deleteStoredRefreshToken(this.db, this.refreshTokenKey.bind(this), token); } // --- Sends --- async getSend(id: string): Promise { return findStoredSend(this.db, id); } async saveSend(send: Send): Promise { await saveStoredSend(this.db, this.safeBind.bind(this), send); } /** * Atomically increment access_count and update updated_at. * Returns true if the row was updated (send still available), * false if max_access_count has already been reached. */ async incrementSendAccessCount(sendId: string): Promise { return incrementStoredSendAccessCount(this.db, sendId); } async deleteSend(id: string, userId: string): Promise { await deleteStoredSend(this.db, id, userId); } async getSendsByIds(ids: string[], userId: string): Promise { return listStoredSendsByIds(this.db, this.sqlChunkSize.bind(this), ids, userId); } async bulkDeleteSends(ids: string[], userId: string): Promise { return deleteStoredSends(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async getAllSends(userId: string): Promise { return listStoredSends(this.db, userId); } async getSendsPage(userId: string, limit: number, offset: number): Promise { return listStoredSendsPage(this.db, userId, limit, offset); } async deleteRefreshTokensByUserId(userId: string): Promise { return deleteStoredRefreshTokensByUserId(this.db, userId); } async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise { return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); } // Keep a short overlap window for rotated refresh token to reduce // multi-context refresh races (e.g. browser extension popup/background). // Expiry is only tightened, never extended. async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise { await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); } private async trustedTwoFactorTokenKey(token: string): Promise { const digest = await this.sha256Hex(token); return `sha256:${digest}`; } // --- Devices --- async upsertDevice( userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string, keys?: { encryptedUserKey?: string | null; encryptedPublicKey?: string | null; encryptedPrivateKey?: string | null; } ): Promise { await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys); } async isKnownDevice(userId: string, deviceIdentifier: string): Promise { return getKnownStoredDevice(this.db, userId, deviceIdentifier); } async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise { return getKnownStoredDeviceByEmail(this.getUser.bind(this), this.isKnownDevice.bind(this), email, deviceIdentifier); } async getDevicesByUserId(userId: string): Promise { return listStoredDevicesByUserId(this.db, userId); } async getDevice(userId: string, deviceIdentifier: string): Promise { return findStoredDevice(this.db, userId, deviceIdentifier); } async updateDeviceKeys( userId: string, deviceIdentifier: string, keys: { encryptedUserKey?: string | null; encryptedPublicKey?: string | null; encryptedPrivateKey?: string | null; } ): Promise { return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); } async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise { return updateStoredDeviceName(this.db, userId, deviceIdentifier, name); } async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise { return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); } async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise { return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); } async deleteDevice(userId: string, deviceIdentifier: string): Promise { return deleteStoredDevice(this.db, userId, deviceIdentifier); } async deleteDevicesByUserId(userId: string): Promise { return deleteStoredDevicesByUserId(this.db, userId); } async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise { return listStoredTrustedTokenSummaries(this.db, userId); } async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise { return deleteStoredTrustedTokensByDevice(this.db, userId, deviceIdentifier); } async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise { return deleteStoredTrustedTokensByUserId(this.db, userId); } // --- Trusted 2FA remember tokens (device-bound) --- async saveTrustedTwoFactorDeviceToken( token: string, userId: string, deviceIdentifier: string, expiresAtMs?: number ): Promise { const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS); await saveStoredTrustedDeviceToken(this.db, this.trustedTwoFactorTokenKey.bind(this), token, userId, deviceIdentifier, expiresAt); } async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise { return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); } // --- Revision dates --- async getRevisionDate(userId: string): Promise { return getStoredRevisionDate(this.db, userId); } async updateRevisionDate(userId: string): Promise { return updateStoredRevisionDate(this.db, userId); } // --- One-time attachment download tokens --- private async ensureUsedAttachmentDownloadTokenTable(): Promise { if (StorageService.attachmentTokenTableReady) return; await ensureStoredAttachmentTokenTable(this.db); StorageService.attachmentTokenTableReady = true; } // Marks an attachment download token JTI as consumed. // Returns true only on first use. Reuse returns false. async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise { await this.ensureUsedAttachmentDownloadTokenTable(); const result = await consumeStoredAttachmentDownloadToken( this.db, this.shouldRunPeriodicCleanup.bind(this), StorageService.lastAttachmentTokenCleanupAt, StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS, jti, expUnixSeconds ); if (result.cleanedUpAt !== null) { StorageService.lastAttachmentTokenCleanupAt = result.cleanedUpAt; } return result.consumed; } }