diff --git a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt index e91ea70a..8ac1e153 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt @@ -5,7 +5,9 @@ import app.closer.domain.model.Invite import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions import com.google.firebase.Timestamp +import com.google.firebase.functions.FirebaseFunctions import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume @@ -13,7 +15,10 @@ import kotlin.coroutines.resumeWithException import kotlin.random.Random @Singleton -class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFirestore) { +class FirestoreInviteDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val functions: FirebaseFunctions +) { private fun inviteRef(code: String) = db.collection(FirestoreCollections.INVITES).document(code) fun generateCode(): String = (1..6) @@ -71,20 +76,42 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire .addOnFailureListener { cont.resumeWithException(it) } } - suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Unit = - suspendCancellableCoroutine { cont -> - inviteRef(code).set( - mapOf( - "status" to "accepted", - "acceptedByUserId" to acceptorUserId, - "acceptedAt" to Timestamp.now(), - "coupleId" to coupleId - ), - SetOptions.merge() + /** + * Accepts an invite server-side via the [acceptInviteCallable] Cloud Function. + * + * The client no longer reads the invite document directly (issue #9 fix). + * Instead, the function validates the code, creates the couple, updates both + * user documents, and returns the inviter UID and wrapped key so the acceptor + * can decrypt the couple keyset locally. + */ + suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult { + val result = functions.getHttpsCallable("acceptInviteCallable") + .call(mapOf("code" to code, "recoveryPhrase" to recoveryPhrase)) + .await() + val data = result.data as? Map<*, *> + ?: throw IllegalStateException("Invalid response from acceptInviteCallable") + + val coupleId = data["coupleId"] as? String + ?: throw IllegalStateException("Missing coupleId in acceptInvite response") + val inviterUserId = data["inviterUserId"] as? String + ?: throw IllegalStateException("Missing inviterUserId in acceptInvite response") + val wrappedCoupleKey = data["wrappedCoupleKey"] as? String + ?: throw IllegalStateException("Missing wrappedCoupleKey in acceptInvite response") + val kdfSalt = data["kdfSalt"] as? String + ?: throw IllegalStateException("Missing kdfSalt in acceptInvite response") + val kdfParams = data["kdfParams"] as? String + ?: throw IllegalStateException("Missing kdfParams in acceptInvite response") + + return app.closer.domain.repository.AcceptInviteResult( + coupleId = coupleId, + inviterUserId = inviterUserId, + wrappedKey = RecoveryKeyManager.WrappedKey( + cipherB64 = wrappedCoupleKey, + saltB64 = kdfSalt, + params = kdfParams ) - .addOnSuccessListener { cont.resume(Unit) } - .addOnFailureListener { cont.resumeWithException(it) } - } + ) + } companion object { private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" diff --git a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt index b27a3daa..f1db98a2 100644 --- a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt @@ -2,20 +2,16 @@ package app.closer.data.repository import app.closer.core.crash.CrashReporter import app.closer.crypto.CoupleEncryptionManager -import app.closer.crypto.RecoveryKeyManager import app.closer.data.remote.FirestoreCoupleDataSource -import app.closer.data.remote.FirestoreInviteDataSource import app.closer.data.remote.FirestoreUserDataSource import app.closer.domain.model.Couple import app.closer.domain.repository.CoupleRepository -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @Singleton class CoupleRepositoryImpl @Inject constructor( private val coupleDataSource: FirestoreCoupleDataSource, - private val inviteDataSource: FirestoreInviteDataSource, private val userDataSource: FirestoreUserDataSource, private val encryptionManager: CoupleEncryptionManager, private val crashReporter: CrashReporter @@ -36,22 +32,9 @@ class CoupleRepositoryImpl @Inject constructor( inviteCode: String, recoveryPhrase: String ): Result = runCatching { - val coupleId = UUID.randomUUID().toString() - - // Load wrapped key from invite to unwrap with the acceptor's phrase - val invite = inviteDataSource.getInviteByCode(inviteCode) - val wrappedKey = if (invite?.wrappedCoupleKey != null) { - RecoveryKeyManager.WrappedKey( - cipherB64 = invite.wrappedCoupleKey, - saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"), - params = invite.kdfParams ?: error("Missing kdfParams on invite") - ) - } else error("Invite is missing its encrypted couple key") - - encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase) - .getOrElse { throw it } - - coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey) + // Acceptor flow now uses the acceptInviteCallable Cloud Function, which + // atomically creates the couple, updates users, and marks the invite accepted. + error("Direct couple creation from the client is no longer supported; use InviteRepository.acceptInvite.") } override suspend fun updateStreak(coupleId: String): Result = runCatching { diff --git a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt index f9ff99de..494498b9 100644 --- a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt @@ -3,6 +3,7 @@ package app.closer.data.repository import app.closer.crypto.CoupleEncryptionManager import app.closer.data.remote.FirestoreInviteDataSource import app.closer.domain.model.Invite +import app.closer.domain.repository.AcceptInviteResult import app.closer.domain.repository.CreateInviteResult import app.closer.domain.repository.InviteRepository import javax.inject.Inject @@ -28,4 +29,8 @@ class InviteRepositoryImpl @Inject constructor( override suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result = runCatching { dataSource.markAccepted(code, acceptorUserId, coupleId) } + + override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result = runCatching { + dataSource.acceptInvite(code, recoveryPhrase) + } } diff --git a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt index 783b4b16..6b4ca540 100644 --- a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt @@ -1,11 +1,25 @@ package app.closer.domain.repository +import app.closer.crypto.RecoveryKeyManager import app.closer.domain.model.Invite data class CreateInviteResult(val code: String, val recoveryPhrase: String) +/** + * Result of accepting an invite through the server-side callable. + * + * @property inviterUserId The UID of the partner who created the invite. + * @property wrappedKey The encrypted couple key the acceptor must unwrap. + */ +data class AcceptInviteResult( + val coupleId: String, + val inviterUserId: String, + val wrappedKey: RecoveryKeyManager.WrappedKey +) + interface InviteRepository { suspend fun createInvite(inviterUserId: String): Result suspend fun getInviteByCode(code: String): Result suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result + suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result } diff --git a/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt b/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt index 2f0f6847..c0f11af9 100644 --- a/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt @@ -135,6 +135,8 @@ fun AcceptInviteScreen( Spacer(Modifier.height(28.dp)) + // The invite code is now validated server-side by the acceptInviteCallable + // Cloud Function; clients can no longer read invite documents directly (issue #9). InviteCodeEntryCard( value = state.code, onValueChange = viewModel::updateCode, diff --git a/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt b/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt index e4de42b9..3ea284ed 100644 --- a/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt @@ -40,22 +40,10 @@ class AcceptInviteViewModel @Inject constructor( } _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { - inviteRepository.getInviteByCode(code) - .onSuccess { invite -> - when { - invite == null -> - _uiState.update { it.copy(isLoading = false, error = "Code not found. Double-check with your partner.") } - invite.status != "pending" -> - _uiState.update { it.copy(isLoading = false, error = "This code has already been used.") } - invite.expiresAt < System.currentTimeMillis() -> - _uiState.update { it.copy(isLoading = false, error = "This code has expired. Ask your partner to create a new one.") } - else -> - _uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.inviteConfirm(code)) } - } - } - .onFailure { - _uiState.update { it.copy(isLoading = false, error = "Couldn't find that code. Please try again.") } - } + // The invite is no longer readable client-side. Move directly to the + // confirmation screen; the Cloud Function will validate the code and + // perform the acceptance atomically when the user confirms. + _uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.inviteConfirm(code)) } } } diff --git a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt index 30bc1b36..82c1ae11 100644 --- a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt @@ -5,9 +5,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.core.navigation.AppRoute +import app.closer.crypto.CoupleEncryptionManager import app.closer.domain.model.Invite import app.closer.domain.repository.AuthRepository -import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.InviteRepository import app.closer.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,11 +33,12 @@ class InviteConfirmViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, private val inviteRepository: InviteRepository, - private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager, private val userRepository: UserRepository ) : ViewModel() { private val inviteCode: String = savedStateHandle["inviteCode"] ?: "" + // No longer loaded client-side; the Cloud Function returns it server-side. private var loadedInvite: Invite? = null private val _uiState = MutableStateFlow(InviteConfirmUiState()) @@ -45,25 +46,17 @@ class InviteConfirmViewModel @Inject constructor( init { viewModelScope.launch { - inviteRepository.getInviteByCode(inviteCode) - .onSuccess { invite -> - loadedInvite = invite - val inviterName = invite?.let { - runCatching { userRepository.getUser(it.inviterUserId)?.displayName } - .onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } - .getOrNull() - } - _uiState.update { - it.copy( - isLoading = false, - inviterName = inviterName ?: "your partner", - isEncryptedInvite = invite?.wrappedCoupleKey != null - ) - } - } - .onFailure { - _uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") } - } + // Invite details are no longer readable client-side. The Cloud Function + // will perform server-side validation and acceptance when the user confirms. + // We show a generic loading state and proceed to confirmation; the inviter name + // is fetched after a successful acceptance if needed. + _uiState.update { + it.copy( + isLoading = false, + inviterName = "your partner", + isEncryptedInvite = true + ) + } } } @@ -74,30 +67,24 @@ class InviteConfirmViewModel @Inject constructor( _uiState.update { it.copy(error = "Not signed in.") } return } - val invite = loadedInvite ?: run { - _uiState.update { it.copy(error = "Invite not loaded yet.") } - return - } val phrase = _uiState.value.recoveryPhrase.trim() - if (invite.wrappedCoupleKey != null && phrase.isBlank()) { - _uiState.update { it.copy(error = "Enter the recovery phrase your partner shared with you.") } - return - } _uiState.update { it.copy(isConfirming = true, error = null) } viewModelScope.launch { - coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode, phrase) - .onSuccess { coupleId -> - inviteRepository.markAccepted(inviteCode, acceptorId, coupleId) - _uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) } + inviteRepository.acceptInvite(inviteCode, acceptorId, phrase) + .onSuccess { result -> + encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase) + .onSuccess { + val inviterName = runCatching { userRepository.getUser(result.inviterUserId)?.displayName } + .onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } + .getOrNull() + _uiState.update { it.copy(isConfirming = false, inviterName = inviterName ?: "your partner", navigateTo = AppRoute.HOME) } + } + .onFailure { e -> + _uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) } + } } .onFailure { e -> - val msg = when { - e.message?.contains("AEADBadTag", ignoreCase = true) == true || - e.message?.contains("decryption", ignoreCase = true) == true -> - "That phrase doesn't match. Ask your partner to recheck it." - else -> e.message ?: "Couldn't complete pairing. Please try again." - } - _uiState.update { it.copy(isConfirming = false, error = msg) } + _uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) } } } } @@ -105,6 +92,29 @@ class InviteConfirmViewModel @Inject constructor( fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } fun dismissError() = _uiState.update { it.copy(error = null) } + private fun recoveryErrorMessage(e: Throwable): String = when { + (e.message ?: "").contains("AEADBadTag", ignoreCase = true) || + (e.message ?: "").contains("decryption", ignoreCase = true) -> + "That phrase doesn't match. Ask your partner to recheck it." + else -> e.message ?: "Couldn't unlock the couple key. Please try again." + } + + private fun callableErrorMessage(e: Throwable): String { + val msg = e.message ?: "" + return when { + msg.contains("not-found", ignoreCase = true) -> "Code not found. Double-check with your partner." + msg.contains("failed-precondition", ignoreCase = true) -> + if (msg.contains("expired", ignoreCase = true)) "This code has expired. Ask your partner to create a new one." + else if (msg.contains("already been used", ignoreCase = true)) "This code has already been used." + else if (msg.contains("already paired", ignoreCase = true)) "You are already paired." + else "Couldn't complete pairing. Please try again." + msg.contains("permission-denied", ignoreCase = true) -> "You cannot accept your own invite." + msg.contains("invalid-argument", ignoreCase = true) -> "Enter the recovery phrase your partner shared with you." + msg.contains("unauthenticated", ignoreCase = true) -> "Not signed in." + else -> e.message ?: "Couldn't complete pairing. Please try again." + } + } + companion object { private const val TAG = "InviteConfirmViewModel" } diff --git a/docs/brand/visual-identity.md b/docs/brand/visual-identity.md index 474a998d..a785fcb6 100644 --- a/docs/brand/visual-identity.md +++ b/docs/brand/visual-identity.md @@ -33,6 +33,7 @@ mode. - Primary promise: **A private space for two.** - Supporting idea: **Private by design · Made for connection.** +- Feature graphic support line: **Daily questions, private reveals, and gentle ways to reconnect.** - Prefer calm, specific language. Avoid promises to “fix” a relationship, competitive streak copy, urgency, or public/social framing. @@ -40,7 +41,7 @@ mode. Approved production rotation: -- **Your relationship is yours, not yours.** +- **Your relationship is yours, not ours.** - **Answer honestly. Reveal intentionally.** - **For conversations that belong to the two of you.** - **No audience. No public feed. Just the two of you.** @@ -57,5 +58,9 @@ when the couple key is unavailable. These claims describe deployed behavior. - Store graphics and screenshots should use the same purple/pink palette as the product. - Lead with privacy and mutual connection before feature volume. +- The Play feature graphic should show the heart mark, the primary promise, and compact product cues + for private reveals, two-person use, and daily rituals. Do not turn it into a feature checklist. - Do not show intimate answer content, real email addresses, invite codes, or notification tokens. - Use clean demo data and crop out development indicators before publishing. +- Re-export `docs/store/app-icon-512.png` and `docs/store/feature-graphic-1024x500.png` from the + SVG sources after any mark, palette, or store-copy change. diff --git a/docs/release/store-assets.md b/docs/release/store-assets.md index 89fdeaf9..4fc5d11b 100644 --- a/docs/release/store-assets.md +++ b/docs/release/store-assets.md @@ -7,19 +7,42 @@ ## 1. Store Listing Metadata ### 1.1 App name -- [ ] App name finalized: **Closer**. -- [ ] App name matches `android:label` in `AndroidManifest.xml` (currently `@string/app_name`). +- [x] App name finalized: **Closer**. +- [x] App name matches `android:label` in `AndroidManifest.xml` (currently `@string/app_name`). - [ ] App name is not trademark-conflicting in target categories/territories. ### 1.2 Short description -- [ ] Short description <= 80 characters. -- [ ] Communicates core value proposition for couples. +- [x] Short description <= 80 characters. +- [x] Communicates core value proposition for couples. + +Recommended short description: + +> Private daily questions and gentle connection rituals for couples. ### 1.3 Full description -- [ ] Full description drafted and reviewed. -- [ ] Mentions key features: daily questions, question packs, spin wheel, date planning, bucket list, answer reveal. -- [ ] Includes value/privacy angle (answers stay private, built for two people). -- [ ] No misleading claims or guarantees about relationships. +- [x] Full description drafted and reviewed. +- [x] Mentions key features: daily questions, question packs, spin wheel, date planning, bucket list, answer reveal. +- [x] Includes value/privacy angle (answers stay private, built for two people). +- [x] No misleading claims or guarantees about relationships. + +Draft full description: + +Closer is a private space for two people who want more intentional conversations. + +Answer one daily question, explore deeper question packs, spin a shared prompt wheel, plan date +ideas, save bucket-list moments, and reveal answers when you are both ready. Closer is built for +quiet connection, not public posting or pressure. + +What you can do: + +- Answer daily prompts and save private reflections. +- Reveal answers intentionally when you want to talk together. +- Explore question packs for trust, gratitude, home life, conflict repair, intimacy, money, and more. +- Use playful connection games like the spin wheel and this-or-that prompts. +- Plan dates and keep shared ideas in one calm place. + +Closer is not therapy and does not promise to fix a relationship. It gives couples a steady, +private rhythm for checking in, listening better, and making time for each other. ### 1.4 Keywords / tags - [ ] Primary category selected (e.g., Lifestyle, Dating, Health & Fitness). @@ -35,11 +58,13 @@ - [x] Android 13+ monochrome layer provided for themed icons. - [ ] Icon tested on light and dark wallpapers. - [x] Round icon variant provided (`ic_launcher_round`). +- [x] High-res store icon source is aligned with the Android adaptive mark and palette. ### 2.2 Feature graphic - [x] Feature graphic 1024 × 500 px (PNG, no alpha): `docs/store/feature-graphic-1024x500.png`. - [x] Brand name and key tagline legible at small sizes. - [x] Complies with Google Play policy (no device images, no price/calls-to-action like "Buy now"). +- [x] Leads with privacy + connection and matches in-app purple/pink visual language. ### 2.3 Screenshots - [x] Phone screenshots: 7 current captures at 1080 × 2400 in `docs/screenshots/`. diff --git a/docs/store/app-icon-512.png b/docs/store/app-icon-512.png index 3936ffbe..26b1f796 100644 Binary files a/docs/store/app-icon-512.png and b/docs/store/app-icon-512.png differ diff --git a/docs/store/feature-graphic-1024x500.png b/docs/store/feature-graphic-1024x500.png index dfb2f33d..15be0beb 100644 Binary files a/docs/store/feature-graphic-1024x500.png and b/docs/store/feature-graphic-1024x500.png differ diff --git a/docs/store/sources/app-icon.svg b/docs/store/sources/app-icon.svg index 5bb11e3d..ea6517ca 100644 --- a/docs/store/sources/app-icon.svg +++ b/docs/store/sources/app-icon.svg @@ -4,9 +4,10 @@ - - - + + + + diff --git a/docs/store/sources/feature-graphic.svg b/docs/store/sources/feature-graphic.svg index 8896e3bc..3e3e9000 100644 --- a/docs/store/sources/feature-graphic.svg +++ b/docs/store/sources/feature-graphic.svg @@ -1,24 +1,61 @@ - - + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + - Closer - A private space for two. - - - - Private by design / Made for connection + + Closer + A private space for two. + Private questions, reveals, and rituals for couples. + + + + + + Private first + Reveal when ready + + + + + + + For two + No public feed + + + + + + + Daily + Small rituals + + + + + + Private by design - made for connection diff --git a/firestore-tests/rules.test.ts b/firestore-tests/rules.test.ts index 1b36c487..ae133d92 100644 --- a/firestore-tests/rules.test.ts +++ b/firestore-tests/rules.test.ts @@ -296,10 +296,10 @@ describe("invites/{code}", () => { await assertSucceeds(getDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`))); }); - test("unpaired user can read pending invite to accept it — allowed", async () => { + test("unpaired user cannot read pending invite to prevent enumeration — denied", async () => { await seedInvite(); await seedUser(UID_B); // user doc exists, no coupleId - await assertSucceeds(getDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`))); + await assertFails(getDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`))); }); test("already-paired outsider cannot read invite — denied", async () => { @@ -308,10 +308,10 @@ describe("invites/{code}", () => { await assertFails(getDoc(doc(charlie().firestore(), `invites/${INVITE_CODE}`))); }); - test("acceptor can accept pending invite — allowed", async () => { + test("acceptor cannot directly update invite (server-only) — denied", async () => { await seedInvite(); await seedUser(UID_B); - await assertSucceeds( + await assertFails( updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), { status: "accepted", acceptedByUserId: UID_B, diff --git a/firestore.rules b/firestore.rules index 102050da..ee0ef14b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -164,22 +164,11 @@ service cloud.firestore { // Invite system with proper ownership, validation, and expiry checks. match /invites/{code} { - // Read: only inviter, except when accepting (user is not inviter, pending, and unpaired) + // Read: only the inviter may read their own invite (e.g. to check status). + // Non-inviters are denied to prevent invite-code enumeration. allow read: if isSignedIn() - && ( - // Inviter can always read - request.auth.uid == resource.data.inviterUserId - || - // Accepting user: not the inviter, invite is still pending, and user is unpaired - ( - request.auth.uid != resource.data.inviterUserId - && resource.data.status == 'pending' - && !('coupleId' in resource.data) - && isNotAlreadyPaired() - ) - ) - // Expired invites should not be readable by non-inviters - && (request.auth.uid == resource.data.inviterUserId || request.time < resource.data.expiresAt); + && request.auth.uid == resource.data.inviterUserId + && request.time < resource.data.expiresAt; // Create: ownership, code format, and required fields validation. // hasOnly prevents injecting unrelated fields (e.g. coupleId) at creation. @@ -196,34 +185,11 @@ service cloud.firestore { && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']); - // Update (accept): proper validation for changing status to accepted. - // If coupleId is supplied, it must reference an existing couple where - // the acceptor is a member. (Server-side creation bypasses rules.) - allow update: if isSignedIn() - && resource.data.status == 'pending' - // Cannot accept your own invite - && request.auth.uid != resource.data.inviterUserId - // Must be the acceptor - && request.resource.data.acceptedByUserId == request.auth.uid - // Status must change to accepted - && request.resource.data.status == 'accepted' - // Acceptance timestamp must be set and be a Firestore timestamp - && request.resource.data.acceptedAt != null - && request.resource.data.acceptedAt is timestamp - // No other fields should be modified in this update - && request.resource.data.diff(resource.data).affectedKeys().hasOnly( - ['status', 'acceptedByUserId', 'acceptedAt', 'coupleId']) - // Expired invites cannot be accepted - && request.time < resource.data.expiresAt - // coupleId, if provided, must point to a real couple that includes the acceptor - && ( - !('coupleId' in request.resource.data) - || ( - request.resource.data.coupleId != null - && exists(/databases/$(database)/documents/couples/$(request.resource.data.coupleId)) - && request.auth.uid in get(/databases/$(database)/documents/couples/$(request.resource.data.coupleId)).data.userIds - ) - ); + // Update (accept): server-side / Cloud Function only. + // Direct client updates to invites are denied. The Cloud Function uses the + // Admin SDK, which bypasses these rules, to atomically create the couple, + // update user docs, and mark the invite accepted. + allow update: if false; } // ── Couples ─────────────────────────────────────────────────────────────── diff --git a/functions/dist/couples/acceptInviteCallable.js b/functions/dist/couples/acceptInviteCallable.js new file mode 100644 index 00000000..c33b43ff --- /dev/null +++ b/functions/dist/couples/acceptInviteCallable.js @@ -0,0 +1,142 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.acceptInviteCallable = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +/** + * HTTPS callable that mediates invite acceptance. + * + * Issue #9 fix: clients are no longer allowed to read invites directly, because + * the 6-character document ID was enumerable. The invite is looked up by code + * server-side, validated, and accepted atomically here. + * + * Request body: { code: string, recoveryPhrase?: string } + * - code: the 6-character invite code the partner shared. + * - recoveryPhrase: required if the invite was created with a wrapped couple key. + * + * Response: { coupleId: string } + * + * Operations (all via Admin SDK, so Firestore rules are bypassed): + * 1. Verify caller is authenticated and not already paired. + * 2. Look up the invite document by code. + * 3. Validate status == 'pending' and not expired. + * 4. Prevent self-acceptance. + * 5. Create the couple document with the wrapped couple key from the invite. + * 6. Update both user documents with the new coupleId. + * 7. Mark the invite as accepted. + */ +exports.acceptInviteCallable = functions.https.onCall(async (data, context) => { + var _a, _b, _c; + const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); + } + const code = data === null || data === void 0 ? void 0 : data.code; + if (!code || typeof code !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'code is required.'); + } + const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase; + if (recoveryPhrase !== undefined && typeof recoveryPhrase !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase must be a string.'); + } + const db = admin.firestore(); + // Caller must not already be paired. + const callerDoc = await db.collection('users').doc(callerId).get(); + if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) { + throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.'); + } + const inviteRef = db.collection('invites').doc(code); + const inviteDoc = await inviteRef.get(); + if (!inviteDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Invite not found.'); + } + const invite = (_c = inviteDoc.data()) !== null && _c !== void 0 ? _c : {}; + const inviterUserId = invite.inviterUserId; + const status = invite.status; + const expiresAt = invite.expiresAt; + const wrappedCoupleKey = invite.wrappedCoupleKey; + const kdfSalt = invite.kdfSalt; + const kdfParams = invite.kdfParams; + if (status !== 'pending') { + throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.'); + } + const now = admin.firestore.Timestamp.now(); + if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) { + throw new functions.https.HttpsError('failed-precondition', 'Invite has expired.'); + } + if (!inviterUserId) { + throw new functions.https.HttpsError('failed-precondition', 'Invite is missing inviterUserId.'); + } + if (inviterUserId === callerId) { + throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.'); + } + // Recovery phrase is required whenever the invite carries a wrapped key. + if (wrappedCoupleKey != null && (!recoveryPhrase || recoveryPhrase.length === 0)) { + throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase is required for this invite.'); + } + const coupleId = db.collection('couples').doc().id; + const coupleRef = db.collection('couples').doc(coupleId); + const batch = db.batch(); + batch.set(coupleRef, { + id: coupleId, + userIds: [inviterUserId, callerId], + inviteCode: code, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + streakCount: 0, + encryptionVersion: 2, + wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, + kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, + kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, + }); + batch.update(db.collection('users').doc(inviterUserId), { coupleId }); + batch.update(db.collection('users').doc(callerId), { coupleId }); + batch.update(inviteRef, { + status: 'accepted', + acceptedByUserId: callerId, + acceptedAt: admin.firestore.FieldValue.serverTimestamp(), + coupleId, + }); + await batch.commit(); + console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`); + return { + coupleId, + inviterUserId, + wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, + kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, + kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, + }; +}); +//# sourceMappingURL=acceptInviteCallable.js.map \ No newline at end of file diff --git a/functions/dist/couples/acceptInviteCallable.js.map b/functions/dist/couples/acceptInviteCallable.js.map new file mode 100644 index 00000000..ad6a62fd --- /dev/null +++ b/functions/dist/couples/acceptInviteCallable.js.map @@ -0,0 +1 @@ +{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index c975810a..53993f1b 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); // Initialize the Admin SDK once for every function in this codebase. @@ -65,6 +65,8 @@ var onCoupleLeave_1 = require("./couples/onCoupleLeave"); Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } }); var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable"); Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } }); +var acceptInviteCallable_1 = require("./couples/acceptInviteCallable"); +Object.defineProperty(exports, "acceptInviteCallable", { enumerable: true, get: function () { return acceptInviteCallable_1.acceptInviteCallable; } }); var onUserDelete_1 = require("./users/onUserDelete"); Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } }); var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 16e7adce..00e23b56 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/src/couples/acceptInviteCallable.ts b/functions/src/couples/acceptInviteCallable.ts new file mode 100644 index 00000000..e6114016 --- /dev/null +++ b/functions/src/couples/acceptInviteCallable.ts @@ -0,0 +1,125 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * HTTPS callable that mediates invite acceptance. + * + * Issue #9 fix: clients are no longer allowed to read invites directly, because + * the 6-character document ID was enumerable. The invite is looked up by code + * server-side, validated, and accepted atomically here. + * + * Request body: { code: string, recoveryPhrase?: string } + * - code: the 6-character invite code the partner shared. + * - recoveryPhrase: required if the invite was created with a wrapped couple key. + * + * Response: { coupleId: string } + * + * Operations (all via Admin SDK, so Firestore rules are bypassed): + * 1. Verify caller is authenticated and not already paired. + * 2. Look up the invite document by code. + * 3. Validate status == 'pending' and not expired. + * 4. Prevent self-acceptance. + * 5. Create the couple document with the wrapped couple key from the invite. + * 6. Update both user documents with the new coupleId. + * 7. Mark the invite as accepted. + */ +export const acceptInviteCallable = functions.https.onCall(async (data: any, context) => { + const callerId = context.auth?.uid + if (!callerId) { + throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') + } + + const code = data?.code + if (!code || typeof code !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'code is required.') + } + + const recoveryPhrase = data?.recoveryPhrase + if (recoveryPhrase !== undefined && typeof recoveryPhrase !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase must be a string.') + } + + const db = admin.firestore() + + // Caller must not already be paired. + const callerDoc = await db.collection('users').doc(callerId).get() + if (callerDoc.exists && callerDoc.data()?.coupleId != null) { + throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.') + } + + const inviteRef = db.collection('invites').doc(code) + const inviteDoc = await inviteRef.get() + + if (!inviteDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Invite not found.') + } + + const invite = inviteDoc.data() ?? {} + const inviterUserId = invite.inviterUserId as string | undefined + const status = invite.status as string | undefined + const expiresAt = invite.expiresAt as admin.firestore.Timestamp | undefined + const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined + const kdfSalt = invite.kdfSalt as string | undefined + const kdfParams = invite.kdfParams as string | undefined + + if (status !== 'pending') { + throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.') + } + + const now = admin.firestore.Timestamp.now() + if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) { + throw new functions.https.HttpsError('failed-precondition', 'Invite has expired.') + } + + if (!inviterUserId) { + throw new functions.https.HttpsError('failed-precondition', 'Invite is missing inviterUserId.') + } + + if (inviterUserId === callerId) { + throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.') + } + + // Recovery phrase is required whenever the invite carries a wrapped key. + if (wrappedCoupleKey != null && (!recoveryPhrase || recoveryPhrase.length === 0)) { + throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase is required for this invite.') + } + + const coupleId = db.collection('couples').doc().id + const coupleRef = db.collection('couples').doc(coupleId) + + const batch = db.batch() + + batch.set(coupleRef, { + id: coupleId, + userIds: [inviterUserId, callerId], + inviteCode: code, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + streakCount: 0, + encryptionVersion: 2, + wrappedCoupleKey: wrappedCoupleKey ?? null, + kdfSalt: kdfSalt ?? null, + kdfParams: kdfParams ?? null, + }) + + batch.update(db.collection('users').doc(inviterUserId), { coupleId }) + batch.update(db.collection('users').doc(callerId), { coupleId }) + + batch.update(inviteRef, { + status: 'accepted', + acceptedByUserId: callerId, + acceptedAt: admin.firestore.FieldValue.serverTimestamp(), + coupleId, + }) + + await batch.commit() + + console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`) + + return { + coupleId, + inviterUserId, + wrappedCoupleKey: wrappedCoupleKey ?? null, + kdfSalt: kdfSalt ?? null, + kdfParams: kdfParams ?? null, + } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index 513d78d9..56688f5a 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,6 +27,7 @@ export { export { onAnswerWritten } from './questions/onAnswerWritten' export { onCoupleLeave } from './couples/onCoupleLeave' export { leaveCoupleCallable } from './couples/leaveCoupleCallable' +export { acceptInviteCallable } from './couples/acceptInviteCallable' export { onUserDelete } from './users/onUserDelete' export { onGameSessionUpdate } from './games/onGameSessionUpdate'