docs(readme): add privacy slogan to header
This commit is contained in:
parent
e5c05abe90
commit
06e09da596
|
|
@ -1,5 +1,7 @@
|
|||
# Closer
|
||||
|
||||
> *Your information is security private. We never look. We never sell it.*
|
||||
>
|
||||
> *Private daily questions for couples who want honest answers before shared conversations.*
|
||||
|
||||
**Product goal:** private, mutual-reveal relationship questions with real encryption and calmer UX.
|
||||
|
|
|
|||
|
|
@ -91,20 +91,25 @@ fun AppNavigation(
|
|||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val bottomRoutes = AppRoute.topLevelRoutes
|
||||
// Debug paired-home preview shows the real bottom nav too, so the menu stays anchored
|
||||
// exactly as it does on the live paired Home (which is the HOME tab route).
|
||||
val bottomRoutes = AppRoute.topLevelRoutes + AppRoute.PAIRED_HOME_PREVIEW
|
||||
val shellTitle = currentRoute
|
||||
?.takeIf { it in shellBackRoutes }
|
||||
?.let(AppRoute::titleFor)
|
||||
// Tab-switch semantics: pop to the graph start, keep a single instance, and
|
||||
// save/restore each tab's own back stack. Every navigation to a top-level
|
||||
// route must go through this so a tab is never pushed on top of another tab.
|
||||
// Tab-switch semantics: pop to the graph start and keep a single instance, so a
|
||||
// tab is never pushed on top of another tab. We deliberately DON'T save/restore
|
||||
// each tab's back stack — tapping a bottom tab always lands on that tab's root,
|
||||
// never a sub-screen you previously drilled into. (Restoring the saved stack made
|
||||
// re-tapping "Settings" reopen a deep page — e.g. the paired-home preview — which
|
||||
// read as "Settings goes to a paired partner".)
|
||||
val selectTab: (String) -> Unit = { route ->
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
saveState = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
val navigateBackOrHome: () -> Unit = {
|
||||
|
|
@ -476,6 +481,9 @@ fun AppNavigation(
|
|||
composable(route = AppRoute.ART_PREVIEW) {
|
||||
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
|
||||
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ object AppRoute {
|
|||
const val YOUR_PROGRESS = "your_progress"
|
||||
const val ACTIVITY = "activity"
|
||||
const val ART_PREVIEW = "art_preview"
|
||||
const val PAIRED_HOME_PREVIEW = "paired_home_preview"
|
||||
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
||||
|
||||
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.awaitClose
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
|
@ -27,9 +28,11 @@ import kotlin.coroutines.resumeWithException
|
|||
* "matchedBy": ["userA", "userB"]
|
||||
* }
|
||||
*
|
||||
* Security rules deny client create/update/delete; matches are written by a
|
||||
* Cloud Function after both partners swipe [SwipeAction.LOVE]. The client layer
|
||||
* still exposes a create helper for the function trigger path.
|
||||
* Under E2EE the server can no longer read swipe content, so mutual-love
|
||||
* detection moved client-side: when a member records the second LOVE on an idea,
|
||||
* the client writes the match marker here (idempotent, keyed by dateIdeaId). A
|
||||
* Cloud Function then fires the "It's a match!" notification on create. Security
|
||||
* rules allow couple members to create (validated shape) but never update/delete.
|
||||
*/
|
||||
@Singleton
|
||||
class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
||||
|
|
@ -37,16 +40,27 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
|
|||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||
.collection(FirestoreCollections.Couples.DATE_MATCHES)
|
||||
|
||||
suspend fun createMatch(coupleId: String, dateIdeaId: String, matchedBy: List<String>): String {
|
||||
val doc = matchesRef(coupleId).document()
|
||||
doc.set(
|
||||
mapOf(
|
||||
"dateIdeaId" to dateIdeaId,
|
||||
"revealedAt" to FieldValue.serverTimestamp(),
|
||||
"matchedBy" to matchedBy
|
||||
)
|
||||
).voidAwait()
|
||||
return doc.id
|
||||
/**
|
||||
* Create the match marker for [dateIdeaId] iff it doesn't already exist. Keyed by
|
||||
* dateIdeaId and guarded by a transaction so both partners loving near-simultaneously
|
||||
* (and repeated swipes) never produce a duplicate. `fcmNotified` lets the notify
|
||||
* function claim the push exactly once.
|
||||
*/
|
||||
suspend fun createMatchIfAbsent(coupleId: String, dateIdeaId: String, matchedBy: List<String>) {
|
||||
val ref = matchesRef(coupleId).document(dateIdeaId)
|
||||
db.runTransaction<Unit> { tx ->
|
||||
if (!tx.get(ref).exists()) {
|
||||
tx.set(
|
||||
ref,
|
||||
mapOf(
|
||||
"dateIdeaId" to dateIdeaId,
|
||||
"revealedAt" to FieldValue.serverTimestamp(),
|
||||
"matchedBy" to matchedBy,
|
||||
"fcmNotified" to false
|
||||
)
|
||||
)
|
||||
}
|
||||
}.await()
|
||||
}
|
||||
|
||||
suspend fun findMatchByDateIdeaId(coupleId: String, dateIdeaId: String): DateMatch? {
|
||||
|
|
@ -76,12 +90,6 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
|
|||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
addOnSuccessListener { cont.resume(Unit) }
|
||||
addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
// ─── Mapper ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package app.closer.data.remote
|
||||
|
||||
import app.closer.crypto.CoupleEncryptionManager
|
||||
import app.closer.crypto.FieldEncryptor
|
||||
import app.closer.domain.model.DateSwipe
|
||||
import app.closer.domain.model.SwipeAction
|
||||
import com.google.crypto.tink.Aead
|
||||
import com.google.firebase.firestore.DocumentSnapshot
|
||||
import com.google.firebase.firestore.FieldValue
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
|
|
@ -21,27 +23,35 @@ import kotlin.coroutines.resumeWithException
|
|||
* Path layout:
|
||||
* couples/{coupleId}/date_swipes/{dateId}
|
||||
*
|
||||
* Each document stores a map keyed by userId:
|
||||
* Each document stores a map keyed by userId. The `action` is E2E-encrypted with
|
||||
* the couple key (like every other game) so the server can never read a partner's
|
||||
* date preferences; only `swipedAt` stays plaintext.
|
||||
* {
|
||||
* "actions": {
|
||||
* "userA": { "action": "love", "swipedAt": 12345 },
|
||||
* "userB": { "action": "skip", "swipedAt": 12346 }
|
||||
* "userA": { "action": "enc:v1:…", "swipedAt": 12345 },
|
||||
* "userB": { "action": "enc:v1:…", "swipedAt": 12346 }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Security rules ensure only couple members can read/write, and each user can
|
||||
* only write their own action entry.
|
||||
* Security rules ensure only couple members can read/write, each user can only
|
||||
* write their own action entry, and the action must be ciphertext. Match detection
|
||||
* is therefore done client-side (the server is blind to swipe content).
|
||||
*/
|
||||
@Singleton
|
||||
class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
||||
class FirestoreDateSwipeDataSource @Inject constructor(
|
||||
private val db: FirebaseFirestore,
|
||||
private val encryptionManager: CoupleEncryptionManager,
|
||||
private val fieldEncryptor: FieldEncryptor
|
||||
) {
|
||||
private fun swipesRef(coupleId: String) =
|
||||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||
.collection(FirestoreCollections.Couples.DATE_SWIPES)
|
||||
|
||||
suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) {
|
||||
val aead = encryptionManager.requireAead(coupleId)
|
||||
val path = swipesRef(coupleId).document(swipe.dateIdeaId)
|
||||
val entry = mapOf(
|
||||
"action" to swipe.action.toFirestoreValue(),
|
||||
"action" to fieldEncryptor.encrypt(swipe.action.toFirestoreValue(), aead, coupleId),
|
||||
"swipedAt" to swipe.swipedAt
|
||||
)
|
||||
path.set(
|
||||
|
|
@ -50,9 +60,20 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
|||
).voidAwait()
|
||||
}
|
||||
|
||||
/** Decrypt a stored action, tolerating legacy plaintext written before E2EE. */
|
||||
private fun decryptAction(raw: String?, aead: Aead?, coupleId: String): String {
|
||||
if (raw.isNullOrEmpty()) return ""
|
||||
return if (fieldEncryptor.isEncrypted(raw)) {
|
||||
fieldEncryptor.decrypt(raw, aead, coupleId) ?: ""
|
||||
} else {
|
||||
raw // legacy plaintext from before this migration
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSwipe(coupleId: String, dateIdeaId: String, userId: String): DateSwipe? {
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
||||
return snap.toDateSwipe(dateIdeaId, userId)
|
||||
return snap.toDateSwipe(dateIdeaId, userId, aead, coupleId)
|
||||
}
|
||||
|
||||
fun observeOwnSwipes(
|
||||
|
|
@ -70,7 +91,8 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
|||
val listener = swipesRef(coupleId)
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null) return@addSnapshotListener
|
||||
val swipes = snap.documents.mapNotNull { it.toDateSwipe(it.id, userId) }
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
val swipes = snap.documents.mapNotNull { it.toDateSwipe(it.id, userId, aead, coupleId) }
|
||||
trySend(swipes)
|
||||
}
|
||||
awaitClose { listener.remove() }
|
||||
|
|
@ -80,8 +102,9 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
|||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> {
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
val snap = swipesRef(coupleId).getQuery()
|
||||
return snap.documents.mapNotNull { it.toDateSwipe(it.id, userId) }
|
||||
return snap.documents.mapNotNull { it.toDateSwipe(it.id, userId, aead, coupleId) }
|
||||
}
|
||||
|
||||
private suspend fun com.google.firebase.firestore.CollectionReference.getQuery() =
|
||||
|
|
@ -92,6 +115,7 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
|||
}
|
||||
|
||||
suspend fun getAllSwipesForDate(coupleId: String, dateIdeaId: String): List<DateSwipe> {
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val actions = snap.get("actions") as? Map<String, Map<String, Any>> ?: emptyMap()
|
||||
|
|
@ -99,7 +123,7 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
|||
DateSwipe(
|
||||
dateIdeaId = dateIdeaId,
|
||||
userId = uid,
|
||||
action = SwipeAction.fromFirestoreValue(data["action"] as? String ?: ""),
|
||||
action = SwipeAction.fromFirestoreValue(decryptAction(data["action"] as? String, aead, coupleId)),
|
||||
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
|
||||
)
|
||||
}
|
||||
|
|
@ -123,13 +147,18 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
|||
// ─── Mappers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun DocumentSnapshot.toDateSwipe(dateIdeaId: String, userId: String): DateSwipe? {
|
||||
private fun DocumentSnapshot.toDateSwipe(
|
||||
dateIdeaId: String,
|
||||
userId: String,
|
||||
aead: Aead?,
|
||||
coupleId: String
|
||||
): DateSwipe? {
|
||||
val actions = get("actions") as? Map<String, Map<String, Any>> ?: return null
|
||||
val data = actions[userId] ?: return null
|
||||
return DateSwipe(
|
||||
dateIdeaId = dateIdeaId,
|
||||
userId = userId,
|
||||
action = SwipeAction.fromFirestoreValue(data["action"] as? String ?: ""),
|
||||
action = SwipeAction.fromFirestoreValue(decryptAction(data["action"] as? String, aead, coupleId)),
|
||||
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,13 @@ import javax.inject.Singleton
|
|||
* shipped as a Cloud Function seed. Firestore is the source of truth for
|
||||
* per-partner swipes and revealed matches.
|
||||
*
|
||||
* Mutual match detection is performed server-side by the
|
||||
* `createDateMatchOnMutualLove` Cloud Function: the `date_matches` collection is
|
||||
* server-write-only (Firestore rules deny client writes), so when both partners
|
||||
* swipe [SwipeAction.LOVE] on an idea the function creates the match and it
|
||||
* arrives on the client via [observeMatches]. [recordSwipe] therefore only
|
||||
* records the swipe and always resolves with a null match.
|
||||
* Swipes are E2E-encrypted (the server can't read them), so mutual-match detection
|
||||
* is done client-side: after recording a LOVE, [recordSwipe] reads everyone's
|
||||
* (decrypted) swipes for that idea and, if both partners loved it, writes the match
|
||||
* marker via [FirestoreDateMatchDataSource.createMatchIfAbsent] (idempotent). A
|
||||
* Cloud Function then fires the "It's a match!" notification on create. The result
|
||||
* still arrives on every device via [observeMatches]. [recordSwipe] resolves with a
|
||||
* null match (the UI consumes matches through [observeMatches]).
|
||||
*/
|
||||
@Singleton
|
||||
class DateMatchRepositoryImpl @Inject constructor(
|
||||
|
|
@ -47,7 +48,19 @@ class DateMatchRepositoryImpl @Inject constructor(
|
|||
swipedAt = System.currentTimeMillis()
|
||||
)
|
||||
swipeDataSource.recordSwipe(coupleId, swipe)
|
||||
// Match creation is server-side (see class docs); surfaced via observeMatches.
|
||||
|
||||
// Server is blind to encrypted swipes, so detect mutual love here: if both
|
||||
// partners have now loved this idea, write the (idempotent) match marker.
|
||||
// Surfaced to the UI via observeMatches; FCM fired by the notify function.
|
||||
if (action == SwipeAction.LOVE) {
|
||||
val lovers = swipeDataSource.getAllSwipesForDate(coupleId, dateIdeaId)
|
||||
.filter { it.action == SwipeAction.LOVE }
|
||||
.map { it.userId }
|
||||
.distinct()
|
||||
if (lovers.size >= 2) {
|
||||
runCatching { matchDataSource.createMatchIfAbsent(coupleId, dateIdeaId, lovers.sorted()) }
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,8 +62,15 @@ class PlayIntegrityChecker @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
val passed = verifyWithServer(token)
|
||||
_state.value = if (passed) DeviceIntegrityResult.PASSED else DeviceIntegrityResult.COMPROMISED
|
||||
// Security review Batch 3: don't fail-open to PASSED when verification can't be
|
||||
// completed. A null verdict (server/network error, or no verdict returned) maps to
|
||||
// UNAVAILABLE — neutral, not a false "passed". Firebase App Check remains the real
|
||||
// server-side gatekeeper; this is only an in-app signal.
|
||||
_state.value = when (verifyWithServer(token)) {
|
||||
true -> DeviceIntegrityResult.PASSED
|
||||
false -> DeviceIntegrityResult.COMPROMISED
|
||||
null -> DeviceIntegrityResult.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestToken(): String = suspendCancellableCoroutine { cont ->
|
||||
|
|
@ -77,20 +84,20 @@ class PlayIntegrityChecker @Inject constructor(
|
|||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
private suspend fun verifyWithServer(token: String): Boolean =
|
||||
/** Returns the server verdict, or null when verification couldn't be completed. */
|
||||
private suspend fun verifyWithServer(token: String): Boolean? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
functions
|
||||
.getHttpsCallable("checkDeviceIntegrity")
|
||||
.call(mapOf("token" to token))
|
||||
.addOnSuccessListener { result ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean ?: true
|
||||
cont.resume(passed)
|
||||
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean
|
||||
cont.resume(passed) // null if the server returned no explicit verdict
|
||||
}
|
||||
.addOnFailureListener {
|
||||
// Fail-open: if server verification is unavailable, don't penalise the user.
|
||||
// Firebase App Check remains the server-side gatekeeper.
|
||||
cont.resume(true)
|
||||
// Couldn't verify (server/network). Don't assert PASSED — surface as
|
||||
// UNAVAILABLE. Firebase App Check remains the server-side gatekeeper.
|
||||
cont.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import androidx.credentials.CredentialManager
|
|||
import androidx.credentials.CustomCredential
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import androidx.credentials.exceptions.NoCredentialException
|
||||
import android.util.Log
|
||||
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -208,7 +210,10 @@ fun LoginScreen(
|
|||
}
|
||||
} catch (_: GetCredentialCancellationException) {
|
||||
// user dismissed — do nothing
|
||||
} catch (_: NoCredentialException) {
|
||||
viewModel.reportError("No Google account found on this device. Add one in Settings, then try again.")
|
||||
} catch (e: Exception) {
|
||||
Log.w("GoogleSignIn", "Google sign-in failed", e)
|
||||
viewModel.reportError("Google sign-in failed. Please try again.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import androidx.credentials.CredentialManager
|
|||
import androidx.credentials.CustomCredential
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import androidx.credentials.exceptions.NoCredentialException
|
||||
import android.util.Log
|
||||
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -226,7 +228,10 @@ fun SignUpScreen(
|
|||
}
|
||||
} catch (_: GetCredentialCancellationException) {
|
||||
// user dismissed — do nothing
|
||||
} catch (_: NoCredentialException) {
|
||||
viewModel.reportError("No Google account found on this device. Add one in Settings, then try again.")
|
||||
} catch (e: Exception) {
|
||||
Log.w("GoogleSignIn", "Google sign-up failed", e)
|
||||
viewModel.reportError("Google sign-up failed. Please try again.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -679,6 +679,9 @@ private fun PrimaryHomeActionCard(
|
|||
) {
|
||||
val colors = action.tone.actionColors()
|
||||
val isDark = isCloserDarkTheme()
|
||||
val showTonightPartnerArt = action.target == HomeActionTarget.DailyQuestion &&
|
||||
(dailyQuestionState == DailyQuestionState.UNANSWERED ||
|
||||
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING)
|
||||
|
||||
// For daily-question actions, route the CTA through the explicit state handlers
|
||||
// so the same button label maps to the correct next step (answer, remind,
|
||||
|
|
@ -774,7 +777,19 @@ private fun PrimaryHomeActionCard(
|
|||
}
|
||||
}
|
||||
|
||||
if (action.target == HomeActionTarget.DailyQuestion) {
|
||||
if (showTonightPartnerArt) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.illustration_tonight_partner_prompt),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(148.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
)
|
||||
}
|
||||
|
||||
if (action.target == HomeActionTarget.DailyQuestion && !showTonightPartnerArt) {
|
||||
Text(
|
||||
text = "Got 5 min?",
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
|
|
@ -782,29 +797,8 @@ private fun PrimaryHomeActionCard(
|
|||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(CloserRadii.Tile),
|
||||
color = colors.accent.copy(alpha = 0.16f),
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Favorite,
|
||||
contentDescription = null,
|
||||
tint = colors.deep,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (showTonightPartnerArt) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = titleOverride,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
|
|
@ -820,6 +814,46 @@ private fun PrimaryHomeActionCard(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(CloserRadii.Tile),
|
||||
color = colors.accent.copy(alpha = 0.16f),
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Favorite,
|
||||
contentDescription = null,
|
||||
tint = colors.deep,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = titleOverride,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = bodyOverride,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HomePulseStrip(stats = stats, streakCount = streakCount)
|
||||
|
|
@ -1410,6 +1444,100 @@ fun HomeScreenPreview() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-only: renders the paired Home from mock state so the paired experience can be viewed
|
||||
* without actually pairing two devices. No data sources, no Firestore — pure UI preview.
|
||||
* Reachable from Settings → "Paired home (debug)" in debug builds only.
|
||||
*/
|
||||
@Composable
|
||||
fun PairedHomePreviewScreen(onNavigate: (String) -> Unit = {}) {
|
||||
val demoState = HomeUiState(
|
||||
isLoading = false,
|
||||
isPaired = true,
|
||||
coupleId = "demo",
|
||||
partnerName = "Sofia",
|
||||
streakCount = 12,
|
||||
unreadActivityCount = 2,
|
||||
dailyQuestion = Question(
|
||||
id = "demo",
|
||||
text = "What's something I did recently that made you feel loved?",
|
||||
category = "emotional_intimacy",
|
||||
depthLevel = 2
|
||||
),
|
||||
dailyQuestionState = DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
|
||||
hasPartnerAnsweredToday = true,
|
||||
partnerAnsweredQuestionId = "demo",
|
||||
answerStats = HomeAnswerStats(total = 24, revealed = 18, private = 6),
|
||||
primaryAction = HomeAction(
|
||||
eyebrow = "Tonight",
|
||||
title = "Sofia answered. Your turn.",
|
||||
body = "She shared what's on her heart tonight — open the question and answer back.",
|
||||
cta = "Answer now",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Daily
|
||||
),
|
||||
secondaryActions = listOf(
|
||||
HomeAction(
|
||||
eyebrow = "Keep playing",
|
||||
title = "Question packs",
|
||||
body = "Fresh prompts for the two of you.",
|
||||
cta = "Browse packs",
|
||||
target = HomeActionTarget.QuestionPacks,
|
||||
tone = HomeActionTone.Pack
|
||||
),
|
||||
HomeAction(
|
||||
eyebrow = "Look back",
|
||||
title = "Your answers",
|
||||
body = "Revisit the moments you've shared.",
|
||||
cta = "Open history",
|
||||
target = HomeActionTarget.AnswerHistory,
|
||||
tone = HomeActionTone.Reflection
|
||||
)
|
||||
),
|
||||
pendingActions = listOf(
|
||||
PendingActionCard(
|
||||
title = "Sofia is waiting to play",
|
||||
subtitle = "A game is ready for you both",
|
||||
priority = 1,
|
||||
target = HomeActionTarget.Game
|
||||
)
|
||||
),
|
||||
categories = listOf(
|
||||
HomeCategorySummary(
|
||||
category = QuestionCategory(
|
||||
id = "communication", displayName = "Communication",
|
||||
description = "", access = "mixed", iconName = "chat"
|
||||
),
|
||||
questionCount = 250
|
||||
),
|
||||
HomeCategorySummary(
|
||||
category = QuestionCategory(
|
||||
id = "trust", displayName = "Trust",
|
||||
description = "", access = "mixed", iconName = "heart"
|
||||
),
|
||||
questionCount = 250
|
||||
),
|
||||
HomeCategorySummary(
|
||||
category = QuestionCategory(
|
||||
id = "intimacy", displayName = "Intimacy",
|
||||
description = "", access = "mixed", iconName = "heart"
|
||||
),
|
||||
questionCount = 180
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HomeContent(
|
||||
state = demoState,
|
||||
snackbarHostState = remember { SnackbarHostState() },
|
||||
onNavigate = onNavigate,
|
||||
onDailyQuestion = {}, onPacks = {}, onCategory = {}, onHistory = {},
|
||||
onSettings = {}, onInvite = {}, onAcceptInvite = {}, onReminder = {},
|
||||
onReveal = {}, onFollowUp = {}, onRefresh = {}, onPartner = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StreakMilestoneDialog(
|
||||
milestone: Int,
|
||||
|
|
|
|||
|
|
@ -474,6 +474,13 @@ fun SettingsScreen(
|
|||
onClick = { onNavigate(AppRoute.ART_PREVIEW) }
|
||||
)
|
||||
SettingsSectionDivider()
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Favorite,
|
||||
label = "Paired home (debug)",
|
||||
subtitle = "Preview the paired Home without pairing",
|
||||
onClick = { onNavigate(AppRoute.PAIRED_HOME_PREVIEW) }
|
||||
)
|
||||
SettingsSectionDivider()
|
||||
}
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Done,
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 640 KiB |
|
|
@ -395,32 +395,48 @@ service cloud.firestore {
|
|||
}
|
||||
}
|
||||
|
||||
// Date swipes: per-couple, per-date partner swipe state.
|
||||
// Date swipes: per-couple, per-date partner swipe state. The action is E2E
|
||||
// ciphertext so the server can't read date preferences; only swipedAt is plaintext.
|
||||
match /date_swipes/{dateIdeaId} {
|
||||
// Read: both couple members can read the shared swipe document.
|
||||
// Read: both couple members can read the shared swipe document (ciphertext).
|
||||
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
|
||||
// Create (doc doesn't exist yet): only the caller's own entry may be present,
|
||||
// and the action must be ciphertext.
|
||||
allow create: 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'])
|
||||
&& 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;
|
||||
&& isCiphertext(request.resource.data.actions[request.auth.uid].action)
|
||||
&& request.resource.data.actions[request.auth.uid].swipedAt is number;
|
||||
|
||||
// Update (partner may already have an entry): a merge write exposes the whole
|
||||
// post-write doc, so diff to ensure ONLY the caller's own entry changed.
|
||||
allow update: if isCouplesMember(coupleId)
|
||||
&& request.resource.data.keys().hasOnly(['actions'])
|
||||
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
|
||||
&& isCiphertext(request.resource.data.actions[request.auth.uid].action)
|
||||
&& request.resource.data.actions[request.auth.uid].swipedAt is number
|
||||
&& resource.data.actions.diff(request.resource.data.actions).affectedKeys().hasOnly([request.auth.uid]);
|
||||
|
||||
// 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.
|
||||
// Date matches: revealed mutual-love matches (matchId == dateIdeaId).
|
||||
// Server is blind to encrypted swipes, so the client writes the marker when it
|
||||
// detects mutual love; a Cloud Function fires the notification on create. The
|
||||
// creator must be one of the two matched members. fcmNotified flips server-side.
|
||||
match /date_matches/{matchId} {
|
||||
allow read: if isCouplesMember(coupleId);
|
||||
allow create, update, delete: if false;
|
||||
allow create: if isCouplesMember(coupleId)
|
||||
&& request.resource.data.keys().hasOnly(['dateIdeaId', 'revealedAt', 'matchedBy', 'fcmNotified'])
|
||||
&& request.resource.data.dateIdeaId is string
|
||||
&& request.resource.data.matchedBy is list
|
||||
&& request.resource.data.matchedBy.size() == 2
|
||||
&& request.auth.uid in request.resource.data.matchedBy
|
||||
&& request.resource.data.fcmNotified == false;
|
||||
allow update, delete: if false;
|
||||
}
|
||||
|
||||
// Date plan preferences: per-partner preferences for building date plans.
|
||||
|
|
|
|||
|
|
@ -42,14 +42,19 @@ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
|||
return
|
||||
}
|
||||
|
||||
// Acknowledge immediately to avoid RevenueCat retries.
|
||||
res.status(200).json({ received: true })
|
||||
|
||||
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
|
||||
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
|
||||
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
|
||||
// so returning 500 on failure recovers the event safely.
|
||||
try {
|
||||
await applyEntitlementEvent(event)
|
||||
} catch (err) {
|
||||
console.error('[revenueCatWebhook] entitlement sync failed:', err)
|
||||
res.status(500).json({ error: 'processing_failed' })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(200).json({ received: true })
|
||||
})
|
||||
|
||||
class ConfigError extends Error {}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,12 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
|||
if (!clientCode) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
|
||||
}
|
||||
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style
|
||||
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
|
||||
// codes and anything that could be abused as the document id.
|
||||
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'code must be 6 valid characters.')
|
||||
}
|
||||
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
|
||||
throw new functions.https.HttpsError(
|
||||
'invalid-argument',
|
||||
|
|
|
|||
|
|
@ -34,27 +34,46 @@ export const leaveCoupleCallable = functions.https.onCall(async (_data, context)
|
|||
}
|
||||
|
||||
const coupleRef = db.collection('couples').doc(coupleId)
|
||||
const coupleDoc = await coupleRef.get()
|
||||
|
||||
if (!coupleDoc.exists) {
|
||||
// Couple doc gone — just clear caller's field.
|
||||
await db.collection('users').doc(callerId).update({ coupleId: null })
|
||||
return { success: true }
|
||||
}
|
||||
// Security review Batch 2: do the membership check, member-clearing, and couple-doc
|
||||
// delete in one transaction so two partners leaving concurrently can't clobber state.
|
||||
// Critically, only clear a member's coupleId if it STILL points at this couple — a
|
||||
// stale concurrent call must never wipe a coupleId set by a fresh re-pair.
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
const coupleSnap = await tx.get(coupleRef)
|
||||
if (!coupleSnap.exists) {
|
||||
const callerRef = db.collection('users').doc(callerId)
|
||||
const callerSnap = await tx.get(callerRef)
|
||||
if (callerSnap.data()?.coupleId === coupleId) {
|
||||
tx.update(callerRef, { coupleId: null })
|
||||
}
|
||||
return { membership: true }
|
||||
}
|
||||
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
if (!userIds.includes(callerId)) {
|
||||
const userIds = (coupleSnap.data()?.userIds ?? []) as string[]
|
||||
if (!userIds.includes(callerId)) {
|
||||
return { membership: false }
|
||||
}
|
||||
|
||||
// Reads must precede writes in a transaction: snapshot every member first.
|
||||
const memberSnaps = await Promise.all(
|
||||
userIds.map((uid) => tx.get(db.collection('users').doc(uid)))
|
||||
)
|
||||
memberSnaps.forEach((snap, i) => {
|
||||
if (snap.data()?.coupleId === coupleId) {
|
||||
tx.update(db.collection('users').doc(userIds[i]), { coupleId: null })
|
||||
}
|
||||
})
|
||||
tx.delete(coupleRef)
|
||||
return { membership: true }
|
||||
})
|
||||
|
||||
if (!result.membership) {
|
||||
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.')
|
||||
}
|
||||
|
||||
// Clear coupleId for all members atomically.
|
||||
const batch = db.batch()
|
||||
for (const uid of userIds) {
|
||||
batch.update(db.collection('users').doc(uid), { coupleId: null })
|
||||
}
|
||||
await batch.commit()
|
||||
|
||||
// Recursively delete the couple document and every subcollection beneath it.
|
||||
// Couple doc is deleted in the transaction; sweep any subcollections left behind.
|
||||
// Idempotent if a concurrent caller already removed them.
|
||||
await db.recursiveDelete(coupleRef)
|
||||
|
||||
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,24 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
const LOVE = 'love'
|
||||
|
||||
interface SwipeEntry {
|
||||
action?: string
|
||||
swipedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a revealed date match when both partners have swiped LOVE on the
|
||||
* same date idea.
|
||||
* Fires the "It's a match!" notification when a date match is created.
|
||||
*
|
||||
* Trigger: couples/{coupleId}/date_swipes/{dateIdeaId} (onWrite)
|
||||
* Trigger: couples/{coupleId}/date_matches/{dateIdeaId} (onCreate)
|
||||
*
|
||||
* The `date_matches` collection is server-write-only — Firestore rules deny all
|
||||
* client writes (`allow create, update, delete: if false`). This trigger is
|
||||
* therefore the single source of truth for match creation. The client only
|
||||
* records swipes and observes `date_matches` for the result.
|
||||
* Date swipes are E2E-encrypted, so the server can no longer detect mutual love.
|
||||
* Mutual-match detection now happens client-side (whichever partner records the
|
||||
* second LOVE writes the match marker, validated by Firestore rules). This trigger
|
||||
* only sends the push to both partners — it never reads swipe content.
|
||||
*
|
||||
* Idempotency: the match document id is the date idea id and creation runs in a
|
||||
* transaction, so repeated swipes on the same idea and concurrent invocations
|
||||
* never produce a duplicate match.
|
||||
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations
|
||||
* (or a client retry) never double-send. The match doc id is the date idea id, so
|
||||
* the marker itself is already de-duplicated by the client transaction + rules.
|
||||
*/
|
||||
export const createDateMatchOnMutualLove = functions.firestore
|
||||
.document('couples/{coupleId}/date_swipes/{dateIdeaId}')
|
||||
.onWrite(async (change, context) => {
|
||||
const after = change.after.data()
|
||||
if (!after) return // swipe document was deleted
|
||||
|
||||
const actions = (after.actions ?? {}) as Record<string, SwipeEntry>
|
||||
const lovedBy = Object.entries(actions)
|
||||
.filter(([, entry]) => entry?.action === LOVE)
|
||||
.map(([uid]) => uid)
|
||||
.sort()
|
||||
|
||||
// A match needs both partners to have loved the same idea.
|
||||
if (lovedBy.length < 2) return
|
||||
export const notifyOnDateMatch = functions.firestore
|
||||
.document('couples/{coupleId}/date_matches/{dateIdeaId}')
|
||||
.onCreate(async (snap, context) => {
|
||||
if (!snap.exists) return
|
||||
|
||||
const { coupleId, dateIdeaId } = context.params as {
|
||||
coupleId: string
|
||||
|
|
@ -44,24 +26,9 @@ export const createDateMatchOnMutualLove = functions.firestore
|
|||
}
|
||||
|
||||
const db = admin.firestore()
|
||||
const matchRef = db
|
||||
.collection('couples')
|
||||
.doc(coupleId)
|
||||
.collection('date_matches')
|
||||
.doc(dateIdeaId)
|
||||
const matchRef = snap.ref
|
||||
|
||||
await db.runTransaction(async (tx) => {
|
||||
const existing = await tx.get(matchRef)
|
||||
if (existing.exists) return
|
||||
tx.set(matchRef, {
|
||||
dateIdeaId,
|
||||
matchedBy: lovedBy,
|
||||
revealedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
fcmNotified: false,
|
||||
})
|
||||
})
|
||||
|
||||
// Atomically claim FCM send so concurrent trigger invocations don't double-send.
|
||||
// Atomically claim the FCM send so concurrent invocations don't double-send.
|
||||
const shouldSend = await db.runTransaction(async (tx) => {
|
||||
const doc = await tx.get(matchRef)
|
||||
if (!doc.exists || doc.data()?.fcmNotified === true) return false
|
||||
|
|
@ -69,11 +36,11 @@ export const createDateMatchOnMutualLove = functions.firestore
|
|||
return true
|
||||
})
|
||||
|
||||
if (shouldSend) {
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
|
||||
}
|
||||
if (!shouldSend) return
|
||||
|
||||
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
|
||||
})
|
||||
|
||||
async function notifyDateMatch(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
// Initialize the Admin SDK once for every function in this codebase.
|
||||
|
|
@ -22,7 +21,7 @@ export {
|
|||
export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder'
|
||||
export { sendReengagementReminder } from './notifications/reengagement'
|
||||
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
||||
export { createDateMatchOnMutualLove } from './dates/createDateMatch'
|
||||
export { notifyOnDateMatch } from './dates/createDateMatch'
|
||||
export {
|
||||
assignDailyQuestion,
|
||||
assignDailyQuestionCallable,
|
||||
|
|
@ -38,10 +37,7 @@ export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
|
|||
export { onUserDelete } from './users/onUserDelete'
|
||||
export { onGameSessionUpdate } from './games/onGameSessionUpdate'
|
||||
|
||||
/**
|
||||
* Basic health check callable.
|
||||
* Useful for verifying function deployment and firebase-tools wiring.
|
||||
*/
|
||||
export const health = functions.https.onRequest((req, res) => {
|
||||
res.status(200).json({ status: 'ok' })
|
||||
})
|
||||
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
|
||||
// was removed to shrink attack surface. Deployment can be verified via
|
||||
// `firebase functions:list`. If an uptime probe is ever needed, re-add it behind
|
||||
// auth / a shared secret rather than as an open endpoint.
|
||||
|
|
|
|||
|
|
@ -98,7 +98,18 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: a
|
|||
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.')
|
||||
}
|
||||
|
||||
const date = data?.date && typeof data.date === 'string' ? data.date : cstDateString()
|
||||
// Security review Batch 2: constrain the client-supplied date. Only today's CST date
|
||||
// may be assigned on demand — this blocks creating arbitrary past/future daily_question
|
||||
// docs and, combined with create()'s ALREADY_EXISTS guard, caps it to one per day
|
||||
// (effective rate limit; repeat calls return already-exists).
|
||||
const today = cstDateString()
|
||||
const date = data?.date && typeof data.date === 'string' ? data.date : today
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.')
|
||||
}
|
||||
if (date !== today) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
|
||||
}
|
||||
const questionId = await pickRandomQuestionId()
|
||||
if (!questionId) {
|
||||
throw new functions.https.HttpsError('internal', 'No active questions available.')
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ export const onAnswerWritten = functions.firestore
|
|||
}
|
||||
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
|
||||
// Security review Batch 2: re-verify the writer actually belongs to this couple
|
||||
// before sending a cross-user notification. Firestore rules already enforce this,
|
||||
// but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping.
|
||||
if (!userIds.includes(userId)) {
|
||||
console.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const partnerId = userIds.find((uid) => uid !== userId)
|
||||
if (!partnerId) {
|
||||
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue