import { Env, Cipher, CipherCard, CipherIdentity, CipherLogin, CipherResponse, CipherSecureNote, CipherSshKey, Attachment, PasswordHistory, } from '../types'; import { StorageService } from '../services/storage'; import { notifyUserVaultSync } from '../durable/notifications-hub'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { readActingDeviceIdentifier } from '../utils/device'; function normalizeOptionalId(value: unknown): string | null { if (value == null) return null; const normalized = String(value).trim(); return normalized ? normalized : null; } function notifyVaultSyncForRequest( request: Request, env: Env, userId: string, revisionDate: string ): void { notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } { if (!source || typeof source !== 'object') return { present: false, value: undefined }; for (const key of aliases) { if (Object.prototype.hasOwnProperty.call(source, key)) { return { present: true, value: source[key] }; } } return { present: false, value: undefined }; } function readCipherProp(source: any, aliases: string[]): { present: boolean; value: T | undefined } { return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined }; } function normalizeCipherTimestamp(value: unknown): string | null { if (value == null || value === '') return null; const parsed = new Date(String(value)); if (Number.isNaN(parsed.getTime())) return null; return parsed.toISOString(); } function readCipherArchivedAt(source: any, fallback: string | null = null): string | null { const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']); return archived.present ? normalizeCipherTimestamp(archived.value) : fallback; } function readCipherRevisionDate(source: any): string | null { const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']); return revision.present ? normalizeCipherTimestamp(revision.value) : null; } function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean { if (!clientRevisionDate) return false; const existingTs = Date.parse(existingUpdatedAt); const clientTs = Date.parse(clientRevisionDate); if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false; return existingTs - clientTs > 1000; } function syncCipherComputedAliases(cipher: Cipher): Cipher { cipher.archivedDate = cipher.archivedAt ?? null; cipher.deletedDate = cipher.deletedAt ?? null; return cipher; } function isValidEncString(value: unknown): value is string { if (typeof value !== 'string') return false; const trimmed = value.trim(); const dot = trimmed.indexOf('.'); if (dot <= 0) return false; const type = Number(trimmed.slice(0, dot)); if (!Number.isInteger(type) || type < 0) return false; const parts = trimmed.slice(dot + 1).split('|'); if (parts.some((part) => part.length === 0)) return false; // Bitwarden's legacy symmetric EncString variants require IV + data, // while the authenticated AES-CBC-HMAC variant requires IV + data + MAC. if (type === 0 || type === 1 || type === 4) return parts.length >= 2; if (type === 2) return parts.length === 3; // Keep newer one-part formats, such as COSE Encrypt0, future-compatible. return parts.length >= 1; } function optionalEncString(value: unknown): string | null { if (value == null || value === '') return null; return isValidEncString(value) ? value.trim() : null; } function sanitizeEncryptedObject>( source: T | null | undefined, encryptedKeys: readonly string[] ): T | null { if (!source || typeof source !== 'object') return source ?? null; const next: Record = { ...source }; for (const key of encryptedKeys) { if (!Object.prototype.hasOwnProperty.call(next, key)) continue; next[key] = optionalEncString(next[key]); } return next as T; } function normalizeCipherForStorage(cipher: Cipher): Cipher { cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); cipher.folderId = normalizeOptionalId(cipher.folderId); const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt'); cipher.archivedAt = hasArchivedAt ? normalizeCipherTimestamp(cipher.archivedAt) ?? null : normalizeCipherTimestamp(cipher.archivedDate) ?? null; return syncCipherComputedAliases(cipher); } export function normalizeCipherLoginForStorage(login: any): any { if (!login || typeof login !== 'object') return login ?? null; return { ...login, fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, }; } export function normalizeCipherLoginForCompatibility(login: any): any { const normalized = normalizeCipherLoginForStorage(login); if (!normalized || typeof normalized !== 'object') return normalized ?? null; const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']); if (!next) return null; next.uris = Array.isArray(next.uris) ? next.uris .map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum'])) .filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null)) : null; next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials); return next; } function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null { if (!Array.isArray(credentials) || credentials.length === 0) return null; const requiredEncryptedKeys = [ 'credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', ]; const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName']; const out: any[] = []; for (const credential of credentials) { if (!credential || typeof credential !== 'object') continue; const next: Record = { ...credential }; let valid = true; for (const key of requiredEncryptedKeys) { if (!isValidEncString(next[key])) { valid = false; break; } next[key] = String(next[key]).trim(); } if (!valid) continue; for (const key of optionalEncryptedKeys) { if (Object.prototype.hasOwnProperty.call(next, key)) { next[key] = optionalEncString(next[key]); } } out.push(next); } return out.length ? out : null; } // Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads. // Keep legacy alias "fingerprint" in parallel for older web payloads. export function normalizeCipherSshKeyForCompatibility(sshKey: any): any { if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null; const candidate = sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null ? sshKey.keyFingerprint : sshKey.fingerprint; const normalizedFingerprint = candidate === undefined || candidate === null ? '' : String(candidate); if ( !isValidEncString(sshKey.privateKey) || !isValidEncString(sshKey.publicKey) || !isValidEncString(normalizedFingerprint) ) { return null; } return { ...sshKey, privateKey: String(sshKey.privateKey).trim(), publicKey: String(sshKey.publicKey).trim(), keyFingerprint: normalizedFingerprint, fingerprint: normalizedFingerprint, }; } // Format attachments for API response export function formatAttachments(attachments: Attachment[]): any[] | null { if (attachments.length === 0) return null; const formatted = attachments .filter((a) => isValidEncString(a.fileName)) .map(a => ({ id: a.id, fileName: a.fileName.trim(), // Bitwarden clients decode attachment size as string in cipher payloads. size: String(Number(a.size) || 0), sizeName: a.sizeName, key: optionalEncString(a.key), url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url! object: 'attachment', })); return formatted.length ? formatted : null; } function normalizeCipherFieldsForCompatibility(fields: any): any[] | null { if (!Array.isArray(fields) || fields.length === 0) return null; const out = fields .map((field: any) => { if (!field || typeof field !== 'object') return null; return { ...field, name: optionalEncString(field.name), value: optionalEncString(field.value), type: Number(field.type) || 0, linkedId: field.linkedId ?? null, }; }) .filter(Boolean); return out.length ? out : null; } function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null { if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null; const out = passwordHistory .filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password)) .map((entry: any) => ({ ...entry, password: String(entry.password).trim(), lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(), })); return out.length ? out : null; } export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean { return isValidEncString(cipher.name); } // Convert internal cipher to API response format. // Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones), // then overlays server-computed fields. This ensures new Bitwarden client fields // survive a round-trip without code changes. export function cipherToResponse( cipher: Cipher, attachments: Attachment[] = [] ): CipherResponse { // Strip internal-only fields that must not appear in the API response const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null); const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ 'title', 'firstName', 'middleName', 'lastName', 'address1', 'address2', 'address3', 'city', 'state', 'postalCode', 'country', 'company', 'email', 'phone', 'ssn', 'username', 'passportNumber', 'licenseNumber', ]); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); return { // Pass through ALL stored cipher fields (known + unknown) ...passthrough, // Server-computed / enforced fields (always override) folderId: normalizeOptionalId(cipher.folderId), type: Number(cipher.type) || 1, organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), creationDate: createdAt, revisionDate: updatedAt, deletedDate: deletedAt, archivedDate: archivedAt ?? null, edit: true, viewPassword: true, permissions: { delete: true, restore: true, }, object: 'cipherDetails', collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], attachments: formatAttachments(attachments), name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name, notes: optionalEncString(cipher.notes), login: normalizedLogin, card: normalizedCard, identity: normalizedIdentity, fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields), passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), sshKey: normalizedSshKey, key: optionalEncString(cipher.key), encryptedFor: (passthrough as any).encryptedFor ?? null, }; } // GET /api/ciphers export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); const url = new URL(request.url); const includeDeleted = url.searchParams.get('deleted') === 'true'; const pagination = parsePagination(url); let filteredCiphers: Cipher[]; let continuationToken: string | null = null; if (pagination) { const pageRows = await storage.getCiphersPage( userId, includeDeleted, pagination.limit + 1, pagination.offset ); const hasNext = pageRows.length > pagination.limit; filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null; } else { const ciphers = await storage.getAllCiphers(userId); filteredCiphers = includeDeleted ? ciphers : ciphers.filter(c => !c.deletedAt); } const attachmentsByCipher = await storage.getAttachmentsByCipherIds( filteredCiphers.map((cipher) => cipher.id) ); // Build responses only for the current page to keep pagination cheap. const cipherResponses: CipherResponse[] = []; for (const cipher of filteredCiphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; cipherResponses.push(cipherToResponse(cipher, attachments)); } return jsonResponse({ data: cipherResponses, object: 'list', continuationToken: continuationToken, }); } // GET /api/ciphers/:id export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( cipherToResponse(cipher, attachments) ); } async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise { if (!folderId) return true; const folder = await storage.getFolder(folderId); return !!(folder && folder.userId === userId); } // POST /api/ciphers export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: any; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } // Handle nested cipher object (from some clients) // Android client sends PascalCase "Cipher" for organization ciphers const cipherData = body.Cipher || body.cipher || body; const createFolderId = readCipherProp(cipherData, ['folderId', 'FolderId']); const createKey = readCipherProp(cipherData, ['key', 'Key']); const createLogin = readCipherProp(cipherData, ['login', 'Login']); const createCard = readCipherProp(cipherData, ['card', 'Card']); const createIdentity = readCipherProp(cipherData, ['identity', 'Identity']); const createSecureNote = readCipherProp(cipherData, ['secureNote', 'SecureNote']); const createSshKey = readCipherProp(cipherData, ['sshKey', 'SshKey']); const createPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); const now = new Date().toISOString(); // Opaque passthrough: spread ALL client fields to preserve unknown/future ones, // then override only server-controlled fields. const cipher: Cipher = { ...cipherData, // Server-controlled fields (always override client values) id: generateUUID(), userId: userId, type: Number(cipherData.type) || 1, favorite: !!cipherData.favorite, reprompt: cipherData.reprompt || 0, createdAt: now, updatedAt: now, archivedAt: readCipherArchivedAt(cipherData, null), deletedAt: null, }; cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId); cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null); cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null); cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null); cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null); cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null); cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null); cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); normalizeCipherForStorage(cipher); // Prevent referencing a folder owned by another user. if (cipher.folderId) { const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId); if (!folderOk) return errorResponse('Folder not found', 404); } await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( cipherToResponse(cipher, []), 200 ); } // PUT /api/ciphers/:id export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const existingCipher = await storage.getCipher(id); if (!existingCipher || existingCipher.userId !== userId) { return errorResponse('Cipher not found', 404); } let body: any; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } // Handle nested cipher object // Android client sends PascalCase "Cipher" for organization ciphers const cipherData = body.Cipher || body.cipher || body; const incomingFolderId = readCipherProp(cipherData, ['folderId', 'FolderId']); const incomingKey = readCipherProp(cipherData, ['key', 'Key']); const incomingLogin = readCipherProp(cipherData, ['login', 'Login']); const incomingCard = readCipherProp(cipherData, ['card', 'Card']); const incomingIdentity = readCipherProp(cipherData, ['identity', 'Identity']); const incomingSecureNote = readCipherProp(cipherData, ['secureNote', 'SecureNote']); const incomingSshKey = readCipherProp(cipherData, ['sshKey', 'SshKey']); const incomingPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingRevisionDate = readCipherRevisionDate(cipherData); if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); } const nextType = Number(cipherData.type) || existingCipher.type; // Opaque passthrough: merge existing stored data with ALL incoming client fields. // Unknown/future fields from the client are preserved; server-controlled fields are protected. const cipher: Cipher = { ...existingCipher, // start with all existing stored data (including unknowns) ...cipherData, // overlay all client data (including new/unknown fields) // Server-controlled fields (never from client) id: existingCipher.id, userId: existingCipher.userId, type: nextType, favorite: cipherData.favorite ?? existingCipher.favorite, reprompt: cipherData.reprompt ?? existingCipher.reprompt, createdAt: existingCipher.createdAt, updatedAt: new Date().toISOString(), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), deletedAt: existingCipher.deletedAt, }; if (incomingFolderId.present) { cipher.folderId = normalizeOptionalId(incomingFolderId.value); } if (incomingKey.present) { cipher.key = incomingKey.value ?? null; } cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null; cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null; cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null; cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null; cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null; if (incomingPasswordHistory.present) { cipher.passwordHistory = incomingPasswordHistory.value ?? null; } // Custom fields deletion compatibility: // - Accept both camelCase "fields" and PascalCase "Fields". // - For full update (PUT/POST on this endpoint), missing fields means cleared fields. // This prevents stale custom fields from being resurrected by merge fallback. const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']); if (incomingFields.present) { cipher.fields = incomingFields.value ?? null; } else if (request.method === 'PUT' || request.method === 'POST') { cipher.fields = null; } normalizeCipherForStorage(cipher); // Prevent referencing a folder owned by another user. if (cipher.folderId) { const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId); if (!folderOk) return errorResponse('Folder not found', 404); } await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( cipherToResponse(cipher, attachments) ); } // DELETE /api/ciphers/:id export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } // Soft delete cipher.deletedAt = new Date().toISOString(); cipher.updatedAt = cipher.deletedAt; syncCipherComputedAliases(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( cipherToResponse(cipher, []) ); } // DELETE /api/ciphers/:id (compat mode) // Bitwarden clients may call DELETE on a trashed item to purge it permanently. // For compatibility: // - If item is active -> soft delete. // - If item is already soft-deleted -> hard delete. export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } if (cipher.deletedAt) { await deleteAllAttachmentsForCipher(env, id); await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); return new Response(null, { status: 204 }); } return handleDeleteCipher(request, env, userId, id); } // DELETE /api/ciphers/:id (permanent) export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } // Delete all attachments first await deleteAllAttachmentsForCipher(env, id); await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); return new Response(null, { status: 204 }); } // PUT /api/ciphers/:id/restore export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } cipher.deletedAt = null; cipher.updatedAt = new Date().toISOString(); syncCipherComputedAliases(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( cipherToResponse(cipher, []) ); } // PUT /api/ciphers/:id/partial - Update only favorite/folderId export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } let body: { folderId?: string | null; favorite?: boolean }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } if (body.folderId !== undefined) { const folderId = normalizeOptionalId(body.folderId); if (folderId) { const folderOk = await verifyFolderOwnership(storage, folderId, userId); if (!folderOk) return errorResponse('Folder not found', 404); } cipher.folderId = folderId; } if (body.favorite !== undefined) { cipher.favorite = body.favorite; } cipher.updatedAt = new Date().toISOString(); syncCipherComputedAliases(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( cipherToResponse(cipher, []) ); } // POST/PUT /api/ciphers/move - Bulk move to folder export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: { ids?: string[]; folderId?: string | null }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } if (!body.ids || !Array.isArray(body.ids)) { return errorResponse('ids array is required', 400); } const folderId = normalizeOptionalId(body.folderId); if (folderId) { const folderOk = await verifyFolderOwnership(storage, folderId, userId); if (!folderOk) return errorResponse('Folder not found', 404); } const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } return new Response(null, { status: 204 }); } async function buildCipherListResponse( request: Request, storage: StorageService, userId: string, ids: string[] ): Promise { const ciphers = await storage.getCiphersByIds(ids, userId); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id)); return jsonResponse({ data: ciphers.map((cipher) => cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []) ), object: 'list', continuationToken: null, }); } function parseCipherIdList(body: { ids?: unknown }): string[] | null { if (!Array.isArray(body.ids)) return null; return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean))); } // PUT/POST /api/ciphers/:id/archive export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } if (cipher.deletedAt) { return errorResponse('Cannot archive a deleted cipher', 400); } cipher.archivedAt = new Date().toISOString(); cipher.updatedAt = cipher.archivedAt; normalizeCipherForStorage(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( cipherToResponse(cipher, attachments) ); } // PUT/POST /api/ciphers/:id/unarchive export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise { const storage = new StorageService(env.DB); const cipher = await storage.getCipher(id); if (!cipher || cipher.userId !== userId) { return errorResponse('Cipher not found', 404); } cipher.archivedAt = null; cipher.updatedAt = new Date().toISOString(); normalizeCipherForStorage(cipher); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( cipherToResponse(cipher, attachments) ); } // PUT/POST /api/ciphers/archive export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: { ids?: unknown }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } const ids = parseCipherIdList(body); if (!ids) { return errorResponse('ids array is required', 400); } const revisionDate = await storage.bulkArchiveCiphers(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } return buildCipherListResponse(request, storage, userId, ids); } // PUT/POST /api/ciphers/unarchive export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: { ids?: unknown }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } const ids = parseCipherIdList(body); if (!ids) { return errorResponse('ids array is required', 400); } const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } return buildCipherListResponse(request, storage, userId, ids); } // POST /api/ciphers/delete - Bulk soft delete export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: { ids?: string[] }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } if (!body.ids || !Array.isArray(body.ids)) { return errorResponse('ids array is required', 400); } const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } return new Response(null, { status: 204 }); } // POST /api/ciphers/restore - Bulk restore export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: { ids?: string[] }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } if (!body.ids || !Array.isArray(body.ids)) { return errorResponse('ids array is required', 400); } const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } return new Response(null, { status: 204 }); } // POST /api/ciphers/delete-permanent - Bulk permanent delete export async function handleBulkPermanentDeleteCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); let body: { ids?: string[] }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } if (!body.ids || !Array.isArray(body.ids)) { return errorResponse('ids array is required', 400); } const ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!ids.length) { return new Response(null, { status: 204 }); } const ownedCiphers = await storage.getCiphersByIds(ids, userId); const ownedIds = ownedCiphers.map((cipher) => cipher.id); if (!ownedIds.length) { return new Response(null, { status: 204 }); } await deleteAllAttachmentsForCiphers(env, ownedIds); const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); } return new Response(null, { status: 204 }); }