Closer/firestore.rules

940 lines
50 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}
}