Closer/firestore.rules

648 lines
32 KiB
Plaintext
Raw Normal View History

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) {
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
return !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields);
}
function isValidSwipeAction(action) {
return action == 'love' || action == 'maybe' || action == 'skip';
}
function isValidDatePlanStatus(status) {
return status == 'draft' || status == 'planned' || status == 'completed';
}
function isValidBucketListCategory(category) {
return category == 'adventure' || category == 'travel' || category == 'food'
|| category == 'learning' || category == 'romance' || category == 'intimacy'
|| category == 'seasonal';
}
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
function isCiphertext(value) {
return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$');
}
function coupleEncryptionEnabled(coupleId) {
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
}
function isEncryptedAnswerPayload(data) {
return (!('writtenText' in data) || data.writtenText == null || isCiphertext(data.writtenText))
&& (!('selectedOptionIds' in data)
|| (data.selectedOptionIds is list
&& (data.selectedOptionIds.size() == 0
|| (data.selectedOptionIds.size() == 1
&& isCiphertext(data.selectedOptionIds[0])))))
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
}
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
function isSealedPayload(value) {
// sealed:v1: + URL-safe base64 no-padding body; 80 chars minimum rules out trivially short values
return value is string && value.matches('^sealed:v1:[A-Za-z0-9_-]{80,}$');
}
function isKeybox(value) {
// keybox:v1: + URL-safe base64 no-padding; ECIES-P256 wrapping a 32-byte key is ~174 chars
return value is string && value.matches('^keybox:v1:[A-Za-z0-9_-]{120,}$');
}
function isCommitmentHash(value) {
// sha256: + URL-safe base64 no-padding of a 32-byte digest = exactly 43 chars
return value is string && value.matches('^sha256:[A-Za-z0-9_-]{43}$');
}
// Returns true when the incoming data satisfies the sealed-answer create shape.
// Plaintext content fields (writtenText, selectedOptionIds, scaleValue) are
// rejected by the hasOnly check below — they must never appear in a sealed doc.
function isSealedAnswerCreate(data) {
return data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'encryptedPayload',
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
])
&& isSealedPayload(data.encryptedPayload)
&& isCommitmentHash(data.commitmentHash)
&& data.schemaVersion == 3
&& data.answerKeyReleased == false
&& data.isRevealed == false;
}
// Only the reveal metadata fields may change after a sealed answer is created.
function isSealedAnswerUpdate() {
return resource.data.schemaVersion == 3
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
}
// Thread sealed answers differ from daily answers: no answerDate (threads use threadId
// as context), no isRevealed field (reveal state is tracked by the thread VM).
function isSealedThreadAnswerCreate(data) {
return data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'encryptedPayload',
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
'createdAt', 'updatedAt'
])
&& isSealedPayload(data.encryptedPayload)
&& isCommitmentHash(data.commitmentHash)
&& data.schemaVersion == 3
&& data.answerKeyReleased == false;
}
function isSealedThreadAnswerUpdate() {
return resource.data.schemaVersion == 3
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['answerKeyReleased', 'updatedAt']);
}
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
function isStartingEncryptionMigration() {
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
&& request.resource.data.encryptionVersion == 1
&& request.resource.data.wrappedCoupleKey is string
&& request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string
&& request.resource.data.encryptionMigrationUsers is map
&& request.resource.data.encryptionMigrationUsers.size() == 0
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'wrappedCoupleKey', 'kdfSalt', 'kdfParams',
'encryptionVersion', 'encryptionMigrationUsers'
]);
}
function isCompletingOwnEncryptionMigration() {
let migrated = request.resource.data.encryptionMigrationUsers;
// Some version-1 couples predate the migration marker. Treat that missing
// map as empty so either partner can safely record their own completion.
let previous = ('encryptionMigrationUsers' in resource.data)
? resource.data.encryptionMigrationUsers
: {};
let changed = migrated.diff(previous).affectedKeys();
let users = resource.data.userIds;
return resource.data.encryptionVersion == 1
&& request.resource.data.encryptionVersion >= 1
&& request.resource.data.encryptionVersion <= 2
&& migrated is map
&& changed.hasOnly([request.auth.uid])
&& migrated[request.auth.uid] == true
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'encryptionVersion', 'encryptionMigrationUsers'
])
&& (request.resource.data.encryptionVersion == 1
|| (migrated[users[0]] == true && migrated[users[1]] == true));
}
function isUpdatingRecoveryWrap() {
return request.resource.data.encryptionVersion >= 1
&& request.resource.data.wrappedCoupleKey is string
&& request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'
]);
}
function isUpdatingCoupleRhythm() {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'streakCount', 'lastAnsweredAt'
]);
}
// ── Users ─────────────────────────────────────────────────────────────────
// Each user owns exactly their own document.
// hasPremium is server-only: clients may not write it directly.
match /users/{uid} {
allow read: if isOwner(uid);
allow create: if isOwner(uid)
&& !request.resource.data.keys().hasAny(['hasPremium']);
allow update: if isOwner(uid)
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']);
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
match /entitlements/{entitlementDoc} {
allow read: if isOwner(uid);
allow write: if false;
}
// Notification queue written server-side only (Cloud Functions).
// No client read needed; the app reacts to FCM push, not this collection.
match /notification_queue/{notificationId} {
allow read, write: if false;
}
// FCM registration tokens: owner can read/write their own tokens.
match /fcmTokens/{tokenId} {
allow read, write: if isOwner(uid);
}
// Per-user ECIES public keys for sealed-answer key release.
// The owner writes their own public key; only the user's current partner may read
// it (to wrap release keys). Restricting to couple members prevents a malicious
// user from reading arbitrary public keys and pre-encrypting speculative release keys.
match /devices/{deviceId} {
allow read: if isOwner(uid)
|| (isSignedIn()
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId
== get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId);
allow create, update: if isOwner(uid)
&& request.resource.data.publicKey is string
&& request.resource.data.publicKey.matches('^pub:v1:')
&& request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']);
allow delete: if false;
}
}
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
// Curated date ideas are readable by any authenticated user.
// Writes are server-only (admin SDK / Cloud Functions seeding).
match /date_ideas/{dateIdeaId} {
allow read: if isSignedIn();
allow create, update, delete: if false;
}
// ── Invite codes ──────────────────────────────────────────────────────────
// Invite system with proper ownership, validation, and expiry checks.
match /invites/{code} {
// Read: only the inviter may read their own invite (e.g. to check status).
// Non-inviters are denied to prevent invite-code enumeration.
allow read: if isSignedIn()
&& request.auth.uid == resource.data.inviterUserId
&& request.time < resource.data.expiresAt;
// Create: ownership, code format, and required fields validation.
// hasOnly prevents injecting unrelated fields (e.g. coupleId) at creation.
allow create: if isSignedIn()
&& request.resource.data.inviterUserId == request.auth.uid
&& isValidInviteCode(code)
&& isValidInviteCode(request.resource.data.code)
&& request.resource.data.code == code
&& request.resource.data.status == 'pending'
&& request.resource.data.expiresAt is timestamp
&& request.time < request.resource.data.expiresAt
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']);
// Update (accept): server-side / Cloud Function only.
// Direct client updates to invites are denied. The Cloud Function uses the
// Admin SDK, which bypasses these rules, to atomically create the couple,
// update user docs, and mark the invite accepted.
allow update: if false;
}
// ── Couples ───────────────────────────────────────────────────────────────
// Only the two members of a couple may read couple data.
// Writes are restricted by field ownership and immutability.
match /couples/{coupleId} {
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: server-side only via the acceptInviteCallable Cloud Function.
// The Admin SDK bypasses these rules. The shape check remains as defense
// in depth in case any other trusted server process creates a couple doc.
allow create: if isSignedIn()
&& request.auth.uid in request.resource.data.userIds
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& 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)
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& isImmutable(['id', 'userIds', 'inviteCode', 'createdAt'])
&& (
isUpdatingCoupleRhythm()
|| isUpdatingRecoveryWrap()
|| isStartingEncryptionMigration()
|| isCompletingOwnEncryptionMigration()
);
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
allow delete: if false;
match /sessions/{sessionId} {
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: either member can start a session
allow create: if isCouplesMember(coupleId)
&& request.resource.data.startedByUserId == request.auth.uid;
// Update: only the user who started the session can update it, OR valid status transitions.
// startedByUserId is immutable for direct client writes.
allow update: if isCouplesMember(coupleId)
// Either the original starter can update
&& (resource.data.startedByUserId == request.auth.uid
// Or status transition is valid: active → completed
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'))
// startedByUserId cannot be changed by clients
&& request.resource.data.startedByUserId == resource.data.startedByUserId
// Only a fixed set of fields may change
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'completedAt']);
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false;
}
// Question threads live under the couple document.
match /question_threads/{threadId} {
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: either member can create a thread
allow create: if isCouplesMember(coupleId)
&& request.resource.data.createdByUserId == request.auth.uid;
// Update: valid state transitions only, currentIndex only incrementable
allow update: if isCouplesMember(coupleId)
// Status transitions must be valid: NOT_STARTED → ANSWERED_BY_ONE → REVEALED → COMPLETED
&& (resource.data.status == 'NOT_STARTED' && request.resource.data.status == 'ANSWERED_BY_ONE'
|| resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'REVEALED'
|| resource.data.status == 'REVEALED' && request.resource.data.status == 'COMPLETED'
|| resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'COMPLETED'
|| resource.data.status == request.resource.data.status
&& request.resource.data.currentIndex > resource.data.currentIndex)
// currentIndex can only be incremented, never decremented or reset
&& request.resource.data.currentIndex != null
&& (resource.data.currentIndex == null || request.resource.data.currentIndex >= resource.data.currentIndex)
// No other fields should change
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'currentIndex']);
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false;
// Answers: each user writes their own; both members can read all answers.
// Accepts schemaVersion 3 (sealed:v1: partner-proof) or schemaVersion 2 (enc:v1: couple-key).
match /answers/{userId} {
allow read: if isCouplesMember(coupleId);
allow delete: if false;
allow create: if isCouplesMember(coupleId)
&& isOwner(userId)
&& request.resource.data.userId == request.auth.uid
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& coupleEncryptionEnabled(coupleId)
&& (
isSealedThreadAnswerCreate(request.resource.data)
|| (request.resource.data.schemaVersion == 2
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'createdAt', 'updatedAt'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow update: if isCouplesMember(coupleId)
&& isOwner(userId)
&& (
isSealedThreadAnswerUpdate()
|| (coupleEncryptionEnabled(coupleId)
&& resource.data.schemaVersion != 3
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'createdAt', 'updatedAt'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
// One-time key release for sealed thread answers (same guards as daily answer release keys).
match /releaseKeys/{recipientId} {
allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId;
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& recipientId != userId
&& recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
// Both answers must exist before either key can be released — prevents early single-sided release.
&& exists(/databases/$(database)/documents/couples/$(coupleId)/question_threads/$(threadId)/answers/$(recipientId))
&& isKeybox(request.resource.data.encryptedAnswerKey)
&& request.resource.data.recipientUserId == recipientId
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
allow update, delete: if false;
}
}
// Discussion messages: any couple member can read, but only the author can write/update/delete
match /messages/{messageId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorUserId == request.auth.uid
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt'])
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& isCiphertext(request.resource.data.text);
allow update: if isCouplesMember(coupleId)
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& coupleEncryptionEnabled(coupleId)
&& resource.data.authorUserId == request.auth.uid
&& request.resource.data.keys().hasOnly(['text'])
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& isCiphertext(request.resource.data.text);
allow delete: if isCouplesMember(coupleId)
&& resource.data.authorUserId == request.auth.uid;
}
// Reactions: any couple member can read, but only the creator can write/update/delete
match /reactions/{reactionId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']);
allow update: if isCouplesMember(coupleId)
&& resource.data.userId == request.auth.uid
&& request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']);
allow delete: if isCouplesMember(coupleId)
&& resource.data.userId == request.auth.uid;
}
}
// Date swipes: per-couple, per-date partner swipe state.
match /date_swipes/{dateIdeaId} {
// Read: both couple members can read the shared swipe document.
allow read: if isCouplesMember(coupleId);
// Create/Update: each member can only write their own action entry.
// The payload must contain an actions.{uid} object with a valid action.
allow create, update: if isCouplesMember(coupleId)
// The path to the current user's action must exist and be the only action written
&& request.resource.data.keys().hasOnly(['actions'])
&& request.resource.data.actions.keys().hasOnly([request.auth.uid])
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
&& isValidSwipeAction(request.resource.data.actions[request.auth.uid].action)
&& request.resource.data.actions[request.auth.uid].action != null
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false;
}
// Date matches: revealed mutual love matches.
// Clients can read; creation of a match is performed by a Cloud Function
// after both partners have swiped 'love'. Direct client writes are denied.
match /date_matches/{matchId} {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if false;
}
// Date plan preferences: per-partner preferences for building date plans.
// Both members can read; either member can write a preference document.
// Document IDs are Firestore auto-IDs (not user IDs).
match /date_plan_preferences/{prefId} {
allow read: if isCouplesMember(coupleId);
allow create, update: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasAll(['dateIdeaId', 'createdAt', 'updatedAt'])
&& request.resource.data.keys().hasOnly([
'dateIdeaId', 'preferredDate', 'preferredTime',
'budget', 'duration', 'createdAt', 'updatedAt'
]);
allow delete: if false;
}
// Date plans: complete plans assembled from partner preferences.
// Both members can read and delete; writes are field-validated.
// createdAt is immutable after creation (excluded from the update allowed-keys set).
match /date_plans/{planId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasAll(['dateIdeaId', 'scheduledDate', 'status', 'createdAt', 'updatedAt'])
&& request.resource.data.keys().hasOnly([
'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration',
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
'createdAt', 'updatedAt'
])
&& isValidDatePlanStatus(request.resource.data.status);
allow update: if isCouplesMember(coupleId)
// Only the explicitly-listed fields may change on update.
// createdAt is intentionally absent — it cannot be modified after creation.
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration',
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
'updatedAt'
])
&& isValidDatePlanStatus(request.resource.data.status);
allow delete: if isCouplesMember(coupleId);
}
// Bucket list items: shared list for both partners.
// addedBy must match the caller on creation; addedBy and addedAt are immutable.
// Marking an item complete requires the caller to own the completedBy field.
match /bucket_list/{itemId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasAll(['title', 'addedBy', 'addedAt', 'isCompleted'])
&& request.resource.data.keys().hasOnly([
'title', 'description', 'category', 'addedBy', 'addedAt',
'completedBy', 'completedAt', 'isCompleted'
])
&& request.resource.data.addedBy == request.auth.uid
&& isValidBucketListCategory(request.resource.data.category);
allow update: if isCouplesMember(coupleId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
])
&& isImmutable(['addedBy', 'addedAt'])
// completedBy must be the caller when marking an item complete
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid);
allow delete: if isCouplesMember(coupleId);
}
// Daily question: server-assigned once per day per couple.
// Writes are server-only (Cloud Functions / Admin SDK).
match /daily_question/{date} {
allow read: if isCouplesMember(coupleId);
allow write: if false;
}
// Daily question answers: each user writes their own; both members read.
match /daily_question/{date}/answers/{userId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.questionId is string
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& request.resource.data.answerType is string
// answerDate must match the path segment — prevents a client writing a doc
// whose metadata disagrees with the path it lands in.
&& request.resource.data.answerDate is string
&& request.resource.data.answerDate == date
&& (
// schemaVersion 3: partner-proof sealed answer.
isSealedAnswerCreate(request.resource.data)
||
// schemaVersion 2: couple-key encrypted answer (legacy path).
(coupleEncryptionEnabled(coupleId)
&& request.resource.data.schemaVersion == 2
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
&& request.resource.data.answerType == resource.data.answerType
&& (
// Sealed answers: only reveal metadata may change; payload is immutable.
isSealedAnswerUpdate()
||
// enc:v1: answers: same field set, content may be updated.
(coupleEncryptionEnabled(coupleId)
&& resource.data.schemaVersion != 3
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
allow delete: if false;
// Release keys: the sender releases their one-time answer key to the recipient
// after both partners have submitted.
match /releaseKeys/{recipientId} {
// Only the recipient can read their own release key.
allow read: if request.auth.uid == recipientId && isCouplesMember(coupleId);
// Create-only: written by the answer owner (sender) after both answers exist.
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& recipientId != userId
&& recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
&& exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(recipientId))
&& isKeybox(request.resource.data.encryptedAnswerKey)
&& request.resource.data.recipientUserId == recipientId
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
allow update: if false;
allow delete: if false;
}
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
}
// 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.
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11) - Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext - Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination - Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked - CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge - CoupleKeyStore: pending recovery phrase storage/clear - FieldEncryptor: switch from android.util.Base64 to java.util.Base64 - All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes - FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion) - CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional) - HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples - Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality - Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement - firebase.json: emulator port 8180 - .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
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;
}
}
}