import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; /** * MotorSignal — Cloudflare R2 Worker Gateway * Secure authorization gateway for MotorSignal telemetry bundle storage. * Firebase ID tokens are cryptographically verified before any R2 access. */ export interface Env { R2_BUCKET: R2Bucket; FIREBASE_PROJECT_ID: string; ENVIRONMENT: string; } interface FirebaseTokenPayload extends JWTPayload { user_id?: string; email?: string; firebase?: { sign_in_provider?: string }; } 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', }; const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com'); const jwks = createRemoteJWKSet(FIREBASE_JWKS); export default { async fetch(request: Request, env: Env): Promise { if (request.method === 'OPTIONS') return new Response(null, { headers: CORS_HEADERS }); const url = new URL(request.url); const path = url.pathname; const projectId = env.FIREBASE_PROJECT_ID || 'motoapp-d22e0'; try { if (path === '/' || path === '/health') { return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' }); } const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401); const idToken = authHeader.substring(7).trim(); const authUser = await verifyFirebaseToken(idToken, projectId); if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401); const authenticatedUid = authUser.sub; if (request.method === 'DELETE' && path === '/account/cloud-data') { if (authUser.firebase?.sign_in_provider === 'anonymous') { return jsonResponse({ error: 'Permanent account required' }, 403); } const prefix = `users/${authenticatedUid}/`; let cursor: string | undefined; let deleted = 0; do { const listed = await env.R2_BUCKET.list({ prefix, cursor }); if (listed.objects.length > 0) { await env.R2_BUCKET.delete(listed.objects.map((object) => object.key)); deleted += listed.objects.length; } cursor = listed.truncated ? listed.cursor : undefined; } while (cursor); return jsonResponse({ success: true, deletedObjects: deleted }); } if (request.method === 'PUT' && path === '/r2/upload') { const remoteRideId = request.headers.get('X-Remote-Ride-Id'); if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400); const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; const clientSha256 = request.headers.get('X-Bundle-Sha256'); const appVersion = request.headers.get('X-App-Version') || 'unknown'; if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400); const payloadBuffer = await request.arrayBuffer(); if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400); const computedSha256 = bufferToHex(await crypto.subtle.digest('SHA-256', payloadBuffer)); if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) { return jsonResponse({ error: 'Payload integrity check failed: SHA-256 mismatch', expected: clientSha256, computed: computedSha256 }, 422); } const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, { httpMetadata: { contentType: 'application/zip' }, customMetadata: { uid: authenticatedUid, remoteRideId, sha256: computedSha256, appVersion, uploadedAt: new Date().toISOString() }, }); return jsonResponse({ success: true, objectKey, size: r2Object.size, etag: r2Object.etag, sha256: computedSha256, uploadedAt: new Date().toISOString() }); } if (request.method === 'POST' && path === '/r2/upload-url') { const body = (await request.json().catch(() => ({}))) as Record; const remoteRideId = body.remoteRideId as string | undefined; if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400); const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; return jsonResponse({ uploadEndpoint: `${url.origin}/r2/upload`, objectKey, method: 'PUT', headers: { 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId } }); } if (request.method === 'GET' && 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.get(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); for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); return new Response(object.body, { status: 200, headers }); } return jsonResponse({ error: 'Endpoint not found' }, 404); } catch (err: unknown) { console.error('MotorSignal Worker exception', err); return jsonResponse({ error: 'Internal Worker Exception' }, 500); } }, }; async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise { try { const { payload } = await jwtVerify(token, jwks, { issuer: `https://securetoken.google.com/${expectedProjectId}`, audience: expectedProjectId, algorithms: ['RS256'], clockTolerance: 60, }); if (typeof payload.sub !== 'string' || payload.sub.trim().length === 0) return null; if (typeof payload.exp !== 'number') return null; return payload as FirebaseTokenPayload; } catch { return null; } } interface OwnedRide { objectKey?: string; } type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid'; /** Reads the authenticated user's ride document through Firestore REST. */ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise { const documentPath = encodeFirestorePath(`users/${uid}/rides/${remoteRideId}`); const endpoint = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/databases/(default)/documents/${documentPath}`; const response = await fetch(endpoint, { method: 'GET', headers: { Authorization: `Bearer ${idToken}`, Accept: 'application/json' } }); if (response.status === 401 || response.status === 403) return 'unauthorized'; if (response.status === 404) return 'not-found'; if (!response.ok) { console.error('Firestore ownership lookup failed', response.status); return 'unauthorized'; } const document = (await response.json()) as FirestoreDocument; const fields = document.fields ?? {}; const ownerId = firestoreString(fields.ownerId); const storedId = firestoreString(fields.id); const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey); if (ownerId !== uid) return 'unauthorized'; if (storedId && storedId !== remoteRideId) return 'invalid'; if (!objectKey || objectKey !== `users/${uid}/rides/${remoteRideId}/telemetry.zip`) return 'invalid'; return { objectKey }; } interface FirestoreValue { stringValue?: string; mapValue?: { fields?: Record }; } interface FirestoreDocument { fields?: Record; } function firestoreString(value: FirestoreValue | undefined): string | undefined { return typeof value?.stringValue === 'string' ? value.stringValue : undefined; } function encodeFirestorePath(path: string): string { return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }); } function bufferToHex(buffer: ArrayBuffer): string { return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, '0')).join(''); } function isValidRideId(rideId: string): boolean { return /^[a-zA-Z0-9_-]{3,100}$/.test(rideId) && !rideId.includes('..') && !rideId.includes('/') && !rideId.includes('\\'); }