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); } } match /groupRides/{groupRideId} { allow read, write: if false; } match /{document=**} { allow read, write: if false; } } }