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. // A missing user doc is treated as unpaired. let userPath = /databases/$(database)/documents/users/$(request.auth.uid); return !exists(userPath) || get(userPath).data.coupleId == null; } // Admin SDK / Cloud Functions bypass Firestore rules, so any operation that // must only be performed server-side is denied for all direct client writes. 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]); } function isValidSwipeAction(action) { return action == 'love' || action == 'maybe' || action == 'skip'; } function isValidDatePlanStatus(status) { return status == 'draft' || status == 'planned' || status == 'completed'; } function isValidBucketListCategory(category) { return category == 'adventure' || category == 'travel' || category == 'food' || category == 'learning' || category == 'romance' || category == 'intimacy' || category == 'seasonal'; } // ── 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']); // Entitlements written server-side only (RevenueCat webhook via Admin SDK). // Client needs read access so FirestoreEntitlementChecker can observe premium state. match /entitlements/{entitlementDoc} { allow read: if isOwner(uid); allow write: if false; } // Notification queue written server-side only (Cloud Functions). // No client read needed; the app reacts to FCM push, not this collection. match /notification_queue/{notificationId} { allow read, write: if false; } // FCM registration tokens: owner can read/write their own tokens. match /fcmTokens/{tokenId} { allow read, write: if isOwner(uid); } } // ── Date ideas (read-only catalog) ───────────────────────────────────────── // Curated date ideas are readable by any authenticated user. // Writes are server-only (admin SDK / Cloud Functions seeding). match /date_ideas/{dateIdeaId} { allow read: if isSignedIn(); allow create, update, delete: if false; } // ── 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. // hasOnly prevents injecting unrelated fields (e.g. coupleId) at creation. 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.expiresAt is timestamp && request.time < request.resource.data.expiresAt && request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt']) && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']); // Update (accept): proper validation for changing status to accepted. // If coupleId is supplied, it must reference an existing couple where // the acceptor is a member. (Server-side creation bypasses rules.) 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 and be a Firestore timestamp && request.resource.data.acceptedAt != null && request.resource.data.acceptedAt is timestamp // 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 // coupleId, if provided, must point to a real couple that includes the acceptor && ( !('coupleId' in request.resource.data) || ( request.resource.data.coupleId != null && exists(/databases/$(database)/documents/couples/$(request.resource.data.coupleId)) && request.auth.uid in get(/databases/$(database)/documents/couples/$(request.resource.data.coupleId)).data.userIds ) ); } // ── 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: acceptor creates the couple doc during pairing (client-side). // Must be a member of the couple and include required fields. allow create: if isSignedIn() && request.auth.uid in request.resource.data.userIds && request.resource.data.keys().hasAll(['id', 'userIds', 'inviteCode', 'createdAt', 'streakCount']) && request.resource.data.keys().hasOnly([ 'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']); // Update: field-level restrictions // - user IDs, invite code, and createdAt are immutable // - encryptionVersion is monotonically non-decreasing (cannot downgrade) // - only the explicitly listed mutable fields may change; everything else // (including currentQuestionId, activePackId, id) is server-only allow update: if isCouplesMember(coupleId) && isImmutable(['userIds', 'inviteCode', 'createdAt']) && (resource.data.encryptionVersion == null || request.resource.data.encryptionVersion >= resource.data.encryptionVersion) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'streakCount', 'lastAnsweredAt', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion' ]); // Delete: server-only (admin SDK only). Admin SDK bypasses rules. allow delete: if false; 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. // startedByUserId is immutable for direct client writes. 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')) // startedByUserId cannot be changed by clients && request.resource.data.startedByUserId == resource.data.startedByUserId // Only a fixed set of fields may change && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'completedAt']); // Delete: server-only (admin SDK). Admin SDK bypasses rules. allow delete: if false; } // 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). Admin SDK bypasses rules. allow delete: if false; // 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; } } // Date swipes: per-couple, per-date partner swipe state. match /date_swipes/{dateIdeaId} { // Read: both couple members can read the shared swipe document. allow read: if isCouplesMember(coupleId); // Create/Update: each member can only write their own action entry. // The payload must contain an actions.{uid} object with a valid action. allow create, update: if isCouplesMember(coupleId) // The path to the current user's action must exist and be the only action written && request.resource.data.keys().hasOnly(['actions']) && request.resource.data.actions.keys().hasOnly([request.auth.uid]) && request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt']) && isValidSwipeAction(request.resource.data.actions[request.auth.uid].action) && request.resource.data.actions[request.auth.uid].action != null && request.resource.data.actions[request.auth.uid].swipedAt is timestamp; // Delete: server-only (admin SDK). Admin SDK bypasses rules. allow delete: if false; } // Date matches: revealed mutual love matches. // Clients can read; creation of a match is performed by a Cloud Function // after both partners have swiped 'love'. Direct client writes are denied. match /date_matches/{matchId} { allow read: if isCouplesMember(coupleId); allow create, update, delete: if false; } // Date plan preferences: per-partner preferences for building date plans. // Both members can read; either member can write a preference document. // Document IDs are Firestore auto-IDs (not user IDs). match /date_plan_preferences/{prefId} { allow read: if isCouplesMember(coupleId); allow create, update: if isCouplesMember(coupleId) && request.resource.data.keys().hasAll(['dateIdeaId', 'createdAt', 'updatedAt']) && request.resource.data.keys().hasOnly([ 'dateIdeaId', 'preferredDate', 'preferredTime', 'budget', 'duration', 'createdAt', 'updatedAt' ]); allow delete: if false; } // Date plans: complete plans assembled from partner preferences. // Both members can read and delete; writes are field-validated. // createdAt is immutable after creation (excluded from the update allowed-keys set). match /date_plans/{planId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.resource.data.keys().hasAll(['dateIdeaId', 'scheduledDate', 'status', 'createdAt', 'updatedAt']) && request.resource.data.keys().hasOnly([ 'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration', 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', 'createdAt', 'updatedAt' ]) && isValidDatePlanStatus(request.resource.data.status); allow update: if isCouplesMember(coupleId) // Only the explicitly-listed fields may change on update. // createdAt is intentionally absent — it cannot be modified after creation. && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration', 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', 'updatedAt' ]) && isValidDatePlanStatus(request.resource.data.status); allow delete: if isCouplesMember(coupleId); } // Bucket list items: shared list for both partners. // addedBy must match the caller on creation; addedBy and addedAt are immutable. // Marking an item complete requires the caller to own the completedBy field. match /bucket_list/{itemId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.resource.data.keys().hasAll(['title', 'addedBy', 'addedAt', 'isCompleted']) && request.resource.data.keys().hasOnly([ 'title', 'description', 'category', 'addedBy', 'addedAt', 'completedBy', 'completedAt', 'isCompleted' ]) && request.resource.data.addedBy == request.auth.uid && isValidBucketListCategory(request.resource.data.category); allow update: if isCouplesMember(coupleId) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt' ]) && isImmutable(['addedBy', 'addedAt']) // completedBy must be the caller when marking an item complete && (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid); allow delete: if isCouplesMember(coupleId); } // Daily question: server-assigned once per day per couple. // Writes are server-only (Cloud Functions / Admin SDK). match /daily_question/{date} { allow read: if isCouplesMember(coupleId); allow write: if false; } // Daily question answers: each user writes their own; both members read. match /daily_question/{date}/answers/{userId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.auth.uid == userId && request.resource.data.keys().hasAll(['userId', 'questionId', 'answerType', 'createdAt', 'updatedAt']) && request.resource.data.userId == request.auth.uid && request.resource.data.questionId is string && request.resource.data.answerType is string; allow update: if isCouplesMember(coupleId) && request.auth.uid == userId && request.resource.data.userId == resource.data.userId && request.resource.data.questionId == resource.data.questionId && request.resource.data.answerType == resource.data.answerType; allow delete: if false; } } // ── entitlement_events ──────────────────────────────────────────────────── // Cloud Functions write idempotency markers here via the Admin SDK. // No client access needed — explicit deny prevents accidental future grants. match /entitlement_events/{eventId} { allow read, write: if false; } } }