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( + '