From c6c8c8df29dacb8919722df76a2488b256f3cb7e Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 13 Oct 2026 20:17:55 -0400 Subject: [PATCH 1/5] fix(sync): completed-ride outbox recovery and cloud confirmation reconciliation --- lib/services/sync/firebase_sync_provider.dart | 29 ++ lib/services/sync/github_sync_provider.dart | 3 + lib/services/sync/sync_manager.dart | 299 +++++++++++++--- lib/services/sync/sync_provider.dart | 15 +- test/completed_ride_sync_recovery_test.dart | 326 ++++++++++++++++++ ...ble_backup_recovery_verification_test.dart | 173 ++++++++++ ...solated_sync_integration_fixture_test.dart | 3 + test/offline_first_sync_contract_test.dart | 3 + test/r2_firestore_retry_hardening_test.dart | 3 + test/sync_persistence_validation_test.dart | 3 + 10 files changed, 812 insertions(+), 45 deletions(-) create mode 100644 test/completed_ride_sync_recovery_test.dart create mode 100644 test/disposable_backup_recovery_verification_test.dart diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 3a5d3d6..1bead10 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -41,6 +41,31 @@ class FirebaseSyncProvider implements SyncProvider { return user == null || user.isAnonymous; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async { + final user = _authService.currentUser; + if (user == null || user.isAnonymous) return false; + if (!await isAvailable()) return false; + try { + final doc = await _firestore! + .collection('users') + .doc(user.uid) + .collection('rides') + .doc(remoteRideId) + .get(); + if (!doc.exists) return false; + final data = doc.data(); + if (data == null) return false; + final bundle = data['telemetryBundle']; + if (bundle is Map && bundle['status'] == 'uploaded') { + return true; + } + return false; + } catch (_) { + return false; + } + } + @override Future uploadRide(SyncItem item) async { final user = _authService.currentUser; @@ -72,6 +97,10 @@ class FirebaseSyncProvider implements SyncProvider { // The cloud identity is generated once and then reused forever. Never // derive the remote identity from the local auto-increment database ID. final remoteRideId = await _db.ensureCloudRideId(item.localRideId); + if (await isRideConfirmedInCloud(remoteRideId)) { + return SyncResult.success(remoteId: remoteRideId); + } + final appIdentity = AppIdentity.current(); final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); diff --git a/lib/services/sync/github_sync_provider.dart b/lib/services/sync/github_sync_provider.dart index ab66e0c..0e299db 100644 --- a/lib/services/sync/github_sync_provider.dart +++ b/lib/services/sync/github_sync_provider.dart @@ -23,6 +23,9 @@ class GitHubSyncProvider implements SyncProvider { @override Future requiresAuthentication() async => auth.getToken() == null; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { final hasAuth = auth.getToken() != null; diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index 786cf61..02d152d 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -6,6 +6,41 @@ import 'sync_provider.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; +/// Diagnostic summary returned by [SyncManager.reconcileCompletedRides]. +class CompletedRideReconciliationReport { + final int discoveredCompletedRides; + final int alreadyTrackedCount; + final int recoveredQueuedCount; + final int recoveredAuthRequiredCount; + final int recoveredAlreadySyncedCount; + final int foreignOwnerSkippedCount; + final int errorCount; + final List errors; + + const CompletedRideReconciliationReport({ + this.discoveredCompletedRides = 0, + this.alreadyTrackedCount = 0, + this.recoveredQueuedCount = 0, + this.recoveredAuthRequiredCount = 0, + this.recoveredAlreadySyncedCount = 0, + this.foreignOwnerSkippedCount = 0, + this.errorCount = 0, + this.errors = const [], + }); + + int get totalRecovered => + recoveredQueuedCount + + recoveredAuthRequiredCount + + recoveredAlreadySyncedCount; + + @override + String toString() => + 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' + 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' + 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' + 'foreignSkipped: $foreignOwnerSkippedCount, errors: $errorCount)'; +} + /// Central provider-agnostic synchronization coordinator. /// /// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted @@ -16,17 +51,22 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final Map _providers = {}; String _activeProviderId = 'fake'; bool _isProcessing = false; + Future? _activeReconciliation; + Future? _currentProcessingFuture; + bool _needsAnotherPass = false; DateTime? Function() _nowProvider; final AppDatabase? _db; final AuthService? _authService; - SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { + SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (initialProviders != null) { for (final p in initialProviders) { registerProvider(p); } } if (defaultProviderId != null) _activeProviderId = defaultProviderId; WidgetsBinding.instance.addObserver(this); - unawaited(processQueue()); + if (autoStart) { + unawaited(_startupRecoveryAndProcess()); + } } void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; @@ -87,13 +127,166 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { return enqueued; } + Future _startupRecoveryAndProcess() async { + await reconcileCompletedRides(); + await processQueue(); + } + Future retryAuthRequired() async { + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + if (isAuthenticated && _db != null) { + await reconcileCompletedRides(); + } for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { + if (isAuthenticated && _db != null && item.providerId == 'firebase') { + await _db.claimRideForCloudOwner(item.localRideId, user.uid); + } await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); } await processQueue(); } + /// Discovers completed rides in local SQLite that are missing from [SyncOutbox] + /// or need cloud confirmation recovery. + Future reconcileCompletedRides({ + String? targetProviderId, + }) async { + if (_activeReconciliation != null) { + return _activeReconciliation!; + } + final future = _performReconcileCompletedRides(targetProviderId: targetProviderId); + _activeReconciliation = future; + try { + return await future; + } finally { + _activeReconciliation = null; + } + } + + Future _performReconcileCompletedRides({ + String? targetProviderId, + }) async { + if (_db == null) { + return const CompletedRideReconciliationReport(); + } + + final providerId = targetProviderId ?? _activeProviderId; + final provider = _providers[providerId]; + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + + int discovered = 0; + int alreadyTracked = 0; + int recoveredQueued = 0; + int recoveredAuthRequired = 0; + int recoveredAlreadySynced = 0; + int foreignOwnerSkipped = 0; + int errorCount = 0; + final List errors = []; + + try { + final completedRides = await (_db.select(_db.rides) + ..where((r) => r.endTime.isNotNull())) + .get(); + discovered = completedRides.length; + + for (final ride in completedRides) { + try { + // Check if already tracked in outbox for this provider + final existingItem = _outbox.getItem(ride.id, providerId); + if (existingItem != null) { + alreadyTracked++; + continue; + } + + // Foreign account check: never claim or upload another user's ride + if (ride.cloudOwnerUid != null && + isAuthenticated && + ride.cloudOwnerUid != user.uid) { + foreignOwnerSkipped++; + continue; + } + if (ride.cloudOwnerUid != null && !isAuthenticated) { + foreignOwnerSkipped++; + continue; + } + + // Claim unclaimed ride if user is authenticated and target is firebase + if (ride.cloudOwnerUid == null && isAuthenticated && providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } + + // Stable cloud identity check + final remoteRideId = await _db.ensureCloudRideId(ride.id); + + // Check if already confirmed uploaded in cloud backend + final isConfirmed = provider != null && + await provider.isRideConfirmedInCloud(remoteRideId); + + if (isConfirmed) { + final syncedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.synced, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + uploadedAt: _now(), + ); + await _outbox.update(syncedItem); + recoveredAlreadySynced++; + } else { + final providerRequiresAuth = provider != null && + await provider.requiresAuthentication(); + + if (providerRequiresAuth || (!isAuthenticated && providerId == 'firebase')) { + final authReqItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.authRequired, + createdAt: ride.endTime ?? ride.startTime, + lastError: 'Authentication required for provider "$providerId"', + ); + await _outbox.update(authReqItem); + recoveredAuthRequired++; + } else { + final queuedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.queued, + createdAt: ride.endTime ?? ride.startTime, + ); + await _outbox.update(queuedItem); + recoveredQueued++; + } + } + } catch (itemErr) { + errorCount++; + errors.add('Ride #${ride.id}: $itemErr'); + } + } + } catch (e) { + errorCount++; + errors.add('Reconciliation failed: $e'); + } + + final report = CompletedRideReconciliationReport( + discoveredCompletedRides: discovered, + alreadyTrackedCount: alreadyTracked, + recoveredQueuedCount: recoveredQueued, + recoveredAuthRequiredCount: recoveredAuthRequired, + recoveredAlreadySyncedCount: recoveredAlreadySynced, + foreignOwnerSkippedCount: foreignOwnerSkipped, + errorCount: errorCount, + errors: errors, + ); + + if (report.totalRecovered > 0) { + notifyListeners(); + } + return report; + } + static Duration calculateRetryDelay(int attemptCount) { switch (attemptCount) { case 1: return const Duration(seconds: 5); @@ -107,52 +300,70 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) unawaited(processQueue()); + if (state == AppLifecycleState.resumed) { + unawaited(_startupRecoveryAndProcess()); + } } Future processQueue() async { - if (_isProcessing) return; - _isProcessing = true; - notifyListeners(); - try { - final now = _now(); - for (final item in _outbox.getEligibleItems(now)) { - final provider = _providers[item.providerId]; - if (provider == null) { - await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); - continue; - } - if (!await provider.isAvailable()) { - await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); - continue; - } - if (await provider.requiresAuthentication()) { - await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); - continue; - } - final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); - await _outbox.update(uploadingItem); - SyncResult result; - try { - result = await provider.uploadRide(uploadingItem); - } catch (e) { - result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); - } - if (result.success) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); - } else if (result.requiresAuth) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); - } else if (result.isRetryable) { - final attempts = uploadingItem.attemptCount + 1; - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); - } else { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); - } - } - } finally { - _isProcessing = false; - notifyListeners(); + if (_currentProcessingFuture != null) { + _needsAnotherPass = true; + return _currentProcessingFuture!; } + final future = _performProcessQueue(); + _currentProcessingFuture = future; + try { + await future; + } finally { + _currentProcessingFuture = null; + } + } + + Future _performProcessQueue() async { + do { + _needsAnotherPass = false; + _isProcessing = true; + notifyListeners(); + try { + final now = _now(); + for (final item in _outbox.getEligibleItems(now)) { + final provider = _providers[item.providerId]; + if (provider == null) { + await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); + continue; + } + if (!await provider.isAvailable()) { + await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); + continue; + } + if (await provider.requiresAuthentication()) { + await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); + continue; + } + final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); + await _outbox.update(uploadingItem); + SyncResult result; + try { + result = await provider.uploadRide(uploadingItem); + } catch (e) { + result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); + } + if (result.success) { + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); + } else if (result.requiresAuth) { + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); + } else if (result.isRetryable) { + final attempts = uploadingItem.attemptCount + 1; + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); + } else { + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); + } + } + } finally { + _isProcessing = false; + notifyListeners(); + } + } while (_needsAnotherPass); } Future cancelSync(int localRideId, String providerId) async { diff --git a/lib/services/sync/sync_provider.dart b/lib/services/sync/sync_provider.dart index 86abe4f..18e3db8 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -16,6 +16,10 @@ abstract class SyncProvider { /// Performs the upload of the local ride referenced by [item]. /// Must return a deterministic [SyncResult]. Future uploadRide(SyncItem item); + + /// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud. + /// Default implementation returns false. + Future isRideConfirmedInCloud(String remoteRideId) async => false; } /// Simulated behavior modes for testing the synchronization engine deterministically @@ -36,6 +40,7 @@ class FakeSyncProvider implements SyncProvider { int delayMs; final List uploadedItems = []; final Map uploadAttemptsPerRide = {}; + final Set confirmedRemoteRideIds = {}; FakeSyncProvider({ this.providerId = 'fake', @@ -54,6 +59,11 @@ class FakeSyncProvider implements SyncProvider { return behavior == FakeSyncBehavior.authRequired; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async { + return confirmedRemoteRideIds.contains(remoteRideId); + } + @override Future uploadRide(SyncItem item) async { if (delayMs > 0) { @@ -66,8 +76,10 @@ class FakeSyncProvider implements SyncProvider { switch (behavior) { case FakeSyncBehavior.success: uploadedItems.add(item); + final remoteId = 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; + confirmedRemoteRideIds.add(remoteId); return SyncResult.success( - remoteId: 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}', + remoteId: remoteId, ); case FakeSyncBehavior.temporaryFailure: @@ -95,5 +107,6 @@ class FakeSyncProvider implements SyncProvider { void reset() { uploadedItems.clear(); uploadAttemptsPerRide.clear(); + confirmedRemoteRideIds.clear(); } } diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart new file mode 100644 index 0000000..0449fc0 --- /dev/null +++ b/test/completed_ride_sync_recovery_test.dart @@ -0,0 +1,326 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/sync/sync_models.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_manager.dart'; + +class _TestAuthService implements AuthService { + AuthUser? _currentUser; + AuthState _authState = const AuthState.unauthenticated(); + + void setAuthenticatedUser(String uid, String email) { + _currentUser = AuthUser( + uid: uid, + email: email, + displayName: 'Test Rider', + isAnonymous: false, + ); + _authState = AuthState.authenticated(_currentUser!); + } + + void setGuestUser() { + _currentUser = const AuthUser( + uid: 'guest_uid_123', + email: null, + displayName: 'Guest Rider', + isAnonymous: true, + ); + _authState = AuthState.anonymous(_currentUser!); + } + + void setLoggedOut() { + _currentUser = null; + _authState = const AuthState.unauthenticated(); + } + + @override + AuthUser? get currentUser => _currentUser; + + @override + AuthState get currentState => _authState; + + @override + Stream get authStateChanges => Stream.value(_authState); + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => false; + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + + @override + Future deleteCurrentUser() async {} + + @override + void dispose() {} + + @override + Future ensureBackendIdentity() async {} + + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake_id_token'; + + @override + Future reauthenticate({String? password}) async {} + + @override + Future reloadCurrentUser() async {} + + @override + Future sendEmailVerification() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async => setGuestUser(); + + @override + Future signInWithEmailAndPassword(String email, String password) async => + setAuthenticatedUser('uid_123', email); + + @override + Future signInWithGoogle() async => + setAuthenticatedUser('uid_google', 'google@example.com'); + + @override + Future signOut() async => setLoggedOut(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('v1.1 Completed-Ride Sync Outbox Recovery Tests', () { + late SharedPreferences prefs; + late AppDatabase db; + late _TestAuthService authService; + late FakeSyncProvider fakeProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + authService = _TestAuthService(); + fakeProvider = FakeSyncProvider(providerId: 'firebase'); + }); + + tearDown(() async { + await db.close(); + }); + + test('1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Simulate a completed ride committed to SQLite during a previous session + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + motorcycleMake: 'Honda', + motorcycleModel: 'CBR600RR', + ); + await db.endRide(rideId, const RidesCompanion()); + + // Confirm outbox starts empty (app terminated before enqueue ran) + final outbox = SyncOutbox(prefs); + expect(outbox.count, equals(0)); + + // Startup initialization of SyncManager (testing step-by-step) + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Reconcile runs + final report = await manager.reconcileCompletedRides(); + + expect(report.discoveredCompletedRides, equals(1)); + expect(report.recoveredQueuedCount, equals(1)); + expect(report.alreadyTrackedCount, equals(0)); + expect(outbox.count, equals(1)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.queued)); + + // Verify ride ownership was claimed for authenticated user + final ownerUid = await db.getCloudOwnerUid(rideId); + expect(ownerUid, equals('rider_alice')); + + // Process queue to complete upload + await manager.processQueue(); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); + expect(fakeProvider.uploadedItems.length, equals(1)); + }); + + test('2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 11, 0, 0)); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // First run reconciles and queues the ride + final report1 = await manager.reconcileCompletedRides(); + expect(report1.recoveredQueuedCount, equals(1)); + expect(outbox.count, equals(1)); + + // Process upload + await manager.processQueue(); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); + expect(fakeProvider.uploadedItems.length, equals(1)); + + // Second run reports ride as already tracked, zero duplicates + final report2 = await manager.reconcileCompletedRides(); + expect(report2.discoveredCompletedRides, equals(1)); + expect(report2.alreadyTrackedCount, equals(1)); + expect(report2.recoveredQueuedCount, equals(0)); + expect(report2.totalRecovered, equals(0)); + expect(outbox.count, equals(1)); + + // Running processQueue again does not re-upload + await manager.processQueue(); + expect(fakeProvider.uploadedItems.length, equals(1)); + }); + + test('3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', () async { + // User is initially a guest and provider requires auth + authService.setGuestUser(); + fakeProvider.behavior = FakeSyncBehavior.authRequired; + + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 12, 0, 0)); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Reconcile as guest -> authRequired + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAuthRequiredCount, equals(1)); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.authRequired)); + + // Ownership remains unclaimed + expect(await db.getCloudOwnerUid(rideId), isNull); + + // Guest now signs in with Google / Email + authService.setAuthenticatedUser('rider_bob', 'bob@example.com'); + fakeProvider.behavior = FakeSyncBehavior.success; + + // Invoking retryAuthRequired claims the ride and triggers queue upload + await manager.retryAuthRequired(); + + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); + expect(fakeProvider.uploadedItems.length, equals(1)); + }); + + test('4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', () async { + authService.setAuthenticatedUser('rider_charlie', 'charlie@example.com'); + + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 13, 0, 0)); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Simulate that cloud backend already has this ride bundle uploaded + fakeProvider.confirmedRemoteRideIds.add(remoteRideId); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAlreadySyncedCount, equals(1)); + expect(report.recoveredQueuedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.synced)); + expect(item?.remoteId, equals(remoteRideId)); + + // Zero upload attempts because it was already confirmed + expect(fakeProvider.uploadedItems.isEmpty, isTrue); + }); + + test('5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Create ride explicitly owned by rider_bob + final rideId = await db.into(db.rides).insert(RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_123'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)), + )); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.foreignOwnerSkippedCount, equals(1)); + expect(report.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); + + // Ownership in db remains unchanged + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + }); + + test('6. In-Progress Isolation: Active rides without endTime are never recovered as completed', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Start ride but do not end it (endTime is null) + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 16, 0, 0)); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.discoveredCompletedRides, equals(0)); + expect(report.totalRecovered, equals(0)); + expect(outbox.getItem(rideId, 'firebase'), isNull); + }); + }); +} diff --git a/test/disposable_backup_recovery_verification_test.dart b/test/disposable_backup_recovery_verification_test.dart new file mode 100644 index 0000000..620419e --- /dev/null +++ b/test/disposable_backup_recovery_verification_test.dart @@ -0,0 +1,173 @@ +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/sync/sync_manager.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; + +class _FakeAuthService implements AuthService { + final AuthUser _user; + + _FakeAuthService([String uid = 'pilot_pixel_8a']) + : _user = AuthUser( + uid: uid, + email: '$uid@example.com', + displayName: 'Pixel Pilot', + isAnonymous: false, + ); + + @override + AuthUser? get currentUser => _user; + @override + AuthState get currentState => AuthState.authenticated(_user); + @override + Stream get authStateChanges => Stream.value(currentState); + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + @override + Future deleteCurrentUser() async {} + @override + void dispose() {} + @override + Future ensureBackendIdentity() async {} + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake_token'; + @override + Future reauthenticate({String? password}) async {} + @override + Future reloadCurrentUser() async {} + @override + Future sendEmailVerification() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future signInAnonymously() async {} + @override + Future signInWithEmailAndPassword(String email, String password) async {} + @override + Future signInWithGoogle() async {} + @override + Future signOut() async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', () async { + final originalFile = File( + r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', + ); + + expect(originalFile.existsSync(), isTrue, reason: 'Original backup file must exist'); + + // 1. Verify original SHA-256 before copy + final originalBytes = await originalFile.readAsBytes(); + final originalHash = sha256.convert(originalBytes).toString(); + const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; + expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256'); + + // 2. Create disposable copy in scratch directory + final scratchDir = Directory( + r'C:\Users\Bill\.gemini\antigravity\brain\eb590221-346f-4525-a5fa-eda41331c6c6\scratch', + ); + if (!scratchDir.existsSync()) { + scratchDir.createSync(recursive: true); + } + final disposableFile = File('${scratchDir.path}\\disposable_motorsignal.sqlite'); + if (disposableFile.existsSync()) { + disposableFile.deleteSync(); + } + await originalFile.copy(disposableFile.path); + expect(disposableFile.existsSync(), isTrue); + + // 3. Open disposable copy via Drift / SQLite + final db = AppDatabase.forTesting(NativeDatabase(disposableFile)); + + try { + // Integrity check + final integrityRows = await db.customSelect('PRAGMA integrity_check').get(); + final integrityResult = integrityRows.first.data['integrity_check']; + expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK'); + + // Count rides and samples + final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get(); + final totalRides = rideCountRows.first.data['count'] as int; + expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides'); + + final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get(); + final completedRides = completedRidesRows.first.data['count'] as int; + + final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get(); + final totalSamples = samplesRows.first.data['count'] as int; + expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples'); + + // 4. Test Foreign Account Isolation against real Pixel backup + // In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2' + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs); + + final fakeProvider = FakeSyncProvider(providerId: 'firebase'); + final foreignAuth = _FakeAuthService('foreign_pilot_123'); + + final foreignSyncManager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: foreignAuth, + autoStart: false, + ); + + final foreignReport = await foreignSyncManager.reconcileCompletedRides(); + expect(foreignReport.errorCount, equals(0)); + expect(foreignReport.discoveredCompletedRides, equals(completedRides)); + expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides'); + expect(foreignReport.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); + + // 5. Test Owner Recovery against disposable backup + final ownerAuth = _FakeAuthService('4btn4W3iOLWgfloMBYYK5hWa1hY2'); + final ownerSyncManager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: ownerAuth, + autoStart: false, + ); + + final ownerReport = await ownerSyncManager.reconcileCompletedRides(); + expect(ownerReport.errorCount, equals(0)); + expect(ownerReport.discoveredCompletedRides, equals(completedRides)); + expect(ownerReport.recoveredQueuedCount, equals(completedRides)); + expect(ownerReport.totalRecovered, equals(completedRides)); + expect(outbox.count, equals(completedRides)); + + // 6. Test repeated reconciliation idempotence as owner + final report2 = await ownerSyncManager.reconcileCompletedRides(); + expect(report2.totalRecovered, equals(0)); + expect(report2.alreadyTrackedCount, equals(completedRides)); + expect(outbox.count, equals(completedRides)); + + } finally { + await db.close(); + } + + // 6. Verify original SHA-256 after operations to prove zero mutation + final postBytes = await originalFile.readAsBytes(); + final postHash = sha256.convert(postBytes).toString(); + expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED'); + }); +} diff --git a/test/isolated_sync_integration_fixture_test.dart b/test/isolated_sync_integration_fixture_test.dart index 760f600..0d7b38e 100644 --- a/test/isolated_sync_integration_fixture_test.dart +++ b/test/isolated_sync_integration_fixture_test.dart @@ -104,6 +104,9 @@ class _IsolatedContractSyncProvider implements SyncProvider { return user == null || user.isAnonymous; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { if (!networkAvailable) { diff --git a/test/offline_first_sync_contract_test.dart b/test/offline_first_sync_contract_test.dart index 3141d3d..db7fe3a 100644 --- a/test/offline_first_sync_contract_test.dart +++ b/test/offline_first_sync_contract_test.dart @@ -20,6 +20,9 @@ class _CountingProvider implements SyncProvider { @override Future requiresAuthentication() async => result == _Result.authRequired; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { uploadCount++; diff --git a/test/r2_firestore_retry_hardening_test.dart b/test/r2_firestore_retry_hardening_test.dart index 6a99cd1..245bd92 100644 --- a/test/r2_firestore_retry_hardening_test.dart +++ b/test/r2_firestore_retry_hardening_test.dart @@ -31,6 +31,9 @@ class _R2ThenFirestoreProvider implements SyncProvider { @override Future requiresAuthentication() async => false; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { attempts++; diff --git a/test/sync_persistence_validation_test.dart b/test/sync_persistence_validation_test.dart index 178f22f..4d5978c 100644 --- a/test/sync_persistence_validation_test.dart +++ b/test/sync_persistence_validation_test.dart @@ -21,6 +21,9 @@ class _ValidationProvider implements SyncProvider { @override Future requiresAuthentication() async => !authenticated; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { uploadCount++; From d431f11a13c085d2aa83af9d11786af7d9800f8f Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 13 Oct 2026 21:31:06 -0400 Subject: [PATCH 2/5] fix(sync): harden cloud confirmation with R2 verification and account isolation --- cloudflare/motorsignal-r2-worker/src/index.ts | 66 ++ lib/services/sync/firebase_sync_provider.dart | 345 ++++++++-- lib/services/sync/github_sync_provider.dart | 13 +- lib/services/sync/r2_upload_service.dart | 409 ++++++++++- lib/services/sync/sync_manager.dart | 460 +++++++++++-- lib/services/sync/sync_provider.dart | 164 ++++- test/cloud_confirmation_contract_test.dart | 566 +++++++++++++++ test/completed_ride_sync_recovery_test.dart | 648 +++++++++++++----- ...ble_backup_recovery_verification_test.dart | 87 ++- ...solated_sync_integration_fixture_test.dart | 4 + test/offline_first_sync_contract_test.dart | 4 + test/r2_firestore_retry_hardening_test.dart | 4 + test/sync_persistence_validation_test.dart | 4 + 13 files changed, 2429 insertions(+), 345 deletions(-) create mode 100644 test/cloud_confirmation_contract_test.dart diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 263ea68..e79051b 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -146,10 +146,76 @@ export default { headers.set('X-MotorSignal-Ride-Id', remoteRideId); const sha256 = object.customMetadata?.sha256; if (sha256) headers.set('X-MotorSignal-Sha256', sha256); + headers.set('X-MotorSignal-Size', String(object.size)); for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); return new Response(object.body, { status: 200, headers }); } + if (request.method === 'HEAD' && path === '/r2/download') { + const remoteRideId = url.searchParams.get('rideId'); + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); + + const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); + if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403); + if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404); + if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409); + + const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; + const object = await env.R2_BUCKET.head(objectKey); + if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404); + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`); + headers.set('X-MotorSignal-Ride-Id', remoteRideId); + const sha256 = object.customMetadata?.sha256; + if (sha256) headers.set('X-MotorSignal-Sha256', sha256); + headers.set('X-MotorSignal-Size', String(object.size)); + for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); + return new Response(null, { status: 200, headers }); + } + + if ((request.method === 'GET' || request.method === 'HEAD') && path === '/r2/verify') { + const remoteRideId = url.searchParams.get('rideId'); + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); + + const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); + if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403); + if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404); + if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409); + + const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; + const object = await env.R2_BUCKET.head(objectKey); + if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2', code: 'OBJECT_NOT_FOUND' }, 404); + + const sha256 = object.customMetadata?.sha256; + const appVersion = object.customMetadata?.appVersion; + const uploadedAt = object.customMetadata?.uploadedAt; + + const headers = new Headers(); + headers.set('etag', object.httpEtag); + headers.set('X-MotorSignal-Ride-Id', remoteRideId); + if (sha256) headers.set('X-MotorSignal-Sha256', sha256); + headers.set('X-MotorSignal-Size', String(object.size)); + for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); + + if (request.method === 'HEAD') { + return new Response(null, { status: 200, headers }); + } + + return jsonResponse({ + verified: true, + remoteRideId, + objectKey, + size: object.size, + etag: object.httpEtag, + sha256, + appVersion, + uploadedAt, + }, 200); + } + return jsonResponse({ error: 'Endpoint not found' }, 404); } catch (err: unknown) { console.error('MotorSignal Worker exception', err); diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 1bead10..0a82075 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -16,14 +16,21 @@ class FirebaseSyncProvider implements SyncProvider { final R2UploadService _r2UploadService; final SyncBundleWorker _bundleWorker; - FirebaseSyncProvider({required AppDatabase db, required this._authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker}) - : _db = db, - _firestore = firestore ?? _resolveFirestore(), - _r2UploadService = r2UploadService ?? R2UploadService(), - _bundleWorker = bundleWorker ?? SyncBundleWorker(db); + FirebaseSyncProvider({ + required AppDatabase db, + required this._authService, + FirebaseFirestore? firestore, + R2UploadService? r2UploadService, + SyncBundleWorker? bundleWorker, + }) : _db = db, + _firestore = firestore ?? _resolveFirestore(), + _r2UploadService = r2UploadService ?? R2UploadService(), + _bundleWorker = bundleWorker ?? SyncBundleWorker(db); static FirebaseFirestore? _resolveFirestore() { - try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} return null; } @@ -32,7 +39,7 @@ class FirebaseSyncProvider implements SyncProvider { @override Future isAvailable() async { - try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; } + return _firestore != null; } @override @@ -43,35 +50,209 @@ class FirebaseSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed; + } + + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async { final user = _authService.currentUser; - if (user == null || user.isAnonymous) return false; - if (!await isAvailable()) return false; + if (user == null || user.isAnonymous) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + message: + 'Sign in to MotorCloud is required to verify cloud ride status.', + ); + } + if (!await isAvailable()) { + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Firebase services are unavailable or unconfigured.', + ); + } + + // Step 1: Firestore document metadata verification + DocumentSnapshot> doc; try { - final doc = await _firestore! + doc = await _firestore! .collection('users') .doc(user.uid) .collection('rides') .doc(remoteRideId) .get(); - if (!doc.exists) return false; - final data = doc.data(); - if (data == null) return false; - final bundle = data['telemetryBundle']; - if (bundle is Map && bundle['status'] == 'uploaded') { - return true; + } on FirebaseException catch (e) { + if (e.code == 'permission-denied' || e.code == 'unauthenticated') { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: 'Firestore access unauthorized (${e.code}): ${e.message}', + ); } - return false; - } catch (_) { - return false; + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Firestore temporary lookup error (${e.code}): ${e.message}', + ); + } catch (e) { + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Firestore lookup exception: $e', + ); + } + + if (!doc.exists) { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Firestore ride document not found.', + ); + } + + final data = doc.data(); + if (data == null) { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Firestore ride document contains no data.', + ); + } + + // Verify ownership in metadata + final ownerId = (data['ownerId'] ?? data['uid'])?.toString(); + if (ownerId != null && ownerId != user.uid) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: ownerId, + message: + 'Firestore ride owner ($ownerId) does not match authenticated user (${user.uid}).', + ); + } + + // Verify remote ID in metadata + final storedId = (data['id'] ?? data['remoteRideId'])?.toString(); + if (storedId != null && storedId != remoteRideId) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: + 'Firestore ride id ($storedId) does not match requested ($remoteRideId).', + ); + } + + final bundle = data['telemetryBundle']; + if (bundle is! Map) { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Firestore document missing telemetryBundle metadata.', + ); + } + + final bundleStatus = bundle['status']?.toString(); + if (bundleStatus != 'uploaded') { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: + 'Firestore telemetryBundle status is "$bundleStatus", not "uploaded".', + ); + } + + final expectedObjectKey = + 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; + final objectKey = (bundle['objectKey'] ?? data['telemetryBundlePath']) + ?.toString(); + if (objectKey == null || objectKey != expectedObjectKey) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: objectKey, + message: + 'telemetryBundle objectKey ($objectKey) does not match expected ($expectedObjectKey).', + ); + } + + final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString(); + final expectedSize = + (bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?; + + // Step 2: R2 bundle verification via authenticated lightweight Worker check + var idToken = await _authService.getIdToken(); + if (idToken == null || idToken.isEmpty) { + idToken = await _authService.getIdToken(true); + } + if (idToken == null || idToken.isEmpty) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: 'Valid Firebase ID token required to verify R2 bundle.', + ); + } + + final r2Result = await _r2UploadService.verifyTelemetryBundle( + idToken: idToken, + remoteRideId: remoteRideId, + expectedSha256: expectedSha256, + expectedSize: expectedSize?.toInt(), + ); + + switch (r2Result.status) { + case CloudConfirmationStatus.confirmed: + return CloudConfirmationResult.confirmed( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey, + sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(), + sha256: r2Result.metadata?.sha256 ?? expectedSha256, + message: 'Firestore metadata and R2 bundle verified.', + ); + + case CloudConfirmationStatus.missing: + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: + r2Result.errorMessage ?? + 'Telemetry bundle missing from Cloudflare R2.', + ); + + case CloudConfirmationStatus.inconsistent: + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: expectedObjectKey, + message: + r2Result.errorMessage ?? + 'R2 telemetry bundle checksum or metadata inconsistent.', + ); + + case CloudConfirmationStatus.unauthorized: + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: + r2Result.errorMessage ?? + 'Unauthorized to access R2 telemetry bundle.', + ); + + case CloudConfirmationStatus.temporarilyUnverifiable: + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: + r2Result.errorMessage ?? + 'R2 verification temporarily unavailable.', + ); } } @override Future uploadRide(SyncItem item) async { final user = _authService.currentUser; - if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); + if (user == null || user.isAnonymous) { + return SyncResult.authRequired( + message: 'Sign in to MotorCloud is required for cloud backup.', + ); + } - final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); + final ride = await (_db.select( + _db.rides, + )..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); if (ride != null && ride.cloudOwnerUid != user.uid) { return SyncResult.permanentFailure( ride.cloudOwnerUid == null @@ -79,7 +260,11 @@ class FirebaseSyncProvider implements SyncProvider { : 'This ride belongs to another MotorCloud account and was not uploaded.', ); } - if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.'); + if (!await isAvailable()) { + return SyncResult.temporaryFailure( + 'Firebase services are unavailable or unconfigured. Ride remains safely stored locally.', + ); + } // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token @@ -88,11 +273,21 @@ class FirebaseSyncProvider implements SyncProvider { if (idToken == null || idToken.isEmpty) { idToken = await _authService.getIdToken(true); } - if (idToken == null || idToken.isEmpty) return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.'); + if (idToken == null || idToken.isEmpty) { + return SyncResult.authRequired( + message: 'Valid Firebase authentication token required for cloud sync.', + ); + } try { - final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); - if (ride == null) return SyncResult.permanentFailure('Local ride #${item.localRideId} not found in database.'); + final ride = await (_db.select( + _db.rides, + )..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); + if (ride == null) { + return SyncResult.permanentFailure( + 'Local ride #${item.localRideId} not found in database.', + ); + } // The cloud identity is generated once and then reused forever. Never // derive the remote identity from the local auto-increment database ID. @@ -102,8 +297,14 @@ class FirebaseSyncProvider implements SyncProvider { } final appIdentity = AppIdentity.current(); - final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); - if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); + final zipBytes = await _bundleWorker.generateCompressedBundle( + item.localRideId, + ); + if (zipBytes == null || zipBytes.isEmpty) { + return SyncResult.permanentFailure( + 'Failed generating compressed telemetry bundle for ride #${item.localRideId}.', + ); + } final sha256Hash = sha256.convert(zipBytes).toString(); var r2Result = await _r2UploadService.uploadTelemetryBundle( @@ -119,7 +320,9 @@ class FirebaseSyncProvider implements SyncProvider { // turning a valid signed-in session into a false authentication error. if (!r2Result.success && r2Result.requiresAuth) { final refreshedToken = await _authService.getIdToken(true); - if (refreshedToken != null && refreshedToken.isNotEmpty && refreshedToken != idToken) { + if (refreshedToken != null && + refreshedToken.isNotEmpty && + refreshedToken != idToken) { idToken = refreshedToken; r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, @@ -132,15 +335,34 @@ class FirebaseSyncProvider implements SyncProvider { } if (!r2Result.success) { - if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required'); - if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); - return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); + if (r2Result.requiresAuth) { + return SyncResult.authRequired( + message: + r2Result.errorMessage ?? 'R2 Gateway authentication required', + ); + } + if (r2Result.isRetryable) { + return SyncResult.temporaryFailure( + r2Result.errorMessage ?? 'Temporary R2 upload error', + ); + } + return SyncResult.permanentFailure( + r2Result.errorMessage ?? 'Permanent R2 upload rejection', + ); } - final objectKey = r2Result.objectKey ?? 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; - final events = await (_db.select(_db.rideEvents)..where((e) => e.rideId.equals(item.localRideId))).get(); - final performanceRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(item.localRideId))).get(); - final summary = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); + final objectKey = + r2Result.objectKey ?? + 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; + final events = await (_db.select( + _db.rideEvents, + )..where((e) => e.rideId.equals(item.localRideId))).get(); + final performanceRuns = await (_db.select( + _db.performanceRuns, + )..where((p) => p.rideId.equals(item.localRideId))).get(); + final summary = await (_db.select( + _db.fieldTestSummaries, + )..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); final firestorePayload = { 'id': remoteRideId, @@ -166,12 +388,37 @@ class FirebaseSyncProvider implements SyncProvider { 'motorcycleModel': ride.motorcycleModel, 'motorcycleYear': ride.motorcycleYear, 'motorcycleVariant': ride.motorcycleVariant, - 'hardBrakingEvents': events.where((e) => e.eventType.contains('Braking')).length, - 'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length, - 'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length, + 'hardBrakingEvents': events + .where((e) => e.eventType.contains('Braking')) + .length, + 'extremeLeanEvents': events + .where( + (e) => + e.eventType.contains('Lean') || + e.eventType.contains('Corner'), + ) + .length, + 'highSpeedEvents': events + .where((e) => e.eventType.contains('Speed')) + .length, 'totalEventsCount': events.length, - 'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(), - 'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash}, + 'performanceRuns': performanceRuns + .map( + (r) => { + 'type': r.type, + 'timeMs': r.timeMs, + 'topSpeedReached': r.topSpeedReached, + 'timestamp': r.timestamp.toUtc().toIso8601String(), + }, + ) + .toList(), + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': objectKey, + 'status': 'uploaded', + 'bundleSizeBytes': zipBytes.length, + 'sha256': sha256Hash, + }, 'telemetryBundlePath': objectKey, 'bundleSizeBytes': zipBytes.length, 'syncVersion': 3, @@ -183,9 +430,21 @@ class FirebaseSyncProvider implements SyncProvider { await _firestore!.collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(firestorePayload, SetOptions(merge: true)); return SyncResult.success(remoteId: remoteRideId); } on FirebaseException catch (e) { - if (e.code == 'permission-denied' || e.code == 'unauthenticated') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}'); - if (e.code == 'unavailable' || e.code == 'network-request-failed' || e.code == 'retry-limit-exceeded') return SyncResult.temporaryFailure('Network or Firebase service temporary error (${e.code}): ${e.message}'); - return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${e.message}'); + if (e.code == 'permission-denied' || e.code == 'unauthenticated') { + return SyncResult.authRequired( + message: 'Firebase authorization error: ${e.message}', + ); + } + if (e.code == 'unavailable' || + e.code == 'network-request-failed' || + e.code == 'retry-limit-exceeded') { + return SyncResult.temporaryFailure( + 'Network or Firebase service temporary error (${e.code}): ${e.message}', + ); + } + return SyncResult.permanentFailure( + 'Firebase sync error (${e.code}): ${e.message}', + ); } catch (e) { return SyncResult.temporaryFailure('Unexpected sync exception: $e'); } diff --git a/lib/services/sync/github_sync_provider.dart b/lib/services/sync/github_sync_provider.dart index 0e299db..1f1a95c 100644 --- a/lib/services/sync/github_sync_provider.dart +++ b/lib/services/sync/github_sync_provider.dart @@ -9,10 +9,7 @@ class GitHubSyncProvider implements SyncProvider { final GitHubSyncService service; final GitHubAuthService auth; - GitHubSyncProvider({ - required this.service, - required this.auth, - }); + GitHubSyncProvider({required this.service, required this.auth}); @override String get providerId => 'github'; @@ -26,12 +23,18 @@ class GitHubSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { final hasAuth = auth.getToken() != null; if (!hasAuth) { return SyncResult.authRequired( - message: 'GitHub developer export requires an explicitly configured GitHub token.', + message: + 'GitHub developer export requires an explicitly configured GitHub token.', ); } diff --git a/lib/services/sync/r2_upload_service.dart b/lib/services/sync/r2_upload_service.dart index 986e095..21f7131 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -2,6 +2,84 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'sync_provider.dart'; + +@immutable +class R2VerificationMetadata { + final bool verified; + final String remoteRideId; + final String? objectKey; + final int? size; + final String? sha256; + final String? etag; + + const R2VerificationMetadata({ + required this.verified, + required this.remoteRideId, + this.objectKey, + this.size, + this.sha256, + this.etag, + }); +} + +@immutable +class R2VerificationResponse { + final CloudConfirmationStatus status; + final String? errorMessage; + final int? statusCode; + final R2VerificationMetadata? metadata; + + const R2VerificationResponse({ + required this.status, + this.errorMessage, + this.statusCode, + this.metadata, + }); + + const R2VerificationResponse.confirmed( + R2VerificationMetadata metadata, { + int statusCode = 200, + }) : this( + status: CloudConfirmationStatus.confirmed, + statusCode: statusCode, + metadata: metadata, + ); + + const R2VerificationResponse.missing(String message, {int statusCode = 404}) + : this( + status: CloudConfirmationStatus.missing, + errorMessage: message, + statusCode: statusCode, + ); + + const R2VerificationResponse.inconsistent( + String message, { + int statusCode = 409, + }) : this( + status: CloudConfirmationStatus.inconsistent, + errorMessage: message, + statusCode: statusCode, + ); + + const R2VerificationResponse.unauthorized( + String message, { + int statusCode = 401, + }) : this( + status: CloudConfirmationStatus.unauthorized, + errorMessage: message, + statusCode: statusCode, + ); + + const R2VerificationResponse.temporarilyUnverifiable( + String message, { + int? statusCode, + }) : this( + status: CloudConfirmationStatus.temporarilyUnverifiable, + errorMessage: message, + statusCode: statusCode, + ); +} @immutable class R2UploadResult { @@ -12,59 +90,332 @@ class R2UploadResult { final int? statusCode; final bool isRetryable; final bool requiresAuth; - const R2UploadResult({required this.success, this.objectKey, this.bytesWritten, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false}); - const R2UploadResult.success({required String objectKey, int? bytesWritten}) : this(success: true, objectKey: objectKey, bytesWritten: bytesWritten, statusCode: 200, isRetryable: false); - const R2UploadResult.authRequired(String message, {int statusCode = 401}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true); - const R2UploadResult.temporaryFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true); - const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false); + const R2UploadResult({ + required this.success, + this.objectKey, + this.bytesWritten, + this.errorMessage, + this.statusCode, + this.isRetryable = true, + this.requiresAuth = false, + }); + const R2UploadResult.success({required String objectKey, int? bytesWritten}) + : this( + success: true, + objectKey: objectKey, + bytesWritten: bytesWritten, + statusCode: 200, + isRetryable: false, + ); + const R2UploadResult.authRequired(String message, {int statusCode = 401}) + : this( + success: false, + errorMessage: message, + statusCode: statusCode, + isRetryable: true, + requiresAuth: true, + ); + const R2UploadResult.temporaryFailure(String message, {int? statusCode}) + : this( + success: false, + errorMessage: message, + statusCode: statusCode, + isRetryable: true, + ); + const R2UploadResult.permanentFailure(String message, {int? statusCode}) + : this( + success: false, + errorMessage: message, + statusCode: statusCode, + isRetryable: false, + ); } /// Uploads immutable ride telemetry bundles through the authenticated Worker gateway. class R2UploadService { - static const String defaultWorkerUrl = String.fromEnvironment('R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev'); + static const String defaultWorkerUrl = String.fromEnvironment( + 'R2_WORKER_URL', + defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev', + ); final http.Client _client; final String _workerBaseUrl; - R2UploadService({http.Client? client, String? workerBaseUrl}) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); + R2UploadService({http.Client? client, String? workerBaseUrl}) + : _client = client ?? http.Client(), + _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll( + RegExp(r'/+$'), + '', + ); String get workerBaseUrl => _workerBaseUrl; - Future uploadTelemetryBundle({required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45)}) async { - if (idToken.trim().isEmpty) return const R2UploadResult.authRequired('Firebase ID token is required for R2 upload.'); - if (zipBytes.isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle is empty.', statusCode: 400); + Future uploadTelemetryBundle({ + required String idToken, + required String remoteRideId, + required Uint8List zipBytes, + required String sha256Hash, + required String appVersion, + Duration timeout = const Duration(seconds: 45), + }) async { + if (idToken.trim().isEmpty) { + return const R2UploadResult.authRequired( + 'Firebase ID token is required for R2 upload.', + ); + } + if (zipBytes.isEmpty) { + return const R2UploadResult.permanentFailure( + 'Telemetry bundle is empty.', + statusCode: 400, + ); + } // Prevent path traversal at the client boundary. The Worker remains the // authoritative verifier of SHA-256 and Firebase UID ownership. - if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) return const R2UploadResult.permanentFailure('Invalid remote ride identifier.', statusCode: 400); - if (sha256Hash.trim().isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle SHA-256 is required.', statusCode: 400); + if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) { + return const R2UploadResult.permanentFailure( + 'Invalid remote ride identifier.', + statusCode: 400, + ); + } + if (sha256Hash.trim().isEmpty) { + return const R2UploadResult.permanentFailure( + 'Telemetry bundle SHA-256 is required.', + statusCode: 400, + ); + } try { - final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: { - 'Authorization': 'Bearer ${idToken.trim()}', - 'Content-Type': 'application/zip', - 'X-Remote-Ride-Id': remoteRideId, - 'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), - 'X-App-Version': appVersion, - }, body: zipBytes).timeout(timeout); + final response = await _client + .put( + Uri.parse('$_workerBaseUrl/r2/upload'), + headers: { + 'Authorization': 'Bearer ${idToken.trim()}', + 'Content-Type': 'application/zip', + 'X-Remote-Ride-Id': remoteRideId, + 'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), + 'X-App-Version': appVersion, + }, + body: zipBytes, + ) + .timeout(timeout); final status = response.statusCode; if (status >= 200 && status < 300) { try { final decoded = jsonDecode(response.body); - if (decoded is! Map) throw const FormatException('Invalid worker response'); + if (decoded is! Map) { + throw const FormatException('Invalid worker response'); + } final data = Map.from(decoded); final objectKey = data['objectKey']; - if (objectKey is! String || !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || !objectKey.startsWith('users/')) return const R2UploadResult.permanentFailure('R2 worker returned an invalid object key.'); + if (objectKey is! String || + !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || + !objectKey.startsWith('users/')) { + return const R2UploadResult.permanentFailure( + 'R2 worker returned an invalid object key.', + ); + } final size = data['size']; - final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length; - return R2UploadResult.success(objectKey: objectKey, bytesWritten: bytes); + final bytes = size is num && size >= 0 + ? size.toInt() + : zipBytes.length; + return R2UploadResult.success( + objectKey: objectKey, + bytesWritten: bytes, + ); } catch (e) { - return R2UploadResult.temporaryFailure('R2 upload response could not be verified: $e', statusCode: status); + return R2UploadResult.temporaryFailure( + 'R2 upload response could not be verified: $e', + statusCode: status, + ); } } - if (status == 401 || status == 403) return R2UploadResult.authRequired('R2 Worker authorization denied ($status).', statusCode: status); - if (status == 400 || status == 404 || status == 409 || status == 422) return R2UploadResult.permanentFailure('R2 Worker rejected the bundle ($status).', statusCode: status); - return R2UploadResult.temporaryFailure('R2 Worker temporary error ($status).', statusCode: status); + if (status == 401 || status == 403) { + return R2UploadResult.authRequired( + 'R2 Worker authorization denied ($status).', + statusCode: status, + ); + } + if (status == 400 || status == 404 || status == 409 || status == 422) { + return R2UploadResult.permanentFailure( + 'R2 Worker rejected the bundle ($status).', + statusCode: status, + ); + } + return R2UploadResult.temporaryFailure( + 'R2 Worker temporary error ($status).', + statusCode: status, + ); } on TimeoutException { - return R2UploadResult.temporaryFailure('Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.'); + return R2UploadResult.temporaryFailure( + 'Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.', + ); } catch (e) { - return R2UploadResult.temporaryFailure('Network error uploading to Cloudflare R2: $e'); + return R2UploadResult.temporaryFailure( + 'Network error uploading to Cloudflare R2: $e', + ); + } + } + + /// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2. + /// Queries the worker via /r2/verify to verify existence, metadata, and hash match + /// without downloading the full ZIP archive. + Future verifyTelemetryBundle({ + required String idToken, + required String remoteRideId, + String? expectedSha256, + int? expectedSize, + Duration timeout = const Duration(seconds: 15), + }) async { + if (idToken.trim().isEmpty) { + return const R2VerificationResponse.unauthorized( + 'Firebase ID token is required for verification.', + ); + } + if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) { + return const R2VerificationResponse.inconsistent( + 'Invalid remote ride identifier format.', + ); + } + + try { + final uri = Uri.parse('$_workerBaseUrl/r2/verify?rideId=$remoteRideId'); + final response = await _client + .get( + uri, + headers: { + 'Authorization': 'Bearer ${idToken.trim()}', + 'Accept': 'application/json', + }, + ) + .timeout(timeout); + + final status = response.statusCode; + + if (status >= 200 && status < 300) { + try { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return const R2VerificationResponse.inconsistent( + 'Worker verification returned invalid response structure.', + ); + } + final data = Map.from(decoded); + final respRideId = data['remoteRideId']?.toString(); + if (respRideId != null && respRideId != remoteRideId) { + return R2VerificationResponse.inconsistent( + 'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).', + statusCode: status, + ); + } + final objectKey = data['objectKey']?.toString(); + if (objectKey != null && + (!objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || + !objectKey.startsWith('users/'))) { + return R2VerificationResponse.inconsistent( + 'Worker verification objectKey ($objectKey) does not match expected pattern.', + statusCode: status, + ); + } + final respSha256 = data['sha256']?.toString(); + if (expectedSha256 != null && + expectedSha256.trim().isNotEmpty && + respSha256 != null && + respSha256.trim().isNotEmpty) { + if (expectedSha256.trim().toLowerCase() != + respSha256.trim().toLowerCase()) { + return R2VerificationResponse.inconsistent( + 'SHA-256 checksum mismatch: expected $expectedSha256 but got $respSha256.', + statusCode: 422, + ); + } + } + final respSize = (data['size'] as num?)?.toInt(); + if (expectedSize != null && + expectedSize > 0 && + respSize != null && + respSize > 0) { + if (expectedSize != respSize) { + return R2VerificationResponse.inconsistent( + 'Size mismatch: expected $expectedSize bytes but got $respSize bytes.', + statusCode: 422, + ); + } + } + return R2VerificationResponse.confirmed( + R2VerificationMetadata( + verified: true, + remoteRideId: remoteRideId, + objectKey: objectKey, + size: respSize, + sha256: respSha256, + etag: data['etag']?.toString(), + ), + statusCode: status, + ); + } catch (e) { + return R2VerificationResponse.temporarilyUnverifiable( + 'Failed to parse worker verification response: $e', + statusCode: status, + ); + } + } + + if (status == 401 || status == 403) { + return R2VerificationResponse.unauthorized( + 'R2 verification unauthorized ($status): ${response.body}', + statusCode: status, + ); + } + + if (status == 404) { + // Distinguish between: + // 1. Live worker without /r2/verify endpoint ("Endpoint not found") -> temporarilyUnverifiable + // 2. Telemetry bundle or ride not found -> missing + try { + final decoded = jsonDecode(response.body); + if (decoded is Map && decoded['error'] == 'Endpoint not found') { + return const R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification endpoint not yet deployed on worker.', + statusCode: 404, + ); + } + } catch (_) {} + return R2VerificationResponse.missing( + 'Telemetry bundle or ride not found in cloud ($status).', + statusCode: status, + ); + } + + if (status == 409 || status == 422) { + return R2VerificationResponse.inconsistent( + 'Cloud metadata or object integrity rejected ($status): ${response.body}', + statusCode: status, + ); + } + + if (status == 429) { + return R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification rate limited (429).', + statusCode: status, + ); + } + + if (status >= 500) { + return R2VerificationResponse.temporarilyUnverifiable( + 'R2 worker server error ($status).', + statusCode: status, + ); + } + + return R2VerificationResponse.temporarilyUnverifiable( + 'Unexpected status ($status) from R2 worker.', + statusCode: status, + ); + } on TimeoutException { + return R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification timed out after ${timeout.inSeconds}s.', + ); + } catch (e) { + return R2VerificationResponse.temporarilyUnverifiable( + 'Network error verifying with R2 worker: $e', + ); } } } diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index 02d152d..c228d74 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -14,6 +14,8 @@ class CompletedRideReconciliationReport { final int recoveredAuthRequiredCount; final int recoveredAlreadySyncedCount; final int foreignOwnerSkippedCount; + final int inconsistentSkippedCount; + final int unverifiablePendingCount; final int errorCount; final List errors; @@ -24,6 +26,8 @@ class CompletedRideReconciliationReport { this.recoveredAuthRequiredCount = 0, this.recoveredAlreadySyncedCount = 0, this.foreignOwnerSkippedCount = 0, + this.inconsistentSkippedCount = 0, + this.unverifiablePendingCount = 0, this.errorCount = 0, this.errors = const [], }); @@ -38,7 +42,8 @@ class CompletedRideReconciliationReport { 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' - 'foreignSkipped: $foreignOwnerSkippedCount, errors: $errorCount)'; + 'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, ' + 'unverifiable: $unverifiablePendingCount, errors: $errorCount)'; } /// Central provider-agnostic synchronization coordinator. @@ -58,11 +63,21 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final AppDatabase? _db; final AuthService? _authService; - SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { - if (initialProviders != null) { - for (final p in initialProviders) { registerProvider(p); } - } + SyncManager( + this._outbox, { + List? initialProviders, + String? defaultProviderId, + DateTime? Function()? nowProvider, + this._db, + this._authService, + bool autoStart = true, + }) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (defaultProviderId != null) _activeProviderId = defaultProviderId; + if (initialProviders != null) { + for (final p in initialProviders) { + registerProvider(p); + } + } WidgetsBinding.instance.addObserver(this); if (autoStart) { unawaited(_startupRecoveryAndProcess()); @@ -72,7 +87,13 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; DateTime _now() => _nowProvider() ?? DateTime.now(); String get activeProviderId => _activeProviderId; - set activeProviderId(String id) { if (_activeProviderId != id) { _activeProviderId = id; notifyListeners(); } } + set activeProviderId(String id) { + if (_activeProviderId != id) { + _activeProviderId = id; + notifyListeners(); + } + } + bool get isProcessing => _isProcessing; int get outboxCount => _outbox.count; List get allItems => _outbox.getAllItems(); @@ -85,29 +106,50 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { for (final item in _outbox.getAllItems()) { if (item.status == SyncStatus.synced) { synced++; - if (item.uploadedAt != null && (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) lastSuccess = item.uploadedAt; - } else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) { + if (item.uploadedAt != null && + (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) { + lastSuccess = item.uploadedAt; + } + } else if (item.status == SyncStatus.failed || + item.status == SyncStatus.cancelled) { failed++; if (item.lastError != null) lastErr = item.lastError; } else { pending++; } } - return SyncManagerState(isProcessing: _isProcessing, pendingCount: pending, syncedCount: synced, failedCount: failed, lastSuccessfulSync: lastSuccess, lastError: lastErr, activeProviderId: _activeProviderId); + return SyncManagerState( + isProcessing: _isProcessing, + pendingCount: pending, + syncedCount: synced, + failedCount: failed, + lastSuccessfulSync: lastSuccess, + lastError: lastErr, + activeProviderId: _activeProviderId, + ); } void registerProvider(SyncProvider provider) { _providers[provider.providerId] = provider; - if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId; + if (_activeProviderId == 'none' || _activeProviderId == 'fake') { + _activeProviderId = provider.providerId; + } notifyListeners(); } + SyncProvider? getProvider(String providerId) => _providers[providerId]; - Future enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async { + Future enqueueRide( + int localRideId, { + String? targetProviderId, + bool autoProcess = true, + }) async { final providerId = targetProviderId ?? _activeProviderId; - if (providerId == 'firebase') { + if (providerId == 'firebase' && _db != null) { final user = _authService?.currentUser; - if (user != null && !user.isAnonymous && _db != null) { + final isAuthenticated = user != null && !user.isAnonymous; + final rideOwner = await _db.getCloudOwnerUid(localRideId); + if (isAuthenticated && rideOwner == null) { await _db.claimRideForCloudOwner(localRideId, user.uid); } } @@ -117,10 +159,17 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { return item; } - Future backupAllRideIds(Iterable rideIds, {String? targetProviderId}) async { + Future backupAllRideIds( + Iterable rideIds, { + String? targetProviderId, + }) async { var enqueued = 0; for (final rideId in rideIds) { - await enqueueRide(rideId, targetProviderId: targetProviderId, autoProcess: false); + await enqueueRide( + rideId, + targetProviderId: targetProviderId, + autoProcess: false, + ); enqueued++; } if (enqueued > 0) unawaited(processQueue()); @@ -138,11 +187,36 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { if (isAuthenticated && _db != null) { await reconcileCompletedRides(); } - for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { - if (isAuthenticated && _db != null && item.providerId == 'firebase') { - await _db.claimRideForCloudOwner(item.localRideId, user.uid); + for (final item in _outbox.getAllItems().where( + (i) => i.status == SyncStatus.authRequired, + )) { + if (item.providerId == 'firebase') { + if (!isAuthenticated) continue; + if (_db != null) { + final rideOwner = await _db.getCloudOwnerUid(item.localRideId); + if (rideOwner != null && rideOwner != user.uid) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be retried', + clearNextRetry: true, + ), + ); + continue; + } + if (rideOwner == null) { + await _db.claimRideForCloudOwner(item.localRideId, user.uid); + } + } } - await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); + await _outbox.update( + item.copyWith( + status: SyncStatus.queued, + clearLastError: true, + clearNextRetry: true, + ), + ); } await processQueue(); } @@ -155,7 +229,9 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { if (_activeReconciliation != null) { return _activeReconciliation!; } - final future = _performReconcileCompletedRides(targetProviderId: targetProviderId); + final future = _performReconcileCompletedRides( + targetProviderId: targetProviderId, + ); _activeReconciliation = future; try { return await future; @@ -182,13 +258,15 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { int recoveredAuthRequired = 0; int recoveredAlreadySynced = 0; int foreignOwnerSkipped = 0; + int inconsistentSkipped = 0; + int unverifiablePending = 0; int errorCount = 0; final List errors = []; try { - final completedRides = await (_db.select(_db.rides) - ..where((r) => r.endTime.isNotNull())) - .get(); + final completedRides = await (_db.select( + _db.rides, + )..where((r) => r.endTime.isNotNull())).get(); discovered = completedRides.length; for (final ride in completedRides) { @@ -213,52 +291,118 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } // Claim unclaimed ride if user is authenticated and target is firebase - if (ride.cloudOwnerUid == null && isAuthenticated && providerId == 'firebase') { + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { await _db.claimRideForCloudOwner(ride.id, user.uid); } // Stable cloud identity check final remoteRideId = await _db.ensureCloudRideId(ride.id); - // Check if already confirmed uploaded in cloud backend - final isConfirmed = provider != null && - await provider.isRideConfirmedInCloud(remoteRideId); + // Deep verification check in cloud backend + final confirmation = provider != null + ? await provider.verifyCloudConfirmation(remoteRideId) + : const CloudConfirmationResult( + status: CloudConfirmationStatus.missing, + ); - if (isConfirmed) { - final syncedItem = SyncItem( - localRideId: ride.id, - providerId: providerId, - status: SyncStatus.synced, - remoteId: remoteRideId, - createdAt: ride.endTime ?? ride.startTime, - uploadedAt: _now(), - ); - await _outbox.update(syncedItem); - recoveredAlreadySynced++; - } else { - final providerRequiresAuth = provider != null && - await provider.requiresAuthentication(); - - if (providerRequiresAuth || (!isAuthenticated && providerId == 'firebase')) { - final authReqItem = SyncItem( + switch (confirmation.status) { + case CloudConfirmationStatus.confirmed: + final syncedItem = SyncItem( localRideId: ride.id, providerId: providerId, - status: SyncStatus.authRequired, + status: SyncStatus.synced, + remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, - lastError: 'Authentication required for provider "$providerId"', + uploadedAt: _now(), ); - await _outbox.update(authReqItem); - recoveredAuthRequired++; - } else { - final queuedItem = SyncItem( + await _outbox.update(syncedItem); + recoveredAlreadySynced++; + break; + + case CloudConfirmationStatus.missing: + final providerRequiresAuth = + provider != null && await provider.requiresAuthentication(); + + if (providerRequiresAuth || + (!isAuthenticated && providerId == 'firebase')) { + final authReqItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.authRequired, + createdAt: ride.endTime ?? ride.startTime, + lastError: + 'Authentication required for provider "$providerId"', + ); + await _outbox.update(authReqItem); + recoveredAuthRequired++; + } else { + final queuedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.queued, + createdAt: ride.endTime ?? ride.startTime, + ); + await _outbox.update(queuedItem); + recoveredQueued++; + } + break; + + case CloudConfirmationStatus.inconsistent: + inconsistentSkipped++; + errors.add( + 'Ride #${ride.id} ($remoteRideId) inconsistent in cloud: ${confirmation.message}', + ); + final inconsistentItem = SyncItem( localRideId: ride.id, providerId: providerId, - status: SyncStatus.queued, + status: SyncStatus.failed, + remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, + lastError: 'Cloud data inconsistent: ${confirmation.message}', + attemptCount: 1, ); - await _outbox.update(queuedItem); - recoveredQueued++; - } + await _outbox.update(inconsistentItem); + break; + + case CloudConfirmationStatus.unauthorized: + final providerRequiresAuth = + provider != null && await provider.requiresAuthentication(); + + if (!isAuthenticated || providerRequiresAuth) { + final authReqItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.authRequired, + createdAt: ride.endTime ?? ride.startTime, + lastError: + 'Authentication required for provider "$providerId"', + ); + await _outbox.update(authReqItem); + recoveredAuthRequired++; + } else { + foreignOwnerSkipped++; + errors.add( + 'Ride #${ride.id} ($remoteRideId) unauthorized: ${confirmation.message}', + ); + } + break; + + case CloudConfirmationStatus.temporarilyUnverifiable: + unverifiablePending++; + final retryItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.retryWait, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + nextRetryAt: _now().add(const Duration(minutes: 2)), + lastError: + 'Cloud temporarily unverifiable: ${confirmation.message}', + ); + await _outbox.update(retryItem); + break; } } catch (itemErr) { errorCount++; @@ -277,6 +421,8 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { recoveredAuthRequiredCount: recoveredAuthRequired, recoveredAlreadySyncedCount: recoveredAlreadySynced, foreignOwnerSkippedCount: foreignOwnerSkipped, + inconsistentSkippedCount: inconsistentSkipped, + unverifiablePendingCount: unverifiablePending, errorCount: errorCount, errors: errors, ); @@ -289,12 +435,18 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { static Duration calculateRetryDelay(int attemptCount) { switch (attemptCount) { - case 1: return const Duration(seconds: 5); - case 2: return const Duration(seconds: 30); - case 3: return const Duration(minutes: 2); - case 4: return const Duration(minutes: 5); - case 5: return const Duration(minutes: 15); - default: return const Duration(minutes: 30); + case 1: + return const Duration(seconds: 5); + case 2: + return const Duration(seconds: 30); + case 3: + return const Duration(minutes: 2); + case 4: + return const Duration(minutes: 5); + case 5: + return const Duration(minutes: 15); + default: + return const Duration(minutes: 30); } } @@ -329,34 +481,172 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { for (final item in _outbox.getEligibleItems(now)) { final provider = _providers[item.providerId]; if (provider == null) { - await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); + await _outbox.update( + item.copyWith( + status: SyncStatus.failed, + lastError: 'Provider "${item.providerId}" not registered', + lastAttemptAt: now, + ), + ); continue; } + + // 1. Confirm local database record exists + final ride = _db != null + ? await (_db.select(_db.rides) + ..where((r) => r.id.equals(item.localRideId))) + .getSingleOrNull() + : null; + if (_db != null && ride == null) { + await _outbox.update( + item.copyWith( + status: SyncStatus.failed, + lastError: + 'Local ride #${item.localRideId} not found in database', + lastAttemptAt: now, + clearNextRetry: true, + ), + ); + continue; + } + + // 2. Immediate pre-upload ownership and account checks + // 2. Immediate pre-upload ownership and account checks + if (item.providerId == 'firebase' && _authService != null) { + final user = _authService.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + if (!isAuthenticated) { + await _outbox.update( + item.copyWith( + status: SyncStatus.authRequired, + lastError: + 'Authentication required for provider "${item.providerId}"', + lastAttemptAt: now, + ), + ); + continue; + } + if (ride != null && + ride.cloudOwnerUid != null && + ride.cloudOwnerUid != user.uid) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be uploaded', + lastAttemptAt: now, + clearNextRetry: true, + ), + ); + continue; + } + if (ride != null && ride.cloudOwnerUid == null && _db != null) { + final claimed = await _db.claimRideForCloudOwner( + ride.id, + user.uid, + ); + if (!claimed) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: 'Failed to claim ride ownership for upload', + lastAttemptAt: now, + clearNextRetry: true, + ), + ); + continue; + } + } + } + if (!await provider.isAvailable()) { - await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); + await _outbox.update( + item.copyWith( + status: SyncStatus.waitingForNetwork, + lastAttemptAt: now, + ), + ); continue; } if (await provider.requiresAuthentication()) { - await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); + await _outbox.update( + item.copyWith( + status: SyncStatus.authRequired, + lastError: + 'Authentication required for provider "${item.providerId}"', + lastAttemptAt: now, + ), + ); continue; } - final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); + + final uidBeforeUpload = _authService?.currentUser?.uid; + final uploadingItem = item.copyWith( + status: SyncStatus.uploading, + lastAttemptAt: now, + ); await _outbox.update(uploadingItem); SyncResult result; try { result = await provider.uploadRide(uploadingItem); } catch (e) { - result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); + result = SyncResult.temporaryFailure( + 'Unhandled provider exception: $e', + timestamp: now, + ); } + + // 3. Post-upload identity integrity check: verify user didn't logout/switch mid-flight + final uidAfterUpload = _authService?.currentUser?.uid; + if (item.providerId == 'firebase' && + _authService != null && + uidBeforeUpload != uidAfterUpload) { + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.authRequired, + lastError: 'User changed or logged out during upload', + clearNextRetry: true, + ), + ); + continue; + } + if (result.success) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.synced, + remoteId: result.remoteId, + uploadedAt: result.timestamp, + clearLastError: true, + clearNextRetry: true, + ), + ); } else if (result.requiresAuth) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.authRequired, + lastError: result.errorMessage, + ), + ); } else if (result.isRetryable) { final attempts = uploadingItem.attemptCount + 1; - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.retryWait, + attemptCount: attempts, + nextRetryAt: now.add(calculateRetryDelay(attempts)), + lastError: result.errorMessage, + ), + ); } else { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.failed, + attemptCount: uploadingItem.attemptCount + 1, + lastError: result.errorMessage, + clearNextRetry: true, + ), + ); } } } finally { @@ -375,9 +665,37 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } Future retryAllPending() async { - final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false); + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + final database = _db; + final retryable = _outbox + .getAllItems() + .where((i) => i.status.isPending || i.status == SyncStatus.failed) + .toList(growable: false); for (final item in retryable) { - await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); + if (item.providerId == 'firebase' && + database != null && + _authService != null) { + final rideOwner = await database.getCloudOwnerUid(item.localRideId); + if (rideOwner != null && isAuthenticated && rideOwner != user.uid) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be retried', + clearNextRetry: true, + ), + ); + continue; + } + } + await _outbox.update( + item.copyWith( + status: SyncStatus.queued, + clearLastError: true, + clearNextRetry: true, + ), + ); } await processQueue(); } diff --git a/lib/services/sync/sync_provider.dart b/lib/services/sync/sync_provider.dart index 18e3db8..3d84bc9 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -1,6 +1,115 @@ import 'dart:async'; import 'sync_models.dart'; +/// Explicit cloud confirmation states representing the exact verification status of a remote ride. +enum CloudConfirmationStatus { + /// Firestore metadata and corresponding R2 bundle are consistently verified. + confirmed, + + /// Required Firestore metadata or R2 object is absent. + missing, + + /// Ride identity, owner, bundle reference, size, or checksum does not match. + inconsistent, + + /// The current user cannot access or verify the ride. + unauthorized, + + /// Network outage, timeout, rate limit, or transient backend failure. + temporarilyUnverifiable, +} + +/// Explicit verification report for a cloud ride. +class CloudConfirmationResult { + final CloudConfirmationStatus status; + final String? message; + final String? remoteRideId; + final String? ownerUid; + final String? objectKey; + final int? sizeBytes; + final String? sha256; + + const CloudConfirmationResult({ + required this.status, + this.message, + this.remoteRideId, + this.ownerUid, + this.objectKey, + this.sizeBytes, + this.sha256, + }); + + const CloudConfirmationResult.confirmed({ + required String remoteRideId, + String? ownerUid, + String? objectKey, + int? sizeBytes, + String? sha256, + String? message, + }) : this( + status: CloudConfirmationStatus.confirmed, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + objectKey: objectKey, + sizeBytes: sizeBytes, + sha256: sha256, + message: message, + ); + + const CloudConfirmationResult.missing({ + required String remoteRideId, + String? message, + }) : this( + status: CloudConfirmationStatus.missing, + remoteRideId: remoteRideId, + message: message, + ); + + const CloudConfirmationResult.inconsistent({ + required String remoteRideId, + String? ownerUid, + String? objectKey, + String? message, + }) : this( + status: CloudConfirmationStatus.inconsistent, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + objectKey: objectKey, + message: message, + ); + + const CloudConfirmationResult.unauthorized({ + required String remoteRideId, + String? ownerUid, + String? message, + }) : this( + status: CloudConfirmationStatus.unauthorized, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + message: message, + ); + + const CloudConfirmationResult.temporarilyUnverifiable({ + required String remoteRideId, + String? message, + }) : this( + status: CloudConfirmationStatus.temporarilyUnverifiable, + remoteRideId: remoteRideId, + message: message, + ); + + bool get isConfirmed => status == CloudConfirmationStatus.confirmed; + bool get isMissing => status == CloudConfirmationStatus.missing; + bool get isInconsistent => status == CloudConfirmationStatus.inconsistent; + bool get isUnauthorized => status == CloudConfirmationStatus.unauthorized; + bool get isTemporarilyUnverifiable => + status == CloudConfirmationStatus.temporarilyUnverifiable; + + @override + String toString() => + 'CloudConfirmationResult(status: $status, ride: $remoteRideId, owner: $ownerUid, message: $message)'; +} + /// Abstract contract for synchronization providers (e.g. Firebase, GitHub, Supabase). /// Implementations must not leak provider-specific SDK types into the core sync engine. abstract class SyncProvider { @@ -18,8 +127,17 @@ abstract class SyncProvider { Future uploadRide(SyncItem item); /// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud. - /// Default implementation returns false. - Future isRideConfirmedInCloud(String remoteRideId) async => false; + /// Backward compatible wrapper around [verifyCloudConfirmation]. + Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed; + } + + /// Deeply verifies whether [remoteRideId] is confirmed and recoverable in the cloud. + /// Default implementation returns [CloudConfirmationStatus.missing]. + Future verifyCloudConfirmation( + String remoteRideId, + ) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId); } /// Simulated behavior modes for testing the synchronization engine deterministically @@ -41,6 +159,7 @@ class FakeSyncProvider implements SyncProvider { final List uploadedItems = []; final Map uploadAttemptsPerRide = {}; final Set confirmedRemoteRideIds = {}; + final Map confirmationResults = {}; FakeSyncProvider({ this.providerId = 'fake', @@ -61,7 +180,37 @@ class FakeSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async { - return confirmedRemoteRideIds.contains(remoteRideId); + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed; + } + + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async { + if (confirmationResults.containsKey(remoteRideId)) { + return confirmationResults[remoteRideId]!; + } + if (confirmedRemoteRideIds.contains(remoteRideId)) { + return CloudConfirmationResult.confirmed(remoteRideId: remoteRideId); + } + if (behavior == FakeSyncBehavior.offline || + behavior == FakeSyncBehavior.temporaryFailure) { + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Fake provider backend unavailable', + ); + } + if (behavior == FakeSyncBehavior.authRequired) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + message: 'Fake provider requires authentication', + ); + } + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Ride not found in fake provider', + ); } @override @@ -76,11 +225,13 @@ class FakeSyncProvider implements SyncProvider { switch (behavior) { case FakeSyncBehavior.success: uploadedItems.add(item); - final remoteId = 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; + final remoteId = + 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; confirmedRemoteRideIds.add(remoteId); - return SyncResult.success( - remoteId: remoteId, + confirmationResults[remoteId] = CloudConfirmationResult.confirmed( + remoteRideId: remoteId, ); + return SyncResult.success(remoteId: remoteId); case FakeSyncBehavior.temporaryFailure: return SyncResult.temporaryFailure( @@ -108,5 +259,6 @@ class FakeSyncProvider implements SyncProvider { uploadedItems.clear(); uploadAttemptsPerRide.clear(); confirmedRemoteRideIds.clear(); + confirmationResults.clear(); } } diff --git a/test/cloud_confirmation_contract_test.dart b/test/cloud_confirmation_contract_test.dart new file mode 100644 index 0000000..71dcadf --- /dev/null +++ b/test/cloud_confirmation_contract_test.dart @@ -0,0 +1,566 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:drift/native.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/sync/firebase_sync_provider.dart'; +import 'package:motorcycle_dash/services/sync/r2_upload_service.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; + +class _MockAuthService implements AuthService { + AuthUser? _currentUser; + AuthState _authState = const AuthState.unauthenticated(); + String? idTokenToReturn = 'valid_mock_jwt_token'; + + void setAuthenticated(String uid, String email) { + _currentUser = AuthUser(uid: uid, email: email, isAnonymous: false); + _authState = AuthState.authenticated(_currentUser!); + } + + void setGuest() { + _currentUser = const AuthUser(uid: 'guest_uid', isAnonymous: true); + _authState = AuthState.anonymous(_currentUser!); + } + + void setSignedOut() { + _currentUser = null; + _authState = const AuthState.unauthenticated(); + } + + @override + AuthUser? get currentUser => _currentUser; + @override + AuthState get currentState => _authState; + @override + Stream get authStateChanges => Stream.value(_authState); + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + @override + Future getIdToken([bool forceRefresh = false]) async => + idTokenToReturn; + @override + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} + @override + Future deleteCurrentUser() async {} + @override + void dispose() {} + @override + Future ensureBackendIdentity() async {} + @override + Future reauthenticate({String? password}) async {} + @override + Future reloadCurrentUser() async {} + @override + Future sendEmailVerification() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future signInAnonymously() async => setGuest(); + @override + Future signInWithEmailAndPassword( + String email, + String password, + ) async => setAuthenticated('user_123', email); + @override + Future signInWithGoogle() async => + setAuthenticated('user_123', 'rider@google.com'); + @override + Future signOut() async => setSignedOut(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Trustworthy Cloud Confirmation & R2 Gateway Verification Contract', () { + late AppDatabase db; + late _MockAuthService authService; + late FakeFirebaseFirestore fakeFirestore; + const testUid = 'pilot_apex_42'; + const remoteRideId = 'ride_12345_20261012'; + const validSha256 = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + const validSize = 4096; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + authService = _MockAuthService() + ..setAuthenticated(testUid, 'pilot@apex.com'); + fakeFirestore = FakeFirebaseFirestore(); + }); + + tearDown(() async { + await db.close(); + }); + + Map createFirestoreRideData({ + String ownerId = testUid, + String rideId = remoteRideId, + String bundleStatus = 'uploaded', + String? objectKey, + String sha256 = validSha256, + int size = validSize, + }) { + final key = objectKey ?? 'users/$ownerId/rides/$rideId/telemetry.zip'; + return { + 'id': rideId, + 'ownerId': ownerId, + 'telemetryBundlePath': key, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': key, + 'status': bundleStatus, + 'bundleSizeBytes': size, + 'sha256': sha256, + }, + }; + } + + test( + '1. Confirmed: Firestore doc + matching R2 bundle returns confirmed status', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + expect(request.url.path, equals('/r2/verify')); + expect(request.url.queryParameters['rideId'], equals(remoteRideId)); + expect( + request.headers['authorization'], + contains('valid_mock_jwt_token'), + ); + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': validSha256, + 'etag': '"mock_etag_123"', + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.confirmed)); + expect(result.isConfirmed, isTrue); + expect(result.sha256, equals(validSha256)); + expect(result.sizeBytes, equals(validSize)); + expect(await provider.isRideConfirmedInCloud(remoteRideId), isTrue); + }, + ); + + test( + '2. Missing: Firestore doc exists but R2 bundle is missing (404) returns missing', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'error': 'Telemetry bundle not found in R2', + 'code': 'OBJECT_NOT_FOUND', + }), + 404, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.missing)); + expect(result.isMissing, isTrue); + expect(await provider.isRideConfirmedInCloud(remoteRideId), isFalse); + }, + ); + + test( + '3. Missing: Firestore doc does not exist returns missing without calling R2', + () async { + var r2Called = false; + final mockClient = http_testing.MockClient((request) async { + r2Called = true; + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.missing)); + expect( + r2Called, + isFalse, + reason: 'R2 must not be queried when Firestore metadata is absent', + ); + }, + ); + + test( + '4. Inconsistent: Checksum mismatch between Firestore and R2 returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(sha256: validSha256)); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': 'different_corrupted_hash_9999', + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.isInconsistent, isTrue); + expect(await provider.isRideConfirmedInCloud(remoteRideId), isFalse); + }, + ); + + test( + '5. Inconsistent: Object size mismatch returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(size: 4096)); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': 99999, // Mismatched size + 'sha256': validSha256, + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + }, + ); + + test( + '6. Inconsistent: Wrong remote ride identity inside doc metadata returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(rideId: 'different_ride_999')); + + final r2Service = R2UploadService( + client: http_testing.MockClient( + (_) async => http.Response('{}', 200), + ), + ); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + }, + ); + + test( + '7. Unauthorized: Wrong owner inside doc returns unauthorized', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(ownerId: 'different_owner_uid')); + + final r2Service = R2UploadService( + client: http_testing.MockClient( + (_) async => http.Response('{}', 200), + ), + ); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.unauthorized)); + expect(result.isUnauthorized, isTrue); + }, + ); + + test( + '8. Unauthorized: R2 Worker HTTP 401/403 returns unauthorized', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({'error': 'Unauthorized token'}), + 401, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.unauthorized)); + }, + ); + + test('9. Rate Limited: HTTP 429 returns temporarilyUnverifiable', () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response('Rate limit exceeded', 429); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + expect(result.isTemporarilyUnverifiable, isTrue); + }); + + test( + '10. Server Error: HTTP 500/503 returns temporarilyUnverifiable', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response('Cloudflare backend error', 503); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + }, + ); + + test( + '11. Network Timeout: Timeout returns temporarilyUnverifiable', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + await Future.delayed(const Duration(milliseconds: 100)); + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + + // We test R2 timeout directly + final r2Direct = await r2Service.verifyTelemetryBundle( + idToken: 'token', + remoteRideId: remoteRideId, + timeout: const Duration(milliseconds: 10), + ); + expect( + r2Direct.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + }, + ); + + test( + '12. Worker Endpoint Undeployed (404 Endpoint not found) returns temporarilyUnverifiable', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + // Current live worker response when /r2/verify is not yet deployed + return http.Response( + jsonEncode({'error': 'Endpoint not found'}), + 404, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + expect(result.isConfirmed, isFalse); + }, + ); + + test( + '13. Legacy bundles without optional fields confirm cleanly when R2 verifies', + () async { + // Legacy document without sha256 or bundleSizeBytes in metadata + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set({ + 'id': remoteRideId, + 'ownerId': testUid, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'status': 'uploaded', + }, + }); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': 1024, + 'sha256': 'legacy_hash', + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.confirmed)); + }, + ); + }); +} diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart index 0449fc0..4d0395a 100644 --- a/test/completed_ride_sync_recovery_test.dart +++ b/test/completed_ride_sync_recovery_test.dart @@ -56,7 +56,11 @@ class _TestAuthService implements AuthService { bool get isEmailVerified => false; @override - Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} @override Future deleteCurrentUser() async {} @@ -68,7 +72,8 @@ class _TestAuthService implements AuthService { Future ensureBackendIdentity() async {} @override - Future getIdToken([bool forceRefresh = false]) async => 'fake_id_token'; + Future getIdToken([bool forceRefresh = false]) async => + 'fake_id_token'; @override Future reauthenticate({String? password}) async {} @@ -86,8 +91,10 @@ class _TestAuthService implements AuthService { Future signInAnonymously() async => setGuestUser(); @override - Future signInWithEmailAndPassword(String email, String password) async => - setAuthenticatedUser('uid_123', email); + Future signInWithEmailAndPassword( + String email, + String password, + ) async => setAuthenticatedUser('uid_123', email); @override Future signInWithGoogle() async => @@ -118,209 +125,512 @@ void main() { await db.close(); }); - test('1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', () async { - authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + test( + '1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); - // Simulate a completed ride committed to SQLite during a previous session - final rideId = await db.startRide( - startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), - motorcycleMake: 'Honda', - motorcycleModel: 'CBR600RR', - ); - await db.endRide(rideId, const RidesCompanion()); + // Simulate a completed ride committed to SQLite during a previous session + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + motorcycleMake: 'Honda', + motorcycleModel: 'CBR600RR', + ); + await db.endRide(rideId, const RidesCompanion()); - // Confirm outbox starts empty (app terminated before enqueue ran) - final outbox = SyncOutbox(prefs); - expect(outbox.count, equals(0)); + // Confirm outbox starts empty (app terminated before enqueue ran) + final outbox = SyncOutbox(prefs); + expect(outbox.count, equals(0)); - // Startup initialization of SyncManager (testing step-by-step) - final manager = SyncManager( - outbox, - initialProviders: [fakeProvider], - defaultProviderId: 'firebase', - db: db, - authService: authService, - autoStart: false, - ); + // Startup initialization of SyncManager (testing step-by-step) + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); - // Reconcile runs - final report = await manager.reconcileCompletedRides(); + // Reconcile runs + final report = await manager.reconcileCompletedRides(); - expect(report.discoveredCompletedRides, equals(1)); - expect(report.recoveredQueuedCount, equals(1)); - expect(report.alreadyTrackedCount, equals(0)); - expect(outbox.count, equals(1)); + expect(report.discoveredCompletedRides, equals(1)); + expect(report.recoveredQueuedCount, equals(1)); + expect(report.alreadyTrackedCount, equals(0)); + expect(outbox.count, equals(1)); - final item = outbox.getItem(rideId, 'firebase'); - expect(item, isNotNull); - expect(item?.status, equals(SyncStatus.queued)); + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.queued)); - // Verify ride ownership was claimed for authenticated user - final ownerUid = await db.getCloudOwnerUid(rideId); - expect(ownerUid, equals('rider_alice')); + // Verify ride ownership was claimed for authenticated user + final ownerUid = await db.getCloudOwnerUid(rideId); + expect(ownerUid, equals('rider_alice')); - // Process queue to complete upload - await manager.processQueue(); - expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); - expect(fakeProvider.uploadedItems.length, equals(1)); - }); + // Process queue to complete upload + await manager.processQueue(); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.length, equals(1)); + }, + ); - test('2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', () async { - authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + test( + '2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); - final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 11, 0, 0)); - await db.endRide(rideId, const RidesCompanion()); + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 11, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); - final outbox = SyncOutbox(prefs); - final manager = SyncManager( - outbox, - initialProviders: [fakeProvider], - defaultProviderId: 'firebase', - db: db, - authService: authService, - autoStart: false, - ); + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); - // First run reconciles and queues the ride - final report1 = await manager.reconcileCompletedRides(); - expect(report1.recoveredQueuedCount, equals(1)); - expect(outbox.count, equals(1)); + // First run reconciles and queues the ride + final report1 = await manager.reconcileCompletedRides(); + expect(report1.recoveredQueuedCount, equals(1)); + expect(outbox.count, equals(1)); - // Process upload - await manager.processQueue(); - expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); - expect(fakeProvider.uploadedItems.length, equals(1)); + // Process upload + await manager.processQueue(); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.length, equals(1)); - // Second run reports ride as already tracked, zero duplicates - final report2 = await manager.reconcileCompletedRides(); - expect(report2.discoveredCompletedRides, equals(1)); - expect(report2.alreadyTrackedCount, equals(1)); - expect(report2.recoveredQueuedCount, equals(0)); - expect(report2.totalRecovered, equals(0)); - expect(outbox.count, equals(1)); + // Second run reports ride as already tracked, zero duplicates + final report2 = await manager.reconcileCompletedRides(); + expect(report2.discoveredCompletedRides, equals(1)); + expect(report2.alreadyTrackedCount, equals(1)); + expect(report2.recoveredQueuedCount, equals(0)); + expect(report2.totalRecovered, equals(0)); + expect(outbox.count, equals(1)); - // Running processQueue again does not re-upload - await manager.processQueue(); - expect(fakeProvider.uploadedItems.length, equals(1)); - }); + // Running processQueue again does not re-upload + await manager.processQueue(); + expect(fakeProvider.uploadedItems.length, equals(1)); + }, + ); - test('3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', () async { - // User is initially a guest and provider requires auth - authService.setGuestUser(); - fakeProvider.behavior = FakeSyncBehavior.authRequired; + test( + '3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', + () async { + // User is initially a guest and provider requires auth + authService.setGuestUser(); + fakeProvider.behavior = FakeSyncBehavior.authRequired; - final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 12, 0, 0)); - await db.endRide(rideId, const RidesCompanion()); + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 12, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); - final outbox = SyncOutbox(prefs); - final manager = SyncManager( - outbox, - initialProviders: [fakeProvider], - defaultProviderId: 'firebase', - db: db, - authService: authService, - autoStart: false, - ); + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); - // Reconcile as guest -> authRequired - final report = await manager.reconcileCompletedRides(); - expect(report.recoveredAuthRequiredCount, equals(1)); - expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.authRequired)); + // Reconcile as guest -> authRequired + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAuthRequiredCount, equals(1)); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.authRequired), + ); - // Ownership remains unclaimed - expect(await db.getCloudOwnerUid(rideId), isNull); + // Ownership remains unclaimed + expect(await db.getCloudOwnerUid(rideId), isNull); - // Guest now signs in with Google / Email - authService.setAuthenticatedUser('rider_bob', 'bob@example.com'); - fakeProvider.behavior = FakeSyncBehavior.success; + // Guest now signs in with Google / Email + authService.setAuthenticatedUser('rider_bob', 'bob@example.com'); + fakeProvider.behavior = FakeSyncBehavior.success; - // Invoking retryAuthRequired claims the ride and triggers queue upload - await manager.retryAuthRequired(); + // Invoking retryAuthRequired claims the ride and triggers queue upload + await manager.retryAuthRequired(); - expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); - expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); - expect(fakeProvider.uploadedItems.length, equals(1)); - }); + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.length, equals(1)); + }, + ); - test('4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', () async { - authService.setAuthenticatedUser('rider_charlie', 'charlie@example.com'); + test( + '4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', + () async { + authService.setAuthenticatedUser( + 'rider_charlie', + 'charlie@example.com', + ); - final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 13, 0, 0)); - await db.endRide(rideId, const RidesCompanion()); - final remoteRideId = await db.ensureCloudRideId(rideId); + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 13, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); - // Simulate that cloud backend already has this ride bundle uploaded - fakeProvider.confirmedRemoteRideIds.add(remoteRideId); + // Simulate that cloud backend already has this ride bundle uploaded + fakeProvider.confirmedRemoteRideIds.add(remoteRideId); - final outbox = SyncOutbox(prefs); - final manager = SyncManager( - outbox, - initialProviders: [fakeProvider], - defaultProviderId: 'firebase', - db: db, - authService: authService, - ); + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); - final report = await manager.reconcileCompletedRides(); - expect(report.recoveredAlreadySyncedCount, equals(1)); - expect(report.recoveredQueuedCount, equals(0)); + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAlreadySyncedCount, equals(1)); + expect(report.recoveredQueuedCount, equals(0)); - final item = outbox.getItem(rideId, 'firebase'); - expect(item?.status, equals(SyncStatus.synced)); - expect(item?.remoteId, equals(remoteRideId)); + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.synced)); + expect(item?.remoteId, equals(remoteRideId)); - // Zero upload attempts because it was already confirmed - expect(fakeProvider.uploadedItems.isEmpty, isTrue); - }); + // Zero upload attempts because it was already confirmed + expect(fakeProvider.uploadedItems.isEmpty, isTrue); + }, + ); - test('5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', () async { - authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + test( + '5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); - // Create ride explicitly owned by rider_bob - final rideId = await db.into(db.rides).insert(RidesCompanion.insert( - cloudRideId: const drift.Value('ride_bob_123'), - cloudOwnerUid: const drift.Value('rider_bob'), - startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), - endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)), - )); + // Create ride explicitly owned by rider_bob + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_123'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)), + ), + ); - final outbox = SyncOutbox(prefs); - final manager = SyncManager( - outbox, - initialProviders: [fakeProvider], - defaultProviderId: 'firebase', - db: db, - authService: authService, - ); + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); - final report = await manager.reconcileCompletedRides(); - expect(report.foreignOwnerSkippedCount, equals(1)); - expect(report.totalRecovered, equals(0)); - expect(outbox.count, equals(0)); + final report = await manager.reconcileCompletedRides(); + expect(report.foreignOwnerSkippedCount, equals(1)); + expect(report.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); - // Ownership in db remains unchanged - expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); - }); + // Ownership in db remains unchanged + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + }, + ); - test('6. In-Progress Isolation: Active rides without endTime are never recovered as completed', () async { - authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + test( + '6. In-Progress Isolation: Active rides without endTime are never recovered as completed', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); - // Start ride but do not end it (endTime is null) - final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 16, 0, 0)); + // Start ride but do not end it (endTime is null) + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 16, 0, 0), + ); - final outbox = SyncOutbox(prefs); - final manager = SyncManager( - outbox, - initialProviders: [fakeProvider], - defaultProviderId: 'firebase', - db: db, - authService: authService, - ); + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); - final report = await manager.reconcileCompletedRides(); - expect(report.discoveredCompletedRides, equals(0)); - expect(report.totalRecovered, equals(0)); - expect(outbox.getItem(rideId, 'firebase'), isNull); - }); + final report = await manager.reconcileCompletedRides(); + expect(report.discoveredCompletedRides, equals(0)); + expect(report.totalRecovered, equals(0)); + expect(outbox.getItem(rideId, 'firebase'), isNull); + }, + ); + + test( + '7. Inconsistent Cloud Confirmation: Preserves local data and reports inconsistent skipped without overwriting', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 17, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + message: 'SHA-256 mismatch detected in storage', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.inconsistentSkippedCount, equals(1)); + expect(report.recoveredAlreadySyncedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.failed)); + expect(item?.lastError, contains('inconsistent')); + + // Local ride remains safe in SQLite + final localRide = await (db.select( + db.rides, + )..where((r) => r.id.equals(rideId))).getSingle(); + expect(localRide.cloudRideId, equals(remoteRideId)); + }, + ); + + test( + '8. Temporarily Unverifiable State: Leaves ride in retryWait with backoff without marking permanently missing', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 18, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'HTTP 503 backend gateway timeout', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.unverifiablePendingCount, equals(1)); + expect(report.recoveredAlreadySyncedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.retryWait)); + expect(item?.nextRetryAt, isNotNull); + expect(item?.lastError, contains('temporarily unverifiable')); + }, + ); + + test( + '9. Pre-Upload Ownership Protection: processQueue cancels foreign-owned outbox item before calling provider', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Create ride owned by rider_bob + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_456'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 19, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 20, 0, 0)), + ), + ); + + // Force an outbox item into the queue (e.g., from stale shared storage) + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.queued, + createdAt: DateTime.now(), + ), + ); + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + await manager.processQueue(); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.cancelled)); + expect(item?.lastError, contains('belongs to another account')); + expect( + fakeProvider.uploadedItems.isEmpty, + isTrue, + reason: 'Must not call provider upload for foreign ride', + ); + }, + ); + + test( + '10. Mid-flight Auth Change Protection: Reverts upload to authRequired if user logs out mid-flight', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 21, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, 'rider_alice'); + + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.queued, + createdAt: DateTime.now(), + ), + ); + + // Provider simulates delay and during delay user logs out + fakeProvider.delayMs = 50; + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Start processQueue + final uploadFuture = manager.processQueue(); + + // Mid-flight logout + await Future.delayed(const Duration(milliseconds: 10)); + authService.setLoggedOut(); + + await uploadFuture; + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.authRequired)); + expect( + item?.lastError, + contains('User changed or logged out during upload'), + ); + }, + ); + + test( + '11. Concurrency Protection: Simultaneous reconcileCompletedRides join the same future', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 22, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final results = await Future.wait([ + manager.reconcileCompletedRides(), + manager.reconcileCompletedRides(), + ]); + + expect(results[0].discoveredCompletedRides, equals(1)); + expect(results[1].discoveredCompletedRides, equals(1)); + expect(outbox.count, equals(1)); + }, + ); + + test( + '12. Stale Persisted Outbox on retryAllPending: Foreign-owned items are cancelled on retry', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_789'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 23, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 13, 0, 0, 0)), + ), + ); + + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.failed, + lastError: 'Previous failure', + createdAt: DateTime.now(), + ), + ); + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + await manager.retryAllPending(); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.cancelled)); + expect(item?.lastError, contains('belongs to another account')); + expect(fakeProvider.uploadedItems.isEmpty, isTrue); + }, + ); }); } diff --git a/test/disposable_backup_recovery_verification_test.dart b/test/disposable_backup_recovery_verification_test.dart index 620419e..b550b13 100644 --- a/test/disposable_backup_recovery_verification_test.dart +++ b/test/disposable_backup_recovery_verification_test.dart @@ -15,12 +15,12 @@ class _FakeAuthService implements AuthService { final AuthUser _user; _FakeAuthService([String uid = 'pilot_pixel_8a']) - : _user = AuthUser( - uid: uid, - email: '$uid@example.com', - displayName: 'Pixel Pilot', - isAnonymous: false, - ); + : _user = AuthUser( + uid: uid, + email: '$uid@example.com', + displayName: 'Pixel Pilot', + isAnonymous: false, + ); @override AuthUser? get currentUser => _user; @@ -34,7 +34,11 @@ class _FakeAuthService implements AuthService { bool get isEmailVerified => true; @override - Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} @override Future deleteCurrentUser() async {} @override @@ -54,7 +58,10 @@ class _FakeAuthService implements AuthService { @override Future signInAnonymously() async {} @override - Future signInWithEmailAndPassword(String email, String password) async {} + Future signInWithEmailAndPassword( + String email, + String password, + ) async {} @override Future signInWithGoogle() async {} @override @@ -69,22 +76,33 @@ void main() { r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', ); - expect(originalFile.existsSync(), isTrue, reason: 'Original backup file must exist'); + expect( + originalFile.existsSync(), + isTrue, + reason: 'Original backup file must exist', + ); // 1. Verify original SHA-256 before copy final originalBytes = await originalFile.readAsBytes(); final originalHash = sha256.convert(originalBytes).toString(); - const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; - expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256'); + const expectedHash = + '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; + expect( + originalHash, + equals(expectedHash), + reason: 'Original backup must match authoritative SHA-256', + ); // 2. Create disposable copy in scratch directory final scratchDir = Directory( - r'C:\Users\Bill\.gemini\antigravity\brain\eb590221-346f-4525-a5fa-eda41331c6c6\scratch', + '${Directory.systemTemp.path}/motorsignal_backup_test_${DateTime.now().millisecondsSinceEpoch}', ); if (!scratchDir.existsSync()) { scratchDir.createSync(recursive: true); } - final disposableFile = File('${scratchDir.path}\\disposable_motorsignal.sqlite'); + final disposableFile = File( + '${scratchDir.path}/disposable_motorsignal.sqlite', + ); if (disposableFile.existsSync()) { disposableFile.deleteSync(); } @@ -96,21 +114,39 @@ void main() { try { // Integrity check - final integrityRows = await db.customSelect('PRAGMA integrity_check').get(); + final integrityRows = await db + .customSelect('PRAGMA integrity_check') + .get(); final integrityResult = integrityRows.first.data['integrity_check']; - expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK'); + expect( + integrityResult, + equals('ok'), + reason: 'SQLite integrity must be OK', + ); // Count rides and samples - final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get(); + final rideCountRows = await db + .customSelect('SELECT COUNT(*) as count FROM rides') + .get(); final totalRides = rideCountRows.first.data['count'] as int; expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides'); - final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get(); + final completedRidesRows = await db + .customSelect( + 'SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL', + ) + .get(); final completedRides = completedRidesRows.first.data['count'] as int; - final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get(); + final samplesRows = await db + .customSelect('SELECT COUNT(*) as count FROM telemetry_samples') + .get(); final totalSamples = samplesRows.first.data['count'] as int; - expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples'); + expect( + totalSamples, + equals(67798), + reason: 'Must contain exactly 67,798 telemetry samples', + ); // 4. Test Foreign Account Isolation against real Pixel backup // In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2' @@ -133,7 +169,11 @@ void main() { final foreignReport = await foreignSyncManager.reconcileCompletedRides(); expect(foreignReport.errorCount, equals(0)); expect(foreignReport.discoveredCompletedRides, equals(completedRides)); - expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides'); + expect( + foreignReport.foreignOwnerSkippedCount, + equals(completedRides), + reason: 'Foreign account must NEVER claim or touch real Pixel rides', + ); expect(foreignReport.totalRecovered, equals(0)); expect(outbox.count, equals(0)); @@ -160,7 +200,6 @@ void main() { expect(report2.totalRecovered, equals(0)); expect(report2.alreadyTrackedCount, equals(completedRides)); expect(outbox.count, equals(completedRides)); - } finally { await db.close(); } @@ -168,6 +207,10 @@ void main() { // 6. Verify original SHA-256 after operations to prove zero mutation final postBytes = await originalFile.readAsBytes(); final postHash = sha256.convert(postBytes).toString(); - expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED'); + expect( + postHash, + equals(expectedHash), + reason: 'Original backup MUST remain 100% UNTOUCHED', + ); }); } diff --git a/test/isolated_sync_integration_fixture_test.dart b/test/isolated_sync_integration_fixture_test.dart index 0d7b38e..44223ee 100644 --- a/test/isolated_sync_integration_fixture_test.dart +++ b/test/isolated_sync_integration_fixture_test.dart @@ -107,6 +107,10 @@ class _IsolatedContractSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { if (!networkAvailable) { diff --git a/test/offline_first_sync_contract_test.dart b/test/offline_first_sync_contract_test.dart index db7fe3a..74248f4 100644 --- a/test/offline_first_sync_contract_test.dart +++ b/test/offline_first_sync_contract_test.dart @@ -23,6 +23,10 @@ class _CountingProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { uploadCount++; diff --git a/test/r2_firestore_retry_hardening_test.dart b/test/r2_firestore_retry_hardening_test.dart index 245bd92..c5c2bd2 100644 --- a/test/r2_firestore_retry_hardening_test.dart +++ b/test/r2_firestore_retry_hardening_test.dart @@ -34,6 +34,10 @@ class _R2ThenFirestoreProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { attempts++; diff --git a/test/sync_persistence_validation_test.dart b/test/sync_persistence_validation_test.dart index 4d5978c..4372913 100644 --- a/test/sync_persistence_validation_test.dart +++ b/test/sync_persistence_validation_test.dart @@ -24,6 +24,10 @@ class _ValidationProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { uploadCount++; From 5e0db4c19172ce0ef2e68378ee342e8985b0ff96 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 13 Oct 2026 22:05:25 -0400 Subject: [PATCH 3/5] fix(sync): harden cloud confirmation states, R2 edge 404 handling, and in-flight cancellation --- cloudflare/motorsignal-r2-worker/src/index.ts | 2 +- lib/services/sync/firebase_sync_provider.dart | 42 ++-- lib/services/sync/r2_upload_service.dart | 33 ++- lib/services/sync/sync_manager.dart | 61 +++++- lib/services/sync/sync_provider.dart | 14 ++ test/cloud_confirmation_contract_test.dart | 196 ++++++++++++++++++ test/completed_ride_sync_recovery_test.dart | 157 ++++++++++++++ 7 files changed, 483 insertions(+), 22 deletions(-) diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index e79051b..1523f28 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -26,7 +26,7 @@ const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version', - 'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256', + 'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256, X-MotorSignal-Size', }; const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com'); diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 0a82075..1ceb393 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -265,6 +265,11 @@ class FirebaseSyncProvider implements SyncProvider { 'Firebase services are unavailable or unconfigured. Ride remains safely stored locally.', ); } + if (ride == null) { + return SyncResult.permanentFailure( + 'Local ride #${item.localRideId} not found in database.', + ); + } // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token @@ -280,21 +285,29 @@ class FirebaseSyncProvider implements SyncProvider { } try { - final ride = await (_db.select( - _db.rides, - )..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); - if (ride == null) { - return SyncResult.permanentFailure( - 'Local ride #${item.localRideId} not found in database.', - ); - } - // The cloud identity is generated once and then reused forever. Never // derive the remote identity from the local auto-increment database ID. final remoteRideId = await _db.ensureCloudRideId(item.localRideId); - if (await isRideConfirmedInCloud(remoteRideId)) { + final confirmation = await verifyCloudConfirmation(remoteRideId); + if (confirmation.isConfirmed) { return SyncResult.success(remoteId: remoteRideId); } + if (confirmation.isInconsistent) { + return SyncResult.permanentFailure( + 'Cloud data inconsistent for ride $remoteRideId: ${confirmation.message}. Re-upload aborted to prevent overwriting cloud object.', + ); + } + if (confirmation.isUnauthorized) { + return SyncResult.authRequired( + message: + 'Cloud verification unauthorized for ride $remoteRideId: ${confirmation.message}', + ); + } + if (confirmation.isTemporarilyUnverifiable) { + return SyncResult.temporaryFailure( + 'Cloud confirmation temporarily unavailable for ride $remoteRideId: ${confirmation.message}', + ); + } final appIdentity = AppIdentity.current(); final zipBytes = await _bundleWorker.generateCompressedBundle( @@ -351,9 +364,14 @@ class FirebaseSyncProvider implements SyncProvider { ); } - final objectKey = - r2Result.objectKey ?? + final expectedObjectKey = 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; + final objectKey = r2Result.objectKey ?? expectedObjectKey; + if (objectKey != expectedObjectKey) { + return SyncResult.permanentFailure( + 'R2 Worker returned an unexpected object key ($objectKey); expected $expectedObjectKey.', + ); + } final events = await (_db.select( _db.rideEvents, )..where((e) => e.rideId.equals(item.localRideId))).get(); diff --git a/lib/services/sync/r2_upload_service.dart b/lib/services/sync/r2_upload_service.dart index 21f7131..83f7f0c 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -367,16 +367,35 @@ class R2UploadService { if (status == 404) { // Distinguish between: // 1. Live worker without /r2/verify endpoint ("Endpoint not found") -> temporarilyUnverifiable - // 2. Telemetry bundle or ride not found -> missing + // 2. Non-JSON 404 (e.g. Cloudflare proxy / edge HTML 404) -> temporarilyUnverifiable + // 3. Telemetry bundle or ride not found from API -> missing try { final decoded = jsonDecode(response.body); - if (decoded is Map && decoded['error'] == 'Endpoint not found') { - return const R2VerificationResponse.temporarilyUnverifiable( - 'R2 verification endpoint not yet deployed on worker.', - statusCode: 404, - ); + if (decoded is Map) { + final error = decoded['error']?.toString(); + final code = decoded['code']?.toString(); + if (error == 'Endpoint not found') { + return const R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification endpoint not yet deployed on worker.', + statusCode: 404, + ); + } + if (code == 'OBJECT_NOT_FOUND' || + code == 'NOT_FOUND' || + error == 'Telemetry bundle not found in R2' || + error == 'Ride not found') { + return R2VerificationResponse.missing( + error ?? 'Telemetry bundle or ride not found in cloud.', + statusCode: 404, + ); + } } - } catch (_) {} + } catch (_) { + return const R2VerificationResponse.temporarilyUnverifiable( + 'Non-JSON 404 received from R2 gateway; worker endpoint may be undeployed or edge routing failed.', + statusCode: 404, + ); + } return R2VerificationResponse.missing( 'Telemetry bundle or ride not found in cloud ($status).', statusCode: status, diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index c228d74..989dea9 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -72,10 +72,16 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { this._authService, bool autoStart = true, }) : _nowProvider = nowProvider ?? (() => DateTime.now()) { - if (defaultProviderId != null) _activeProviderId = defaultProviderId; + if (defaultProviderId != null) { + _activeProviderId = defaultProviderId; + } else if (initialProviders != null && initialProviders.isNotEmpty) { + _activeProviderId = initialProviders.first.providerId; + } else { + _activeProviderId = 'fake'; + } if (initialProviders != null) { for (final p in initialProviders) { - registerProvider(p); + _providers[p.providerId] = p; } } WidgetsBinding.instance.addObserver(this); @@ -130,11 +136,16 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } void registerProvider(SyncProvider provider) { + final wasRegistered = _providers.containsKey(provider.providerId); _providers[provider.providerId] = provider; if (_activeProviderId == 'none' || _activeProviderId == 'fake') { _activeProviderId = provider.providerId; } notifyListeners(); + if (!wasRegistered && _db != null) { + unawaited(reconcileCompletedRides(targetProviderId: provider.providerId)); + unawaited(processQueue()); + } } SyncProvider? getProvider(String providerId) => _providers[providerId]; @@ -149,6 +160,30 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final user = _authService?.currentUser; final isAuthenticated = user != null && !user.isAnonymous; final rideOwner = await _db.getCloudOwnerUid(localRideId); + if (rideOwner != null && isAuthenticated && rideOwner != user.uid) { + final cancelledItem = SyncItem( + localRideId: localRideId, + providerId: providerId, + status: SyncStatus.cancelled, + createdAt: _now(), + lastError: 'Ride belongs to another account and cannot be uploaded', + ); + await _outbox.update(cancelledItem); + notifyListeners(); + return cancelledItem; + } + if (rideOwner != null && !isAuthenticated) { + final cancelledItem = SyncItem( + localRideId: localRideId, + providerId: providerId, + status: SyncStatus.cancelled, + createdAt: _now(), + lastError: 'Cannot upload another user\'s ride while unauthenticated', + ); + await _outbox.update(cancelledItem); + notifyListeners(); + return cancelledItem; + } if (isAuthenticated && rideOwner == null) { await _db.claimRideForCloudOwner(localRideId, user.uid); } @@ -274,6 +309,22 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { // Check if already tracked in outbox for this provider final existingItem = _outbox.getItem(ride.id, providerId); if (existingItem != null) { + if (!existingItem.status.isTerminal && + ride.cloudOwnerUid != null && + isAuthenticated && + ride.cloudOwnerUid != user.uid && + providerId == 'firebase') { + await _outbox.update( + existingItem.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be uploaded', + clearNextRetry: true, + ), + ); + foreignOwnerSkipped++; + continue; + } alreadyTracked++; continue; } @@ -611,6 +662,12 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { continue; } + // 4. In-flight cancellation protection: verify item was not cancelled while uploading + final currentItem = _outbox.getItem(item.localRideId, item.providerId); + if (currentItem?.status == SyncStatus.cancelled) { + continue; + } + if (result.success) { await _outbox.update( uploadingItem.copyWith( diff --git a/lib/services/sync/sync_provider.dart b/lib/services/sync/sync_provider.dart index 3d84bc9..f36599f 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -222,10 +222,24 @@ class FakeSyncProvider implements SyncProvider { uploadAttemptsPerRide[item.localRideId] = (uploadAttemptsPerRide[item.localRideId] ?? 0) + 1; + final existingRemoteId = item.remoteId; + if (existingRemoteId != null) { + final confirmation = await verifyCloudConfirmation(existingRemoteId); + if (confirmation.isConfirmed) { + return SyncResult.success(remoteId: existingRemoteId); + } + if (confirmation.isInconsistent) { + return SyncResult.permanentFailure( + 'Cloud data inconsistent: ${confirmation.message}', + ); + } + } + switch (behavior) { case FakeSyncBehavior.success: uploadedItems.add(item); final remoteId = + existingRemoteId ?? 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; confirmedRemoteRideIds.add(remoteId); confirmationResults[remoteId] = CloudConfirmationResult.confirmed( diff --git a/test/cloud_confirmation_contract_test.dart b/test/cloud_confirmation_contract_test.dart index 71dcadf..0f69f19 100644 --- a/test/cloud_confirmation_contract_test.dart +++ b/test/cloud_confirmation_contract_test.dart @@ -12,6 +12,7 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart'; import 'package:motorcycle_dash/services/sync/firebase_sync_provider.dart'; import 'package:motorcycle_dash/services/sync/r2_upload_service.dart'; import 'package:motorcycle_dash/services/sync/sync_provider.dart'; +import 'package:motorcycle_dash/services/sync/sync_models.dart'; class _MockAuthService implements AuthService { AuthUser? _currentUser; @@ -562,5 +563,200 @@ void main() { expect(result.status, equals(CloudConfirmationStatus.confirmed)); }, ); + + test( + '14. Edge Routing: Non-JSON HTML 404 from proxy returns temporarilyUnverifiable instead of missing', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + '404 Not Found

404 Not Found

cloudflare', + 404, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + }, + ); + + test( + '15. uploadRide Protection: Aborts with permanentFailure when cloud confirmation is inconsistent (never overwrites object)', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + // Firestore document exists + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set(createFirestoreRideData(rideId: actualRemoteRideId)); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/verify') { + // Return inconsistent hash + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': actualRemoteRideId, + 'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': 'corrupted_hash_mismatch_123', + }), + 200, + ); + } + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + return http.Response('{}', 200); + } + return http.Response('{}', 404); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isFalse); + expect(result.isRetryable, isFalse); + expect(result.errorMessage, contains('inconsistent')); + expect(uploadAttempted, isFalse, reason: 'Must NEVER attempt R2 upload when cloud data is inconsistent'); + }, + ); + + test( + '16. uploadRide Protection: Defers with temporaryFailure when cloud verification is temporarilyUnverifiable (no heavy upload)', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set(createFirestoreRideData(rideId: actualRemoteRideId)); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/verify') { + return http.Response('Rate limit exceeded', 429); + } + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + return http.Response('{}', 200); + } + return http.Response('{}', 404); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isFalse); + expect(result.isRetryable, isTrue); + expect(result.errorMessage, contains('temporarily unavailable')); + expect(uploadAttempted, isFalse, reason: 'Must NOT upload bundle when verification is rate limited'); + }, + ); + + test( + '17. uploadRide Protection: Returns authRequired when cloud verification is unauthorized', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set(createFirestoreRideData(ownerId: 'different_owner_uid', rideId: actualRemoteRideId)); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + } + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isFalse); + expect(result.requiresAuth, isTrue); + expect(uploadAttempted, isFalse); + }, + ); }); } diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart index 4d0395a..5cbce4e 100644 --- a/test/completed_ride_sync_recovery_test.dart +++ b/test/completed_ride_sync_recovery_test.dart @@ -632,5 +632,162 @@ void main() { expect(fakeProvider.uploadedItems.isEmpty, isTrue); }, ); + + test( + '13. In-Flight Cancellation Protection: cancelSync preserves cancelled status even after upload completes', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, 'rider_alice'); + + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.queued, + createdAt: DateTime.now(), + ), + ); + + // Simulate provider with 50ms upload latency + fakeProvider.delayMs = 50; + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Start processing queue + final processFuture = manager.processQueue(); + + // While upload is in flight, user cancels sync + await Future.delayed(const Duration(milliseconds: 10)); + await manager.cancelSync(rideId, 'firebase'); + + await processFuture; + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.cancelled), + reason: 'Item must remain cancelled and not get overwritten with synced'); + }, + ); + + test( + '14. Default Active Provider: Selects defaultProviderId or first initial provider, defaulting to fake for bare test manager', + () async { + final outbox = SyncOutbox(prefs); + final managerExplicit = SyncManager( + outbox, + defaultProviderId: 'firebase', + autoStart: false, + ); + expect(managerExplicit.activeProviderId, equals('firebase')); + + final customProvider = FakeSyncProvider(providerId: 'custom_gateway'); + final managerWithProviders = SyncManager( + outbox, + initialProviders: [customProvider], + autoStart: false, + ); + expect(managerWithProviders.activeProviderId, equals('custom_gateway')); + + final managerNoProviders = SyncManager( + outbox, + autoStart: false, + ); + expect(managerNoProviders.activeProviderId, equals('fake')); + }, + ); + + test( + '15. Immediate Enqueue Ownership Protection: enqueueRide cancels foreign-owned rides immediately', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_999'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 11, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 12, 0, 0)), + ), + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final item = await manager.enqueueRide(rideId, autoProcess: false); + expect(item.status, equals(SyncStatus.cancelled)); + expect(item.lastError, contains('belongs to another account')); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.cancelled)); + }, + ); + + test( + '16. Failed Cloud Verification followed by Successful Retry: Recovers to synced without re-upload', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 13, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Step 1: Initial reconciliation fails with temporary verification error (e.g. HTTP 503) + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'HTTP 503 gateway timeout', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final report1 = await manager.reconcileCompletedRides(); + expect(report1.unverifiablePendingCount, equals(1)); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.retryWait)); + + // Step 2: Backend recovers and verifies the bundle in R2 + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.confirmed(remoteRideId: remoteRideId); + fakeProvider.confirmedRemoteRideIds.add(remoteRideId); + + // Process queue on retry + await manager.retryAllPending(); + + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.isEmpty, isTrue, + reason: 'Must confirm and mark synced without re-uploading the bundle'); + }, + ); }); } From cff473467825f32bf66d3873968b2f2c02b4661e Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 10:34:06 -0400 Subject: [PATCH 4/5] fix(sync): harden R2 cloud confirmation verification, add worker tests, and resolve CI backup gate - Enforce positive bundle size, matching SHA-256, user-scoped object keys, and legacyUnverified status for historical bundles in R2UploadService and FirebaseSyncProvider - Update SyncManager reconciliation to accept legacyUnverified status, mark historical rides as synced without re-upload, and track legacy unverified counts - Add 18-test vitest suite for Cloudflare Worker /r2/verify endpoint covering CORS, auth, validation, ownership, HEAD/GET, legacy bundles, and errors - Harden test/disposable_backup_recovery_verification_test.dart with candidate path discovery and CI skip guard --- .../motorsignal-r2-worker/package-lock.json | 3200 +++++++++++++++++ cloudflare/motorsignal-r2-worker/package.json | 2 + .../src/r2-verify.test.ts | 340 ++ lib/services/sync/firebase_sync_provider.dart | 39 +- lib/services/sync/r2_upload_service.dart | 91 +- lib/services/sync/sync_manager.dart | 21 + lib/services/sync/sync_provider.dart | 28 +- test/cloud_confirmation_contract_test.dart | 221 +- test/completed_ride_sync_recovery_test.dart | 41 + ...ble_backup_recovery_verification_test.dart | 122 +- 10 files changed, 3997 insertions(+), 108 deletions(-) create mode 100644 cloudflare/motorsignal-r2-worker/package-lock.json create mode 100644 cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts diff --git a/cloudflare/motorsignal-r2-worker/package-lock.json b/cloudflare/motorsignal-r2-worker/package-lock.json new file mode 100644 index 0000000..d08c16d --- /dev/null +++ b/cloudflare/motorsignal-r2-worker/package-lock.json @@ -0,0 +1,3200 @@ +{ + "name": "motorsignal-r2-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "motorsignal-r2-worker", + "version": "1.0.0", + "dependencies": { + "jose": "^6.1.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240806.0", + "typescript": "^5.5.4", + "vitest": "^5.0.4", + "wrangler": "^3.70.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/cloudflare/motorsignal-r2-worker/package.json b/cloudflare/motorsignal-r2-worker/package.json index 5e104ae..dc4a810 100644 --- a/cloudflare/motorsignal-r2-worker/package.json +++ b/cloudflare/motorsignal-r2-worker/package.json @@ -4,6 +4,7 @@ "description": "Cloudflare Worker gateway for MotorSignal R2 telemetry bundle storage", "main": "src/index.ts", "scripts": { + "test": "vitest run", "dev": "wrangler dev", "deploy": "wrangler deploy" }, @@ -13,6 +14,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20240806.0", "typescript": "^5.5.4", + "vitest": "^2.1.8", "wrangler": "^3.70.0" } } diff --git a/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts b/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts new file mode 100644 index 0000000..f342a35 --- /dev/null +++ b/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock jose before importing worker +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => vi.fn()), + jwtVerify: vi.fn(async (token: string) => { + if (token === 'valid_token') { + return { + payload: { + sub: 'test_user_123', + exp: Math.floor(Date.now() / 1000) + 3600, + firebase: { sign_in_provider: 'password' }, + }, + }; + } + if (token === 'anon_token') { + return { + payload: { + sub: 'anon_user_456', + exp: Math.floor(Date.now() / 1000) + 3600, + firebase: { sign_in_provider: 'anonymous' }, + }, + }; + } + throw new Error('Invalid token'); + }), +})); + +import worker, { type Env } from './index'; + +describe('Worker /r2/verify endpoint', () => { + let mockEnv: Env; + let mockR2Head: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + mockR2Head = vi.fn(); + mockEnv = { + R2_BUCKET: { + head: mockR2Head, + } as any, + FIREBASE_PROJECT_ID: 'motoapp-test', + ENVIRONMENT: 'test', + GOOGLE_SERVICE_ACCOUNT_EMAIL: 'sa@test.iam.gserviceaccount.com', + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: 'test-key', + GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle', + }; + }); + + function mockValidFirestore(uid = 'test_user_123', rideId = 'ride_abc_123') { + mockFirestore(200, { + fields: { + ownerId: { stringValue: uid }, + id: { stringValue: rideId }, + telemetryBundlePath: { + stringValue: `users/${uid}/rides/${rideId}/telemetry.zip`, + }, + }, + }); + } + + function mockFirestore(status: number, data?: any) { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes('firestore.googleapis.com')) { + return new Response(JSON.stringify(data || {}), { status }); + } + return new Response('Not Found', { status: 404 }); + })); + } + + it('1. returns CORS headers on OPTIONS preflight request', async () => { + const req = new Request('https://worker.local/r2/verify?rideId=ride_1', { + method: 'OPTIONS', + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(200); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + + it('2. returns 404 Endpoint not found for unknown paths', async () => { + const req = new Request('https://worker.local/unknown-path', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(404); + const body = await res.json() as any; + expect(body.error).toBe('Endpoint not found'); + }); + + it('3. rejects /r2/verify with 401 when Authorization header is missing', async () => { + const req = new Request('https://worker.local/r2/verify?rideId=ride_1', { + method: 'GET', + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(401); + const body = await res.json() as any; + expect(body.error).toContain('Missing or malformed Authorization header'); + }); + + it('4. rejects /r2/verify with 401 when Authorization header lacks Bearer prefix', async () => { + const req = new Request('https://worker.local/r2/verify?rideId=ride_1', { + method: 'GET', + headers: { Authorization: 'Basic dXNlcjpwYXNz' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(401); + }); + + it('5. rejects /r2/verify with 401 when Firebase token verification fails', async () => { + const req = new Request('https://worker.local/r2/verify?rideId=ride_1', { + method: 'GET', + headers: { Authorization: 'Bearer invalid_or_expired_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(401); + const body = await res.json() as any; + expect(body.error).toContain('Invalid, expired, or unauthorized'); + }); + + it('6. rejects /r2/verify with 400 when rideId query parameter is missing', async () => { + const req = new Request('https://worker.local/r2/verify', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(400); + const body = await res.json() as any; + expect(body.error).toContain('Invalid rideId parameter'); + }); + + it('7. rejects /r2/verify with 400 when rideId contains invalid characters or path traversal', async () => { + const req = new Request('https://worker.local/r2/verify?rideId=../bad/traversal', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(400); + const body = await res.json() as any; + expect(body.error).toContain('Invalid rideId parameter'); + }); + + it('8. returns 403 UNAUTHORIZED when Firestore returns 403', async () => { + mockFirestore(403); + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(403); + const body = await res.json() as any; + expect(body.code).toBe('UNAUTHORIZED'); + }); + + it('9. returns 404 NOT_FOUND when Firestore ride document does not exist', async () => { + mockFirestore(404); + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(404); + const body = await res.json() as any; + expect(body.code).toBe('NOT_FOUND'); + }); + + it('10. returns 409 INVALID_METADATA when Firestore document has invalid or missing bundle path', async () => { + mockFirestore(200, { + fields: { + ownerId: { stringValue: 'test_user_123' }, + id: { stringValue: 'ride_abc_123' }, + telemetryBundlePath: { stringValue: 'invalid/bundle/path.zip' }, + }, + }); + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(409); + const body = await res.json() as any; + expect(body.code).toBe('INVALID_METADATA'); + }); + + it('11. returns 404 OBJECT_NOT_FOUND when bundle does not exist in R2', async () => { + mockValidFirestore(); + mockR2Head.mockResolvedValue(null); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(404); + const body = await res.json() as any; + expect(body.code).toBe('OBJECT_NOT_FOUND'); + expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_abc_123/telemetry.zip'); + }); + + it('12. returns 200 with verification details on GET when object exists in R2', async () => { + mockValidFirestore(); + mockR2Head.mockResolvedValue({ + size: 4096, + httpEtag: '"etag-12345"', + customMetadata: { + sha256: 'abc123def456', + appVersion: '1.1.0', + uploadedAt: '2026-10-12T10:00:00.000Z', + }, + }); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.verified).toBe(true); + expect(body.remoteRideId).toBe('ride_abc_123'); + expect(body.objectKey).toBe('users/test_user_123/rides/ride_abc_123/telemetry.zip'); + expect(body.size).toBe(4096); + expect(body.etag).toBe('"etag-12345"'); + expect(body.sha256).toBe('abc123def456'); + expect(body.appVersion).toBe('1.1.0'); + expect(body.uploadedAt).toBe('2026-10-12T10:00:00.000Z'); + }); + + it('13. sets verification properties in JSON body on GET and headers on HEAD', async () => { + mockValidFirestore(); + mockR2Head.mockResolvedValue({ + size: 8192, + httpEtag: '"etag-8192"', + customMetadata: { sha256: 'sha_test_789' }, + }); + + const getReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const getRes = await worker.fetch(getReq, mockEnv); + const getBody = await getRes.json() as any; + expect(getBody.etag).toBe('"etag-8192"'); + expect(getBody.remoteRideId).toBe('ride_abc_123'); + expect(getBody.sha256).toBe('sha_test_789'); + expect(getBody.size).toBe(8192); + + const headReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'HEAD', + headers: { Authorization: 'Bearer valid_token' }, + }); + const headRes = await worker.fetch(headReq, mockEnv); + expect(headRes.headers.get('etag')).toBe('"etag-8192"'); + expect(headRes.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123'); + expect(headRes.headers.get('X-MotorSignal-Sha256')).toBe('sha_test_789'); + expect(headRes.headers.get('X-MotorSignal-Size')).toBe('8192'); + }); + + it('14. handles HEAD request on /r2/verify with 200 and headers but null body', async () => { + mockValidFirestore(); + mockR2Head.mockResolvedValue({ + size: 4096, + httpEtag: '"etag-head"', + customMetadata: { sha256: 'sha_head' }, + }); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'HEAD', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(200); + expect(res.body).toBeNull(); + expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123'); + expect(res.headers.get('X-MotorSignal-Size')).toBe('4096'); + }); + + it('15. handles HEAD request on /r2/verify when bundle not found in R2 with 404', async () => { + mockValidFirestore(); + mockR2Head.mockResolvedValue(null); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'HEAD', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(404); + const body = await res.json() as any; + expect(body.code).toBe('OBJECT_NOT_FOUND'); + }); + + it('16. correctly returns undefined sha256 for historical bundles without checksum metadata', async () => { + mockValidFirestore('test_user_123', 'ride_legacy_001'); + mockR2Head.mockResolvedValue({ + size: 1024, + httpEtag: '"etag-legacy"', + customMetadata: undefined, + }); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_legacy_001', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.verified).toBe(true); + expect(body.sha256).toBeUndefined(); + expect(res.headers.has('X-MotorSignal-Sha256')).toBe(false); + }); + + it('17. targets user-scoped R2 object key matching authenticated user', async () => { + mockValidFirestore('test_user_123', 'ride_custom_id'); + mockR2Head.mockResolvedValue({ + size: 512, + httpEtag: '"etag-scoped"', + }); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_custom_id', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + await worker.fetch(req, mockEnv); + expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_custom_id/telemetry.zip'); + }); + + it('18. returns 500 when unexpected internal worker exception occurs', async () => { + mockValidFirestore('test_user_123', 'ride_err_1'); + mockR2Head.mockRejectedValue(new Error('R2 cluster unreachable')); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_err_1', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(500); + const body = await res.json() as any; + expect(body.error).toBe('Internal Worker Exception'); + }); +}); diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 1ceb393..3a7db17 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -51,7 +51,7 @@ class FirebaseSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async { final result = await verifyCloudConfirmation(remoteRideId); - return result.isConfirmed; + return result.isConfirmed || result.isLegacyUnverified; } @override @@ -169,10 +169,33 @@ class FirebaseSyncProvider implements SyncProvider { ); } + final syncVersion = (data['syncVersion'] as num?)?.toInt() ?? 1; final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString(); final expectedSize = (bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?; + // For modern bundles (syncVersion >= 3), require positive size and non-empty sha256 + if (syncVersion >= 3) { + if (expectedSha256 == null || expectedSha256.trim().isEmpty) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: expectedObjectKey, + message: + 'Modern bundle (syncVersion >= 3) missing required sha256 metadata.', + ); + } + if (expectedSize == null || expectedSize <= 0) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: expectedObjectKey, + message: + 'Modern bundle (syncVersion >= 3) has invalid bundleSizeBytes ($expectedSize).', + ); + } + } + // Step 2: R2 bundle verification via authenticated lightweight Worker check var idToken = await _authService.getIdToken(); if (idToken == null || idToken.isEmpty) { @@ -204,6 +227,18 @@ class FirebaseSyncProvider implements SyncProvider { message: 'Firestore metadata and R2 bundle verified.', ); + case CloudConfirmationStatus.legacyUnverified: + return CloudConfirmationResult.legacyUnverified( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey, + sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(), + sha256: r2Result.metadata?.sha256 ?? expectedSha256, + message: + r2Result.errorMessage ?? + 'Historical bundle verified in R2 without full checksum metadata.', + ); + case CloudConfirmationStatus.missing: return CloudConfirmationResult.missing( remoteRideId: remoteRideId, @@ -289,7 +324,7 @@ class FirebaseSyncProvider implements SyncProvider { // derive the remote identity from the local auto-increment database ID. final remoteRideId = await _db.ensureCloudRideId(item.localRideId); final confirmation = await verifyCloudConfirmation(remoteRideId); - if (confirmation.isConfirmed) { + if (confirmation.isConfirmed || confirmation.isLegacyUnverified) { return SyncResult.success(remoteId: remoteRideId); } if (confirmation.isInconsistent) { diff --git a/lib/services/sync/r2_upload_service.dart b/lib/services/sync/r2_upload_service.dart index 83f7f0c..9013673 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -46,6 +46,17 @@ class R2VerificationResponse { metadata: metadata, ); + const R2VerificationResponse.legacyUnverified( + R2VerificationMetadata metadata, { + int statusCode = 200, + String? message, + }) : this( + status: CloudConfirmationStatus.legacyUnverified, + statusCode: statusCode, + metadata: metadata, + errorMessage: message, + ); + const R2VerificationResponse.missing(String message, {int statusCode = 404}) : this( status: CloudConfirmationStatus.missing, @@ -298,26 +309,45 @@ class R2UploadService { } final data = Map.from(decoded); final respRideId = data['remoteRideId']?.toString(); - if (respRideId != null && respRideId != remoteRideId) { + if (respRideId == null || respRideId != remoteRideId) { return R2VerificationResponse.inconsistent( 'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).', statusCode: status, ); } final objectKey = data['objectKey']?.toString(); - if (objectKey != null && - (!objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || - !objectKey.startsWith('users/'))) { + if (objectKey == null || + !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || + !objectKey.startsWith('users/')) { return R2VerificationResponse.inconsistent( 'Worker verification objectKey ($objectKey) does not match expected pattern.', statusCode: status, ); } + final respSize = (data['size'] as num?)?.toInt(); + if (respSize == null || respSize <= 0) { + return R2VerificationResponse.inconsistent( + 'Worker verification returned invalid or non-positive object size ($respSize).', + statusCode: status, + ); + } + if (expectedSize != null && + expectedSize > 0 && + expectedSize != respSize) { + return R2VerificationResponse.inconsistent( + 'Size mismatch: expected $expectedSize bytes but got $respSize bytes.', + statusCode: 422, + ); + } + final respSha256 = data['sha256']?.toString(); - if (expectedSha256 != null && - expectedSha256.trim().isNotEmpty && - respSha256 != null && - respSha256.trim().isNotEmpty) { + if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { + if (respSha256 == null || respSha256.trim().isEmpty) { + return R2VerificationResponse.inconsistent( + 'Missing required SHA-256 in R2 worker response for ride with expected hash $expectedSha256.', + statusCode: status, + ); + } if (expectedSha256.trim().toLowerCase() != respSha256.trim().toLowerCase()) { return R2VerificationResponse.inconsistent( @@ -326,29 +356,30 @@ class R2UploadService { ); } } - final respSize = (data['size'] as num?)?.toInt(); - if (expectedSize != null && - expectedSize > 0 && - respSize != null && - respSize > 0) { - if (expectedSize != respSize) { - return R2VerificationResponse.inconsistent( - 'Size mismatch: expected $expectedSize bytes but got $respSize bytes.', - statusCode: 422, - ); - } - } - return R2VerificationResponse.confirmed( - R2VerificationMetadata( - verified: true, - remoteRideId: remoteRideId, - objectKey: objectKey, - size: respSize, - sha256: respSha256, - etag: data['etag']?.toString(), - ), - statusCode: status, + + final metadata = R2VerificationMetadata( + verified: true, + remoteRideId: remoteRideId, + objectKey: objectKey, + size: respSize, + sha256: respSha256, + etag: data['etag']?.toString(), ); + + // If bundle lacks modern checksum metadata, report as legacyUnverified rather than confirmed. + if (expectedSha256 == null || + expectedSha256.trim().isEmpty || + respSha256 == null || + respSha256.trim().isEmpty) { + return R2VerificationResponse.legacyUnverified( + metadata, + statusCode: status, + message: + 'Historical bundle verified in R2 without full checksum metadata.', + ); + } + + return R2VerificationResponse.confirmed(metadata, statusCode: status); } catch (e) { return R2VerificationResponse.temporarilyUnverifiable( 'Failed to parse worker verification response: $e', diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index 989dea9..b24f452 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -13,6 +13,7 @@ class CompletedRideReconciliationReport { final int recoveredQueuedCount; final int recoveredAuthRequiredCount; final int recoveredAlreadySyncedCount; + final int recoveredLegacyUnverifiedCount; final int foreignOwnerSkippedCount; final int inconsistentSkippedCount; final int unverifiablePendingCount; @@ -25,6 +26,7 @@ class CompletedRideReconciliationReport { this.recoveredQueuedCount = 0, this.recoveredAuthRequiredCount = 0, this.recoveredAlreadySyncedCount = 0, + this.recoveredLegacyUnverifiedCount = 0, this.foreignOwnerSkippedCount = 0, this.inconsistentSkippedCount = 0, this.unverifiablePendingCount = 0, @@ -42,6 +44,7 @@ class CompletedRideReconciliationReport { 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' + 'legacyUnverified: $recoveredLegacyUnverifiedCount, ' 'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, ' 'unverifiable: $unverifiablePendingCount, errors: $errorCount)'; } @@ -292,6 +295,7 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { int recoveredQueued = 0; int recoveredAuthRequired = 0; int recoveredAlreadySynced = 0; + int recoveredLegacyUnverified = 0; int foreignOwnerSkipped = 0; int inconsistentSkipped = 0; int unverifiablePending = 0; @@ -372,6 +376,22 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { recoveredAlreadySynced++; break; + case CloudConfirmationStatus.legacyUnverified: + final legacySyncedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.synced, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + uploadedAt: _now(), + lastError: + 'Legacy ride cloud-confirmed without modern checksum', + ); + await _outbox.update(legacySyncedItem); + recoveredLegacyUnverified++; + recoveredAlreadySynced++; + break; + case CloudConfirmationStatus.missing: final providerRequiresAuth = provider != null && await provider.requiresAuthentication(); @@ -471,6 +491,7 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { recoveredQueuedCount: recoveredQueued, recoveredAuthRequiredCount: recoveredAuthRequired, recoveredAlreadySyncedCount: recoveredAlreadySynced, + recoveredLegacyUnverifiedCount: recoveredLegacyUnverified, foreignOwnerSkippedCount: foreignOwnerSkipped, inconsistentSkippedCount: inconsistentSkipped, unverifiablePendingCount: unverifiablePending, diff --git a/lib/services/sync/sync_provider.dart b/lib/services/sync/sync_provider.dart index f36599f..aafb41f 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -17,6 +17,9 @@ enum CloudConfirmationStatus { /// Network outage, timeout, rate limit, or transient backend failure. temporarilyUnverifiable, + + /// Bundle exists in cloud (e.g. historical ride) but lacks modern cryptographic checksum verification. + legacyUnverified, } /// Explicit verification report for a cloud ride. @@ -98,7 +101,26 @@ class CloudConfirmationResult { message: message, ); + const CloudConfirmationResult.legacyUnverified({ + required String remoteRideId, + String? ownerUid, + String? objectKey, + int? sizeBytes, + String? sha256, + String? message, + }) : this( + status: CloudConfirmationStatus.legacyUnverified, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + objectKey: objectKey, + sizeBytes: sizeBytes, + sha256: sha256, + message: message, + ); + bool get isConfirmed => status == CloudConfirmationStatus.confirmed; + bool get isLegacyUnverified => + status == CloudConfirmationStatus.legacyUnverified; bool get isMissing => status == CloudConfirmationStatus.missing; bool get isInconsistent => status == CloudConfirmationStatus.inconsistent; bool get isUnauthorized => status == CloudConfirmationStatus.unauthorized; @@ -130,7 +152,7 @@ abstract class SyncProvider { /// Backward compatible wrapper around [verifyCloudConfirmation]. Future isRideConfirmedInCloud(String remoteRideId) async { final result = await verifyCloudConfirmation(remoteRideId); - return result.isConfirmed; + return result.isConfirmed || result.isLegacyUnverified; } /// Deeply verifies whether [remoteRideId] is confirmed and recoverable in the cloud. @@ -181,7 +203,7 @@ class FakeSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async { final result = await verifyCloudConfirmation(remoteRideId); - return result.isConfirmed; + return result.isConfirmed || result.isLegacyUnverified; } @override @@ -225,7 +247,7 @@ class FakeSyncProvider implements SyncProvider { final existingRemoteId = item.remoteId; if (existingRemoteId != null) { final confirmation = await verifyCloudConfirmation(existingRemoteId); - if (confirmation.isConfirmed) { + if (confirmation.isConfirmed || confirmation.isLegacyUnverified) { return SyncResult.success(remoteId: existingRemoteId); } if (confirmation.isInconsistent) { diff --git a/test/cloud_confirmation_contract_test.dart b/test/cloud_confirmation_contract_test.dart index 0f69f19..dce373c 100644 --- a/test/cloud_confirmation_contract_test.dart +++ b/test/cloud_confirmation_contract_test.dart @@ -560,7 +560,8 @@ void main() { ); final result = await provider.verifyCloudConfirmation(remoteRideId); - expect(result.status, equals(CloudConfirmationStatus.confirmed)); + expect(result.status, equals(CloudConfirmationStatus.legacyUnverified)); + expect(result.isLegacyUnverified, isTrue); }, ); @@ -758,5 +759,223 @@ void main() { expect(uploadAttempted, isFalse); }, ); + + test( + '18. Modern bundle (syncVersion >= 3) missing required sha256 returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set({ + 'id': remoteRideId, + 'ownerId': testUid, + 'syncVersion': 3, + 'bundleSizeBytes': 2048, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'status': 'uploaded', + 'bundleSizeBytes': 2048, + }, + }); + + final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200)); + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.isInconsistent, isTrue); + expect(result.message, contains('missing required sha256')); + }, + ); + + test( + '19. Modern bundle (syncVersion >= 3) with non-positive bundleSizeBytes returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set({ + 'id': remoteRideId, + 'ownerId': testUid, + 'syncVersion': 3, + 'bundleSizeBytes': 0, + 'sha256': validSha256, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'status': 'uploaded', + 'bundleSizeBytes': 0, + 'sha256': validSha256, + }, + }); + + final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200)); + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.isInconsistent, isTrue); + expect(result.message, contains('invalid bundleSizeBytes')); + }, + ); + + test( + '20. R2 Worker response with invalid objectKey pattern returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'wrong_path/telemetry.zip', + 'size': validSize, + 'sha256': validSha256, + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.message, contains('objectKey')); + }, + ); + + test( + '21. R2 Worker response with non-positive size returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': 0, + 'sha256': validSha256, + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.message, contains('size')); + }, + ); + + test( + '22. uploadRide Protection: Legacy unverified ride confirms without re-uploading bundle', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + // Legacy Firestore record without sha256 + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set({ + 'id': actualRemoteRideId, + 'ownerId': testUid, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'status': 'uploaded', + }, + }); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/verify') { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': actualRemoteRideId, + 'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'size': 1024, + 'sha256': 'legacy_hash', + }), + 200, + ); + } + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + } + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isTrue); + expect(result.remoteId, equals(actualRemoteRideId)); + expect(uploadAttempted, isFalse, reason: 'Must not re-upload existing historical bundle'); + }, + ); }); } diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart index 5cbce4e..7afaa72 100644 --- a/test/completed_ride_sync_recovery_test.dart +++ b/test/completed_ride_sync_recovery_test.dart @@ -789,5 +789,46 @@ void main() { reason: 'Must confirm and mark synced without re-uploading the bundle'); }, ); + + test( + '17. Legacy Unverified Cloud Confirmation: Recovers as synced without re-upload and tracks count', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.legacyUnverified( + remoteRideId: remoteRideId, + ownerUid: 'rider_alice', + message: 'Historical bundle without checksum', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final report = await manager.reconcileCompletedRides(); + + expect(report.recoveredLegacyUnverifiedCount, equals(1)); + expect(report.recoveredAlreadySyncedCount, equals(1)); + expect(report.totalRecovered, equals(1)); + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.synced)); + expect(item?.lastError, contains('without modern checksum')); + expect(fakeProvider.uploadedItems.isEmpty, isTrue, + reason: 'Must never re-upload existing legacy unverified bundle'); + }, + ); }); } diff --git a/test/disposable_backup_recovery_verification_test.dart b/test/disposable_backup_recovery_verification_test.dart index b550b13..f52089b 100644 --- a/test/disposable_backup_recovery_verification_test.dart +++ b/test/disposable_backup_recovery_verification_test.dart @@ -15,12 +15,12 @@ class _FakeAuthService implements AuthService { final AuthUser _user; _FakeAuthService([String uid = 'pilot_pixel_8a']) - : _user = AuthUser( - uid: uid, - email: '$uid@example.com', - displayName: 'Pixel Pilot', - isAnonymous: false, - ); + : _user = AuthUser( + uid: uid, + email: '$uid@example.com', + displayName: 'Pixel Pilot', + isAnonymous: false, + ); @override AuthUser? get currentUser => _user; @@ -34,11 +34,7 @@ class _FakeAuthService implements AuthService { bool get isEmailVerified => true; @override - Future createUserWithEmailAndPassword( - String email, - String password, { - String? displayName, - }) async {} + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} @override Future deleteCurrentUser() async {} @override @@ -58,40 +54,45 @@ class _FakeAuthService implements AuthService { @override Future signInAnonymously() async {} @override - Future signInWithEmailAndPassword( - String email, - String password, - ) async {} + Future signInWithEmailAndPassword(String email, String password) async {} @override Future signInWithGoogle() async {} @override Future signOut() async {} } +File? _findBackupFile() { + final candidates = [ + r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', + 'output/master-consolidation-20261012/backup-071303/motorsignal.sqlite', + '../backup-071303/motorsignal.sqlite', + '../../output/master-consolidation-20261012/backup-071303/motorsignal.sqlite', + ]; + for (final path in candidates) { + final file = File(path); + if (file.existsSync()) { + return file; + } + } + return null; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', () async { - final originalFile = File( - r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', - ); + final originalFile = _findBackupFile(); - expect( - originalFile.existsSync(), - isTrue, - reason: 'Original backup file must exist', - ); + test( + 'Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', + () async { + expect(originalFile, isNotNull, reason: 'Original backup file must exist when test is executed'); + final file = originalFile!; // 1. Verify original SHA-256 before copy - final originalBytes = await originalFile.readAsBytes(); + final originalBytes = await file.readAsBytes(); final originalHash = sha256.convert(originalBytes).toString(); - const expectedHash = - '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; - expect( - originalHash, - equals(expectedHash), - reason: 'Original backup must match authoritative SHA-256', - ); + const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; + expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256'); // 2. Create disposable copy in scratch directory final scratchDir = Directory( @@ -100,13 +101,11 @@ void main() { if (!scratchDir.existsSync()) { scratchDir.createSync(recursive: true); } - final disposableFile = File( - '${scratchDir.path}/disposable_motorsignal.sqlite', - ); + final disposableFile = File('${scratchDir.path}/disposable_motorsignal.sqlite'); if (disposableFile.existsSync()) { disposableFile.deleteSync(); } - await originalFile.copy(disposableFile.path); + await file.copy(disposableFile.path); expect(disposableFile.existsSync(), isTrue); // 3. Open disposable copy via Drift / SQLite @@ -114,39 +113,21 @@ void main() { try { // Integrity check - final integrityRows = await db - .customSelect('PRAGMA integrity_check') - .get(); + final integrityRows = await db.customSelect('PRAGMA integrity_check').get(); final integrityResult = integrityRows.first.data['integrity_check']; - expect( - integrityResult, - equals('ok'), - reason: 'SQLite integrity must be OK', - ); + expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK'); // Count rides and samples - final rideCountRows = await db - .customSelect('SELECT COUNT(*) as count FROM rides') - .get(); + final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get(); final totalRides = rideCountRows.first.data['count'] as int; expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides'); - final completedRidesRows = await db - .customSelect( - 'SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL', - ) - .get(); + final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get(); final completedRides = completedRidesRows.first.data['count'] as int; - final samplesRows = await db - .customSelect('SELECT COUNT(*) as count FROM telemetry_samples') - .get(); + final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get(); final totalSamples = samplesRows.first.data['count'] as int; - expect( - totalSamples, - equals(67798), - reason: 'Must contain exactly 67,798 telemetry samples', - ); + expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples'); // 4. Test Foreign Account Isolation against real Pixel backup // In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2' @@ -169,11 +150,7 @@ void main() { final foreignReport = await foreignSyncManager.reconcileCompletedRides(); expect(foreignReport.errorCount, equals(0)); expect(foreignReport.discoveredCompletedRides, equals(completedRides)); - expect( - foreignReport.foreignOwnerSkippedCount, - equals(completedRides), - reason: 'Foreign account must NEVER claim or touch real Pixel rides', - ); + expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides'); expect(foreignReport.totalRecovered, equals(0)); expect(outbox.count, equals(0)); @@ -200,17 +177,18 @@ void main() { expect(report2.totalRecovered, equals(0)); expect(report2.alreadyTrackedCount, equals(completedRides)); expect(outbox.count, equals(completedRides)); + } finally { await db.close(); } // 6. Verify original SHA-256 after operations to prove zero mutation - final postBytes = await originalFile.readAsBytes(); + final postBytes = await file.readAsBytes(); final postHash = sha256.convert(postBytes).toString(); - expect( - postHash, - equals(expectedHash), - reason: 'Original backup MUST remain 100% UNTOUCHED', - ); - }); + expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED'); + }, + skip: originalFile == null + ? 'Real Pixel backup not present in this environment (e.g. CI runner)' + : null, + ); } From de6d575cee9bd3c00c5879595495bd9a8ee51a4f Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 11:27:46 -0400 Subject: [PATCH 5/5] fix(sync): harden worker metadata headers, token refresh on confirmation, and reconciliation ownership isolation - Set X-MotorSignal-* headers on Worker GET /r2/verify responses and return 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429/5xx --- cloudflare/motorsignal-r2-worker/src/index.ts | 10 +- .../src/r2-verify.test.ts | 34 ++++ lib/services/sync/firebase_sync_provider.dart | 19 ++- lib/services/sync/r2_upload_service.dart | 10 ++ lib/services/sync/sync_manager.dart | 47 +++--- test/cloud_confirmation_contract_test.dart | 149 ++++++++++++++++-- test/completed_ride_sync_recovery_test.dart | 80 ++++++++-- 7 files changed, 304 insertions(+), 45 deletions(-) diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 1523f28..33fc3ac 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -156,6 +156,7 @@ export default { if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); + if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error' }, 503); if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403); if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404); if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409); @@ -181,6 +182,7 @@ export default { if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); + if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error', code: 'TEMPORARY_ERROR' }, 503); if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403); if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404); if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409); @@ -204,7 +206,8 @@ export default { return new Response(null, { status: 200, headers }); } - return jsonResponse({ + headers.set('Content-Type', 'application/json'); + return new Response(JSON.stringify({ verified: true, remoteRideId, objectKey, @@ -213,7 +216,7 @@ export default { sha256, appVersion, uploadedAt, - }, 200); + }), { status: 200, headers }); } return jsonResponse({ error: 'Endpoint not found' }, 404); @@ -241,7 +244,7 @@ async function verifyFirebaseToken(token: string, expectedProjectId: string): Pr } interface OwnedRide { objectKey?: string; } -type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid'; +type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid' | 'temporary-error'; /** Reads the authenticated user's ride document through Firestore REST. */ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise { @@ -251,6 +254,7 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri if (response.status === 401 || response.status === 403) return 'unauthorized'; if (response.status === 404) return 'not-found'; + if (response.status === 429 || response.status >= 500) return 'temporary-error'; if (!response.ok) { console.error('Firestore ownership lookup failed', response.status); return 'unauthorized'; diff --git a/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts b/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts index f342a35..d14a9ec 100644 --- a/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts +++ b/cloudflare/motorsignal-r2-worker/src/r2-verify.test.ts @@ -337,4 +337,38 @@ describe('Worker /r2/verify endpoint', () => { const body = await res.json() as any; expect(body.error).toBe('Internal Worker Exception'); }); + + it('19. returns 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429 or 503', async () => { + mockFirestore(503, { error: 'Service Unavailable' }); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(503); + const body = await res.json() as any; + expect(body.code).toBe('TEMPORARY_ERROR'); + expect(body.error).toContain('Firestore temporary lookup error'); + }); + + it('20. sets X-MotorSignal-* headers on GET as well as HEAD', async () => { + mockValidFirestore(); + mockR2Head.mockResolvedValue({ + size: 2048, + httpEtag: '"etag-2048"', + customMetadata: { sha256: 'sha256_get_test' }, + }); + + const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', { + method: 'GET', + headers: { Authorization: 'Bearer valid_token' }, + }); + const res = await worker.fetch(req, mockEnv); + expect(res.status).toBe(200); + expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123'); + expect(res.headers.get('X-MotorSignal-Sha256')).toBe('sha256_get_test'); + expect(res.headers.get('X-MotorSignal-Size')).toBe('2048'); + expect(res.headers.get('etag')).toBe('"etag-2048"'); + }); }); diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 3a7db17..bacfbfa 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -209,13 +209,30 @@ class FirebaseSyncProvider implements SyncProvider { ); } - final r2Result = await _r2UploadService.verifyTelemetryBundle( + var r2Result = await _r2UploadService.verifyTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, expectedSha256: expectedSha256, expectedSize: expectedSize?.toInt(), ); + // If verification is rejected as unauthorized (e.g. expired cached token), + // force refresh the ID token once and retry before concluding unauthorized. + if (r2Result.status == CloudConfirmationStatus.unauthorized) { + final refreshedToken = await _authService.getIdToken(true); + if (refreshedToken != null && + refreshedToken.isNotEmpty && + refreshedToken != idToken) { + idToken = refreshedToken; + r2Result = await _r2UploadService.verifyTelemetryBundle( + idToken: idToken, + remoteRideId: remoteRideId, + expectedSha256: expectedSha256, + expectedSize: expectedSize?.toInt(), + ); + } + } + switch (r2Result.status) { case CloudConfirmationStatus.confirmed: return CloudConfirmationResult.confirmed( diff --git a/lib/services/sync/r2_upload_service.dart b/lib/services/sync/r2_upload_service.dart index 9013673..f32dd42 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -267,6 +267,16 @@ class R2UploadService { /// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2. /// Queries the worker via /r2/verify to verify existence, metadata, and hash match /// without downloading the full ZIP archive. + /// + /// CRITICAL ARCHITECTURAL DISTINCTION: + /// R2 HEAD / metadata checks prove that an object exists in Cloudflare R2 and that its stored + /// metadata (SHA-256 hash, byte size, object key, remote ride identity) matches the expected + /// Firestore and local contract. + /// They do NOT independently prove that the stored ZIP archive's binary payload is readable, + /// decompresses cleanly, or is uncorrupted at rest. Full bundle downloads on startup are + /// intentionally avoided to protect mobile bandwidth and startup latency. For future stronger + /// verification, background sampling or periodic full restore-integrity checks should be + /// scheduled rather than downloading every bundle on every launch or reconciliation pass. Future verifyTelemetryBundle({ required String idToken, required String remoteRideId, diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index b24f452..a3e1021 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -345,13 +345,6 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { continue; } - // Claim unclaimed ride if user is authenticated and target is firebase - if (ride.cloudOwnerUid == null && - isAuthenticated && - providerId == 'firebase') { - await _db.claimRideForCloudOwner(ride.id, user.uid); - } - // Stable cloud identity check final remoteRideId = await _db.ensureCloudRideId(ride.id); @@ -364,6 +357,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { switch (confirmation.status) { case CloudConfirmationStatus.confirmed: + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } final syncedItem = SyncItem( localRideId: ride.id, providerId: providerId, @@ -377,6 +375,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { break; case CloudConfirmationStatus.legacyUnverified: + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } final legacySyncedItem = SyncItem( localRideId: ride.id, providerId: providerId, @@ -409,6 +412,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { await _outbox.update(authReqItem); recoveredAuthRequired++; } else { + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } final queuedItem = SyncItem( localRideId: ride.id, providerId: providerId, @@ -544,11 +552,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } Future _performProcessQueue() async { - do { - _needsAnotherPass = false; - _isProcessing = true; - notifyListeners(); - try { + _isProcessing = true; + notifyListeners(); + try { + do { + _needsAnotherPass = false; final now = _now(); for (final item in _outbox.getEligibleItems(now)) { final provider = _providers[item.providerId]; @@ -684,7 +692,10 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } // 4. In-flight cancellation protection: verify item was not cancelled while uploading - final currentItem = _outbox.getItem(item.localRideId, item.providerId); + final currentItem = _outbox.getItem( + item.localRideId, + item.providerId, + ); if (currentItem?.status == SyncStatus.cancelled) { continue; } @@ -727,11 +738,11 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { ); } } - } finally { - _isProcessing = false; - notifyListeners(); - } - } while (_needsAnotherPass); + } while (_needsAnotherPass); + } finally { + _isProcessing = false; + notifyListeners(); + } } Future cancelSync(int localRideId, String providerId) async { diff --git a/test/cloud_confirmation_contract_test.dart b/test/cloud_confirmation_contract_test.dart index dce373c..67205ca 100644 --- a/test/cloud_confirmation_contract_test.dart +++ b/test/cloud_confirmation_contract_test.dart @@ -18,6 +18,8 @@ class _MockAuthService implements AuthService { AuthUser? _currentUser; AuthState _authState = const AuthState.unauthenticated(); String? idTokenToReturn = 'valid_mock_jwt_token'; + String? refreshedIdToken; + int getIdTokenCalls = 0; void setAuthenticated(String uid, String email) { _currentUser = AuthUser(uid: uid, email: email, isAnonymous: false); @@ -45,8 +47,14 @@ class _MockAuthService implements AuthService { @override bool get isEmailVerified => true; @override - Future getIdToken([bool forceRefresh = false]) async => - idTokenToReturn; + Future getIdToken([bool forceRefresh = false]) async { + getIdTokenCalls++; + if (forceRefresh && refreshedIdToken != null) { + return refreshedIdToken; + } + return idTokenToReturn; + } + @override Future createUserWithEmailAndPassword( String email, @@ -624,7 +632,8 @@ void main() { jsonEncode({ 'verified': true, 'remoteRideId': actualRemoteRideId, - 'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'objectKey': + 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', 'size': validSize, 'sha256': 'corrupted_hash_mismatch_123', }), @@ -657,7 +666,12 @@ void main() { expect(result.success, isFalse); expect(result.isRetryable, isFalse); expect(result.errorMessage, contains('inconsistent')); - expect(uploadAttempted, isFalse, reason: 'Must NEVER attempt R2 upload when cloud data is inconsistent'); + expect( + uploadAttempted, + isFalse, + reason: + 'Must NEVER attempt R2 upload when cloud data is inconsistent', + ); }, ); @@ -709,7 +723,11 @@ void main() { expect(result.success, isFalse); expect(result.isRetryable, isTrue); expect(result.errorMessage, contains('temporarily unavailable')); - expect(uploadAttempted, isFalse, reason: 'Must NOT upload bundle when verification is rate limited'); + expect( + uploadAttempted, + isFalse, + reason: 'Must NOT upload bundle when verification is rate limited', + ); }, ); @@ -728,7 +746,12 @@ void main() { .doc(testUid) .collection('rides') .doc(actualRemoteRideId) - .set(createFirestoreRideData(ownerId: 'different_owner_uid', rideId: actualRemoteRideId)); + .set( + createFirestoreRideData( + ownerId: 'different_owner_uid', + rideId: actualRemoteRideId, + ), + ); var uploadAttempted = false; final mockClient = http_testing.MockClient((request) async { @@ -781,7 +804,9 @@ void main() { }, }); - final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200)); + final mockClient = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); final r2Service = R2UploadService(client: mockClient); final provider = FirebaseSyncProvider( db: db, @@ -820,7 +845,9 @@ void main() { }, }); - final mockClient = http_testing.MockClient((request) async => http.Response('{}', 200)); + final mockClient = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); final r2Service = R2UploadService(client: mockClient); final provider = FirebaseSyncProvider( db: db, @@ -931,7 +958,8 @@ void main() { 'ownerId': testUid, 'telemetryBundle': { 'provider': 'cloudflare-r2', - 'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'objectKey': + 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', 'status': 'uploaded', }, }); @@ -943,7 +971,8 @@ void main() { jsonEncode({ 'verified': true, 'remoteRideId': actualRemoteRideId, - 'objectKey': 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'objectKey': + 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', 'size': 1024, 'sha256': 'legacy_hash', }), @@ -974,7 +1003,105 @@ void main() { expect(result.success, isTrue); expect(result.remoteId, equals(actualRemoteRideId)); - expect(uploadAttempted, isFalse, reason: 'Must not re-upload existing historical bundle'); + expect( + uploadAttempted, + isFalse, + reason: 'Must not re-upload existing historical bundle', + ); + }, + ); + + test( + '23. Expired Firebase token triggers forceRefresh and confirms on retry', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + authService.idTokenToReturn = 'expired_old_token'; + authService.refreshedIdToken = 'fresh_valid_jwt_token'; + + var callCount = 0; + final mockClient = http_testing.MockClient((request) async { + callCount++; + final authHeader = request.headers['Authorization']; + if (authHeader == 'Bearer expired_old_token') { + return http.Response( + jsonEncode({'error': 'Token expired', 'code': 'UNAUTHORIZED'}), + 401, + ); + } + if (authHeader == 'Bearer fresh_valid_jwt_token') { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': validSha256, + }), + 200, + ); + } + return http.Response('{}', 403); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.confirmed)); + expect(result.isConfirmed, isTrue); + expect( + callCount, + equals(2), + reason: 'Should have retried once with refreshed token', + ); + }, + ); + + test( + '24. Worker returns 503 TEMPORARY_ERROR when Firestore temporarily fails (maps to temporarilyUnverifiable)', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'error': 'Firestore temporary lookup error', + 'code': 'TEMPORARY_ERROR', + }), + 503, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + expect(result.isConfirmed, isFalse); }, ); }); diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart index 7afaa72..6c256f3 100644 --- a/test/completed_ride_sync_recovery_test.dart +++ b/test/completed_ride_sync_recovery_test.dart @@ -676,8 +676,12 @@ void main() { await processFuture; final item = outbox.getItem(rideId, 'firebase'); - expect(item?.status, equals(SyncStatus.cancelled), - reason: 'Item must remain cancelled and not get overwritten with synced'); + expect( + item?.status, + equals(SyncStatus.cancelled), + reason: + 'Item must remain cancelled and not get overwritten with synced', + ); }, ); @@ -700,10 +704,7 @@ void main() { ); expect(managerWithProviders.activeProviderId, equals('custom_gateway')); - final managerNoProviders = SyncManager( - outbox, - autoStart: false, - ); + final managerNoProviders = SyncManager(outbox, autoStart: false); expect(managerNoProviders.activeProviderId, equals('fake')); }, ); @@ -737,7 +738,10 @@ void main() { final item = await manager.enqueueRide(rideId, autoProcess: false); expect(item.status, equals(SyncStatus.cancelled)); expect(item.lastError, contains('belongs to another account')); - expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.cancelled)); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.cancelled), + ); }, ); @@ -771,7 +775,10 @@ void main() { final report1 = await manager.reconcileCompletedRides(); expect(report1.unverifiablePendingCount, equals(1)); - expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.retryWait)); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.retryWait), + ); // Step 2: Backend recovers and verifies the bundle in R2 fakeProvider.confirmationResults[remoteRideId] = @@ -785,8 +792,12 @@ void main() { outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced), ); - expect(fakeProvider.uploadedItems.isEmpty, isTrue, - reason: 'Must confirm and mark synced without re-uploading the bundle'); + expect( + fakeProvider.uploadedItems.isEmpty, + isTrue, + reason: + 'Must confirm and mark synced without re-uploading the bundle', + ); }, ); @@ -826,8 +837,53 @@ void main() { final item = outbox.getItem(rideId, 'firebase'); expect(item?.status, equals(SyncStatus.synced)); expect(item?.lastError, contains('without modern checksum')); - expect(fakeProvider.uploadedItems.isEmpty, isTrue, - reason: 'Must never re-upload existing legacy unverified bundle'); + expect( + fakeProvider.uploadedItems.isEmpty, + isTrue, + reason: 'Must never re-upload existing legacy unverified bundle', + ); + }, + ); + + test( + '18. Reconciliation Never Claims Unauthorized Rides: cloudOwnerUid remains null when verification returns unauthorized', + () async { + authService.setAuthenticatedUser('rider_eve', 'eve@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 15, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Verification returns unauthorized (foreign-owned or permission denied) + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + message: 'Foreign owner in cloud metadata', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final report = await manager.reconcileCompletedRides(); + + // Must NOT claim ownership for rider_eve + final localOwner = await db.getCloudOwnerUid(rideId); + expect( + localOwner, + isNull, + reason: 'Must NEVER claim an unauthorized ride', + ); + expect(report.foreignOwnerSkippedCount, equals(1)); + expect(report.errors.isNotEmpty, isTrue); }, ); });