987 lines
46 KiB
Plaintext
987 lines
46 KiB
Plaintext
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() && 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 canReadRide() {
|
|
return signedIn() && (
|
|
isOrganizer() ||
|
|
(
|
|
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
|
hasParticipantAccess(request.auth.uid) ||
|
|
resource.data.visibility == 'public' ||
|
|
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
allow get: if canReadRide();
|
|
|
|
allow list: if signedIn() && (
|
|
resource.data.organizerUid == request.auth.uid ||
|
|
(
|
|
'accessUids' in resource.data &&
|
|
request.auth.uid in resource.data.accessUids
|
|
)
|
|
);
|
|
|
|
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
|
|
)
|
|
);
|
|
|
|
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
|
|
)
|
|
);
|
|
}
|
|
|
|
function validOrganizerRideUpdate() {
|
|
let hasPrevAccess = 'accessUids' in resource.data;
|
|
let hasNextAccess = 'accessUids' in request.resource.data;
|
|
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
|
|
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
|
|
|
|
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
|
|
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
|
|
|
|
let added = nextAccess.removeAll(prevAccess);
|
|
let removed = prevAccess.removeAll(nextAccess);
|
|
let countDelta = nextCount - prevCount;
|
|
|
|
return validStatusTransition() && validRouteAttachment() && (
|
|
(added.size() == 0 && removed.size() == 0 && countDelta == 0) ||
|
|
(added.size() == 1 && removed.size() == 0 && countDelta == 0) ||
|
|
(added.size() == 1 && removed.size() == 0 && countDelta == 1) ||
|
|
(added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0))
|
|
);
|
|
}
|
|
|
|
function validParticipantRideUpdate() {
|
|
let hasPrevAccess = 'accessUids' in resource.data;
|
|
let hasNextAccess = 'accessUids' in request.resource.data;
|
|
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
|
|
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
|
|
|
|
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
|
|
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
|
|
|
|
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() &&
|
|
(
|
|
// Join open ride: caller added to accessUids, count +1
|
|
(
|
|
resource.data.joinPolicy == 'open' &&
|
|
added == [request.auth.uid] &&
|
|
removed.size() == 0 &&
|
|
countDelta == 1
|
|
) ||
|
|
// Accept invite: caller was already in accessUids, count +1
|
|
(
|
|
request.auth.uid in prevAccess &&
|
|
added.size() == 0 &&
|
|
removed.size() == 0 &&
|
|
countDelta == 1
|
|
) ||
|
|
// Decline invite or leave: caller removed from accessUids, count unchanged (declined) or count -1 (left accepted)
|
|
(
|
|
request.auth.uid in prevAccess &&
|
|
removed == [request.auth.uid] &&
|
|
added.size() == 0 &&
|
|
(countDelta == -1 || countDelta == 0)
|
|
)
|
|
);
|
|
}
|
|
|
|
allow update: if signedIn() && (
|
|
(isOrganizer() && validOrganizerRideUpdate()) ||
|
|
validParticipantRideUpdate()
|
|
);
|
|
|
|
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 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, organizer, or accepted peer (if not mutually blocked)
|
|
allow get: if signedIn() && (
|
|
request.auth.uid == participantUid ||
|
|
request.auth.uid == rideOrganizer() ||
|
|
(
|
|
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.role == 'organizer' &&
|
|
request.resource.data.status == 'accepted'
|
|
) ||
|
|
// Organizer inviting a non-blocked rider
|
|
(
|
|
request.auth.uid == rideOrganizer() &&
|
|
request.resource.data.status == 'invited' &&
|
|
request.resource.data.role == 'participant' &&
|
|
!areMutuallyBlocked(request.auth.uid, participantUid)
|
|
) ||
|
|
// Rider requesting to join (joinPolicy == requestToJoin)
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
request.auth.uid != rideOrganizer() &&
|
|
rideDoc().data.joinPolicy == 'requestToJoin' &&
|
|
request.resource.data.status == 'requested' &&
|
|
request.resource.data.role == 'participant' &&
|
|
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
|
) ||
|
|
// Rider joining open ride (joinPolicy == open)
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
request.auth.uid != rideOrganizer() &&
|
|
rideDoc().data.joinPolicy == 'open' &&
|
|
request.resource.data.status == 'accepted' &&
|
|
request.resource.data.role == 'participant' &&
|
|
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
|
)
|
|
);
|
|
|
|
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
|
|
allow update: if signedIn() && (
|
|
// Invitee accepting own invitation (invited -> accepted)
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
resource.data.status == 'invited' &&
|
|
request.resource.data.status == 'accepted' &&
|
|
request.resource.data.role == resource.data.role
|
|
) ||
|
|
// Invitee declining own invitation (invited -> declined)
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
resource.data.status == 'invited' &&
|
|
request.resource.data.status == 'declined' &&
|
|
request.resource.data.role == resource.data.role
|
|
) ||
|
|
// Participant leaving (accepted -> left)
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
resource.data.status == 'accepted' &&
|
|
request.resource.data.status == 'left' &&
|
|
request.resource.data.role == resource.data.role
|
|
) ||
|
|
// Organizer approving request (requested -> accepted)
|
|
(
|
|
request.auth.uid == rideOrganizer() &&
|
|
resource.data.status == 'requested' &&
|
|
request.resource.data.status == 'accepted'
|
|
) ||
|
|
// Organizer declining request (requested -> declined)
|
|
(
|
|
request.auth.uid == rideOrganizer() &&
|
|
resource.data.status == 'requested' &&
|
|
request.resource.data.status == 'declined'
|
|
) ||
|
|
// Organizer removing participant (status -> removed)
|
|
(
|
|
request.auth.uid == rideOrganizer() &&
|
|
request.resource.data.status == 'removed'
|
|
) ||
|
|
// Organizer role transfer
|
|
(
|
|
request.auth.uid == rideOrganizer() &&
|
|
request.resource.data.role in ['organizer', 'participant']
|
|
)
|
|
);
|
|
|
|
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 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 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.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;
|
|
}
|
|
|
|
match /{document=**} {
|
|
allow read, write: if false;
|
|
}
|
|
}
|
|
}
|