diff --git a/firestore.rules b/firestore.rules index e18c81f7..779c348d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -117,6 +117,25 @@ service cloud.firestore { .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) { @@ -670,6 +689,9 @@ service cloud.firestore { // 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) @@ -681,16 +703,17 @@ service cloud.firestore { // whose metadata disagrees with the path it lands in. && request.resource.data.answerDate is string && request.resource.data.answerDate == date - // schemaVersion 3: partner-proof sealed answer (the only accepted shape). - && isSealedAnswerCreate(request.resource.data); + // 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 - // Sealed answers: only reveal metadata may change; payload is immutable. - && isSealedAnswerUpdate(); + // Only reveal metadata may change; the encrypted payload is immutable. + && (isCoupleKeyAnswerUpdate() || isSealedAnswerUpdate()); allow delete: if false; @@ -718,6 +741,20 @@ service cloud.firestore { 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).