"use client" export type LegacyStorageMigrationMode = "preserve" | "copy" | "transform" export interface LegacyStorageMigrationEntry { findingId: string api: string sourceKey: string | null targetKey: string | null dynamic: boolean mode: LegacyStorageMigrationMode preserveSource: boolean backupRequired: boolean } export interface LegacyStorageMigrationManifest { manifestChecksum: string storage: LegacyStorageMigrationEntry[] } export interface LegacyStorageMigrationResult { status: "applied" | "already-applied" | "not-required" copied: number backupKeys: string[] } export interface LegacyStorageMigrationOptions { localStorage?: Storage | null sessionStorage?: Storage | null } const MIGRATION_PREFIX = "exxat-ds:legacy-migration:" function browserStorage(driver: "local" | "session"): Storage | null { if (typeof window === "undefined") return null try { return driver === "session" ? window.sessionStorage : window.localStorage } catch { return null } } function entryDriver(entry: LegacyStorageMigrationEntry): "local" | "session" { return entry.api === "sessionStorage" ? "session" : "local" } function exactKey(entry: LegacyStorageMigrationEntry, key: string): string { if ( entry.api === "exxat-persisted-state" && !key.startsWith("exxat-ds:") ) { return `exxat-ds:${key}` } return key } function safeRemove(store: Storage, key: string): void { try { store.removeItem(key) } catch { // Best effort cleanup. The original source value is never removed. } } /** * Low-level engine for a caller-verified, static, copy-only storage manifest. * * The operation preflights every source and target, writes a backup before any * target, never removes source keys, and rolls back newly created targets if a * write fails. The caller must establish manifest provenance with the migration * CLI before invoking this browser API. Transform migrations require app-owned * code and are rejected. */ export function applyLegacyStorageMigration( manifest: LegacyStorageMigrationManifest, options: LegacyStorageMigrationOptions = {}, ): LegacyStorageMigrationResult { if ( !manifest || typeof manifest.manifestChecksum !== "string" || !manifest.manifestChecksum || !Array.isArray(manifest.storage) ) { throw new Error("Legacy storage migration manifest is malformed.") } const copies = manifest.storage.filter(entry => entry.mode !== "preserve") if (copies.length === 0) { return { status: "not-required", copied: 0, backupKeys: [] } } const stores = { local: "localStorage" in options ? options.localStorage ?? null : browserStorage("local"), session: "sessionStorage" in options ? options.sessionStorage ?? null : browserStorage("session"), } const operations = copies.map(entry => { if ( entry.mode !== "copy" || entry.dynamic || !entry.sourceKey || !entry.targetKey || !entry.preserveSource || !entry.backupRequired ) { throw new Error( `Storage ${entry.findingId} is not eligible for automatic copy-only migration.`, ) } if ( !new Set([ "localStorage", "sessionStorage", "exxat-persisted-state", ]).has(entry.api) ) { throw new Error( `Storage ${entry.findingId} uses unsupported API ${entry.api}.`, ) } const driver = entryDriver(entry) const store = stores[driver] if (!store) { throw new Error(`Browser ${driver} storage is unavailable.`) } return { entry, driver, store, sourceKey: exactKey(entry, entry.sourceKey), targetKey: exactKey(entry, entry.targetKey), } }) const sourceKeys = new Set( operations.map(operation => `${operation.driver}:${operation.sourceKey}`), ) const targetKeys = new Set() for (const operation of operations) { const targetIdentity = `${operation.driver}:${operation.targetKey}` if ( operation.targetKey.startsWith(MIGRATION_PREFIX) || targetKeys.has(targetIdentity) || sourceKeys.has(targetIdentity) ) { throw new Error( `Storage target ${operation.targetKey} collides with a source, target, or migration control key.`, ) } targetKeys.add(targetIdentity) } const receiptKey = `${MIGRATION_PREFIX}receipt:${manifest.manifestChecksum}` const receiptDriver = operations.some(operation => operation.driver === "local") ? "local" : operations[0].driver const receiptStore = stores[receiptDriver] if (!receiptStore) throw new Error("Browser storage is unavailable.") const lockKey = `${MIGRATION_PREFIX}lock:${manifest.manifestChecksum}` const lockToken = `${Date.now()}-${Math.random().toString(36).slice(2)}` const currentLock = receiptStore.getItem(lockKey) if (currentLock) { try { const parsed = JSON.parse(currentLock) if (Number(parsed.expiresAt) > Date.now()) { throw new Error("Legacy storage migration is already running.") } } catch (error) { if ( error instanceof Error && error.message === "Legacy storage migration is already running." ) { throw error } } } receiptStore.setItem( lockKey, JSON.stringify({ token: lockToken, expiresAt: Date.now() + 30_000 }), ) const acquiredLock = JSON.parse(receiptStore.getItem(lockKey) ?? "{}") if (acquiredLock.token !== lockToken) { throw new Error("Could not acquire the legacy storage migration lock.") } try { const rawReceipt = receiptStore.getItem(receiptKey) const hasReceipt = rawReceipt != null const pending = [] for (const operation of operations) { const sourceValue = operation.store.getItem(operation.sourceKey) if (sourceValue == null) continue const targetValue = operation.store.getItem(operation.targetKey) if (!hasReceipt && targetValue != null && targetValue !== sourceValue) { throw new Error( `Storage target ${operation.targetKey} already contains different data.`, ) } if (targetValue == null) pending.push({ ...operation, sourceValue }) } if (pending.length === 0) { if (hasReceipt) { let copied = 0 try { copied = Number(JSON.parse(rawReceipt).copied) || 0 } catch { // The receipt's presence remains the idempotency marker. } return { status: "already-applied", copied, backupKeys: [] } } receiptStore.setItem(receiptKey, JSON.stringify({ copied: 0 })) return { status: "applied", copied: 0, backupKeys: [] } } const byDriver = new Map< "local" | "session", Array<(typeof pending)[number]> >() for (const operation of pending) { const bucket = byDriver.get(operation.driver) ?? [] bucket.push(operation) byDriver.set(operation.driver, bucket) } const backups: Array<{ store: Storage; key: string }> = [] for (const [driver, driverOperations] of byDriver) { const store = stores[driver] if (!store) throw new Error(`Browser ${driver} storage is unavailable.`) const backupKey = `${MIGRATION_PREFIX}backup:${manifest.manifestChecksum}:${driver}` const payload = driverOperations.map(operation => ({ sourceKey: operation.sourceKey, targetKey: operation.targetKey, value: operation.sourceValue, })) try { store.setItem(backupKey, JSON.stringify(payload)) if (store.getItem(backupKey) == null) { throw new Error("backup verification failed") } backups.push({ store, key: backupKey }) } catch (error) { for (const backup of backups) safeRemove(backup.store, backup.key) throw new Error( `Could not persist the required legacy storage backup: ${ error instanceof Error ? error.message : String(error) }`, ) } } const written: Array<{ store: Storage; key: string }> = [] try { for (const operation of pending) { operation.store.setItem(operation.targetKey, operation.sourceValue) if ( operation.store.getItem(operation.targetKey) !== operation.sourceValue ) { throw new Error(`verification failed for ${operation.targetKey}`) } written.push({ store: operation.store, key: operation.targetKey }) } receiptStore.setItem( receiptKey, JSON.stringify({ copied: written.length + (() => { if (!rawReceipt) return 0 try { return Number(JSON.parse(rawReceipt).copied) || 0 } catch { return 0 } })(), backupKeys: backups.map(backup => backup.key), }), ) if (receiptStore.getItem(receiptKey) == null) { throw new Error("migration receipt verification failed") } } catch (error) { for (const target of written.reverse()) safeRemove(target.store, target.key) throw new Error( `Legacy storage migration rolled back: ${ error instanceof Error ? error.message : String(error) }`, ) } return { status: "applied", copied: written.length, backupKeys: backups.map(backup => backup.key), } } finally { try { const lock = JSON.parse(receiptStore.getItem(lockKey) ?? "{}") if (lock.token === lockToken) receiptStore.removeItem(lockKey) } catch { // A stale lease expires after 30 seconds. } } }