/** * Bridges between the legacy single-file CloudAdapter interface and the * shared EncryptedObjectStorage surface used by plugin sync Providers. */ import type { EncryptedObjectAccount, EncryptedObjectDeleteResult, EncryptedObjectReadResult, EncryptedObjectStorage, EncryptedObjectStorageCapabilities, EncryptedObjectWriteResult, } from '../../../domain/encryptedObjectStorage'; // EncryptedObjectStorageCapabilities used for session capability gating. import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY, } from '../../../domain/encryptedObjectStorage'; import type { CloudProvider, OAuthTokens, ProviderAccount, SyncedFile, } from '../../../domain/sync'; import type { CloudAdapter } from './index'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); function syncedFileToBytes(syncedFile: SyncedFile): Uint8Array { return textEncoder.encode(JSON.stringify(syncedFile)); } function bytesToSyncedFile(bytes: Uint8Array): SyncedFile { const raw = textDecoder.decode(bytes); // Require a complete JSON object. Do not accept a valid prefix with trailing // garbage — that hides provider corruption from read-and-verify recovery. return JSON.parse(raw) as SyncedFile; } /** Thrown when a conditional write is rejected (revision / precondition). */ export class ConditionalWriteConflictError extends Error { readonly code = 'conditional_write_conflict'; constructor(message = 'Encrypted object conditional write was rejected', options?: { cause?: unknown }) { super(message, options); this.name = 'ConditionalWriteConflictError'; } } export function isConditionalWriteConflictError(error: unknown): boolean { if (error instanceof ConditionalWriteConflictError) return true; if (!error || typeof error !== 'object') return false; const maybe = error as { name?: unknown; code?: unknown; message?: unknown; data?: { pluginCode?: unknown } }; if (maybe.name === 'ConditionalWriteConflictError') return true; if (maybe.code === 'conditional_write_conflict' || maybe.code === -32009 || maybe.code === 'failed_precondition') { return true; } if (maybe.data?.pluginCode === 'failed_precondition') return true; return typeof maybe.message === 'string' && /failed[_ ]precondition|expectedRevision|revision mismatch|conditional write/i.test(maybe.message); } /** * Adapt a legacy CloudAdapter into EncryptedObjectStorage. * Uses a single default object key matching the historical vault file name. */ export function cloudAdapterAsEncryptedObjectStorage( adapter: CloudAdapter, providerId: string, options: { objectKey?: string; capabilities?: EncryptedObjectStorageCapabilities; } = {}, ): EncryptedObjectStorage { const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY; const capabilities: EncryptedObjectStorageCapabilities = options.capabilities ?? { revisions: false, conditionalWrites: false, atomicReplacement: true, }; return { providerId, async connect(): Promise<{ account: EncryptedObjectAccount }> { if (!adapter.isAuthenticated) { throw new Error(`Cloud provider ${providerId} is not authenticated`); } await adapter.initializeSync(); const account = adapter.accountInfo; if (!account) { return { account: { id: providerId } }; } return { account: { ...account } }; }, async disconnect(): Promise { adapter.signOut(); }, async getAccount(): Promise { return adapter.accountInfo ? { ...adapter.accountInfo } : null; }, async getCapabilities(): Promise { return { ...capabilities }; }, async readObject(key: string): Promise { if (key !== objectKey) { return { found: false, key, bytes: null }; } const file = await adapter.download(); if (!file) return { found: false, key, bytes: null }; const bytes = syncedFileToBytes(file); return { found: true, key, bytes, revision: file.meta?.version != null ? String(file.meta.version) : undefined, contentType: 'application/json', }; }, async writeObject(key: string, bytes: Uint8Array): Promise { if (key !== objectKey) { throw new Error(`Cloud adapter ${providerId} only supports object key ${objectKey}`); } const syncedFile = bytesToSyncedFile(bytes); // Skip a pre-upload GET for `created`. The single-object vault path does not // consume that flag, and WebDAV already pays for pad+verify inside upload. await adapter.upload(syncedFile); return { created: false, revision: syncedFile.meta?.version != null ? String(syncedFile.meta.version) : undefined, }; }, async deleteObject(key: string): Promise { if (key !== objectKey) { return { deleted: false }; } const existing = await adapter.download(); if (!existing) return { deleted: false }; await adapter.deleteSync(); return { deleted: true }; }, }; } /** * Adapt EncryptedObjectStorage into the legacy CloudAdapter surface so the * existing encrypt→upload / download→decrypt manager path can drive WebDAV and * plugin providers through one code path. * * Authentication and resourceId must match pre-bridge CloudAdapter semantics: * config-backed providers (WebDAV) report authenticated as soon as credentials * exist so getConnectedAdapter can reuse the cached instance; resourceId must * preserve the persisted path (or the backing adapter's authoritative id) rather * than always forcing the default object key. */ export function encryptedObjectStorageAsCloudAdapter( storage: EncryptedObjectStorage, options: { objectKey?: string; account?: ProviderAccount | null; /** When true, getConnectedAdapter reuses this instance without recreating. */ initiallyAuthenticated?: boolean; /** Seeded resource id (e.g. path restored from provider connection storage). */ resourceId?: string | null; /** * Prefer the backing adapter's resource id after connect/upload (WebDAV sets * `/netcatty-vault.json` via initializeSync; plugins may use object keys). */ resolveResourceId?: () => string | null | undefined; /** * When true, re-issue connect() even if a prior session was established. * Required for plugin providers whose in-process clients die on runtime * restart/replace while the CloudAdapter cache stays alive. */ rebindSession?: boolean; /** * When true, skip the host full-byte re-read after writeObject. Use only when * the backing storage already performs Netcatty-grade write verification * (WebDAV pad+verify). Plugin providers keep host-owned verify. */ assumeVerifiedWrites?: boolean; } = {}, ): CloudAdapter { const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY; let account: ProviderAccount | null = options.account ?? null; let resourceId: string | null = options.resourceId ?? null; let authenticated = options.initiallyAuthenticated === true; /** Distinct from credential presence: plugin providers need an explicit connect. */ let sessionConnected = false; /** * Last observed remote revision for conditional writes. * - string: known revision * - null: confirmed absent (must-not-exist write) * - undefined: unknown / unconditional */ let lastRevision: string | null | undefined; const refreshResourceId = (fallback?: string | null): string | null => { const resolved = options.resolveResourceId?.(); if (typeof resolved === 'string' && resolved.length > 0) { resourceId = resolved; return resourceId; } if (typeof fallback === 'string' && fallback.length > 0) { resourceId = fallback; return resourceId; } return resourceId; }; let capabilities: EncryptedObjectStorageCapabilities | null = null; const ensureConnected = async (): Promise => { if (sessionConnected && !options.rebindSession) return; // rebindSession: always re-run connect so a replaced plugin runtime gets a // fresh client. Connect is expected to be idempotent when still healthy. const result = await storage.connect(); account = result.account; capabilities = await storage.getCapabilities(); authenticated = true; sessionConnected = true; refreshResourceId(objectKey); }; /** After I/O failure, force the next ensureConnected to re-issue connect(). */ const markSessionStale = (): void => { sessionConnected = false; }; return { get isAuthenticated() { return authenticated; }, get accountInfo() { return account; }, get resourceId() { return resourceId; }, signOut() { authenticated = false; sessionConnected = false; lastRevision = undefined; capabilities = null; account = null; resourceId = null; // Fire-and-forget is intentional for CloudAdapter.signOut sync API; // disconnectProvider should call initializeSync/connect after a full await // path when a future async signOut is added to CloudAdapter. void Promise.resolve(storage.disconnect()).catch(() => { // Plugin runtime may already be gone; local sign-out still succeeds. }); }, async initializeSync(): Promise { try { await ensureConnected(); return refreshResourceId(objectKey); } catch (error) { markSessionStale(); throw error; } }, async upload(syncedFile: SyncedFile, uploadOptions?: { signal?: AbortSignal }): Promise { try { await ensureConnected(); const bytes = syncedFileToBytes(syncedFile); if ( capabilities?.maxObjectBytes != null && bytes.byteLength > capabilities.maxObjectBytes ) { throw new Error( `Encrypted object exceeds provider maxObjectBytes (${capabilities.maxObjectBytes})`, ); } if ( capabilities?.maxObjects != null && capabilities.maxObjects < 1 ) { throw new Error( `Encrypted object provider reports maxObjects < 1 (${capabilities.maxObjects})`, ); } const conditional = capabilities?.conditionalWrites === true; let writeResult: EncryptedObjectWriteResult; try { writeResult = await storage.writeObject(objectKey, bytes, { ...(conditional && lastRevision !== undefined ? { expectedRevision: lastRevision } : {}), signal: uploadOptions?.signal, }); } catch (writeError) { if (conditional && isConditionalWriteConflictError(writeError)) { throw new ConditionalWriteConflictError( writeError instanceof Error ? writeError.message : String(writeError), { cause: writeError }, ); } throw writeError; } if (options.assumeVerifiedWrites === true) { // Backing adapter already verified (e.g. WebDAV pad+verify). Still honor // maxObjectBytes above and refresh revision from the write result. if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) { lastRevision = writeResult.revision; } } else { // Host-owned write verification: re-read and compare ciphertext bytes. const verified = await storage.readObject(objectKey, { signal: uploadOptions?.signal }); if (!verified.found || !verified.bytes) { throw new Error('Encrypted object write verification failed: object missing after write'); } if (verified.bytes.byteLength !== bytes.byteLength) { throw new Error('Encrypted object write verification failed: size mismatch'); } for (let i = 0; i < bytes.byteLength; i += 1) { if (verified.bytes[i] !== bytes[i]) { throw new Error('Encrypted object write verification failed: content mismatch'); } } if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) { lastRevision = writeResult.revision; } else if (typeof verified.revision === 'string' && verified.revision.length > 0) { lastRevision = verified.revision; } else { lastRevision = undefined; } } authenticated = true; return refreshResourceId(objectKey) ?? objectKey; } catch (error) { markSessionStale(); throw error; } }, async download(downloadOptions?: { signal?: AbortSignal }): Promise { try { await ensureConnected(); const result = await storage.readObject(objectKey, { signal: downloadOptions?.signal }); if (!result.found || !result.bytes) { // Confirmed absence: next conditional write must use expectedRevision null. lastRevision = null; return null; } if (typeof result.revision === 'string' && result.revision.length > 0) { lastRevision = result.revision; } else { lastRevision = undefined; } return bytesToSyncedFile(result.bytes); } catch (error) { markSessionStale(); throw error; } }, async deleteSync(deleteOptions?: { signal?: AbortSignal }): Promise { try { await ensureConnected(); await storage.deleteObject(objectKey, { ...(typeof lastRevision === 'string' ? { expectedRevision: lastRevision } : {}), signal: deleteOptions?.signal, }); lastRevision = null; } catch (error) { markSessionStale(); throw error; } }, getTokens(): OAuthTokens | null { return null; }, }; } /** * WebDAV-specific capabilities: atomic replacement via temp+MOVE, no native revisions. */ export function webdavEncryptedObjectCapabilities(): EncryptedObjectStorageCapabilities { return { revisions: false, conditionalWrites: false, atomicReplacement: true, }; } export function isWebdavProvider(provider: CloudProvider): boolean { return provider === 'webdav'; }