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; } } function isServer() { // Check if request comes from admin SDK (no request.auth) return request.auth == null; } function isImmutable(fields) { // Helper to check that certain fields haven't changed during an update // fields: list of field names that should be immutable if (resource == null) { // Create operation - nothing to check return true; } return fields.every(f => resource.data[f] == request.resource.data[f]); } // ── 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 couple data. // Writes are restricted by field ownership and immutability. match /couples/{coupleId} { // Read: both members can read allow read: if isCouplesMember(coupleId); // Create: only via invite flow (server-side or admin SDK) allow create: if isServer(); // Update: field-level restrictions // - user IDs are immutable (cannot change who is in the couple) // - invite code is immutable (cannot change the code) // - createdAt is immutable (cannot change when the couple was formed) // - streakCount and lastStreakAt: server-only (via Cloud Functions or admin SDK) // - All other fields: both members can update normally allow update: if isCouplesMember(coupleId) // Check immutable fields haven't changed && isImmutable(['userIds', 'inviteCode', 'createdAt']) // streakCount and lastStreakAt must not be modified by clients && !request.resource.data.diff(resource.data).affectedKeys().hasAny(['streakCount', 'lastStreakAt']); // Delete: server-only (admin SDK only) allow delete: if isServer(); match /sessions/{sessionId} { // Read: both members can read allow read: if isCouplesMember(coupleId); // Create: either member can start a session allow create: if isCouplesMember(coupleId) && request.resource.data.startedByUserId == request.auth.uid; // Update: only the user who started the session can update it, OR valid status transitions allow update: if isCouplesMember(coupleId) // Either the original starter can update && (resource.data.startedByUserId == request.auth.uid // Or status transition is valid: active → completed || (resource.data.status == 'active' && request.resource.data.status == 'completed')) // Check that other fields haven't been tampered with && (request.resource.data.startedByUserId == resource.data.startedByUserId || isServer()); // Delete: server-only (admin SDK) allow delete: if isServer(); } // Question threads live under the couple document. match /question_threads/{threadId} { // Read: both members can read allow read: if isCouplesMember(coupleId); // Create: either member can create a thread allow create: if isCouplesMember(coupleId) && request.resource.data.createdByUserId == request.auth.uid; // Update: valid state transitions only, currentIndex only incrementable allow update: if isCouplesMember(coupleId) // Status transitions must be valid: NOT_STARTED → ANSWERED_BY_ONE → REVEALED → COMPLETED && (resource.data.status == 'NOT_STARTED' && request.resource.data.status == 'ANSWERED_BY_ONE' || resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'REVEALED' || resource.data.status == 'REVEALED' && request.resource.data.status == 'COMPLETED' || resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'COMPLETED') // currentIndex can only be incremented, never decremented or reset && request.resource.data.currentIndex != null && (resource.data.currentIndex == null || request.resource.data.currentIndex >= resource.data.currentIndex) // No other fields should change && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'currentIndex']); // Delete: server-only (admin SDK) allow delete: if isServer(); // 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 read, but only the author can write/update/delete match /messages/{messageId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.resource.data.authorUserId == request.auth.uid; allow update: if isCouplesMember(coupleId) && resource.data.authorUserId == request.auth.uid; allow delete: if isCouplesMember(coupleId) && resource.data.authorUserId == request.auth.uid; } // Reactions: any couple member can read, but only the creator can write/update/delete match /reactions/{reactionId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.resource.data.userId == request.auth.uid; allow update: if isCouplesMember(coupleId) && resource.data.userId == request.auth.uid; allow delete: if isCouplesMember(coupleId) && resource.data.userId == request.auth.uid; } } } } }