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 fields is list && !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}$'); } // A field is acceptable if it's absent, null, or enc:v1 ciphertext — never plaintext. function cipherOrAbsent(data, key) { return !(key in data) || data[key] == null || isCiphertext(data[key]); } // Date plan user content must be ciphertext; dateIdeaId/scheduledDate/status/timestamps // stay plaintext because Firestore queries and ordering depend on them. function isDatePlanContentEncrypted(data) { return cipherOrAbsent(data, 'scheduledTime') && cipherOrAbsent(data, 'budget') && cipherOrAbsent(data, 'duration') && cipherOrAbsent(data, 'activity') && cipherOrAbsent(data, 'food') && cipherOrAbsent(data, 'conversationPrompts') && cipherOrAbsent(data, 'optionalChallenge'); } function coupleEncryptionEnabled(coupleId) { return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1; } // 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 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} { // Owner reads their own doc; a paired partner may read the other's doc (name + photo) // — they share a coupleId. Without this, partner name/photo never load (shows // "Your partner" / blank avatar everywhere: pairing screen, Home, games). allow read: if isOwner(uid) || ( request.auth != null && resource.data.coupleId != null && exists(/databases/$(database)/documents/users/$(request.auth.uid)) && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId == resource.data.coupleId ); 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} { // Written server-side (Admin SDK bypasses rules). The owner may read their own // activity feed and flip a notification's `read` flag — nothing else. allow read: if isOwner(uid); allow update: if isOwner(uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['read']); allow create, delete: if false; } // Per-user outcome mirrors for cross-relationship progress stats. // Writes are server-side only (submitOutcomeCallable); direct client writes denied. match /outcomes/{dayKey} { allow read: if isOwner(uid) && dayKey in ['day_0', 'day_30', 'day_60', 'day_90']; allow create, update, delete: 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 is server-side only for writes. Clients may only read their // own pending invites. The invite document ID is a 6-character code; it is // enumerable, so direct client create/update/delete is denied. 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. // Expired invites remain readable by the inviter for diagnostics. allow read: if isSignedIn() && request.auth.uid == resource.data.inviterUserId; // Create / Update / Delete: server-side / Cloud Functions only. // The Admin SDK bypasses these rules. Direct client writes are denied // because 6-character codes are enumerable and invite creation involves // rate limiting, uniqueness checks, and key material the client cannot // be trusted to produce safely. allow create, update, delete: 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() ); // 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. // Strict couples must use schemaVersion 3 (sealed:v1: partner-proof). // schemaVersion 2 is accepted only for v1 migration couples. 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); allow update: if isCouplesMember(coupleId) && isOwner(userId) && isSealedThreadAnswerUpdate(); // 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); // Text messages carry ciphertext in `text`; image messages carry only a `mediaUrl` // pointing at the encrypted bytes in Storage (the photo itself is E2E-encrypted). allow create: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && request.resource.data.authorUserId == request.auth.uid && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl']) && ( (request.resource.data.get('type', 'text') == 'image' && request.resource.data.mediaUrl is string && request.resource.data.mediaUrl.size() > 0) || (request.resource.data.get('type', 'text') == 'text' && 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. The action is E2E // ciphertext so the server can't read date preferences; only swipedAt is plaintext. match /date_swipes/{dateIdeaId} { // Read: both couple members can read the shared swipe document (ciphertext). allow read: if isCouplesMember(coupleId); // Create (doc doesn't exist yet): only the caller's own entry may be present, // and the action must be ciphertext. allow create: if isCouplesMember(coupleId) && 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']) && isCiphertext(request.resource.data.actions[request.auth.uid].action) && request.resource.data.actions[request.auth.uid].swipedAt is number; // Update (partner may already have an entry): a merge write exposes the whole // post-write doc, so diff to ensure ONLY the caller's own entry changed. allow update: if isCouplesMember(coupleId) && request.resource.data.keys().hasOnly(['actions']) && request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt']) && isCiphertext(request.resource.data.actions[request.auth.uid].action) && request.resource.data.actions[request.auth.uid].swipedAt is number && resource.data.actions.diff(request.resource.data.actions).affectedKeys().hasOnly([request.auth.uid]); // Delete: server-only (admin SDK). Admin SDK bypasses rules. allow delete: if false; } // Date matches: revealed mutual-love matches (matchId == dateIdeaId). // Server is blind to encrypted swipes, so the client writes the marker when it // detects mutual love; a Cloud Function fires the notification on create. The // creator must be one of the two matched members. fcmNotified flips server-side. match /date_matches/{matchId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.resource.data.keys().hasOnly(['dateIdeaId', 'revealedAt', 'matchedBy', 'fcmNotified']) && request.resource.data.dateIdeaId is string && request.resource.data.matchedBy is list && request.resource.data.matchedBy.size() == 2 && request.auth.uid in request.resource.data.matchedBy && request.resource.data.fcmNotified == false; allow 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' ]) // Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries). && cipherOrAbsent(request.resource.data, 'preferredTime') && cipherOrAbsent(request.resource.data, 'budget') && cipherOrAbsent(request.resource.data, 'duration'); 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) && isDatePlanContentEncrypted(request.resource.data); 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) && isDatePlanContentEncrypted(request.resource.data); 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) // Strict E2EE: user content must be ciphertext. && isCiphertext(request.resource.data.title) && (!('description' in request.resource.data) || request.resource.data.description == null || isCiphertext(request.resource.data.description)); 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) // Strict E2EE: title/description remain ciphertext (merged result is always encrypted). && isCiphertext(request.resource.data.title) && (!('description' in request.resource.data) || request.resource.data.description == null || isCiphertext(request.resource.data.description)); allow delete: if isCouplesMember(coupleId); } // Couple Lore stores revealed answer summaries. Summary text must remain // encrypted with the couple key; prompts/metadata can stay plaintext. match /lore/{loreId} { allow read: if isCouplesMember(coupleId); allow create, update: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && request.resource.data.keys().hasOnly([ 'questionId', 'questionText', 'ownAnswer', 'partnerAnswer', 'modeTag', 'date', 'schemaVersion', 'savedAt' ]) && request.resource.data.questionId is string && request.resource.data.questionText is string && request.resource.data.date is string && request.resource.data.schemaVersion == 2 && isCiphertext(request.resource.data.ownAnswer) && (!('partnerAnswer' in request.resource.data) || request.resource.data.partnerAnswer == null || isCiphertext(request.resource.data.partnerAnswer)); allow delete: if false; } // Outcomes: couple-level 30/60/90 day check-ins. Both members can read. // Writes are server-side only via submitOutcomeCallable; direct client writes denied. match /outcomes/{dayKey} { allow read: if isCouplesMember(coupleId) && dayKey in ['day_0', 'day_30', 'day_60', 'day_90']; allow create, update, delete: if false; } // 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 (the only accepted shape). && isSealedAnswerCreate(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(); 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} { // The recipient reads the key released to them. The sender (answer owner = {userId}) // must also be able to read their own released doc, because writeReleaseKey does an // idempotency existence-check get() before writing — without this, that get() was // PERMISSION_DENIED, releaseOwnKey threw, and the daily reveal failed. The keybox is // ECIES-encrypted to the recipient, so the sender reading it leaks nothing. allow read: if isCouplesMember(coupleId) && (request.auth.uid == recipientId || request.auth.uid == userId); // 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; } } }