fix(games): atomic session start to prevent duplicate sessions on concurrent start (F-RACE-001)
Simultaneous game start by both partners created two divergent active sessions (TOCTOU: a
non-transactional check-then-create in GameSessionManager.startGameWithCouple). Each partner
ended up in a separate session with different questions → no shared reveal.
Fix: QuestionSessionRepository.startSessionAtomically runs a Firestore transaction on a
per-couple pointer doc (couples/{cid}/sessions/_active). It reads the pointer (+ the pointed
session) and either returns AlreadyActive (caller joins the existing session) or atomically
creates the new session and re-points the lock. Concurrent starts contend on the one pointer,
so the loser's transaction retries, sees the now-set pointer, and joins instead of duplicating.
The pointer self-heals (checks the pointed session's status) so no clear-on-finish is needed,
and it carries no status/completedAt so it's invisible to the active/history queries.
GameSessionManager routes all 7 games through it. firestore.rules adds member-write for
sessions/_active (deployed).
Verified live on both emulators: atomic create → 1 session + pointer; sequential 2nd start →
joins (1 session); literal parallel-tap race → 1 session (was 2); 0 FATAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
164acf415d
commit
23dd6a75e8
|
|
@ -105,6 +105,15 @@ conversation w/ content). **partner_started_game**: bg deliver + content-free
|
||||||
present in code. Full 17×{fg/bg/killed} matrix not exhaustively run; routing centralized + code-verified for the rest.
|
present in code. Full 17×{fg/bg/killed} matrix not exhaustively run; routing centralized + code-verified for the rest.
|
||||||
|
|
||||||
## Pass F — Resilience / lifecycle / concurrency / time
|
## Pass F — Resilience / lifecycle / concurrency / time
|
||||||
|
**R7 DEEP DIVE:** **concurrency race — FOUND + FIXED F-RACE-001 (P1):** simultaneous game start created 2 divergent
|
||||||
|
sessions (TOCTOU in `startGameWithCouple`). **Fixed** via atomic transactional create on a per-couple `sessions/_active`
|
||||||
|
pointer (`startSessionAtomically`) + rule + deploy. **Verified live:** atomic create → 1 session + pointer; sequential
|
||||||
|
2nd start → joins (1); **parallel-tap race → 1 session (was 2)**; 0 FATAL. **Malformed/abusive deep-link intents**
|
||||||
|
(unknown type, missing extras, injection/path-traversal) → 0 crash. **Killed-state cold-start** chat deep-link →
|
||||||
|
conversation loads, 0 crash. Minor follow-up note: the race-loser sometimes lands on the Play hub rather than
|
||||||
|
WaitingForPartner→"Join the game" (no dup/crash; pre-existing routing).
|
||||||
|
|
||||||
|
|
||||||
**R3:** offline (airplane mode) → Today renders from cache, no crash ✅; rotation/config-change → landscape renders,
|
**R3:** offline (airplane mode) → Today renders from cache, no crash ✅; rotation/config-change → landscape renders,
|
||||||
state preserved, no crash ✅; process-death/restore → ~6 cold restarts all clean to Home (auth persists) ✅;
|
state preserved, no crash ✅; process-death/restore → ~6 cold restarts all clean to Home (auth persists) ✅;
|
||||||
concurrency → both devices played games simultaneously, sessions synced + B-001 auto-complete on concurrent finish ✅.
|
concurrency → both devices played games simultaneously, sessions synced + B-001 auto-complete on concurrent finish ✅.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Claude QA Report — Full-App QA (living report)
|
# Claude QA Report — Full-App QA (living report)
|
||||||
|
|
||||||
> **RUN-STATE: Round 7 (multi-angle DEEP DIVE) — 2026-06-25, client HEAD `f47c8e2`, functions deployed. Plan updated with a "Multi-angle attack mandate" + live raw-API D3.** Attacked security/data/concurrency from multiple angles (admin ground-truth read, raw Firestore REST as member+non-member, killed/cold state, malformed intents, simultaneous-start race). **Security cornerstone = FULLY CLEAN (deep):** D1 at-rest — messages/previews + all 4 game-answer collections (ToT/HowWell/DesireSync/Wheel, both users) + capsules + date-swipe actions all `enc:v1:`; couple key phrase-wrapped (argon2id), recovery phrase server-blind + `encryptedRecoveryPhrase` wiped on acceptance, plaintext `inviteCode` not exploitable (invite readable only by inviter; no code-encrypted secret persisted). D3 raw-API — **non-member denied ALL reads/writes (403)**; real premium path `users/{uid}/entitlements/premium` write **denied (403, server-only) → no self-grant**; cross-couple denied. Robustness — malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal values) → 0 crash; killed-state cold-start chat deep-link → conversation loads. **NEW FINDING: F-RACE-001 (P1)** — simultaneous game start creates **two divergent active sessions** (TOCTOU). **SEVERITY BOARD: P1 = 1 open (F-RACE-001), P0/P2/P3 = 0 open.** Baseline restored (duplicate sessions ended, 0 active, couple intact). Two hardening notes → Future.md (App Check not enforced on Firestore; user-doc update rule allows arbitrary non-`hasPremium` fields).**
|
> **RUN-STATE: Round 7 (multi-angle DEEP DIVE) — 2026-06-25, client HEAD `f47c8e2`, functions deployed. Plan updated with a "Multi-angle attack mandate" + live raw-API D3.** Attacked security/data/concurrency from multiple angles (admin ground-truth read, raw Firestore REST as member+non-member, killed/cold state, malformed intents, simultaneous-start race). **Security cornerstone = FULLY CLEAN (deep):** D1 at-rest — messages/previews + all 4 game-answer collections (ToT/HowWell/DesireSync/Wheel, both users) + capsules + date-swipe actions all `enc:v1:`; couple key phrase-wrapped (argon2id), recovery phrase server-blind + `encryptedRecoveryPhrase` wiped on acceptance, plaintext `inviteCode` not exploitable (invite readable only by inviter; no code-encrypted secret persisted). D3 raw-API — **non-member denied ALL reads/writes (403)**; real premium path `users/{uid}/entitlements/premium` write **denied (403, server-only) → no self-grant**; cross-couple denied. Robustness — malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal values) → 0 crash; killed-state cold-start chat deep-link → conversation loads. **F-RACE-001 (P1) — FIXED + VERIFIED LIVE** (atomic transactional session create). **SEVERITY BOARD: 0 open at ALL levels (P0–P3).** Baseline restored (duplicate sessions ended, 0 active, couple intact). Two hardening notes → Future.md (App Check not enforced on Firestore; user-doc update rule allows arbitrary non-`hasPremium` fields).**
|
||||||
>
|
>
|
||||||
> **F-RACE-001 (P1, NEW — concurrency):** When BOTH partners start the *same* game within ~the same second, the couple
|
> **F-RACE-001 (P1, NEW — concurrency):** When BOTH partners start the *same* game within ~the same second, the couple
|
||||||
> ends up with **2 active sessions with different question sets** (proven live: QA "Which should end the date?" vs Sam
|
> ends up with **2 active sessions with different question sets** (proven live: QA "Which should end the date?" vs Sam
|
||||||
|
|
@ -16,6 +16,15 @@
|
||||||
> else create the session doc (client-generated id) + set the sentinel in the same transaction. Clear the sentinel on
|
> else create the session doc (client-generated id) + set the sentinel in the same transaction. Clear the sentinel on
|
||||||
> finish. Needs a `firestore.rules` update (member-only sentinel write) + a rules deploy + re-verify all 7 games. (This
|
> finish. Needs a `firestore.rules` update (member-only sentinel write) + a rules deploy + re-verify all 7 games. (This
|
||||||
> is an architectural change to the core game flow — flagged for a focused fix-phase implementation.)
|
> is an architectural change to the core game flow — flagged for a focused fix-phase implementation.)
|
||||||
|
> **FIX IMPLEMENTED + VERIFIED LIVE (2026-06-25):** `QuestionSessionRepository.startSessionAtomically` runs a Firestore
|
||||||
|
> transaction on a per-couple pointer doc `couples/{cid}/sessions/_active` — reads the pointer (+ the pointed session),
|
||||||
|
> and either returns `AlreadyActive` (caller joins) or atomically `set`s the new session + re-points the lock. Concurrent
|
||||||
|
> starts contend on the one pointer doc, so the loser's transaction retries, re-reads the now-set pointer, and joins
|
||||||
|
> instead of creating a duplicate. `GameSessionManager.startGameWithCouple` now calls it (all 7 games funnel through it);
|
||||||
|
> rule added (`sessions/_active` member-writable) + deployed. **Verified:** atomic create → 1 session + pointer written;
|
||||||
|
> sequential 2nd start → joins (1 session); **literal parallel-tap race → 1 session (was 2)**; 0 FATAL; pointer
|
||||||
|
> self-heals on completion (checks pointed session status). Files: `domain/repository/QuestionSessionRepository.kt`,
|
||||||
|
> `data/repository/QuestionSessionRepositoryImpl.kt`, `domain/usecase/GameSessionManager.kt`, `firestore.rules`.
|
||||||
|
|
||||||
> **RUN-STATE: Round 6 (branding + Future.md regression QA) COMPLETE — 2026-06-25. Client HEAD `f47c8e2` on both emulators (build == HEAD, reinstalled).** Scope: regression-verify the new surfaces from the branding drop (`95cad84`: white-keyhole launcher/notification icons, animated app-icon-chip loader + fill, cold-launch splash, pairing hero) and the Future.md backlog clear (`f47c8e2`: inclusive gender options, turn-aware Home copy, push rate-limit budget split, results-push suppression via new ActiveGameSessionMonitor, paywall retry/offline/hide-Continue, auth privacy rotator). **0 new issues — SEVERITY BOARD STILL 0 open P0–P3.** LIVE-VERIFIED: animated loader (chip+fill, both themes), splash→handoff (white-keyhole icon, no white flash), launcher icon (round mask), This or That + How Well open with no crash (confirms #4's new VM injection is sound), paywall purchase screen shows friendly "Couldn't load plans" + Try again with Continue hidden (no dead button) + online→generic message (#5), onboarding carousel illustration. Unit tests green (NotificationRateLimiter rewritten + PartnerNotificationManagerTest repaired). No FATAL on either device all session. CODE/UNIT-VERIFIED (live deferred, low-risk over proven patterns + fragile multi-text-field/2-device paths): #1 gender step (EditProfile + onboarding sex step — same option list as the shipping Female/Male), #8 rotator on SignUp/Forgot (reuses the Login-proven BrandMessageRotator), #2 "Your turn to play." (static string in the proven GAME_WAITING path), #3 weekly-cap exemption (unit-tested; only triggers at ≥100/wk), #4 results-suppression timing (mechanism + VM wiring verified; simultaneous-finish timing is non-deterministic to drive). Baseline restored: 5554 signed out during the sign-up pass, re-signed-in QA (`Y05AKO2IlTPMa0JQW1BiNIM0uzK2`) via admin custom token; couple `Xal3Kw3gjSdn0niERYKJ` intact, Sam paired.**
|
> **RUN-STATE: Round 6 (branding + Future.md regression QA) COMPLETE — 2026-06-25. Client HEAD `f47c8e2` on both emulators (build == HEAD, reinstalled).** Scope: regression-verify the new surfaces from the branding drop (`95cad84`: white-keyhole launcher/notification icons, animated app-icon-chip loader + fill, cold-launch splash, pairing hero) and the Future.md backlog clear (`f47c8e2`: inclusive gender options, turn-aware Home copy, push rate-limit budget split, results-push suppression via new ActiveGameSessionMonitor, paywall retry/offline/hide-Continue, auth privacy rotator). **0 new issues — SEVERITY BOARD STILL 0 open P0–P3.** LIVE-VERIFIED: animated loader (chip+fill, both themes), splash→handoff (white-keyhole icon, no white flash), launcher icon (round mask), This or That + How Well open with no crash (confirms #4's new VM injection is sound), paywall purchase screen shows friendly "Couldn't load plans" + Try again with Continue hidden (no dead button) + online→generic message (#5), onboarding carousel illustration. Unit tests green (NotificationRateLimiter rewritten + PartnerNotificationManagerTest repaired). No FATAL on either device all session. CODE/UNIT-VERIFIED (live deferred, low-risk over proven patterns + fragile multi-text-field/2-device paths): #1 gender step (EditProfile + onboarding sex step — same option list as the shipping Female/Male), #8 rotator on SignUp/Forgot (reuses the Login-proven BrandMessageRotator), #2 "Your turn to play." (static string in the proven GAME_WAITING path), #3 weekly-cap exemption (unit-tested; only triggers at ≥100/wk), #4 results-suppression timing (mechanism + VM wiring verified; simultaneous-finish timing is non-deterministic to drive). Baseline restored: 5554 signed out during the sign-up pass, re-signed-in QA (`Y05AKO2IlTPMa0JQW1BiNIM0uzK2`) via admin custom token; couple `Xal3Kw3gjSdn0niERYKJ` intact, Sam paired.**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import app.closer.data.remote.FirestoreCollections
|
||||||
import app.closer.domain.model.GameType
|
import app.closer.domain.model.GameType
|
||||||
import app.closer.domain.model.QuestionSession
|
import app.closer.domain.model.QuestionSession
|
||||||
import app.closer.domain.repository.QuestionSessionRepository
|
import app.closer.domain.repository.QuestionSessionRepository
|
||||||
|
import app.closer.domain.repository.SessionStartOutcome
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -53,6 +54,76 @@ class QuestionSessionRepositoryImpl @Inject constructor(
|
||||||
doc.id
|
doc.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun startSessionAtomically(
|
||||||
|
session: QuestionSession
|
||||||
|
): Result<SessionStartOutcome> = runCatching {
|
||||||
|
val sessionsCol = firestore.collection(FirestoreCollections.COUPLES)
|
||||||
|
.document(session.coupleId)
|
||||||
|
.collection(FirestoreCollections.Couples.SESSIONS)
|
||||||
|
val pointerRef = sessionsCol.document(ACTIVE_POINTER_ID)
|
||||||
|
val newRef = sessionsCol.document() // pre-generate id for the create branch
|
||||||
|
|
||||||
|
firestore.runTransaction { tx ->
|
||||||
|
// ── all reads must happen before any writes in a Firestore transaction ──
|
||||||
|
val pointer = tx.get(pointerRef)
|
||||||
|
val activeId = pointer.getString("activeSessionId")?.takeIf { it.isNotBlank() }
|
||||||
|
val activeSnap = activeId?.let { tx.get(sessionsCol.document(it)) }
|
||||||
|
|
||||||
|
if (activeSnap != null && activeSnap.exists() &&
|
||||||
|
activeSnap.getString("status") == "active"
|
||||||
|
) {
|
||||||
|
// A session is already active → converge: the caller joins it (no duplicate created).
|
||||||
|
// Concurrent starts contend on [pointerRef]; the loser's transaction retries, re-reads
|
||||||
|
// the now-set pointer, and lands here. Stale/completed pointers fall through to create.
|
||||||
|
SessionStartOutcome.AlreadyActive(snapToSession(activeSnap, session.coupleId))
|
||||||
|
} else {
|
||||||
|
val data = mapOf(
|
||||||
|
"id" to newRef.id,
|
||||||
|
"coupleId" to session.coupleId,
|
||||||
|
"categoryId" to session.categoryId,
|
||||||
|
"questionIds" to session.questionIds,
|
||||||
|
"startedByUserId" to session.startedByUserId,
|
||||||
|
"startedAt" to session.startedAt,
|
||||||
|
"completedAt" to session.completedAt,
|
||||||
|
"partnerCompletedAt" to session.partnerCompletedAt,
|
||||||
|
"isPremium" to session.isPremium,
|
||||||
|
"status" to session.status,
|
||||||
|
"gameType" to session.gameType,
|
||||||
|
"completedByUsers" to session.completedByUsers
|
||||||
|
)
|
||||||
|
tx.set(newRef, data)
|
||||||
|
// Re-point the per-couple active-session lock to the new session.
|
||||||
|
tx.set(
|
||||||
|
pointerRef,
|
||||||
|
mapOf(
|
||||||
|
"activeSessionId" to newRef.id,
|
||||||
|
"updatedAt" to System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SessionStartOutcome.Created(session.copy(id = newRef.id))
|
||||||
|
}
|
||||||
|
}.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapToSession(
|
||||||
|
doc: com.google.firebase.firestore.DocumentSnapshot,
|
||||||
|
coupleId: String
|
||||||
|
): QuestionSession = QuestionSession(
|
||||||
|
id = doc.getString("id") ?: doc.id,
|
||||||
|
coupleId = doc.getString("coupleId") ?: coupleId,
|
||||||
|
categoryId = doc.getString("categoryId") ?: "",
|
||||||
|
questionIds = (doc.get("questionIds") as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
|
||||||
|
startedByUserId = doc.getString("startedByUserId") ?: "",
|
||||||
|
startedAt = doc.getLong("startedAt") ?: 0L,
|
||||||
|
completedAt = doc.getLong("completedAt"),
|
||||||
|
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
|
||||||
|
isPremium = doc.getBoolean("isPremium") ?: false,
|
||||||
|
status = doc.getString("status") ?: "active",
|
||||||
|
gameType = doc.getString("gameType") ?: GameType.WHEEL,
|
||||||
|
completedByUsers = (doc.get("completedByUsers") as? List<*>)?.filterIsInstance<String>()
|
||||||
|
?: emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> =
|
override suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> =
|
||||||
runCatching {
|
runCatching {
|
||||||
firestore.collection(FirestoreCollections.COUPLES)
|
firestore.collection(FirestoreCollections.COUPLES)
|
||||||
|
|
@ -223,4 +294,9 @@ class QuestionSessionRepositoryImpl @Inject constructor(
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Per-couple pointer doc that serializes concurrent game starts (F-RACE-001 lock). */
|
||||||
|
private const val ACTIVE_POINTER_ID = "_active"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,25 @@ package app.closer.domain.repository
|
||||||
import app.closer.domain.model.QuestionSession
|
import app.closer.domain.model.QuestionSession
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/** Outcome of an atomic session start: we either created a fresh session, or one was already active. */
|
||||||
|
sealed interface SessionStartOutcome {
|
||||||
|
val session: QuestionSession
|
||||||
|
data class Created(override val session: QuestionSession) : SessionStartOutcome
|
||||||
|
data class AlreadyActive(override val session: QuestionSession) : SessionStartOutcome
|
||||||
|
}
|
||||||
|
|
||||||
interface QuestionSessionRepository {
|
interface QuestionSessionRepository {
|
||||||
/** Saves the session and returns its (possibly auto-generated) document id. */
|
/** Saves the session and returns its (possibly auto-generated) document id. */
|
||||||
suspend fun saveSession(session: QuestionSession): Result<String>
|
suspend fun saveSession(session: QuestionSession): Result<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically start a session: creates a new active session ONLY if the couple has none, via a
|
||||||
|
* Firestore transaction on a per-couple pointer doc. Concurrent starts converge to a single
|
||||||
|
* session — the loser gets [SessionStartOutcome.AlreadyActive] instead of creating a duplicate
|
||||||
|
* (fixes F-RACE-001, the simultaneous-start race that produced two divergent sessions).
|
||||||
|
*/
|
||||||
|
suspend fun startSessionAtomically(session: QuestionSession): Result<SessionStartOutcome>
|
||||||
|
|
||||||
suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>>
|
suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>>
|
||||||
|
|
||||||
// Active session queries
|
// Active session queries
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import app.closer.domain.model.QuestionSession
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.CoupleRepository
|
import app.closer.domain.repository.CoupleRepository
|
||||||
import app.closer.domain.repository.QuestionSessionRepository
|
import app.closer.domain.repository.QuestionSessionRepository
|
||||||
|
import app.closer.domain.repository.SessionStartOutcome
|
||||||
import app.closer.domain.repository.UserRepository
|
import app.closer.domain.repository.UserRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -81,17 +82,6 @@ class GameSessionManager @Inject constructor(
|
||||||
categoryId: String?,
|
categoryId: String?,
|
||||||
questionIds: List<String>?
|
questionIds: List<String>?
|
||||||
): Result<QuestionSession> {
|
): Result<QuestionSession> {
|
||||||
val activeSession = sessionRepository.getActiveSessionForCouple(couple.id)
|
|
||||||
if (activeSession != null) {
|
|
||||||
val partnerId = couple.userIds.firstOrNull { it != userId }
|
|
||||||
val partnerName = partnerId
|
|
||||||
?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
|
|
||||||
?.displayName ?: "Partner"
|
|
||||||
return Result.failure(
|
|
||||||
Exception("partner_active_session|$partnerName|${gameTypeLabel(gameType)}")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val session = QuestionSession(
|
val session = QuestionSession(
|
||||||
coupleId = couple.id,
|
coupleId = couple.id,
|
||||||
categoryId = categoryId ?: "",
|
categoryId = categoryId ?: "",
|
||||||
|
|
@ -101,9 +91,28 @@ class GameSessionManager @Inject constructor(
|
||||||
status = "active"
|
status = "active"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use the id assigned by the repository so the started game observes the right doc
|
// Atomic "create only if no active session" (Firestore transaction on a per-couple pointer):
|
||||||
// (an empty id builds an invalid Firestore path and crashes the game on open).
|
// two partners tapping start at the same instant converge to ONE session instead of two
|
||||||
return sessionRepository.saveSession(session).map { id -> session.copy(id = id) }
|
// divergent ones (F-RACE-001). The loser is told a session is already active and joins it.
|
||||||
|
return sessionRepository.startSessionAtomically(session).fold(
|
||||||
|
onSuccess = { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
is SessionStartOutcome.Created -> Result.success(outcome.session)
|
||||||
|
is SessionStartOutcome.AlreadyActive -> {
|
||||||
|
val partnerId = couple.userIds.firstOrNull { it != userId }
|
||||||
|
val partnerName = partnerId
|
||||||
|
?.let { runCatching { userRepository.getUser(it) }.getOrNull() }
|
||||||
|
?.displayName ?: "Partner"
|
||||||
|
Result.failure(
|
||||||
|
Exception(
|
||||||
|
"partner_active_session|$partnerName|${gameTypeLabel(outcome.session.gameType)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { Result.failure(it) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,12 @@ service cloud.firestore {
|
||||||
// Read: both members can read
|
// Read: both members can read
|
||||||
allow read: if isCouplesMember(coupleId);
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
|
||||||
|
// Per-couple active-session pointer used as an atomic lock so two partners starting a game
|
||||||
|
// at the same instant converge to ONE session instead of two divergent ones (F-RACE-001).
|
||||||
|
// It holds no game content (only activeSessionId + updatedAt) and carries no status/
|
||||||
|
// completedAt, so it never appears in the active-session or history queries.
|
||||||
|
allow create, update: if sessionId == '_active' && isCouplesMember(coupleId);
|
||||||
|
|
||||||
// Create: either member can start a session
|
// Create: either member can start a session
|
||||||
allow create: if isCouplesMember(coupleId)
|
allow create: if isCouplesMember(coupleId)
|
||||||
&& request.resource.data.startedByUserId == request.auth.uid;
|
&& request.resource.data.startedByUserId == request.auth.uid;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue