diff --git a/firestore.rules b/firestore.rules index 6ba3c6fa..c3ed6ff5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -35,6 +35,21 @@ service cloud.firestore { } } + function isServer() { + // Check if request comes from admin SDK (no request.auth) + return request.auth == null; + } + + function isImmutable(fields) { + // Helper to check that certain fields haven't changed during an update + // fields: list of field names that should be immutable + if (resource == null) { + // Create operation - nothing to check + return true; + } + return fields.every(f => resource.data[f] == request.resource.data[f]); + } + // ── Users ───────────────────────────────────────────────────────────────── // Each user owns exactly their own document. // hasPremium is server-only: clients may not write it directly. @@ -96,18 +111,81 @@ service cloud.firestore { } // ── Couples ─────────────────────────────────────────────────────────────── - // Only the two members of a couple may read or write couple data. + // Only the two members of a couple may read couple data. + // Writes are restricted by field ownership and immutability. match /couples/{coupleId} { - allow read, write: if isCouplesMember(coupleId); + // Read: both members can read + allow read: if isCouplesMember(coupleId); + + // Create: only via invite flow (server-side or admin SDK) + allow create: if isServer(); + + // Update: field-level restrictions + // - user IDs are immutable (cannot change who is in the couple) + // - invite code is immutable (cannot change the code) + // - createdAt is immutable (cannot change when the couple was formed) + // - currentQuestionId: either member can set (either can pick a question) + // - streakCount and lastStreakAt: server-only (via Cloud Functions or admin SDK) + // - Any other fields: both members can update normally + allow update: if isCouplesMember(coupleId) + // Check immutable fields haven't changed + && isImmutable(['userIds', 'inviteCode', 'createdAt']) + // Allow currentQuestionId updates + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['currentQuestionId']) + // No other fields should be changed + // Check that streakCount and lastStreakAt are not in the update + && !request.resource.data.diff(resource.data).affectedKeys().hasAny(['streakCount', 'lastStreakAt']); + + // Delete: server-only (admin SDK only) + allow delete: if isServer(); match /sessions/{sessionId} { - allow read, write: if isCouplesMember(coupleId); + // 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 + 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')) + // Check that other fields haven't been tampered with + && (request.resource.data.startedByUserId == resource.data.startedByUserId + || isServer()); + + // Delete: server-only (admin SDK) + allow delete: if isServer(); } // Question threads live under the couple document. match /question_threads/{threadId} { - allow read, write: if isCouplesMember(coupleId); + // 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') + // 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) + allow delete: if isServer(); // Answers: each user writes their own; both members can read all answers. match /answers/{userId} { @@ -115,14 +193,26 @@ service cloud.firestore { allow read: if isCouplesMember(coupleId); } - // Discussion messages: any couple member can write and read. + // Discussion messages: any couple member can read, but only the author can write/update/delete match /messages/{messageId} { - allow read, write: if isCouplesMember(coupleId); + allow read: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && request.resource.data.authorUserId == request.auth.uid; + allow update: if isCouplesMember(coupleId) + && resource.data.authorUserId == request.auth.uid; + allow delete: if isCouplesMember(coupleId) + && resource.data.authorUserId == request.auth.uid; } - // Reactions: any couple member can write and read. + // Reactions: any couple member can read, but only the creator can write/update/delete match /reactions/{reactionId} { - allow read, write: if isCouplesMember(coupleId); + allow read: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && request.resource.data.userId == request.auth.uid; + allow update: if isCouplesMember(coupleId) + && resource.data.userId == request.auth.uid; + allow delete: if isCouplesMember(coupleId) + && resource.data.userId == request.auth.uid; } } }