rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function isRegisteredUser() { return signedIn() && (!('firebase' in request.auth.token) || !('sign_in_provider' in request.auth.token.firebase) || request.auth.token.firebase.sign_in_provider != 'anonymous'); } 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() && 'organizerUid' in resource.data && resource.data.organizerUid == request.auth.uid; } function hasParticipantAccess(uid) { return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) && get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status in ['invited', 'accepted']; } function isFriendOfOrganizer() { return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid)); } function hasClubAffiliation(data) { return 'clubId' in data && data.clubId != null; } function validClubId(clubId) { return clubId is string && clubId.size() >= 1 && clubId.size() <= 64 && clubId.matches('^\\S+$'); } function clubExists(clubId) { return exists(/databases/$(database)/documents/clubs/$(clubId)); } function clubIsActive(clubId) { return clubExists(clubId) && (!('archivedAt' in get(/databases/$(database)/documents/clubs/$(clubId)).data) || get(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null); } function hasClubMemberDoc(clubId, uid) { return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)); } function getClubMemberData(clubId, uid) { return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data; } function isActiveClubMember(clubId, uid) { return isRegisteredUser() && hasClubMemberDoc(clubId, uid) && getClubMemberData(clubId, uid).status == 'active'; } function isActiveClubAdmin(clubId, uid) { return isActiveClubMember(clubId, uid) && getClubMemberData(clubId, uid).role in ['owner', 'admin']; } function sameClubAffiliation(prevData, nextData) { let prevHas = hasClubAffiliation(prevData); let nextHas = hasClubAffiliation(nextData); return (!prevHas && !nextHas) || (prevHas && nextHas && prevData.clubId == nextData.clubId); } function isClubRideMember() { return hasClubAffiliation(resource.data) && isActiveClubMember(resource.data.clubId, request.auth.uid); } function isClubRideAdmin() { return hasClubAffiliation(resource.data) && isActiveClubAdmin(resource.data.clubId, request.auth.uid); } function canReadRide() { return signedIn() && ( isOrganizer() || isClubRideAdmin() || ( !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( hasParticipantAccess(request.auth.uid) || resource.data.visibility == 'public' || (resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) || isClubRideMember() ) ) ); } allow get: if canReadRide(); allow list: if signedIn() && ( ( 'organizerUid' in resource.data && resource.data.organizerUid == request.auth.uid ) || ( 'accessUids' in resource.data && request.auth.uid in resource.data.accessUids ) || ( hasClubAffiliation(resource.data) && isActiveClubAdmin(resource.data.clubId, request.auth.uid) ) ); 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', 'accessUids']) && request.resource.data.accessUids == [request.auth.uid] && 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 ) ) && ( !hasClubAffiliation(request.resource.data) || ( validClubId(request.resource.data.clubId) && clubIsActive(request.resource.data.clubId) && isActiveClubAdmin(request.resource.data.clubId, request.auth.uid) ) ); function validStatusTransition() { let prev = 'status' in resource.data ? resource.data.status : 'scheduled'; let next = 'status' in request.resource.data ? request.resource.data.status : prev; return prev in ['scheduled', 'assembling', 'underway'] && ( 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 ) ); } function getRideAccessUids(data) { return 'accessUids' in data ? data.accessUids : ('organizerUid' in data ? [data.organizerUid] : []); } function getRideCount(data) { return 'participantCount' in data ? data.participantCount : 1; } function rideWithinCapacity(data, nextCount) { return !('participantLimit' in data) || data.participantLimit == null || nextCount <= data.participantLimit; } function validOrganizerRideUpdate() { let prevAccess = getRideAccessUids(resource.data); let nextAccess = getRideAccessUids(request.resource.data); let prevCount = getRideCount(resource.data); let nextCount = getRideCount(request.resource.data); let added = nextAccess.removeAll(prevAccess); let removed = prevAccess.removeAll(nextAccess); let countDelta = nextCount - prevCount; return validStatusTransition() && validRouteAttachment() && rideWithinCapacity(request.resource.data, nextCount) && ( // General metadata or status update: access and count unchanged (added.size() == 0 && removed.size() == 0 && countDelta == 0) || // Organizer approval or open join: exactly +1 accessUid, count +1, only in scheduled/assembling ( ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && added.size() == 1 && removed.size() == 0 && countDelta == 1 && !areMutuallyBlocked(resource.data.organizerUid, added[0]) && (!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, added[0])) && existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted' ) || // Organizer invitation: exactly +1 accessUid, count unchanged (countDelta == 0), only in scheduled/assembling ( ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && added.size() == 1 && removed.size() == 0 && countDelta == 0 && !areMutuallyBlocked(resource.data.organizerUid, added[0]) && existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'invited' ) || // Organizer removal: exactly -1 accessUid, count -1, only in scheduled/assembling/underway ( ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) && added.size() == 0 && removed.size() == 1 && countDelta == -1 && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left'] ) ); } function validParticipantOpenJoin(added, removed, countDelta, nextCount) { return resource.data.joinPolicy == 'open' && ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && request.auth.uid != resource.data.organizerUid && added == [request.auth.uid] && removed.size() == 0 && countDelta == 1 && rideWithinCapacity(resource.data, nextCount) && !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, request.auth.uid)) && existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted'; } function validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) { return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && request.auth.uid in prevAccess && added.size() == 0 && removed.size() == 0 && countDelta == 1 && rideWithinCapacity(resource.data, nextCount) && existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted'; } function validParticipantLeave(prevAccess, added, removed, countDelta) { return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) && request.auth.uid != resource.data.organizerUid && added.size() == 0 && removed == [request.auth.uid] && countDelta == -1 && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'left'; } function validParticipantDeclineInvite(prevAccess, added, removed, countDelta) { return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) && request.auth.uid != resource.data.organizerUid && added.size() == 0 && (removed.size() == 0 || removed == [request.auth.uid]) && countDelta == 0 && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'declined'; } function validParticipantRideUpdate() { let prevAccess = getRideAccessUids(resource.data); let nextAccess = getRideAccessUids(request.resource.data); let prevCount = getRideCount(resource.data); let nextCount = getRideCount(request.resource.data); let added = nextAccess.removeAll(prevAccess); let removed = prevAccess.removeAll(nextAccess); let countDelta = nextCount - prevCount; return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) && canReadRide() && ( validParticipantOpenJoin(added, removed, countDelta, nextCount) || validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) || validParticipantLeave(prevAccess, added, removed, countDelta) || validParticipantDeclineInvite(prevAccess, added, removed, countDelta) ); } function validClubAdminModeration(prevStatus, nextStatus) { let prevAccess = getRideAccessUids(resource.data); let nextAccess = getRideAccessUids(request.resource.data); let prevCount = getRideCount(resource.data); let nextCount = getRideCount(request.resource.data); let added = nextAccess.removeAll(prevAccess); let removed = prevAccess.removeAll(nextAccess); let countDelta = nextCount - prevCount; return request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'accessUids', 'participantCount', 'updatedAt' ]) && ( // Approval: count +1, access added 1, only in scheduled or assembling ( prevStatus in ['scheduled', 'assembling'] && nextStatus == prevStatus && added.size() == 1 && removed.size() == 0 && countDelta == 1 && rideWithinCapacity(resource.data, nextCount) && !areMutuallyBlocked(resource.data.organizerUid, added[0]) && isActiveClubMember(resource.data.clubId, added[0]) && existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted' ) || // Removal: count -1, access removed 1, only in scheduled, assembling, or underway ( prevStatus in ['scheduled', 'assembling', 'underway'] && nextStatus == prevStatus && added.size() == 0 && removed.size() == 1 && countDelta == -1 && removed[0] != resource.data.organizerUid && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left'] ) ); } function validClubAdminRideUpdate() { let clubId = resource.data.clubId; let prevStatus = 'status' in resource.data ? resource.data.status : null; let nextStatus = 'status' in request.resource.data ? request.resource.data.status : prevStatus; return isActiveClubAdmin(clubId, request.auth.uid) && prevStatus != null && ( // Sub-case A: Scheduled metadata edit ( prevStatus == 'scheduled' && nextStatus == 'scheduled' && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'title', 'description', 'visibility', 'joinPolicy', 'scheduledStartAt', 'departureAt', 'participantLimit', 'meetingPoint', 'destination', 'stops', 'routeId', 'routeTitle', 'routeDistanceMeters', 'routeStopCount', 'updatedAt' ]) && validRouteAttachment() ) || // Sub-case B: Dedicated narrow admin cancellation (scheduled or assembling only) ( prevStatus in ['scheduled', 'assembling'] && nextStatus == 'canceled' && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'status', 'canceledAt', 'canceledReason', 'updatedAt' ]) ) || // Sub-case C: Club admin participant moderation (approve, remove) validClubAdminModeration(prevStatus, nextStatus) ); } allow update: if signedIn() && ('status' in resource.data && !(resource.data.status in ['completed', 'canceled'])) && sameClubAffiliation(resource.data, request.resource.data) && ( (isOrganizer() && validOrganizerRideUpdate()) || validParticipantRideUpdate() || (hasClubAffiliation(resource.data) && validClubAdminRideUpdate()) ); allow delete: if isOrganizer(); // --- PARTICIPANTS SUBCOLLECTION --- match /participants/{participantUid} { function rideDoc() { return exists(/databases/$(database)/documents/group_rides/$(groupRideId)) ? get(/databases/$(database)/documents/group_rides/$(groupRideId)) : (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)) ? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)) : null); } function rideOrganizer() { return rideDoc().data.organizerUid; } function isAuthorizedModerator() { return request.auth.uid == rideOrganizer() || (hasClubAffiliation(rideDoc().data) && isActiveClubAdmin(rideDoc().data.clubId, request.auth.uid)); } 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'; } // Collection listing is strictly disallowed (point-read architecture over known accessUids) allow list: if false; // Single-document read: self, moderator, or accepted peer (if not mutually blocked) allow get: if signedIn() && ( request.auth.uid == participantUid || isAuthorizedModerator() || ( isAcceptedParticipant(request.auth.uid) && !areMutuallyBlocked(request.auth.uid, participantUid) ) ); // 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.auth.uid == rideOrganizer() && request.resource.data.userId == request.auth.uid && request.resource.data.role == 'organizer' && request.resource.data.status == 'accepted' && request.resource.data.keys().hasAll(['userId', 'role', 'status']) && request.resource.data.keys().hasOnly([ 'userId', 'role', 'status', 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', 'createdAt', 'updatedAt' ]) ) || // Organizer inviting a non-blocked rider (scheduled or assembling, within capacity) ( request.auth.uid == rideOrganizer() && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && request.resource.data.userId == participantUid && request.resource.data.status == 'invited' && request.resource.data.role == 'participant' && !areMutuallyBlocked(request.auth.uid, participantUid) && request.resource.data.keys().hasAll(['userId', 'role', 'status']) && request.resource.data.keys().hasOnly([ 'userId', 'role', 'status', 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', 'invitedBy', 'createdAt', 'updatedAt' ]) ) || // Rider requesting to join (joinPolicy == requestToJoin) - SINGLE DOCUMENT WRITE, NO PARENT MUTATION ( request.auth.uid == participantUid && request.auth.uid != rideOrganizer() && request.resource.data.userId == request.auth.uid && request.resource.data.role == 'participant' && request.resource.data.status == 'requested' && rideDoc().data.joinPolicy == 'requestToJoin' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && !areMutuallyBlocked(request.auth.uid, rideOrganizer()) && (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) && request.resource.data.keys().hasAll(['userId', 'role', 'status']) && request.resource.data.keys().hasOnly([ 'userId', 'role', 'status', 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', 'createdAt', 'updatedAt' ]) ) || // Rider joining open ride (joinPolicy == open) - Cross-document bound to parent ( request.auth.uid == participantUid && request.auth.uid != rideOrganizer() && request.resource.data.userId == request.auth.uid && request.resource.data.role == 'participant' && request.resource.data.status == 'accepted' && rideDoc().data.joinPolicy == 'open' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) && !areMutuallyBlocked(request.auth.uid, rideOrganizer()) && request.auth.uid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 && request.resource.data.keys().hasAll(['userId', 'role', 'status']) && request.resource.data.keys().hasOnly([ 'userId', 'role', 'status', 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', 'createdAt', 'updatedAt' ]) ) ); // State transitions: participant can accept/decline/leave; moderator can approve/decline/remove or organizer can transfer allow update: if signedIn() && ( // Status updates must strictly constrain changed keys to status and updatedAt ( request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) && ( // Invitee accepting own invitation (invited -> accepted) ( request.auth.uid == participantUid && resource.data.status == 'invited' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && request.resource.data.status == 'accepted' && request.resource.data.role == resource.data.role && participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 ) || // Invitee declining own invitation (invited -> declined) ( request.auth.uid == participantUid && resource.data.status == 'invited' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role ) || // Accepted participant leaving (accepted -> left) - Cross-document bound ( request.auth.uid == participantUid && resource.data.status == 'accepted' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && request.resource.data.status == 'left' && request.resource.data.role == resource.data.role && !(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1 ) || // Requested rider withdrawing request (requested -> left) - Single document update ( request.auth.uid == participantUid && resource.data.status == 'requested' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && request.resource.data.status == 'left' && request.resource.data.role == resource.data.role ) || // Authorized moderator approving request (requested -> accepted) - Cross-document bound ( isAuthorizedModerator() && resource.data.status == 'requested' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, participantUid)) && !areMutuallyBlocked(rideOrganizer(), participantUid) && request.resource.data.status == 'accepted' && request.resource.data.role == resource.data.role && participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 ) || // Authorized moderator declining request (requested -> declined) - Single document update ( isAuthorizedModerator() && resource.data.status == 'requested' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role ) || // Authorized moderator removing participant (status -> removed) - Cross-document bound ( isAuthorizedModerator() && participantUid != rideOrganizer() && resource.data.role != 'organizer' && resource.data.status == 'accepted' && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && request.resource.data.status == 'removed' && request.resource.data.role == resource.data.role && !(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) && getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1 ) ) ) || // Organizer role transfer ( request.auth.uid == rideOrganizer() && resource.data.status == 'accepted' && request.resource.data.status == resource.data.status && ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && request.resource.data.role in ['organizer', 'participant'] && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'updatedAt']) ) ); allow delete: if signedIn() && resource.data.role != 'organizer' && resource.data.status != 'accepted' && ( request.auth.uid == participantUid || isAuthorizedModerator() ); } // --- LIVE PRESENCE SUBCOLLECTION --- // Ephemeral location and status sharing during an active or assembling ride. match /live_presence/{presenceUid} { function rideDoc() { return exists(/databases/$(database)/documents/group_rides/$(groupRideId)) ? get(/databases/$(database)/documents/group_rides/$(groupRideId)) : (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)) ? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)) : null); } 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 'status' in rideDoc().data && 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']); } // Collection listing is strictly disallowed allow list: if false; // READ (SINGLE DOCUMENT GET ONLY): // Self access: permitted as required by own-presence lifecycle / cleanup. // Peer read: caller accepted, target accepted, ride live-eligible, sharing enabled, not mutually blocked. allow get: if signedIn() && ( request.auth.uid == presenceUid || ( isAcceptedParticipant(request.auth.uid) && isAcceptedParticipant(presenceUid) && rideIsLiveEligible() && (resource == null || 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(); } // --- CLUB HANDLE RESERVATIONS --- match /club_handles/{slug} { allow list: if false; allow get: if isRegisteredUser(); allow create: if isRegisteredUser() && request.resource.data.slug == slug && request.resource.data.ownerUid == request.auth.uid && request.resource.data.clubId is string && request.resource.data.clubId.size() > 0 // Post-write club document invariant && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.slug == slug && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.ownerUid == request.auth.uid && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.memberCount == 1 && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.archivedAt == null // Post-write owner membership invariant && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.uid == request.auth.uid && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.clubId == request.resource.data.clubId && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.status == 'active' && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.role == 'owner'; allow delete: if false; allow update: if false; } // --- CLUBS & MEMBERSHIP --- match /clubs/{clubId} { function getClubDoc() { return get(/databases/$(database)/documents/clubs/$(clubId)); } function isCallerClubOwner() { return isRegisteredUser() && getClubDoc().data.ownerUid == request.auth.uid; } function hasMemberDoc(uid) { return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)); } function memberData(uid) { return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data; } function isActiveMember(uid) { return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'active'; } function isPendingInvite(uid) { return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'invited'; } function isClubAdmin(uid) { return isActiveMember(uid) && (memberData(uid).role == 'admin' || memberData(uid).role == 'owner'); } function isBanned(uid) { return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid)); } // Club logo/banner URLs: null or empty string clears; otherwise must be // an https:// URL (case-insensitive scheme, matching URL() semantics in the app layer). function isValidClubImageUrl(url) { return url == null || url == '' || (url is string && url.size() <= 2048 && url.matches('(?i)^https://\\S+$')); } function isValidMemberCountIncrement() { let targetUid = ('lastMembershipChangeUid' in request.resource.data) ? request.resource.data.lastMembershipChangeUid : request.auth.uid; let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid); return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid)) && request.resource.data.memberCount == resource.data.memberCount + 1 && getAfter(memberDocPath).data.status == 'active' && (!exists(memberDocPath) || get(memberDocPath).data.status != 'active'); } function isValidMemberCountDecrement() { let targetUid = ('lastMembershipChangeUid' in request.resource.data) ? request.resource.data.lastMembershipChangeUid : request.auth.uid; let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid); return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid)) && request.resource.data.memberCount == resource.data.memberCount - 1 && request.resource.data.memberCount >= 1 && getAfter(memberDocPath).data.status in ['left', 'removed'] && exists(memberDocPath) && get(memberDocPath).data.status == 'active'; } // Point-read access to club root: // Active members, owner, or invited riders evaluating an invite may read private clubs. // Unlisted and public clubs can be point-read by any registered rider. allow get: if isRegisteredUser() && ( resource.data.visibility in ['public', 'unlisted'] || resource.data.ownerUid == request.auth.uid || isActiveMember(request.auth.uid) || isPendingInvite(request.auth.uid) ); // Discovery list query: Safe public discovery or owner's owned clubs query only allow list: if isRegisteredUser() && ( (resource.data.visibility == 'public' && resource.data.archivedAt == null) || (resource.data.ownerUid == request.auth.uid && resource.data.archivedAt == null) ); // Club creation: Atomic batch/transaction creating club + owner membership + slug reservation allow create: if isRegisteredUser() && request.resource.data.id == clubId && request.resource.data.ownerUid == request.auth.uid && request.resource.data.memberCount == 1 && request.resource.data.archivedAt == null && request.resource.data.slug is string && request.resource.data.slug.size() >= 3 && request.resource.data.slug.size() <= 30 && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.role == 'owner' && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.status == 'active' && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.clubId == clubId && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.clubId == clubId && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.ownerUid == request.auth.uid && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.slug == request.resource.data.slug; // Club update: Ownership transfer, metadata edit, or member count change allow update: if isRegisteredUser() && ( // Case 1: Owner transferring ownership atomically (cannot mutate memberCount, slug, createdAt) ( request.auth.uid == resource.data.ownerUid && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['ownerUid', 'updatedAt']) && request.resource.data.ownerUid != resource.data.ownerUid && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.role == 'owner' && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.status == 'active' && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(resource.data.ownerUid)).data.role != 'owner' ) || // Case 2: Owner general metadata update or archiving (slug, ownerUid, memberCount, createdAt immutable) ( request.auth.uid == resource.data.ownerUid && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'name', 'description', 'visibility', 'admissionPolicy', 'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus', 'archivedAt', 'updatedAt' ]) && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private']) && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly']) && (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl)) && (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl)) ) || // Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount) ( isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'name', 'description', 'visibility', 'admissionPolicy', 'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus', 'updatedAt' ]) && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private']) && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly']) && (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl)) && (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl)) ) || // Case 4: Member count increment/decrement by membership transitions ONLY ( request.resource.data.diff(resource.data).affectedKeys().hasOnly(['memberCount', 'updatedAt', 'lastMembershipChangeUid']) && (isValidMemberCountIncrement() || isValidMemberCountDecrement()) ) ); allow delete: if false; // --- MEMBERS SUBCOLLECTION --- match /members/{memberUid} { // Point read: Caller themselves, club management, or peer active member (if not mutually blocked) allow get: if isRegisteredUser() && ( request.auth.uid == memberUid || isCallerClubOwner() || isClubAdmin(request.auth.uid) || (isActiveMember(request.auth.uid) && resource.data.status == 'active' && !areMutuallyBlocked(request.auth.uid, memberUid)) ); // List roster: Active members or club management only allow list: if isRegisteredUser() && ( isCallerClubOwner() || isClubAdmin(request.auth.uid) || (isActiveMember(request.auth.uid) && resource.data.status == 'active') ); // Create membership doc: Owner creation, open join, request to join, or admin invitation allow create: if isRegisteredUser() && !isBanned(memberUid) && ( // Owner creating self doc during club creation ( request.auth.uid == memberUid && request.resource.data.role == 'owner' && request.resource.data.status == 'active' && request.resource.data.clubId == clubId && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid == request.auth.uid && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == 1 && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null && getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.clubId == clubId && getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.ownerUid == request.auth.uid ) || // Rider joining open club ( request.auth.uid == memberUid && get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'open' && request.resource.data.role == 'member' && request.resource.data.status == 'active' && !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid) && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1 ) || // Rider requesting to join ( request.auth.uid == memberUid && get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'request' && request.resource.data.role == 'member' && request.resource.data.status == 'requested' && !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid) ) || // Admin / Owner inviting rider ( isClubAdmin(request.auth.uid) && request.resource.data.status == 'invited' && request.resource.data.role == 'member' && !areMutuallyBlocked(request.auth.uid, memberUid) ) ); function isValidOpenReentry() { return request.auth.uid == memberUid && resource.data.status in ['left', 'declined', 'removed'] && request.resource.data.status == 'active' && request.resource.data.role == 'member' && request.resource.data.uid == memberUid && request.resource.data.clubId == clubId && !isBanned(memberUid) && getClubDoc().data.admissionPolicy == 'open' && getClubDoc().data.archivedAt == null && !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid) && request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'joinedAt', 'updatedAt']) && (!('joinedAt' in request.resource.data) || request.resource.data.joinedAt is timestamp) && (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp) && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount + 1; } function isValidRequestReentry() { return request.auth.uid == memberUid && resource.data.status in ['left', 'declined', 'removed'] && request.resource.data.status == 'requested' && request.resource.data.role == 'member' && request.resource.data.uid == memberUid && request.resource.data.clubId == clubId && !isBanned(memberUid) && getClubDoc().data.admissionPolicy == 'request' && getClubDoc().data.archivedAt == null && !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid) && request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'requestedAt', 'updatedAt']) && (!('requestedAt' in request.resource.data) || request.resource.data.requestedAt is timestamp) && (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp) && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount; } // Transitions allow update: if isRegisteredUser() && ( // Invitee accepts invite (invited -> active) ( request.auth.uid == memberUid && resource.data.status == 'invited' && request.resource.data.status == 'active' && request.resource.data.role == resource.data.role && !isBanned(memberUid) && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1 ) || // Invitee declines invite (invited -> declined) (request.auth.uid == memberUid && resource.data.status == 'invited' && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role) || // Member leaves (active -> left), non-owner only ( request.auth.uid == memberUid && resource.data.status == 'active' && request.resource.data.status == 'left' && resource.data.role != 'owner' && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1 ) || // Requestor cancels request (requested -> left) (request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') || // Re-entry to open club (left/declined/removed -> active) isValidOpenReentry() || // Re-entry request to request-policy club (left/declined/removed -> requested) isValidRequestReentry() || // Admin/Owner approves request (requested -> active) ( isClubAdmin(request.auth.uid) && resource.data.status == 'requested' && request.resource.data.status == 'active' && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1 ) || // Admin/Owner declines request (requested -> declined) (isClubAdmin(request.auth.uid) && resource.data.status == 'requested' && request.resource.data.status == 'declined') || // Admin/Owner removes active member (active -> removed), hierarchy enforced ( isClubAdmin(request.auth.uid) && resource.data.status == 'active' && request.resource.data.status == 'removed' && resource.data.role != 'owner' && (isCallerClubOwner() || resource.data.role != 'admin') && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1 ) || // Admin/Owner removes non-active member (requested/invited -> removed) ( isClubAdmin(request.auth.uid) && resource.data.status != 'active' && request.resource.data.status == 'removed' && resource.data.role != 'owner' && (isCallerClubOwner() || resource.data.role != 'admin') ) || // Owner updates role (role update or ownership transfer) (isCallerClubOwner() && request.resource.data.status == resource.data.status) || // Display rank update by admin/owner (isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayRank', 'updatedAt'])) ); allow delete: if false; } // --- ADMINISTRATIVE BANS SUBCOLLECTION --- match /bans/{bannedUid} { allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid); } } // --- COLLECTION GROUP: MEMBERS --- // Scoped strictly so caller can list only documents belonging to their own UID. match /{path=**}/members/{memberUid} { allow list: if isRegisteredUser() && resource.data.uid == request.auth.uid; } // --- MOTORCLOUD RIDER SWARM ROAD ALERTS --- match /road_alerts/{alertId} { function validAlertKeys() { return request.resource.data.keys().hasOnly([ 'id', 'category', 'latitude', 'longitude', 'heading', 'roadName', 'description', 'reporterUid', 'reportedAt', 'expiresAt', 'confirmations', 'dismissals', 'groupRideId' ]); } function validAlertCreate() { return signedIn() && request.resource.data.id == alertId && request.resource.data.reporterUid == request.auth.uid && validAlertKeys() && request.resource.data.category in [ 'police', 'hazard', 'accident', 'speedCamera', 'roadClosure', 'construction', 'traffic', 'other' ] && (request.resource.data.latitude is int || request.resource.data.latitude is float) && (request.resource.data.longitude is int || request.resource.data.longitude is float) && request.resource.data.latitude >= -90.0 && request.resource.data.latitude <= 90.0 && request.resource.data.longitude >= -180.0 && request.resource.data.longitude <= 180.0 && request.resource.data.confirmations == 1 && request.resource.data.dismissals == 0 && request.resource.data.reportedAt is int && request.resource.data.expiresAt is int && request.resource.data.expiresAt > request.resource.data.reportedAt && request.resource.data.description is string && request.resource.data.description.size() <= 256; } function validAlertUpdate() { return signedIn() && validAlertKeys() && request.resource.data.id == resource.data.id && request.resource.data.reporterUid == resource.data.reporterUid && request.resource.data.category == resource.data.category && request.resource.data.latitude == resource.data.latitude && request.resource.data.longitude == resource.data.longitude && request.resource.data.reportedAt == resource.data.reportedAt && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt']) && ( ( request.resource.data.confirmations == resource.data.confirmations + 1 && request.resource.data.dismissals == resource.data.dismissals && request.resource.data.expiresAt == resource.data.expiresAt ) || ( request.resource.data.dismissals == resource.data.dismissals + 1 && request.resource.data.confirmations == resource.data.confirmations && request.resource.data.expiresAt <= resource.data.expiresAt && request.resource.data.expiresAt >= resource.data.reportedAt ) ); } allow read: if signedIn(); allow create: if validAlertCreate(); allow update: if validAlertUpdate(); allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid; } match /{document=**} { allow read, write: if false; } } }