Closer/firestore.rules

297 lines
14 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';
}
// ── 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']);
}
// ── 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)
// - streakCount and lastStreakAt: server-only (via Cloud Functions or admin SDK)
// - All other fields: both members can update normally
allow update: if isCouplesMember(coupleId)
// Check immutable fields haven't changed
&& isImmutable(['userIds', 'inviteCode', 'createdAt'])
// streakCount and lastStreakAt must not be modified by clients
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['streakCount', 'lastStreakAt']);
// 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; each member can write their own preference.
match /date_plan_preferences/{userId} {
allow read: if isCouplesMember(coupleId);
allow create, update: 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'])
&& request.resource.data.actions[request.auth.uid].action != null
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
allow delete: if false;
}
// Date plans: complete plans assembled from partner preferences.
// Both members can read; create/update/delete by both members.
match /date_plans/{planId} {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if isCouplesMember(coupleId);
}
// Bucket list items: shared list for both partners.
// Both members can read; create/update/delete by both members.
match /bucket_list/{itemId} {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if isCouplesMember(coupleId);
}
}
}
}