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; } // The other member of a 2-person couple (relative to uid). function otherCoupleMember(coupleId, uid) { return get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[0] == uid ? get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[1] : get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[0]; } // Has the partner already written their reflection for this date? Once true, the author's // reflection is sealed (edits are no longer allowed). function partnerReflectedDate(coupleId, dateId, uid) { return exists(/databases/$(database)/documents/couples/$(coupleId)/date_reflections/$(dateId)/answers/$(otherCoupleMember(coupleId, uid))); } 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 isPublicKey(value) { // pub:v1: + URL-safe base64 no-padding of a Tink ECIES public keyset JSON. return value is string && value.matches('^pub:v1:[A-Za-z0-9_-]{40,}$'); } 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']); } // Couple-key daily answer (schemaVersion 2): the answer doc is metadata only (no content). // The encrypted content lives in the read-gated `secure` subdoc, so this doc can stay // readable (drives the partner's "your turn" indicator) without leaking the answer. function isCoupleKeyAnswerCreate(data) { return data.keys().hasOnly([ 'userId', 'questionId', 'answerType', 'schemaVersion', 'answerDate', 'createdAt', 'updatedAt', 'isRevealed' ]) && data.schemaVersion == 2 && data.isRevealed == false; } // After a couple-key answer is created, only the reveal flag may flip (drives the opened push). function isCoupleKeyAnswerUpdate() { return resource.data.schemaVersion == 2 && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isRevealed', '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']); // Field allowlist (hardening): the owner may update ONLY known profile/aux fields. This blocks // the server-owned `hasPremium`/`premium` flags AND any arbitrary junk keys a client could set on // its own doc. Entitlements live in the server-only `entitlements/premium` subdoc; no gate reads // these root fields, so this is defense-in-depth, not a fix for a live hole. Keep this list in sync // with `User.kt` + `FirestoreUserDataSource` if a new client-written field is added. allow update: if isOwner(uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId', 'plan', 'createdAt', 'lastActiveAt', 'fcmToken', 'notifPartnerAnswered', 'notifChatMessage', // Daily/streak/promotional prefs mirrored so the scheduled senders can honor them. 'notifDailyReminder', 'notifStreakReminder', 'notifPromotional', // M-001: quiet-hours window mirrored for server-side push suppression. 'quietHoursEnabled', 'quietHoursStartMinutes', 'quietHoursEndMinutes', 'timezone' ]); // Entitlements written server-side only (RevenueCat webhook via Admin SDK). // Client needs read access so FirestoreEntitlementChecker can observe premium state. match /entitlements/{entitlementDoc} { // Owner reads their own; a paired partner may also read it so premium can be shared // across the couple (chat media unlocks if EITHER partner is premium). allow read: if isOwner(uid) || ( request.auth != null && exists(/databases/$(database)/documents/users/$(uid)) && get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null && exists(/databases/$(database)/documents/users/$(request.auth.uid)) && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId == get(/databases/$(database)/documents/users/$(uid)).data.coupleId ); 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); // Per-couple active-session pointer used as an atomic lock so two partners starting a game // at the same instant converge to ONE session instead of two divergent ones (F-RACE-001). // It holds no game content (only activeSessionId + updatedAt) and carries no status/ // completedAt, so it never appears in the active-session or history queries. allow create, update: if sessionId == '_active' && isCouplesMember(coupleId); // Create: either member can start a session allow create: if isCouplesMember(coupleId) && request.resource.data.startedByUserId == request.auth.uid; // Update: any couple member may record session progress/completion. // (Async two-device games mark each player done via `completedByUsers`; the // session flips active→completed once both are in. The previous rule only // allowed `status`/`completedAt`, so every `completedByUsers` write was denied // and finished games never closed — locking the couple out of new games. B-001.) allow update: if isCouplesMember(coupleId) // startedByUserId is immutable for direct client writes. && request.resource.data.startedByUserId == resource.data.startedByUserId // Only session progress/completion/join fields may change. (`joinedByUsers` records the // non-starter opening the session → drives the partner_joined_game push. The server-only // `joinNotifiedAt`/`startNotifiedAt`/`finishNotifiedAt` flags are intentionally NOT here, // so clients can't spoof a "notified" claim — the Cloud Function writes those via Admin.) && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['status', 'completedAt', 'completedByUsers', 'joinedByUsers']) // Defense-in-depth: a member may only ADD THEIR OWN uid to the progress arrays — never // spoof the partner's join/completion, and never remove entries. (old ⊆ new ⊆ old ∪ {self}; // `get(...,[])` tolerates docs created before these fields existed.) Compatible with the // client writes: markUserComplete / markUserJoined / abandonSession all leave the arrays as // old or old+self. && request.resource.data.get('completedByUsers', []) .hasAll(resource.data.get('completedByUsers', [])) && request.resource.data.get('completedByUsers', []) .hasOnly(resource.data.get('completedByUsers', []).concat([request.auth.uid])) && request.resource.data.get('joinedByUsers', []) .hasAll(resource.data.get('joinedByUsers', [])) && request.resource.data.get('joinedByUsers', []) .hasOnly(resource.data.get('joinedByUsers', []).concat([request.auth.uid])) // status is monotonic: stay the same, or transition active → completed (never revert). && (request.resource.data.status == resource.data.status || (resource.data.status == 'active' && request.resource.data.status == 'completed')); // 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; } } // Conversations: the Messages inbox. Each conversation (the couple chat or a per-question // discussion) holds E2E-encrypted messages; only metadata + the encrypted last-message // preview live on the conversation doc. match /conversations/{conversationId} { allow read: if isCouplesMember(coupleId); // Members may create/merge-update the conversation doc within the allowed shape; any // last-message preview must be ciphertext (encrypted on-device before write). allow write: if isCouplesMember(coupleId) && request.resource.data.keys().hasOnly( ['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing']) && (!('lastMessagePreview' in request.resource.data) || isCiphertext(request.resource.data.lastMessagePreview)); // Messages: author-only create; text is ciphertext OR it's an image message pointing at // encrypted Storage bytes; immutable after creation. 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', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted']) && ( (request.resource.data.get('type', 'text') in ['image', 'voice'] && request.resource.data.mediaUrl is string && request.resource.data.mediaUrl.size() > 0) || (request.resource.data.get('type', 'text') == 'text' && isCiphertext(request.resource.data.text)) ); // Reactions: any couple member may change ONLY the reactions map. // Unsend: only the author may set the `deleted` tombstone. allow update: if isCouplesMember(coupleId) && ( request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions']) || (resource.data.authorUserId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted'])) ); allow delete: if false; } } // 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); } // Date Replay — completed-date log. PLAINTEXT app metadata (date-idea title/category + timestamp, // not private words; the reflection content is E2EE below). Idempotent merge on doc id = matchId. match /date_history/{dateId} { allow read: if isCouplesMember(coupleId); allow create, update: if isCouplesMember(coupleId) && request.resource.data.keys().hasOnly(['dateIdeaId', 'title', 'category', 'completedAt', 'addedBy']) && request.resource.data.addedBy is string && request.resource.data.completedAt is number; allow delete: if isCouplesMember(coupleId); } // Date reflection metadata: each user writes their own; both members read (drives "your turn"). // Mirrors daily_question/answers — encrypted content is in the read-gated `secure` subdoc below. match /date_reflections/{dateId}/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.isRevealed == false && request.resource.data.keys().hasOnly(['userId', 'schemaVersion', 'createdAt', 'updatedAt', 'isRevealed']); allow update: if isCouplesMember(coupleId) && request.auth.uid == userId && request.resource.data.userId == resource.data.userId // Only the reveal flag may flip; the encrypted payload is immutable. && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRevealed', 'updatedAt']); allow delete: if false; // Couple-key encrypted reflection content. Read-gated: you can read your PARTNER's content only // once YOU have also reflected (the "private until both" gate). Your own content is always readable. match /secure/{doc} { allow read: if isCouplesMember(coupleId) && (request.auth.uid == userId || exists(/databases/$(database)/documents/couples/$(coupleId)/date_reflections/$(dateId)/answers/$(request.auth.uid))); allow create: if isCouplesMember(coupleId) && request.auth.uid == userId && isCiphertext(request.resource.data.encryptedPayload) && request.resource.data.keys().hasOnly(['encryptedPayload']); // Author may edit their OWN still-sealed reflection ONLY until the partner reflects. Once the // partner has reflected the content is immutable (the "sealed until both reveal" guarantee). allow update: if isCouplesMember(coupleId) && request.auth.uid == userId && !partnerReflectedDate(coupleId, dateId, userId) && isCiphertext(request.resource.data.encryptedPayload) && request.resource.data.keys().hasOnly(['encryptedPayload']); allow delete: if false; } } // ── E2EE conversation backup ──────────────────────────────────────────── // Members read/write their own couple's backup. The manifest holds pointers + a `generation` // counter (optimistic concurrency); chunk/snapshot BODIES are enc:v1: ciphertext (server-blind). // The snapshot blob itself lives in Storage. recursiveDelete(coupleRef) cascades this subtree. match /backup/{doc} { allow read, write: if isCouplesMember(coupleId); match /chunks/{seq} { allow read: if isCouplesMember(coupleId); allow create, update: if isCouplesMember(coupleId) && isCiphertext(request.resource.data.payload); allow delete: if isCouplesMember(coupleId); // compaction folds chunks into a snapshot } } // ── Partner-assisted restore requests ─────────────────────────────────── // The recovering member (recipientUid) creates their OWN request carrying a FRESH ECIES public // key. The PARTNER (the other member) writes the keybox — the couple key wrapped to that pubkey — // only after confirming the out-of-band 6-digit code. Server never sees plaintext key material. match /restore_requests/{recipientUid} { allow read: if isCouplesMember(coupleId); // Recipient creates their own request (no keybox yet). allow create: if isCouplesMember(coupleId) && request.auth.uid == recipientUid && request.resource.data.recipientUid == recipientUid && isPublicKey(request.resource.data.recipientPublicKey) && request.resource.data.status == 'REQUESTED' && !('keybox' in request.resource.data) && request.resource.data.keys().hasOnly( ['recipientUid', 'recipientPublicKey', 'requestNonce', 'status', 'createdAt', 'expiresAt']); // The PARTNER (not the recipient) writes the keybox + flips status to READY; pubkey/nonce immutable. allow update: if isCouplesMember(coupleId) && request.auth.uid != recipientUid && recipientUid in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds && request.resource.data.recipientPublicKey == resource.data.recipientPublicKey && request.resource.data.requestNonce == resource.data.requestNonce && isKeybox(request.resource.data.keybox) && request.resource.data.status == 'READY' && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['keybox', 'status', 'fulfilledAt']); // Either member may flip status only (decline / expire / mark restored) — never touching keys. allow update: if isCouplesMember(coupleId) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']) && request.resource.data.status in ['DECLINED', 'EXPIRED', 'RESTORED']; // Only the recipient consumes (deletes) their own request after unwrapping. allow delete: if isCouplesMember(coupleId) && request.auth.uid == recipientUid; } // 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; } // Memory Lane capsules: member-readable; author creates with ENCRYPTED content (title/content/ // promptUsed are enc:v1:). status flips sealed→unlocked (client or the scheduled unlock fn). match /capsules/{capsuleId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && coupleEncryptionEnabled(coupleId) && request.resource.data.authorId == request.auth.uid && request.resource.data.keys().hasOnly( ['authorId', 'title', 'content', 'promptUsed', 'unlockAt', 'createdAt', 'status']) && isCiphertext(request.resource.data.title) && isCiphertext(request.resource.data.content) && (!('promptUsed' in request.resource.data) || request.resource.data.promptUsed == null || isCiphertext(request.resource.data.promptUsed)); allow update: if isCouplesMember(coupleId) && ( // Author re-saves encrypted content before unlock (request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'content', 'promptUsed', 'unlockAt']) && isCiphertext(request.resource.data.title) && isCiphertext(request.resource.data.content)) || // Status transition (e.g. sealed → unlocked) request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']) ); allow delete: if isCouplesMember(coupleId); } // Connection Challenges: catalog-referenced (no free-text user content), members track progress. match /challenges/{challengeId} { allow read: if isCouplesMember(coupleId); allow create: if isCouplesMember(coupleId) && request.resource.data.keys().hasOnly(['challengeId', 'startedAt', 'status', 'completions']); allow update: if isCouplesMember(coupleId) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['completions', 'status']); allow delete: if isCouplesMember(coupleId); } // 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} { // The answer doc holds only metadata (no content) so the partner can see THAT you // answered ("your turn / waiting for you"); the encrypted content lives in the // read-gated `secure` subdoc below. Both members may read metadata. 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 2 = couple-key (current); 3 = legacy sealed partner-proof. && (isCoupleKeyAnswerCreate(request.resource.data) || 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 // Only reveal metadata may change; the encrypted payload is immutable. && (isCoupleKeyAnswerUpdate() || 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; } // Couple-key encrypted answer content (schemaVersion 2). Read-gated: you can read your // PARTNER's content only once YOU have also answered — the cryptographic "private until // both answered" gate. Your own content is always readable. match /secure/{doc} { allow read: if isCouplesMember(coupleId) && (request.auth.uid == userId || exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(request.auth.uid))); allow create: if isCouplesMember(coupleId) && request.auth.uid == userId && isCiphertext(request.resource.data.encryptedPayload) && request.resource.data.keys().hasOnly(['encryptedPayload']); allow update, 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; } } }