docs(readme): add privacy slogan to header
This commit is contained in:
parent
e5c05abe90
commit
06e09da596
|
|
@ -1,5 +1,7 @@
|
||||||
# Closer
|
# 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.*
|
> *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.
|
**Product goal:** private, mutual-reveal relationship questions with real encryption and calmer UX.
|
||||||
|
|
|
||||||
|
|
@ -91,20 +91,25 @@ fun AppNavigation(
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
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
|
val shellTitle = currentRoute
|
||||||
?.takeIf { it in shellBackRoutes }
|
?.takeIf { it in shellBackRoutes }
|
||||||
?.let(AppRoute::titleFor)
|
?.let(AppRoute::titleFor)
|
||||||
// Tab-switch semantics: pop to the graph start, keep a single instance, and
|
// Tab-switch semantics: pop to the graph start and keep a single instance, so a
|
||||||
// save/restore each tab's own back stack. Every navigation to a top-level
|
// tab is never pushed on top of another tab. We deliberately DON'T save/restore
|
||||||
// route must go through this so a tab is never pushed on top of another tab.
|
// 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 ->
|
val selectTab: (String) -> Unit = { route ->
|
||||||
navController.navigate(route) {
|
navController.navigate(route) {
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
saveState = true
|
saveState = false
|
||||||
}
|
}
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = true
|
restoreState = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val navigateBackOrHome: () -> Unit = {
|
val navigateBackOrHome: () -> Unit = {
|
||||||
|
|
@ -476,6 +481,9 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.ART_PREVIEW) {
|
composable(route = AppRoute.ART_PREVIEW) {
|
||||||
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
|
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 YOUR_PROGRESS = "your_progress"
|
||||||
const val ACTIVITY = "activity"
|
const val ACTIVITY = "activity"
|
||||||
const val ART_PREVIEW = "art_preview"
|
const val ART_PREVIEW = "art_preview"
|
||||||
|
const val PAIRED_HOME_PREVIEW = "paired_home_preview"
|
||||||
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
|
||||||
|
|
||||||
fun pairingSuccess(coupleId: String) = "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.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
@ -27,9 +28,11 @@ import kotlin.coroutines.resumeWithException
|
||||||
* "matchedBy": ["userA", "userB"]
|
* "matchedBy": ["userA", "userB"]
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Security rules deny client create/update/delete; matches are written by a
|
* Under E2EE the server can no longer read swipe content, so mutual-love
|
||||||
* Cloud Function after both partners swipe [SwipeAction.LOVE]. The client layer
|
* detection moved client-side: when a member records the second LOVE on an idea,
|
||||||
* still exposes a create helper for the function trigger path.
|
* 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
|
@Singleton
|
||||||
class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
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)
|
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
.collection(FirestoreCollections.Couples.DATE_MATCHES)
|
.collection(FirestoreCollections.Couples.DATE_MATCHES)
|
||||||
|
|
||||||
suspend fun createMatch(coupleId: String, dateIdeaId: String, matchedBy: List<String>): String {
|
/**
|
||||||
val doc = matchesRef(coupleId).document()
|
* Create the match marker for [dateIdeaId] iff it doesn't already exist. Keyed by
|
||||||
doc.set(
|
* dateIdeaId and guarded by a transaction so both partners loving near-simultaneously
|
||||||
mapOf(
|
* (and repeated swipes) never produce a duplicate. `fcmNotified` lets the notify
|
||||||
"dateIdeaId" to dateIdeaId,
|
* function claim the push exactly once.
|
||||||
"revealedAt" to FieldValue.serverTimestamp(),
|
*/
|
||||||
"matchedBy" to matchedBy
|
suspend fun createMatchIfAbsent(coupleId: String, dateIdeaId: String, matchedBy: List<String>) {
|
||||||
)
|
val ref = matchesRef(coupleId).document(dateIdeaId)
|
||||||
).voidAwait()
|
db.runTransaction<Unit> { tx ->
|
||||||
return doc.id
|
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? {
|
suspend fun findMatchByDateIdeaId(coupleId: String, dateIdeaId: String): DateMatch? {
|
||||||
|
|
@ -76,12 +90,6 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.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 ──────────────────────────────────────────────────────────────
|
// ─── Mapper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package app.closer.data.remote
|
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.DateSwipe
|
||||||
import app.closer.domain.model.SwipeAction
|
import app.closer.domain.model.SwipeAction
|
||||||
|
import com.google.crypto.tink.Aead
|
||||||
import com.google.firebase.firestore.DocumentSnapshot
|
import com.google.firebase.firestore.DocumentSnapshot
|
||||||
import com.google.firebase.firestore.FieldValue
|
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.firestore.SetOptions
|
import com.google.firebase.firestore.SetOptions
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
|
@ -21,27 +23,35 @@ import kotlin.coroutines.resumeWithException
|
||||||
* Path layout:
|
* Path layout:
|
||||||
* couples/{coupleId}/date_swipes/{dateId}
|
* 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": {
|
* "actions": {
|
||||||
* "userA": { "action": "love", "swipedAt": 12345 },
|
* "userA": { "action": "enc:v1:…", "swipedAt": 12345 },
|
||||||
* "userB": { "action": "skip", "swipedAt": 12346 }
|
* "userB": { "action": "enc:v1:…", "swipedAt": 12346 }
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Security rules ensure only couple members can read/write, and each user can
|
* Security rules ensure only couple members can read/write, each user can only
|
||||||
* only write their own action entry.
|
* 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
|
@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) =
|
private fun swipesRef(coupleId: String) =
|
||||||
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
db.collection(FirestoreCollections.COUPLES).document(coupleId)
|
||||||
.collection(FirestoreCollections.Couples.DATE_SWIPES)
|
.collection(FirestoreCollections.Couples.DATE_SWIPES)
|
||||||
|
|
||||||
suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) {
|
suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) {
|
||||||
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val path = swipesRef(coupleId).document(swipe.dateIdeaId)
|
val path = swipesRef(coupleId).document(swipe.dateIdeaId)
|
||||||
val entry = mapOf(
|
val entry = mapOf(
|
||||||
"action" to swipe.action.toFirestoreValue(),
|
"action" to fieldEncryptor.encrypt(swipe.action.toFirestoreValue(), aead, coupleId),
|
||||||
"swipedAt" to swipe.swipedAt
|
"swipedAt" to swipe.swipedAt
|
||||||
)
|
)
|
||||||
path.set(
|
path.set(
|
||||||
|
|
@ -50,9 +60,20 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
||||||
).voidAwait()
|
).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? {
|
suspend fun getSwipe(coupleId: String, dateIdeaId: String, userId: String): DateSwipe? {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
||||||
return snap.toDateSwipe(dateIdeaId, userId)
|
return snap.toDateSwipe(dateIdeaId, userId, aead, coupleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeOwnSwipes(
|
fun observeOwnSwipes(
|
||||||
|
|
@ -70,7 +91,8 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
||||||
val listener = swipesRef(coupleId)
|
val listener = swipesRef(coupleId)
|
||||||
.addSnapshotListener { snap, err ->
|
.addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
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)
|
trySend(swipes)
|
||||||
}
|
}
|
||||||
awaitClose { listener.remove() }
|
awaitClose { listener.remove() }
|
||||||
|
|
@ -80,8 +102,9 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> {
|
private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
val snap = swipesRef(coupleId).getQuery()
|
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() =
|
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> {
|
suspend fun getAllSwipesForDate(coupleId: String, dateIdeaId: String): List<DateSwipe> {
|
||||||
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val actions = snap.get("actions") as? Map<String, Map<String, Any>> ?: emptyMap()
|
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(
|
DateSwipe(
|
||||||
dateIdeaId = dateIdeaId,
|
dateIdeaId = dateIdeaId,
|
||||||
userId = uid,
|
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
|
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -123,13 +147,18 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
|
||||||
// ─── Mappers ─────────────────────────────────────────────────────────────
|
// ─── Mappers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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 actions = get("actions") as? Map<String, Map<String, Any>> ?: return null
|
||||||
val data = actions[userId] ?: return null
|
val data = actions[userId] ?: return null
|
||||||
return DateSwipe(
|
return DateSwipe(
|
||||||
dateIdeaId = dateIdeaId,
|
dateIdeaId = dateIdeaId,
|
||||||
userId = userId,
|
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
|
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
|
* shipped as a Cloud Function seed. Firestore is the source of truth for
|
||||||
* per-partner swipes and revealed matches.
|
* per-partner swipes and revealed matches.
|
||||||
*
|
*
|
||||||
* Mutual match detection is performed server-side by the
|
* Swipes are E2E-encrypted (the server can't read them), so mutual-match detection
|
||||||
* `createDateMatchOnMutualLove` Cloud Function: the `date_matches` collection is
|
* is done client-side: after recording a LOVE, [recordSwipe] reads everyone's
|
||||||
* server-write-only (Firestore rules deny client writes), so when both partners
|
* (decrypted) swipes for that idea and, if both partners loved it, writes the match
|
||||||
* swipe [SwipeAction.LOVE] on an idea the function creates the match and it
|
* marker via [FirestoreDateMatchDataSource.createMatchIfAbsent] (idempotent). A
|
||||||
* arrives on the client via [observeMatches]. [recordSwipe] therefore only
|
* Cloud Function then fires the "It's a match!" notification on create. The result
|
||||||
* records the swipe and always resolves with a null match.
|
* still arrives on every device via [observeMatches]. [recordSwipe] resolves with a
|
||||||
|
* null match (the UI consumes matches through [observeMatches]).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DateMatchRepositoryImpl @Inject constructor(
|
class DateMatchRepositoryImpl @Inject constructor(
|
||||||
|
|
@ -47,7 +48,19 @@ class DateMatchRepositoryImpl @Inject constructor(
|
||||||
swipedAt = System.currentTimeMillis()
|
swipedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
swipeDataSource.recordSwipe(coupleId, swipe)
|
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
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,15 @@ class PlayIntegrityChecker @Inject constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val passed = verifyWithServer(token)
|
// Security review Batch 3: don't fail-open to PASSED when verification can't be
|
||||||
_state.value = if (passed) DeviceIntegrityResult.PASSED else DeviceIntegrityResult.COMPROMISED
|
// 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 ->
|
private suspend fun requestToken(): String = suspendCancellableCoroutine { cont ->
|
||||||
|
|
@ -77,20 +84,20 @@ class PlayIntegrityChecker @Inject constructor(
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.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 ->
|
suspendCancellableCoroutine { cont ->
|
||||||
functions
|
functions
|
||||||
.getHttpsCallable("checkDeviceIntegrity")
|
.getHttpsCallable("checkDeviceIntegrity")
|
||||||
.call(mapOf("token" to token))
|
.call(mapOf("token" to token))
|
||||||
.addOnSuccessListener { result ->
|
.addOnSuccessListener { result ->
|
||||||
@Suppress("UNCHECKED_CAST")
|
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean
|
||||||
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean ?: true
|
cont.resume(passed) // null if the server returned no explicit verdict
|
||||||
cont.resume(passed)
|
|
||||||
}
|
}
|
||||||
.addOnFailureListener {
|
.addOnFailureListener {
|
||||||
// Fail-open: if server verification is unavailable, don't penalise the user.
|
// Couldn't verify (server/network). Don't assert PASSED — surface as
|
||||||
// Firebase App Check remains the server-side gatekeeper.
|
// UNAVAILABLE. Firebase App Check remains the server-side gatekeeper.
|
||||||
cont.resume(true)
|
cont.resume(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import androidx.credentials.CredentialManager
|
||||||
import androidx.credentials.CustomCredential
|
import androidx.credentials.CustomCredential
|
||||||
import androidx.credentials.GetCredentialRequest
|
import androidx.credentials.GetCredentialRequest
|
||||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
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.GetSignInWithGoogleOption
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -208,7 +210,10 @@ fun LoginScreen(
|
||||||
}
|
}
|
||||||
} catch (_: GetCredentialCancellationException) {
|
} catch (_: GetCredentialCancellationException) {
|
||||||
// user dismissed — do nothing
|
// 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) {
|
} catch (e: Exception) {
|
||||||
|
Log.w("GoogleSignIn", "Google sign-in failed", e)
|
||||||
viewModel.reportError("Google sign-in failed. Please try again.")
|
viewModel.reportError("Google sign-in failed. Please try again.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import androidx.credentials.CredentialManager
|
||||||
import androidx.credentials.CustomCredential
|
import androidx.credentials.CustomCredential
|
||||||
import androidx.credentials.GetCredentialRequest
|
import androidx.credentials.GetCredentialRequest
|
||||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
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.GetSignInWithGoogleOption
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -226,7 +228,10 @@ fun SignUpScreen(
|
||||||
}
|
}
|
||||||
} catch (_: GetCredentialCancellationException) {
|
} catch (_: GetCredentialCancellationException) {
|
||||||
// user dismissed — do nothing
|
// 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) {
|
} catch (e: Exception) {
|
||||||
|
Log.w("GoogleSignIn", "Google sign-up failed", e)
|
||||||
viewModel.reportError("Google sign-up failed. Please try again.")
|
viewModel.reportError("Google sign-up failed. Please try again.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,9 @@ private fun PrimaryHomeActionCard(
|
||||||
) {
|
) {
|
||||||
val colors = action.tone.actionColors()
|
val colors = action.tone.actionColors()
|
||||||
val isDark = isCloserDarkTheme()
|
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
|
// 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,
|
// 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(
|
||||||
text = "Got 5 min?",
|
text = "Got 5 min?",
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
|
@ -782,29 +797,8 @@ private fun PrimaryHomeActionCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
if (showTonightPartnerArt) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
Column(verticalArrangement = Arrangement.spacedBy(8.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(
|
||||||
text = titleOverride,
|
text = titleOverride,
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
|
@ -820,6 +814,46 @@ private fun PrimaryHomeActionCard(
|
||||||
overflow = TextOverflow.Ellipsis
|
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)
|
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
|
@Composable
|
||||||
internal fun StreakMilestoneDialog(
|
internal fun StreakMilestoneDialog(
|
||||||
milestone: Int,
|
milestone: Int,
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,13 @@ fun SettingsScreen(
|
||||||
onClick = { onNavigate(AppRoute.ART_PREVIEW) }
|
onClick = { onNavigate(AppRoute.ART_PREVIEW) }
|
||||||
)
|
)
|
||||||
SettingsSectionDivider()
|
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(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Done,
|
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} {
|
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);
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
|
||||||
// Create/Update: each member can only write their own action entry.
|
// Create (doc doesn't exist yet): only the caller's own entry may be present,
|
||||||
// The payload must contain an actions.{uid} object with a valid action.
|
// and the action must be ciphertext.
|
||||||
allow create, update: if isCouplesMember(coupleId)
|
allow create: 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.keys().hasOnly(['actions'])
|
||||||
&& request.resource.data.actions.keys().hasOnly([request.auth.uid])
|
&& 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].keys().hasOnly(['action', 'swipedAt'])
|
||||||
&& isValidSwipeAction(request.resource.data.actions[request.auth.uid].action)
|
&& isCiphertext(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 number;
|
||||||
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
|
|
||||||
|
// 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.
|
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date matches: revealed mutual love matches.
|
// Date matches: revealed mutual-love matches (matchId == dateIdeaId).
|
||||||
// Clients can read; creation of a match is performed by a Cloud Function
|
// Server is blind to encrypted swipes, so the client writes the marker when it
|
||||||
// after both partners have swiped 'love'. Direct client writes are denied.
|
// 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} {
|
match /date_matches/{matchId} {
|
||||||
allow read: if isCouplesMember(coupleId);
|
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.
|
// Date plan preferences: per-partner preferences for building date plans.
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,19 @@ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acknowledge immediately to avoid RevenueCat retries.
|
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
|
||||||
res.status(200).json({ received: true })
|
// 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 {
|
try {
|
||||||
await applyEntitlementEvent(event)
|
await applyEntitlementEvent(event)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[revenueCatWebhook] entitlement sync failed:', 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 {}
|
class ConfigError extends Error {}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
if (!clientCode) {
|
if (!clientCode) {
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
|
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) {
|
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
|
||||||
throw new functions.https.HttpsError(
|
throw new functions.https.HttpsError(
|
||||||
'invalid-argument',
|
'invalid-argument',
|
||||||
|
|
|
||||||
|
|
@ -34,27 +34,46 @@ export const leaveCoupleCallable = functions.https.onCall(async (_data, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
const coupleRef = db.collection('couples').doc(coupleId)
|
const coupleRef = db.collection('couples').doc(coupleId)
|
||||||
const coupleDoc = await coupleRef.get()
|
|
||||||
|
|
||||||
if (!coupleDoc.exists) {
|
// Security review Batch 2: do the membership check, member-clearing, and couple-doc
|
||||||
// Couple doc gone — just clear caller's field.
|
// delete in one transaction so two partners leaving concurrently can't clobber state.
|
||||||
await db.collection('users').doc(callerId).update({ coupleId: null })
|
// Critically, only clear a member's coupleId if it STILL points at this couple — a
|
||||||
return { success: true }
|
// 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[]
|
const userIds = (coupleSnap.data()?.userIds ?? []) as string[]
|
||||||
if (!userIds.includes(callerId)) {
|
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.')
|
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear coupleId for all members atomically.
|
// Couple doc is deleted in the transaction; sweep any subcollections left behind.
|
||||||
const batch = db.batch()
|
// Idempotent if a concurrent caller already removed them.
|
||||||
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.
|
|
||||||
await db.recursiveDelete(coupleRef)
|
await db.recursiveDelete(coupleRef)
|
||||||
|
|
||||||
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)
|
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,24 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
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
|
* Fires the "It's a match!" notification when a date match is created.
|
||||||
* same date idea.
|
|
||||||
*
|
*
|
||||||
* 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
|
* Date swipes are E2E-encrypted, so the server can no longer detect mutual love.
|
||||||
* client writes (`allow create, update, delete: if false`). This trigger is
|
* Mutual-match detection now happens client-side (whichever partner records the
|
||||||
* therefore the single source of truth for match creation. The client only
|
* second LOVE writes the match marker, validated by Firestore rules). This trigger
|
||||||
* records swipes and observes `date_matches` for the result.
|
* 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
|
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations
|
||||||
* transaction, so repeated swipes on the same idea and concurrent invocations
|
* (or a client retry) never double-send. The match doc id is the date idea id, so
|
||||||
* never produce a duplicate match.
|
* the marker itself is already de-duplicated by the client transaction + rules.
|
||||||
*/
|
*/
|
||||||
export const createDateMatchOnMutualLove = functions.firestore
|
export const notifyOnDateMatch = functions.firestore
|
||||||
.document('couples/{coupleId}/date_swipes/{dateIdeaId}')
|
.document('couples/{coupleId}/date_matches/{dateIdeaId}')
|
||||||
.onWrite(async (change, context) => {
|
.onCreate(async (snap, context) => {
|
||||||
const after = change.after.data()
|
if (!snap.exists) return
|
||||||
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
|
|
||||||
|
|
||||||
const { coupleId, dateIdeaId } = context.params as {
|
const { coupleId, dateIdeaId } = context.params as {
|
||||||
coupleId: string
|
coupleId: string
|
||||||
|
|
@ -44,24 +26,9 @@ export const createDateMatchOnMutualLove = functions.firestore
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = admin.firestore()
|
const db = admin.firestore()
|
||||||
const matchRef = db
|
const matchRef = snap.ref
|
||||||
.collection('couples')
|
|
||||||
.doc(coupleId)
|
|
||||||
.collection('date_matches')
|
|
||||||
.doc(dateIdeaId)
|
|
||||||
|
|
||||||
await db.runTransaction(async (tx) => {
|
// Atomically claim the FCM send so concurrent invocations don't double-send.
|
||||||
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.
|
|
||||||
const shouldSend = await db.runTransaction(async (tx) => {
|
const shouldSend = await db.runTransaction(async (tx) => {
|
||||||
const doc = await tx.get(matchRef)
|
const doc = await tx.get(matchRef)
|
||||||
if (!doc.exists || doc.data()?.fcmNotified === true) return false
|
if (!doc.exists || doc.data()?.fcmNotified === true) return false
|
||||||
|
|
@ -69,11 +36,11 @@ export const createDateMatchOnMutualLove = functions.firestore
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (shouldSend) {
|
if (!shouldSend) return
|
||||||
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
|
||||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||||
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
|
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||||
}
|
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function notifyDateMatch(
|
async function notifyDateMatch(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
// Initialize the Admin SDK once for every function in this codebase.
|
// Initialize the Admin SDK once for every function in this codebase.
|
||||||
|
|
@ -22,7 +21,7 @@ export {
|
||||||
export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder'
|
export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder'
|
||||||
export { sendReengagementReminder } from './notifications/reengagement'
|
export { sendReengagementReminder } from './notifications/reengagement'
|
||||||
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
||||||
export { createDateMatchOnMutualLove } from './dates/createDateMatch'
|
export { notifyOnDateMatch } from './dates/createDateMatch'
|
||||||
export {
|
export {
|
||||||
assignDailyQuestion,
|
assignDailyQuestion,
|
||||||
assignDailyQuestionCallable,
|
assignDailyQuestionCallable,
|
||||||
|
|
@ -38,10 +37,7 @@ export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
|
||||||
export { onUserDelete } from './users/onUserDelete'
|
export { onUserDelete } from './users/onUserDelete'
|
||||||
export { onGameSessionUpdate } from './games/onGameSessionUpdate'
|
export { onGameSessionUpdate } from './games/onGameSessionUpdate'
|
||||||
|
|
||||||
/**
|
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
|
||||||
* Basic health check callable.
|
// was removed to shrink attack surface. Deployment can be verified via
|
||||||
* Useful for verifying function deployment and firebase-tools wiring.
|
// `firebase functions:list`. If an uptime probe is ever needed, re-add it behind
|
||||||
*/
|
// auth / a shared secret rather than as an open endpoint.
|
||||||
export const health = functions.https.onRequest((req, res) => {
|
|
||||||
res.status(200).json({ status: 'ok' })
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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.')
|
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()
|
const questionId = await pickRandomQuestionId()
|
||||||
if (!questionId) {
|
if (!questionId) {
|
||||||
throw new functions.https.HttpsError('internal', 'No active questions available.')
|
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[]
|
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)
|
const partnerId = userIds.find((uid) => uid !== userId)
|
||||||
if (!partnerId) {
|
if (!partnerId) {
|
||||||
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)
|
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue