2026-06-16 01:13:20 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:45:04 -05:00
|
|
|
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
|
|
|
|
|
// Handle case where user doc might not exist (use getIfExists to avoid throw)
|
|
|
|
|
try {
|
|
|
|
|
let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid));
|
|
|
|
|
return !('coupleId' in userDoc.data) || userDoc.data.coupleId == null;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// User doc doesn't exist - treat as unpaired
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 01:13:20 -05:00
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────
|
|
|
|
|
// Each user owns exactly their own document.
|
2026-06-16 20:16:47 -05:00
|
|
|
// hasPremium is server-only: clients may not write it directly.
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
|
|
|
match /users/{uid} {
|
2026-06-16 20:16:47 -05:00
|
|
|
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']);
|
2026-06-16 01:13:20 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Invite codes ──────────────────────────────────────────────────────────
|
2026-06-16 21:45:04 -05:00
|
|
|
// Invite system with proper ownership, validation, and expiry checks.
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
|
|
|
match /invites/{code} {
|
2026-06-16 21:45:04 -05:00
|
|
|
// 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
|
|
|
|
|
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.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt']);
|
|
|
|
|
|
|
|
|
|
// Update (accept): proper validation for changing status to accepted
|
2026-06-16 01:13:20 -05:00
|
|
|
allow update: if isSignedIn()
|
|
|
|
|
&& resource.data.status == 'pending'
|
2026-06-16 21:45:04 -05:00
|
|
|
// 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
|
|
|
|
|
&& request.resource.data.acceptedAt != null
|
|
|
|
|
// No other fields should be modified in this update
|
2026-06-16 01:13:20 -05:00
|
|
|
&& request.resource.data.keys().hasOnly(
|
2026-06-16 21:45:04 -05:00
|
|
|
['status', 'acceptorUserId', 'acceptedAt', 'coupleId'])
|
|
|
|
|
// Expired invites cannot be accepted
|
|
|
|
|
&& request.time < resource.data.expiresAt;
|
2026-06-16 01:13:20 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Couples ───────────────────────────────────────────────────────────────
|
|
|
|
|
// Only the two members of a couple may read or write couple data.
|
|
|
|
|
|
|
|
|
|
match /couples/{coupleId} {
|
|
|
|
|
allow read, write: if isCouplesMember(coupleId);
|
|
|
|
|
|
2026-06-16 19:44:28 -05:00
|
|
|
match /sessions/{sessionId} {
|
|
|
|
|
allow read, write: if isCouplesMember(coupleId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 01:13:20 -05:00
|
|
|
// Question threads live under the couple document.
|
|
|
|
|
match /question_threads/{threadId} {
|
|
|
|
|
allow read, write: if isCouplesMember(coupleId);
|
|
|
|
|
|
|
|
|
|
// 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 write and read.
|
|
|
|
|
match /messages/{messageId} {
|
|
|
|
|
allow read, write: if isCouplesMember(coupleId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reactions: any couple member can write and read.
|
|
|
|
|
match /reactions/{reactionId} {
|
|
|
|
|
allow read, write: if isCouplesMember(coupleId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|