rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function ownsUser(userId) { return signedIn() && request.auth.uid == userId; } function validUserCreate(userId) { return ownsUser(userId) && request.resource.data.id == userId && request.resource.data.keys().hasOnly([ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', 'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences' ]); } function validUserUpdate(userId) { return ownsUser(userId) && request.resource.data.id == resource.data.id && request.resource.data.createdAt == resource.data.createdAt && request.resource.data.keys().hasOnly([ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', 'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences' ]); } function validPublicProfileCreate(userId) { return ownsUser(userId) && request.resource.data.id == userId && request.resource.data.keys().hasOnly([ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', 'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests', 'updatedAt' ]); } function validPublicProfileUpdate(userId) { return ownsUser(userId) && request.resource.data.id == resource.data.id && request.resource.data.keys().hasOnly([ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', 'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests', 'updatedAt' ]); } function validRideCreate(userId, rideId) { return ownsUser(userId) && request.resource.data.ownerId == userId && request.resource.data.id == rideId; } function validRideUpdate(userId) { return ownsUser(userId) && resource.data.ownerId == userId && request.resource.data.ownerId == resource.data.ownerId && request.resource.data.id == resource.data.id; } function isBlockedBy(sourceUid, targetUid) { return exists(/databases/$(database)/documents/users/$(targetUid)/blocks/$(sourceUid)); } function areMutuallyBlocked(userA, userB) { return isBlockedBy(userA, userB) || isBlockedBy(userB, userA); } // --- HANDLE RESERVATIONS --- // Internal uniqueness index. Read access is guarded to prevent // turning handle lookup into an open user enumeration directory. match /handles/{handle} { allow list: if false; allow get: if signedIn() && ( resource == null || resource.data.uid == request.auth.uid || ( !areMutuallyBlocked(request.auth.uid, resource.data.uid) && get(/databases/$(database)/documents/public_profiles/$(resource.data.uid)).data.isSearchableByHandle == true ) ); allow create: if signedIn() && request.resource.data.uid == request.auth.uid && request.resource.data.handle == handle; allow delete: if signedIn() && resource.data.uid == request.auth.uid; allow update: if false; } // --- PUBLIC PROFILES --- // Contains ONLY safe public fields. No email, tokens, rides, or billing. match /public_profiles/{userId} { allow list: if false; allow get: if signedIn() && ( ownsUser(userId) || ( !areMutuallyBlocked(request.auth.uid, userId) && ( resource.data.isSearchableByHandle == true || exists(/databases/$(database)/documents/users/$(userId)/friends/$(request.auth.uid)) ) ) ); allow create: if validPublicProfileCreate(userId); allow update: if validPublicProfileUpdate(userId); allow delete: if ownsUser(userId); } // --- PRIVATE USER ACCOUNTS --- // Strictly private to the account owner. match /users/{userId} { allow create: if validUserCreate(userId); allow read: if ownsUser(userId); allow update: if validUserUpdate(userId); allow delete: if ownsUser(userId); match /rides/{rideId} { allow create: if validRideCreate(userId, rideId); allow update: if validRideUpdate(userId); allow list: if ownsUser(userId); allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId; match /events/{eventId} { allow read, write: if ownsUser(userId); } match /performance/{runId} { allow read, write: if ownsUser(userId); } } match /motorcycles/{motorcycleId} { allow create, update: if ownsUser(userId) && request.resource.data.ownerId == userId; allow read, delete: if ownsUser(userId); } match /trips/{tripId} { allow read, write: if ownsUser(userId); } match /events/{eventId} { allow read, write: if ownsUser(userId); } // --- SOCIAL SUBCOLLECTIONS --- match /friends/{friendId} { allow read: if ownsUser(userId); allow create: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId) && userId != friendId && !areMutuallyBlocked(userId, friendId) && ( (request.auth.uid == userId && exists(/databases/$(database)/documents/users/$(userId)/friend_requests_incoming/$(friendId))) || (request.auth.uid == friendId && exists(/databases/$(database)/documents/users/$(friendId)/friend_requests_incoming/$(userId))) ); allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId); allow update: if false; } match /friend_requests_incoming/{senderId} { allow read: if ownsUser(userId); allow create: if signedIn() && request.auth.uid == senderId && senderId != userId && request.resource.data.receiverUid == userId && request.resource.data.senderUid == senderId && !areMutuallyBlocked(userId, senderId) && !exists(/databases/$(database)/documents/users/$(userId)/friends/$(senderId)) && get(/databases/$(database)/documents/public_profiles/$(userId)).data.allowFriendRequests == true; allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == senderId); allow update: if false; } match /friend_requests_outgoing/{receiverId} { allow read: if ownsUser(userId); allow create: if ownsUser(userId) && receiverId != userId && request.resource.data.receiverUid == receiverId && request.resource.data.senderUid == userId && !areMutuallyBlocked(userId, receiverId); allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == receiverId); allow update: if false; } match /blocks/{blockedId} { allow read, write: if ownsUser(userId); } } // --- GROUP RIDES --- // Top-level social object distinct from personal rides, accounts, and telemetry. match /group_rides/{groupRideId} { function isOrganizer() { return signedIn() && resource.data.organizerUid == request.auth.uid; } function isParticipant() { return signedIn() && exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)); } function isFriendOfOrganizer() { return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid)); } function canReadRide() { return signedIn() && ( // Organizer or active participant can always read if not mutually blocked (isOrganizer() || isParticipant()) || ( !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( resource.data.visibility == 'public' || (resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) ) ) ); } allow read: if canReadRide(); allow create: if signedIn() && request.resource.data.organizerUid == request.auth.uid && request.resource.data.id == groupRideId && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) && request.resource.data.participantCount >= 1 && ( !('routeId' in request.resource.data) || request.resource.data.routeId == null || ( exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) && get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid ) ); function validStatusTransition() { let prev = resource.data.status; let next = request.resource.data.status; return prev == next || (prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) || (prev == 'assembling' && next in ['underway', 'canceled']) || (prev == 'underway' && next in ['completed', 'canceled']); } function validRouteAttachment() { return !('routeId' in request.resource.data) || request.resource.data.routeId == null || ( (resource != null && 'routeId' in resource.data && request.resource.data.routeId == resource.data.routeId) || ( exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) && get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid ) ); } allow update: if signedIn() && ( // Organizer can update ride authority fields with valid status transitions and owned route attachment (isOrganizer() && validStatusTransition() && validRouteAttachment()) || // Participants/invitees can only atomically adjust participantCount during join/leave transactions ( canReadRide() && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt']) ) ); allow delete: if isOrganizer(); // --- PARTICIPANTS SUBCOLLECTION --- match /participants/{participantUid} { function rideDoc() { return get(/databases/$(database)/documents/group_rides/$(groupRideId)); } function rideOrganizer() { return rideDoc().data.organizerUid; } allow read: if signedIn() && ( request.auth.uid == participantUid || request.auth.uid == rideOrganizer() || exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) || rideDoc().data.visibility == 'public' || (rideDoc().data.visibility == 'friendsOnly' && exists(/databases/$(database)/documents/users/$(rideOrganizer())/friends/$(request.auth.uid))) ); // Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create allow create: if signedIn() && ( // Self-organizer initialization on ride create (request.auth.uid == participantUid && request.resource.data.role == 'organizer') || // Organizer inviting a non-blocked rider ( request.auth.uid == rideOrganizer() && request.resource.data.status == 'invited' && !areMutuallyBlocked(request.auth.uid, participantUid) ) || // Rider requesting to join or joining open ride ( request.auth.uid == participantUid && (request.resource.data.status == 'requested' || request.resource.data.status == 'accepted') && !areMutuallyBlocked(request.auth.uid, rideOrganizer()) ) ); // State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer allow update: if signedIn() && ( // Participant responding to invite or leaving ( request.auth.uid == participantUid && ( request.resource.data.status in ['accepted', 'declined', 'left'] || // Transfer target becoming organizer (request.resource.data.role == 'organizer' && request.auth.uid == rideOrganizer()) ) ) || // Organizer approving, removing, or transferring ( request.auth.uid == rideOrganizer() ) ); allow delete: if signedIn() && ( request.auth.uid == participantUid || request.auth.uid == rideOrganizer() ); } // --- LIVE PRESENCE SUBCOLLECTION --- // Ephemeral location and status sharing during an active or assembling ride. match /live_presence/{presenceUid} { function rideDoc() { return get(/databases/$(database)/documents/group_rides/$(groupRideId)); } function isAcceptedParticipant(uid) { return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) && get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted'; } function rideIsLiveEligible() { return rideDoc().data.status in ['scheduled', 'assembling', 'underway']; } function validCoordinates(data) { return data.latitude >= -90.0 && data.latitude <= 90.0 && data.longitude >= -180.0 && data.longitude <= 180.0; } function validPresenceKeys(data) { return data.keys().hasAll(['userId', 'groupRideId', 'latitude', 'longitude', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']) && data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']); } // READ: // Caller must be signed in, be an accepted participant of this ride, target must be accepted participant, // ride must be live-eligible, and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target). allow read: if signedIn() && isAcceptedParticipant(request.auth.uid) && isAcceptedParticipant(presenceUid) && rideIsLiveEligible() && ( request.auth.uid == presenceUid || (resource.data.sharingEnabled == true && !areMutuallyBlocked(request.auth.uid, presenceUid)) ); // WRITE (CREATE/UPDATE): // Caller must write only their own document, must be an accepted participant, // ride must be live-eligible, valid coordinates, valid schema keys, // request.resource.data.userId == request.auth.uid and groupRideId == groupRideId. allow create, update: if signedIn() && request.auth.uid == presenceUid && request.resource.data.userId == request.auth.uid && request.resource.data.groupRideId == groupRideId && isAcceptedParticipant(request.auth.uid) && rideIsLiveEligible() && validCoordinates(request.resource.data) && validPresenceKeys(request.resource.data); // DELETE: // Rider can delete their own live presence record, or ride organizer during cleanup. allow delete: if signedIn() && ( request.auth.uid == presenceUid || request.auth.uid == rideDoc().data.organizerUid ); } } // --- ROUTES --- // User-created motorcycle routes (standalone or attached to group rides). match /routes/{routeId} { function isRouteOwner() { return signedIn() && resource.data.ownerUid == request.auth.uid; } function isAcceptedGroupParticipant(groupRideId, uid) { return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) && get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted'; } function canReadRoute() { return signedIn() && ( isRouteOwner() || ( !areMutuallyBlocked(request.auth.uid, resource.data.ownerUid) && ( resource.data.visibility == 'public' || ( resource.data.visibility == 'groupOnly' && resource.data.attachedGroupRideId != null && isAcceptedGroupParticipant(resource.data.attachedGroupRideId, request.auth.uid) ) ) ) ); } // Validates embedded stop representation. // Note: Client/domain validation additionally enforces unique stop IDs, sequential 0..N-1 indexing, // and exhaustive validation of all stops across large routes. // Exhaustive checking of all stops across large arrays is bounded in Firestore Rules due to expression limits. function validStop(stop) { return stop is map && stop.id is string && stop.id.size() >= 1 && stop.id.size() <= 64 && stop.displayName is string && stop.displayName.size() >= 1 && stop.displayName.size() <= 80 && (stop.latitude is int || stop.latitude is float) && stop.latitude >= -90.0 && stop.latitude <= 90.0 && (stop.longitude is int || stop.longitude is float) && stop.longitude >= -180.0 && stop.longitude <= 180.0 && ( ('stopType' in stop && stop.stopType in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) || ('type' in stop && stop.type in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) ); } function validStops(stops) { return stops is list && stops.size() <= 25 && (stops.size() < 1 || validStop(stops[0])) && (stops.size() < 2 || validStop(stops[1])) && (stops.size() < 3 || validStop(stops[2])) && (stops.size() < 4 || validStop(stops[3])) && (stops.size() < 5 || validStop(stops[4])); } function validRouteKeys(data) { return data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']) && data.keys().hasOnly([ 'id', 'ownerUid', 'ownerHandle', 'title', 'description', 'visibility', 'stops', 'straightLineDistanceMeters', 'estimatedDistanceMeters', 'estimatedDurationSeconds', 'source', 'version', 'attachedGroupRideId', 'createdAt', 'updatedAt' ]); } function validRouteCommon(data) { return validRouteKeys(data) && data.title is string && data.title.size() >= 3 && data.title.size() <= 80 && (!('ownerHandle' in data) || (data.ownerHandle is string && data.ownerHandle.size() <= 40)) && (!('description' in data) || (data.description is string && data.description.size() <= 500)) && (!('visibility' in data) || data.visibility in ['private', 'groupOnly', 'public']) && ( !('visibility' in data) || data.visibility != 'groupOnly' || (('attachedGroupRideId' in data) && data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0) ) && ( !('attachedGroupRideId' in data) || data.attachedGroupRideId == null || ( data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0 && data.attachedGroupRideId.size() <= 64 && exists(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)) && get(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)).data.organizerUid == request.auth.uid ) ) && (!('straightLineDistanceMeters' in data) || ((data.straightLineDistanceMeters is int || data.straightLineDistanceMeters is float) && data.straightLineDistanceMeters >= 0)) && (!('estimatedDistanceMeters' in data) || data.estimatedDistanceMeters == null || ((data.estimatedDistanceMeters is int || data.estimatedDistanceMeters is float) && data.estimatedDistanceMeters >= 0)) && (!('estimatedDurationSeconds' in data) || data.estimatedDurationSeconds == null || (data.estimatedDurationSeconds is int && data.estimatedDurationSeconds >= 0)) && (!('source' in data) || data.source in ['manual', 'planner', 'gpxImport', 'groupRideConversion']) && (!('version' in data) || (data.version is int && data.version >= 1)) && validStops(data.stops); } function validRouteCreate() { return signedIn() && request.resource.data.id == routeId && request.resource.data.ownerUid == request.auth.uid && validRouteCommon(request.resource.data); } function validRouteUpdate() { return isRouteOwner() && request.resource.data.id == resource.data.id && request.resource.data.ownerUid == resource.data.ownerUid && request.resource.data.createdAt == resource.data.createdAt && validRouteCommon(request.resource.data); } allow read: if canReadRoute(); allow create: if validRouteCreate(); allow update: if validRouteUpdate(); allow delete: if isRouteOwner(); } match /{document=**} { allow read, write: if false; } } }