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) { return !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields); } 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'; } function isCiphertext(value) { return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$'); } function coupleEncryptionEnabled(coupleId) { return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1; } function isEncryptedAnswerPayload(data) { return (!('writtenText' in data) || data.writtenText == null || isCiphertext(data.writtenText)) && (!('selectedOptionIds' in data) || (data.selectedOptionIds is list && (data.selectedOptionIds.size() == 0 || (data.selectedOptionIds.size() == 1 && isCiphertext(data.selectedOptionIds[0]))))) && (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue)); } // Sealed-answer helpers (schemaVersion 3, partner-proof reveal). function isSealedPayload(value) { // sealed:v1: + URL-safe base64 no-padding body; 80 chars minimum rules out trivially short values return value is string && value.matches('^sealed:v1:[A-Za-z0-9_-]{80,}$'); } function isKeybox(value) { // keybox:v1: + URL-safe base64 no-padding; ECIES-P256 wrapping a 32-byte key is ~174 chars return value is string && value.matches('^keybox:v1:[A-Za-z0-9_-]{120,}$'); } function isCommitmentHash(value) { // sha256: + URL-safe base64 no-padding of a 32-byte digest = exactly 43 chars return value is string && value.matches('^sha256:[A-Za-z0-9_-]{43}$'); } // Returns true when the incoming data satisfies the sealed-answer create shape. // Plaintext content fields (writtenText, selectedOptionIds, scaleValue) are // rejected by the hasOnly check below — they must never appear in a sealed doc. function isSealedAnswerCreate(data) { return data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'encryptedPayload', 'commitmentHash', 'schemaVersion', 'answerKeyReleased', 'answerDate', 'createdAt', 'updatedAt', 'isRevealed' ]) && isSealedPayload(data.encryptedPayload) && isCommitmentHash(data.commitmentHash) && data.schemaVersion == 3 && data.answerKeyReleased == false && data.isRevealed == false; } // Only the reveal metadata fields may change after a sealed answer is created. function isSealedAnswerUpdate() { return resource.data.schemaVersion == 3 && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']); } // Thread sealed answers differ from daily answers: no answerDate (threads use threadId // as context), no isRevealed field (reveal state is tracked by the thread VM). function isSealedThreadAnswerCreate(data) { return data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'encryptedPayload', 'commitmentHash', 'schemaVersion', 'answerKeyReleased', 'createdAt', 'updatedAt' ]) && isSealedPayload(data.encryptedPayload) && isCommitmentHash(data.commitmentHash) && data.schemaVersion == 3 && data.answerKeyReleased == false; } function isSealedThreadAnswerUpdate() { return resource.data.schemaVersion == 3 && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['answerKeyReleased', 'updatedAt']); } function isStartingEncryptionMigration() { return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0) && request.resource.data.encryptionVersion == 1 && request.resource.data.wrappedCoupleKey is string && request.resource.data.kdfSalt is string && request.resource.data.kdfParams is string && request.resource.data.encryptionMigrationUsers is map && request.resource.data.encryptionMigrationUsers.size() == 0 && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion', 'encryptionMigrationUsers' ]); } function isCompletingOwnEncryptionMigration() { let migrated = request.resource.data.encryptionMigrationUsers; // Some version-1 couples predate the migration marker. Treat that missing // map as empty so either partner can safely record their own completion. let previous = ('encryptionMigrationUsers' in resource.data) ? resource.data.encryptionMigrationUsers : {}; let changed = migrated.diff(previous).affectedKeys(); let users = resource.data.userIds; return resource.data.encryptionVersion == 1 && request.resource.data.encryptionVersion >= 1 && request.resource.data.encryptionVersion <= 2 && migrated is map && changed.hasOnly([request.auth.uid]) && migrated[request.auth.uid] == true && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'encryptionVersion', 'encryptionMigrationUsers' ]) && (request.resource.data.encryptionVersion == 1 || (migrated[users[0]] == true && migrated[users[1]] == true)); } function isUpdatingRecoveryWrap() { return request.resource.data.encryptionVersion >= 1 && request.resource.data.wrappedCoupleKey is string && request.resource.data.kdfSalt is string && request.resource.data.kdfParams is string && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'wrappedCoupleKey', 'kdfSalt', 'kdfParams' ]); } function isUpdatingCoupleRhythm() { return request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'streakCount', 'lastAnsweredAt' ]); } // ── 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); } // Per-user ECIES public keys for sealed-answer key release. // The owner writes their own public key; only the user's current partner may read // it (to wrap release keys). Restricting to couple members prevents a malicious // user from reading arbitrary public keys and pre-encrypting speculative release keys. match /devices/{deviceId} { allow read: if isOwner(uid) || (isSignedIn() && get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null && get(/databases/$(database)/documents/users/$(uid)).data.coupleId == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId); allow create, update: if isOwner(uid) && request.resource.data.publicKey is string && request.resource.data.publicKey.matches('^pub:v1:') && request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']); allow delete: if false; } } // ── 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 the inviter may read their own invite (e.g. to check status). // Non-inviters are denied to prevent invite-code enumeration. allow read: if isSignedIn() && 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', 'createdAt', 'expiresAt', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']) && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']); // Update (accept): server-side / Cloud Function only. // Direct client updates to invites are denied. The Cloud Function uses the // Admin SDK, which bypasses these rules, to atomically create the couple, // update user docs, and mark the invite accepted. allow update: if false; } // ── 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: server-side only via the acceptInviteCallable Cloud Function. // The Admin SDK bypasses these rules. The shape check remains as defense // in depth in case any other trusted server process creates a couple doc. allow create: if isSignedIn() && request.auth.uid in request.resource.data.userIds && request.resource.data.keys().hasAll([ 'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion' ]) && request.resource.data.encryptionVersion == 2 && request.resource.data.wrappedCoupleKey is string && request.resource.data.kdfSalt is string && request.resource.data.kdfParams is string && 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(['id', 'userIds', 'inviteCode', 'createdAt']) && ( isUpdatingCoupleRhythm() || isUpdatingRecoveryWrap() || isStartingEncryptionMigration() || isCompletingOwnEncryptionMigration() ); // 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' || resource.data.status == request.resource.data.status && request.resource.data.currentIndex > resource.data.currentIndex) // 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. // Accepts schemaVersion 3 (sealed:v1: partner-proof) or schemaVersion 2 (enc:v1: couple-key). match /answers/{userId} { allow read: if isCouplesMember(coupleId); allow delete: if false; allow create: if isCouplesMember(coupleId) && isOwner(userId) && request.resource.data.userId == request.auth.uid && coupleEncryptionEnabled(coupleId) && ( isSealedThreadAnswerCreate(request.resource.data) || (request.resource.data.schemaVersion == 2 && request.resource.data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'schemaVersion', 'createdAt', 'updatedAt' ]) && isEncryptedAnswerPayload(request.resource.data)) ); allow update: if isCouplesMember(coupleId) && isOwner(userId) && ( isSealedThreadAnswerUpdate() || (coupleEncryptionEnabled(coupleId) && resource.data.schemaVersion != 3 && request.resource.data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'schemaVersion', 'createdAt', 'updatedAt' ]) && isEncryptedAnswerPayload(request.resource.data)) ); // One-time key release for sealed thread answers (same guards as daily answer release keys). match /releaseKeys/{recipientId} { allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId; allow create: if isCouplesMember(coupleId) && request.auth.uid == userId && recipientId != userId && recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds // Both answers must exist before either key can be released — prevents early single-sided release. && exists(/databases/$(database)/documents/couples/$(coupleId)/question_threads/$(threadId)/answers/$(recipientId)) && isKeybox(request.resource.data.encryptedAnswerKey) && request.resource.data.recipientUserId == recipientId && request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']); allow update, delete: if false; } } // 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) && coupleEncryptionEnabled(coupleId) && request.resource.data.authorUserId == request.auth.uid && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt']) && isCiphertext(request.resource.data.text); allow update: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && resource.data.authorUserId == request.auth.uid && request.resource.data.keys().hasOnly(['text']) && isCiphertext(request.resource.data.text); 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 && request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']); allow update: if isCouplesMember(coupleId) && resource.data.userId == request.auth.uid && request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']); 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.userId == request.auth.uid && request.resource.data.questionId is string && request.resource.data.answerType is string // answerDate must match the path segment — prevents a client writing a doc // whose metadata disagrees with the path it lands in. && request.resource.data.answerDate is string && request.resource.data.answerDate == date && ( // schemaVersion 3: partner-proof sealed answer. isSealedAnswerCreate(request.resource.data) || // schemaVersion 2: couple-key encrypted answer (legacy path). (coupleEncryptionEnabled(coupleId) && request.resource.data.schemaVersion == 2 && request.resource.data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'schemaVersion', 'answerDate', 'createdAt', 'updatedAt', 'isRevealed' ]) && isEncryptedAnswerPayload(request.resource.data)) ); 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 && ( // Sealed answers: only reveal metadata may change; payload is immutable. isSealedAnswerUpdate() || // enc:v1: answers: same field set, content may be updated. (coupleEncryptionEnabled(coupleId) && resource.data.schemaVersion != 3 && request.resource.data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'schemaVersion', 'answerDate', 'createdAt', 'updatedAt', 'isRevealed' ]) && isEncryptedAnswerPayload(request.resource.data)) ); allow delete: if false; // Release keys: the sender releases their one-time answer key to the recipient // after both partners have submitted. match /releaseKeys/{recipientId} { // Only the recipient can read their own release key. allow read: if request.auth.uid == recipientId && isCouplesMember(coupleId); // Create-only: written by the answer owner (sender) after both answers exist. allow create: if isCouplesMember(coupleId) && request.auth.uid == userId && recipientId != userId && recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds && exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(recipientId)) && isKeybox(request.resource.data.encryptedAnswerKey) && request.resource.data.recipientUserId == recipientId && request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']); allow update: if false; allow delete: if false; } } // Games use enc:v1: (schemaVersion 2 / shared couple key). // They are company-proof but not partner-proof: a modified client could read the // partner's encrypted slot before the reveal screen. Sealed per-answer keys are not // used here because games are real-time simultaneous — both players submit and see // results together; there is no single async "reveal" event to gate on. match /{gameCollection}/{sessionId} { allow read: if isCouplesMember(coupleId) && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']; allow create: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'] && request.resource.data.answers is map && request.resource.data.answers.keys().hasOnly([request.auth.uid]) && isCiphertext(request.resource.data.answers[request.auth.uid]) && request.resource.data.keys().hasOnly(['answers', 'categoryName', 'questions']); allow update: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'] && request.resource.data.answers is map && request.resource.data.answers.diff(resource.data.answers).affectedKeys() .hasOnly([request.auth.uid]) && isCiphertext(request.resource.data.answers[request.auth.uid]) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['answers']); 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; } } }