rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // ── Helpers ────────────────────────────────────────────────────────────── function isSignedIn() { return request.auth != null; } function isOwner(uid) { return isSignedIn() && request.auth.uid == uid; } function isCouplesMember(coupleId) { return isSignedIn() && request.auth.uid in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds; } function isValidInviteCode(code) { // Code must be exactly 6 alphanumeric characters return code.matches('^[a-zA-Z0-9]{6}$'); } function isNotAlreadyPaired() { // Check that the requesting user does not already have a coupleId // Handle case where user doc might not exist (use getIfExists to avoid throw) try { let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid)); return !('coupleId' in userDoc.data) || userDoc.data.coupleId == null; } catch (e) { // User doc doesn't exist - treat as unpaired return true; } } // ── Users ───────────────────────────────────────────────────────────────── // Each user owns exactly their own document. // hasPremium is server-only: clients may not write it directly. match /users/{uid} { allow read: if isOwner(uid); allow create: if isOwner(uid) && !request.resource.data.keys().hasAny(['hasPremium']); allow update: if isOwner(uid) && !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']); } // ── Invite codes ────────────────────────────────────────────────────────── // Invite system with proper ownership, validation, and expiry checks. match /invites/{code} { // Read: only inviter, except when accepting (user is not inviter, pending, and unpaired) allow read: if isSignedIn() && ( // Inviter can always read request.auth.uid == resource.data.inviterUserId || // Accepting user: not the inviter, invite is still pending, and user is unpaired ( request.auth.uid != resource.data.inviterUserId && resource.data.status == 'pending' && !('coupleId' in resource.data) && isNotAlreadyPaired() ) ) // Expired invites should not be readable by non-inviters && (request.auth.uid == resource.data.inviterUserId || request.time < resource.data.expiresAt); // Create: ownership, code format, and required fields validation allow create: if isSignedIn() && request.resource.data.inviterUserId == request.auth.uid && isValidInviteCode(code) && isValidInviteCode(request.resource.data.code) && request.resource.data.code == code && request.resource.data.status == 'pending' && request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt']); // Update (accept): proper validation for changing status to accepted allow update: if isSignedIn() && resource.data.status == 'pending' // Cannot accept your own invite && request.auth.uid != resource.data.inviterUserId // Must be the acceptor && request.resource.data.acceptorUserId == request.auth.uid // Status must change to accepted && request.resource.data.status == 'accepted' // Acceptance timestamp must be set && request.resource.data.acceptedAt != null // No other fields should be modified in this update && request.resource.data.keys().hasOnly( ['status', 'acceptorUserId', 'acceptedAt', 'coupleId']) // Expired invites cannot be accepted && request.time < resource.data.expiresAt; } // ── Couples ─────────────────────────────────────────────────────────────── // Only the two members of a couple may read or write couple data. match /couples/{coupleId} { allow read, write: if isCouplesMember(coupleId); match /sessions/{sessionId} { allow read, write: if isCouplesMember(coupleId); } // Question threads live under the couple document. match /question_threads/{threadId} { allow read, write: if isCouplesMember(coupleId); // Answers: each user writes their own; both members can read all answers. match /answers/{userId} { allow write: if isOwner(userId); allow read: if isCouplesMember(coupleId); } // Discussion messages: any couple member can write and read. match /messages/{messageId} { allow read, write: if isCouplesMember(coupleId); } // Reactions: any couple member can write and read. match /reactions/{reactionId} { allow read, write: if isCouplesMember(coupleId); } } } } }