382 lines
19 KiB
Plaintext
382 lines
19 KiB
Plaintext
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) {
|
|
// 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]);
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|
|
|
|
// ── 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 inviter, except when accepting (user is not inviter, pending, and unpaired)
|
|
allow read: if isSignedIn()
|
|
&& (
|
|
// Inviter can always read
|
|
request.auth.uid == resource.data.inviterUserId
|
|
||
|
|
// Accepting user: not the inviter, invite is still pending, and user is unpaired
|
|
(
|
|
request.auth.uid != resource.data.inviterUserId
|
|
&& resource.data.status == 'pending'
|
|
&& !('coupleId' in resource.data)
|
|
&& isNotAlreadyPaired()
|
|
)
|
|
)
|
|
// Expired invites should not be readable by non-inviters
|
|
&& (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
|
|
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt'])
|
|
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt']);
|
|
|
|
// Update (accept): proper validation for changing status to accepted.
|
|
// If coupleId is supplied, it must reference an existing couple where
|
|
// the acceptor is a member. (Server-side creation bypasses rules.)
|
|
allow update: if isSignedIn()
|
|
&& resource.data.status == 'pending'
|
|
// Cannot accept your own invite
|
|
&& request.auth.uid != resource.data.inviterUserId
|
|
// Must be the acceptor
|
|
&& request.resource.data.acceptorUserId == request.auth.uid
|
|
// Status must change to accepted
|
|
&& request.resource.data.status == 'accepted'
|
|
// Acceptance timestamp must be set and be a Firestore timestamp
|
|
&& request.resource.data.acceptedAt != null
|
|
&& request.resource.data.acceptedAt is timestamp
|
|
// No other fields should be modified in this update
|
|
&& request.resource.data.keys().hasOnly(
|
|
['status', 'acceptorUserId', 'acceptedAt', 'coupleId'])
|
|
// Expired invites cannot be accepted
|
|
&& request.time < resource.data.expiresAt
|
|
// coupleId, if provided, must point to a real couple that includes the acceptor
|
|
&& (
|
|
!('coupleId' in request.resource.data)
|
|
|| (
|
|
request.resource.data.coupleId != null
|
|
&& exists(/databases/$(database)/documents/couples/$(request.resource.data.coupleId))
|
|
&& request.auth.uid in get(/databases/$(database)/documents/couples/$(request.resource.data.coupleId)).data.userIds
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── 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: only via invite flow (server-side or admin SDK).
|
|
// Admin SDK bypasses rules; direct client writes are denied.
|
|
allow create: if false;
|
|
|
|
// 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)
|
|
// - All other fields (including streakCount and lastAnsweredAt): both members can update
|
|
allow update: if isCouplesMember(coupleId)
|
|
&& isImmutable(['userIds', 'inviteCode', 'createdAt']);
|
|
|
|
// 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')
|
|
// 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.
|
|
match /answers/{userId} {
|
|
allow write: if isOwner(userId);
|
|
allow read: if isCouplesMember(coupleId);
|
|
}
|
|
|
|
// 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)
|
|
&& 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 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;
|
|
allow update: if isCouplesMember(coupleId)
|
|
&& resource.data.userId == request.auth.uid;
|
|
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.keys().hasAll(['userId', 'questionId', 'answerType', 'createdAt', 'updatedAt'])
|
|
&& request.resource.data.userId == request.auth.uid
|
|
&& request.resource.data.questionId is string
|
|
&& request.resource.data.answerType is string;
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
}
|