import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto'; import type { Cipher, Folder, ListResponse, SessionState, VaultDraft, VaultDraftField, } from '../types'; import { BULK_API_CHUNK_SIZE, chunkArray, parseErrorMessage, parseJson, type AuthedFetch, } from './shared'; export async function getFolders(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/folders'); if (!resp.ok) throw new Error('Failed to load folders'); const body = await parseJson>(resp); return body?.data || []; } export async function createFolder( authedFetch: AuthedFetch, session: SessionState, name: string ): Promise<{ id: string; name?: string | null }> { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const enc = base64ToBytes(session.symEncKey); const mac = base64ToBytes(session.symMacKey); const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac); const resp = await authedFetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Create folder failed'); const body = await parseJson<{ id?: string; name?: string | null }>(resp); if (!body?.id) throw new Error('Create folder failed'); return { id: body.id, name: body.name ?? null }; } export async function encryptFolderImportName(session: SessionState, name: string): Promise { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const enc = base64ToBytes(session.symEncKey); const mac = base64ToBytes(session.symMacKey); return encryptBw(new TextEncoder().encode(name), enc, mac); } export async function deleteFolder(authedFetch: AuthedFetch, folderId: string): Promise { const id = String(folderId || '').trim(); if (!id) throw new Error('Folder id is required'); const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, { method: 'DELETE', }); if (!resp.ok) throw new Error('Delete folder failed'); } export async function bulkDeleteFolders(authedFetch: AuthedFetch, ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { const resp = await authedFetch('/api/folders/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: chunk }), }); if (!resp.ok) throw new Error('Bulk delete folders failed'); } } export async function updateFolder( authedFetch: AuthedFetch, session: SessionState, folderId: string, name: string ): Promise { const id = String(folderId || '').trim(); if (!id) throw new Error('Folder id is required'); if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const enc = base64ToBytes(session.symEncKey); const mac = base64ToBytes(session.symMacKey); const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac); const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Update folder failed'); } export async function getCiphers(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/ciphers?deleted=true'); if (!resp.ok) throw new Error('Failed to load ciphers'); const body = await parseJson>(resp); return body?.data || []; } export interface CiphersImportPayload { ciphers: Array>; folders: Array<{ name: string }>; folderRelationships: Array<{ key: number; value: number }>; } export interface ImportedCipherMapEntry { index: number; sourceId: string | null; id: string; } export async function importCiphers( authedFetch: AuthedFetch, payload: CiphersImportPayload, options?: { returnCipherMap?: boolean } ): Promise { const returnCipherMap = !!options?.returnCipherMap; const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import'; const totalItems = (payload.folders?.length || 0) + (payload.ciphers?.length || 0); const responses: ImportedCipherMapEntry[] = []; const folderChunkSize = Math.min(BULK_API_CHUNK_SIZE, Math.max(0, BULK_API_CHUNK_SIZE - 1)); if (totalItems <= BULK_API_CHUNK_SIZE || payload.folders.length > folderChunkSize) { const resp = await authedFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed')); if (!returnCipherMap) return null; const body = (await parseJson<{ cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>; }>(resp)) || {}; if (!Array.isArray(body.cipherMap)) return []; for (const row of body.cipherMap) { const index = Number(row?.index); const id = String(row?.id || '').trim(); if (!Number.isFinite(index) || !id) continue; const sourceRaw = String(row?.sourceId || '').trim(); responses.push({ index, id, sourceId: sourceRaw || null, }); } return responses; } const folders = payload.folders || []; const relationshipsByCipher = new Map(); for (const relation of payload.folderRelationships || []) { relationshipsByCipher.set(Number(relation.key), Number(relation.value)); } for (const cipherChunkStart of Array.from({ length: Math.ceil(payload.ciphers.length / BULK_API_CHUNK_SIZE) }, (_, i) => i * BULK_API_CHUNK_SIZE)) { const cipherChunk = payload.ciphers.slice(cipherChunkStart, cipherChunkStart + BULK_API_CHUNK_SIZE); const usedFolderIndices = Array.from( new Set( cipherChunk .map((_, localIndex) => relationshipsByCipher.get(cipherChunkStart + localIndex)) .filter((value): value is number => Number.isFinite(value as number) && (value as number) >= 0) ) ); const folderIndexMap = new Map(); const chunkFolders = usedFolderIndices.map((folderIndex, localIndex) => { folderIndexMap.set(folderIndex, localIndex); return folders[folderIndex]; }); const chunkRelationships = cipherChunk .map((_, localIndex) => { const originalCipherIndex = cipherChunkStart + localIndex; const originalFolderIndex = relationshipsByCipher.get(originalCipherIndex); if (!Number.isFinite(originalFolderIndex as number)) return null; const localFolderIndex = folderIndexMap.get(Number(originalFolderIndex)); if (!Number.isFinite(localFolderIndex as number)) return null; return { key: localIndex, value: Number(localFolderIndex) }; }) .filter((value): value is { key: number; value: number } => !!value); const resp = await authedFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ciphers: cipherChunk, folders: chunkFolders, folderRelationships: chunkRelationships, }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed')); if (!returnCipherMap) continue; const body = (await parseJson<{ cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>; }>(resp)) || {}; for (const row of body.cipherMap || []) { const localIndex = Number(row?.index); const id = String(row?.id || '').trim(); if (!Number.isFinite(localIndex) || !id) continue; const sourceRaw = String(row?.sourceId || '').trim(); responses.push({ index: cipherChunkStart + localIndex, id, sourceId: sourceRaw || null, }); } } return returnCipherMap ? responses : null; } export interface AttachmentDownloadInfo { id: string; url: string; fileName: string | null; key: string | null; size: string | null; sizeName: string | null; } export async function getAttachmentDownloadInfo( authedFetch: AuthedFetch, cipherId: string, attachmentId: string ): Promise { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Failed to load attachment')); const body = (await parseJson<{ id?: string; url?: string; fileName?: string | null; key?: string | null; size?: string | null; sizeName?: string | null; }>(resp)) || {}; const id = String(body.id || attachmentId || '').trim(); const url = String(body.url || '').trim(); if (!id || !url) throw new Error('Invalid attachment download response'); return { id, url, fileName: body.fileName ?? null, key: body.key ?? null, size: body.size ?? null, sizeName: body.sizeName ?? null, }; } function looksLikeCipherString(value: unknown): boolean { return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); } export async function uploadCipherAttachment( authedFetch: AuthedFetch, session: SessionState, cipherId: string, file: File, cipherForKey?: Cipher | null ): Promise { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const id = String(cipherId || '').trim(); if (!id) throw new Error('Cipher id is required'); if (!file) throw new Error('File is required'); const userEnc = base64ToBytes(session.symEncKey); const userMac = base64ToBytes(session.symMacKey); const itemKeys = await getCipherKeys(cipherForKey || null, userEnc, userMac); const encryptedFileName = await encryptTextValue(file.name, itemKeys.enc, itemKeys.mac); if (!encryptedFileName) throw new Error('Invalid attachment name'); const attachmentRawKey = crypto.getRandomValues(new Uint8Array(64)); const attachmentWrappedKey = await encryptBw(attachmentRawKey, itemKeys.enc, itemKeys.mac); const fileBytes = new Uint8Array(await file.arrayBuffer()); const encryptedBytes = await encryptBwFileData(fileBytes, attachmentRawKey.slice(0, 32), attachmentRawKey.slice(32, 64)); const metaResp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/v2`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: encryptedFileName, key: attachmentWrappedKey, fileSize: encryptedBytes.byteLength, }), }); if (!metaResp.ok) throw new Error(await parseErrorMessage(metaResp, 'Create attachment failed')); const meta = (await parseJson<{ attachmentId?: string; url?: string; }>(metaResp)) || {}; const attachmentId = String(meta.attachmentId || '').trim(); const uploadUrl = String(meta.url || '').trim(); if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed'); const payload = new ArrayBuffer(encryptedBytes.byteLength); new Uint8Array(payload).set(encryptedBytes); const formData = new FormData(); formData.set('data', new Blob([payload], { type: 'application/octet-stream' }), encryptedFileName); const uploadResp = await authedFetch(uploadUrl, { method: 'POST', body: formData, }); if (!uploadResp.ok) { try { await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/${encodeURIComponent(attachmentId)}`, { method: 'DELETE' }); } catch { // ignore rollback failure } throw new Error(await parseErrorMessage(uploadResp, 'Upload attachment failed')); } } export async function deleteCipherAttachment( authedFetch: AuthedFetch, cipherId: string, attachmentId: string ): Promise { const cid = String(cipherId || '').trim(); const aid = String(attachmentId || '').trim(); if (!cid || !aid) throw new Error('Attachment id is required'); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cid)}/attachment/${encodeURIComponent(aid)}`, { method: 'DELETE', }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed')); } export async function downloadCipherAttachmentDecrypted( authedFetch: AuthedFetch, session: SessionState, cipher: Cipher, attachmentId: string ): Promise<{ fileName: string; bytes: Uint8Array }> { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const cid = String(cipher?.id || '').trim(); const aid = String(attachmentId || '').trim(); if (!cid || !aid) throw new Error('Attachment id is required'); const info = await getAttachmentDownloadInfo(authedFetch, cid, aid); const rawResp = await fetch(info.url, { cache: 'no-store' }); if (!rawResp.ok) throw new Error('Download attachment failed'); const encryptedBytes = new Uint8Array(await rawResp.arrayBuffer()); const userEnc = base64ToBytes(session.symEncKey); const userMac = base64ToBytes(session.symMacKey); const itemKeys = await getCipherKeys(cipher, userEnc, userMac); let fileEnc = itemKeys.enc; let fileMac = itemKeys.mac; const keyCipher = String(info.key || '').trim(); if (keyCipher && looksLikeCipherString(keyCipher)) { try { const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac); if (fileRawKey.length >= 64) { fileEnc = fileRawKey.slice(0, 32); fileMac = fileRawKey.slice(32, 64); } } catch { // fallback to item key } } const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); const fileNameRaw = String(info.fileName || '').trim(); let fileName = fileNameRaw || `attachment-${aid}`; if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { try { fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName; } catch { // keep fallback name } } return { fileName, bytes: plainBytes }; } function asNullable(v: string): string | null { const s = String(v || '').trim(); return s ? s : null; } function parseFieldType(v: number | string): 0 | 1 | 2 | 3 { if (typeof v === 'number') { if (v === 1 || v === 2 || v === 3) return v; return 0; } const s = String(v).trim().toLowerCase(); if (s === '1' || s === 'hidden') return 1; if (s === '2' || s === 'boolean' || s === 'checkbox') return 2; if (s === '3' || s === 'linked' || s === 'link') return 3; return 0; } async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array): Promise { const s = String(value || ''); if (!s.trim()) return null; return encryptBw(new TextEncoder().encode(s), enc, mac); } async function encryptCustomFields( fields: VaultDraftField[], enc: Uint8Array, mac: Uint8Array ): Promise> { const out: Array<{ type: number; name: string | null; value: string | null }> = []; for (const field of fields || []) { const label = String(field.label || '').trim(); if (!label) continue; out.push({ type: parseFieldType(field.type), name: await encryptTextValue(label, enc, mac), value: await encryptTextValue(String(field.value || ''), enc, mac), }); } return out; } async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise> { const out: Array<{ uri: string | null; match: null }> = []; for (const uri of uris || []) { const trimmed = String(uri || '').trim(); if (!trimmed) continue; out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null }); } return out; } function toIsoDateOrNow(value: unknown): string { const raw = String(value ?? '').trim(); if (!raw) return new Date().toISOString(); const parsed = new Date(raw); if (!Number.isFinite(parsed.getTime())) return new Date().toISOString(); return parsed.toISOString(); } async function encryptMaybeFidoValue( value: unknown, enc: Uint8Array, mac: Uint8Array, fallback = '' ): Promise { const normalized = String(value ?? '').trim() || fallback; if (looksLikeCipherString(normalized)) return normalized; return encryptBw(new TextEncoder().encode(normalized), enc, mac); } async function encryptMaybeNullableFidoValue( value: unknown, enc: Uint8Array, mac: Uint8Array ): Promise { const normalized = String(value ?? '').trim(); if (!normalized) return null; if (looksLikeCipherString(normalized)) return normalized; return encryptBw(new TextEncoder().encode(normalized), enc, mac); } async function normalizeFido2Credentials( credentials: Array> | null | undefined, enc: Uint8Array, mac: Uint8Array ): Promise> | null> { if (!Array.isArray(credentials) || credentials.length === 0) return null; const out: Array> = []; for (const credential of credentials) { if (!credential || typeof credential !== 'object') continue; out.push({ credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac), keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'), keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'), keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'), keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac), rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac), rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac), userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac), userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac), userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac), counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'), discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'), creationDate: toIsoDateOrNow(credential.creationDate), }); } return out.length ? out : null; } async function getCipherKeys( cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array ): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> { if (cipher?.key) { try { const raw = await decryptBw(cipher.key, userEnc, userMac); if (raw.length >= 64) return { enc: raw.slice(0, 32), mac: raw.slice(32, 64), key: cipher.key }; } catch { // use user key } } return { enc: userEnc, mac: userMac, key: null }; } async function buildCipherPayload( session: SessionState, draft: VaultDraft, cipher: Cipher | null ): Promise> { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const userEnc = base64ToBytes(session.symEncKey); const userMac = base64ToBytes(session.symMacKey); const keys = await getCipherKeys(cipher, userEnc, userMac); const type = Number(draft.type || cipher?.type || 1); const payload: Record = { type, favorite: !!draft.favorite, folderId: asNullable(draft.folderId), reprompt: draft.reprompt ? 1 : 0, name: await encryptTextValue(draft.name, keys.enc, keys.mac), notes: await encryptTextValue(draft.notes, keys.enc, keys.mac), login: null, card: null, identity: null, secureNote: null, sshKey: null, fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac), }; if (cipher?.id) { payload.id = cipher.id; payload.key = keys.key; } if (type === 1) { const existingFido2 = cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) ? (cipher.login as any).fido2Credentials : draft.loginFido2Credentials; payload.login = { username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac), }; } else if (type === 3) { payload.card = { cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac), number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac), brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac), expMonth: await encryptTextValue(draft.cardExpMonth, keys.enc, keys.mac), expYear: await encryptTextValue(draft.cardExpYear, keys.enc, keys.mac), code: await encryptTextValue(draft.cardCode, keys.enc, keys.mac), }; } else if (type === 4) { payload.identity = { title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac), firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac), middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac), lastName: await encryptTextValue(draft.identLastName, keys.enc, keys.mac), username: await encryptTextValue(draft.identUsername, keys.enc, keys.mac), company: await encryptTextValue(draft.identCompany, keys.enc, keys.mac), ssn: await encryptTextValue(draft.identSsn, keys.enc, keys.mac), passportNumber: await encryptTextValue(draft.identPassportNumber, keys.enc, keys.mac), licenseNumber: await encryptTextValue(draft.identLicenseNumber, keys.enc, keys.mac), email: await encryptTextValue(draft.identEmail, keys.enc, keys.mac), phone: await encryptTextValue(draft.identPhone, keys.enc, keys.mac), address1: await encryptTextValue(draft.identAddress1, keys.enc, keys.mac), address2: await encryptTextValue(draft.identAddress2, keys.enc, keys.mac), address3: await encryptTextValue(draft.identAddress3, keys.enc, keys.mac), city: await encryptTextValue(draft.identCity, keys.enc, keys.mac), state: await encryptTextValue(draft.identState, keys.enc, keys.mac), postalCode: await encryptTextValue(draft.identPostalCode, keys.enc, keys.mac), country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac), }; } else if (type === 5) { const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac); payload.sshKey = { privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac), publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac), keyFingerprint: encryptedFingerprint, fingerprint: encryptedFingerprint, }; } else if (type === 2) { payload.secureNote = { type: 0 }; } return payload; } export async function buildCipherImportPayload(session: SessionState, draft: VaultDraft): Promise> { return buildCipherPayload(session, draft, null); } export async function createCipher( authedFetch: AuthedFetch, session: SessionState, draft: VaultDraft ): Promise<{ id: string }> { const payload = await buildCipherPayload(session, draft, null); const resp = await authedFetch('/api/ciphers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Create item failed'); const body = await parseJson<{ id?: string }>(resp); if (!body?.id) throw new Error('Create item failed'); return { id: body.id }; } export async function updateCipher( authedFetch: AuthedFetch, session: SessionState, cipher: Cipher, draft: VaultDraft ): Promise { const payload = await buildCipherPayload(session, draft, cipher); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Update item failed'); } export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Delete item failed'); } export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { const resp = await authedFetch('/api/ciphers/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: chunk }), }); if (!resp.ok) throw new Error('Bulk delete failed'); } } export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { const resp = await authedFetch('/api/ciphers/delete-permanent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: chunk }), }); if (!resp.ok) throw new Error('Bulk permanent delete failed'); } } export async function bulkRestoreCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { const resp = await authedFetch('/api/ciphers/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: chunk }), }); if (!resp.ok) throw new Error('Bulk restore failed'); } } export async function bulkMoveCiphers( authedFetch: AuthedFetch, ids: string[], folderId: string | null ): Promise { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { const resp = await authedFetch('/api/ciphers/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: chunk, folderId }), }); if (!resp.ok) throw new Error('Bulk move failed'); } }