fix: prevent invite code enumeration via Cloud Function (batch v0.2.18)

- Remove client-side read access to invites (only inviter can read own invite)
- Deny direct client update to invites (server-side only via Admin SDK)
- Add acceptInviteCallable Cloud Function: validates code, creates couple,
  updates user docs, marks invite accepted, returns wrapped key for local decryption
- Update Android client: FirestoreInviteDataSource calls callable function,
  InviteConfirmViewModel uses acceptInvite + unwrapAndStore flow
- Deprecate CoupleRepositoryImpl.createCouple (client-side path removed)
- Update Firestore rules tests: unpaired read now denied, direct update now denied
- 118/118 tests passing
This commit is contained in:
null 2026-06-19 21:46:12 -05:00
parent 749d3aa6fd
commit 39255c8733
21 changed files with 502 additions and 168 deletions

View File

@ -5,7 +5,9 @@ import app.closer.domain.model.Invite
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 com.google.firebase.Timestamp import com.google.firebase.Timestamp
import com.google.firebase.functions.FirebaseFunctions
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
@ -13,7 +15,10 @@ import kotlin.coroutines.resumeWithException
import kotlin.random.Random import kotlin.random.Random
@Singleton @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) private fun inviteRef(code: String) = db.collection(FirestoreCollections.INVITES).document(code)
fun generateCode(): String = (1..6) fun generateCode(): String = (1..6)
@ -71,20 +76,42 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Unit = /**
suspendCancellableCoroutine { cont -> * Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
inviteRef(code).set( *
mapOf( * The client no longer reads the invite document directly (issue #9 fix).
"status" to "accepted", * Instead, the function validates the code, creates the couple, updates both
"acceptedByUserId" to acceptorUserId, * user documents, and returns the inviter UID and wrapped key so the acceptor
"acceptedAt" to Timestamp.now(), * can decrypt the couple keyset locally.
"coupleId" to coupleId */
), suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult {
SetOptions.merge() 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 { companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"

View File

@ -2,20 +2,16 @@ package app.closer.data.repository
import app.closer.core.crash.CrashReporter import app.closer.core.crash.CrashReporter
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.RecoveryKeyManager
import app.closer.data.remote.FirestoreCoupleDataSource import app.closer.data.remote.FirestoreCoupleDataSource
import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.data.remote.FirestoreUserDataSource import app.closer.data.remote.FirestoreUserDataSource
import app.closer.domain.model.Couple import app.closer.domain.model.Couple
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class CoupleRepositoryImpl @Inject constructor( class CoupleRepositoryImpl @Inject constructor(
private val coupleDataSource: FirestoreCoupleDataSource, private val coupleDataSource: FirestoreCoupleDataSource,
private val inviteDataSource: FirestoreInviteDataSource,
private val userDataSource: FirestoreUserDataSource, private val userDataSource: FirestoreUserDataSource,
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val crashReporter: CrashReporter private val crashReporter: CrashReporter
@ -36,22 +32,9 @@ class CoupleRepositoryImpl @Inject constructor(
inviteCode: String, inviteCode: String,
recoveryPhrase: String recoveryPhrase: String
): Result<String> = runCatching { ): Result<String> = runCatching {
val coupleId = UUID.randomUUID().toString() // Acceptor flow now uses the acceptInviteCallable Cloud Function, which
// atomically creates the couple, updates users, and marks the invite accepted.
// Load wrapped key from invite to unwrap with the acceptor's phrase error("Direct couple creation from the client is no longer supported; use InviteRepository.acceptInvite.")
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)
} }
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching { override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {

View File

@ -3,6 +3,7 @@ package app.closer.data.repository
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.FirestoreInviteDataSource import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.model.Invite import app.closer.domain.model.Invite
import app.closer.domain.repository.AcceptInviteResult
import app.closer.domain.repository.CreateInviteResult import app.closer.domain.repository.CreateInviteResult
import app.closer.domain.repository.InviteRepository import app.closer.domain.repository.InviteRepository
import javax.inject.Inject import javax.inject.Inject
@ -28,4 +29,8 @@ class InviteRepositoryImpl @Inject constructor(
override suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit> = runCatching { override suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit> = runCatching {
dataSource.markAccepted(code, acceptorUserId, coupleId) dataSource.markAccepted(code, acceptorUserId, coupleId)
} }
override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult> = runCatching {
dataSource.acceptInvite(code, recoveryPhrase)
}
} }

View File

@ -1,11 +1,25 @@
package app.closer.domain.repository package app.closer.domain.repository
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite import app.closer.domain.model.Invite
data class CreateInviteResult(val code: String, val recoveryPhrase: String) 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 { interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?> suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit> suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult>
} }

View File

@ -135,6 +135,8 @@ fun AcceptInviteScreen(
Spacer(Modifier.height(28.dp)) 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( InviteCodeEntryCard(
value = state.code, value = state.code,
onValueChange = viewModel::updateCode, onValueChange = viewModel::updateCode,

View File

@ -40,22 +40,10 @@ class AcceptInviteViewModel @Inject constructor(
} }
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
inviteRepository.getInviteByCode(code) // The invite is no longer readable client-side. Move directly to the
.onSuccess { invite -> // confirmation screen; the Cloud Function will validate the code and
when { // perform the acceptance atomically when the user confirms.
invite == null -> _uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.inviteConfirm(code)) }
_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.") }
}
} }
} }

View File

@ -5,9 +5,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.crypto.CoupleEncryptionManager
import app.closer.domain.model.Invite import app.closer.domain.model.Invite
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.InviteRepository import app.closer.domain.repository.InviteRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -33,11 +33,12 @@ class InviteConfirmViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val inviteRepository: InviteRepository, private val inviteRepository: InviteRepository,
private val coupleRepository: CoupleRepository, private val encryptionManager: CoupleEncryptionManager,
private val userRepository: UserRepository private val userRepository: UserRepository
) : ViewModel() { ) : ViewModel() {
private val inviteCode: String = savedStateHandle["inviteCode"] ?: "" private val inviteCode: String = savedStateHandle["inviteCode"] ?: ""
// No longer loaded client-side; the Cloud Function returns it server-side.
private var loadedInvite: Invite? = null private var loadedInvite: Invite? = null
private val _uiState = MutableStateFlow(InviteConfirmUiState()) private val _uiState = MutableStateFlow(InviteConfirmUiState())
@ -45,25 +46,17 @@ class InviteConfirmViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
inviteRepository.getInviteByCode(inviteCode) // Invite details are no longer readable client-side. The Cloud Function
.onSuccess { invite -> // will perform server-side validation and acceptance when the user confirms.
loadedInvite = invite // We show a generic loading state and proceed to confirmation; the inviter name
val inviterName = invite?.let { // is fetched after a successful acceptance if needed.
runCatching { userRepository.getUser(it.inviterUserId)?.displayName } _uiState.update {
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } it.copy(
.getOrNull() isLoading = false,
} inviterName = "your partner",
_uiState.update { isEncryptedInvite = true
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.") }
}
} }
} }
@ -74,30 +67,24 @@ class InviteConfirmViewModel @Inject constructor(
_uiState.update { it.copy(error = "Not signed in.") } _uiState.update { it.copy(error = "Not signed in.") }
return return
} }
val invite = loadedInvite ?: run {
_uiState.update { it.copy(error = "Invite not loaded yet.") }
return
}
val phrase = _uiState.value.recoveryPhrase.trim() 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) } _uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode, phrase) inviteRepository.acceptInvite(inviteCode, acceptorId, phrase)
.onSuccess { coupleId -> .onSuccess { result ->
inviteRepository.markAccepted(inviteCode, acceptorId, coupleId) encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
_uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) } .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 -> .onFailure { e ->
val msg = when { _uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) }
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) }
} }
} }
} }
@ -105,6 +92,29 @@ class InviteConfirmViewModel @Inject constructor(
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = 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 { companion object {
private const val TAG = "InviteConfirmViewModel" private const val TAG = "InviteConfirmViewModel"
} }

View File

@ -33,6 +33,7 @@ mode.
- Primary promise: **A private space for two.** - Primary promise: **A private space for two.**
- Supporting idea: **Private by design · Made for connection.** - 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, - Prefer calm, specific language. Avoid promises to “fix” a relationship, competitive streak copy,
urgency, or public/social framing. urgency, or public/social framing.
@ -40,7 +41,7 @@ mode.
Approved production rotation: Approved production rotation:
- **Your relationship is yours, not yours.** - **Your relationship is yours, not ours.**
- **Answer honestly. Reveal intentionally.** - **Answer honestly. Reveal intentionally.**
- **For conversations that belong to the two of you.** - **For conversations that belong to the two of you.**
- **No audience. No public feed. Just 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. - Store graphics and screenshots should use the same purple/pink palette as the product.
- Lead with privacy and mutual connection before feature volume. - 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. - 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. - 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.

View File

@ -7,19 +7,42 @@
## 1. Store Listing Metadata ## 1. Store Listing Metadata
### 1.1 App name ### 1.1 App name
- [ ] App name finalized: **Closer**. - [x] App name finalized: **Closer**.
- [ ] App name matches `android:label` in `AndroidManifest.xml` (currently `@string/app_name`). - [x] App name matches `android:label` in `AndroidManifest.xml` (currently `@string/app_name`).
- [ ] App name is not trademark-conflicting in target categories/territories. - [ ] App name is not trademark-conflicting in target categories/territories.
### 1.2 Short description ### 1.2 Short description
- [ ] Short description <= 80 characters. - [x] Short description <= 80 characters.
- [ ] Communicates core value proposition for couples. - [x] Communicates core value proposition for couples.
Recommended short description:
> Private daily questions and gentle connection rituals for couples.
### 1.3 Full description ### 1.3 Full description
- [ ] Full description drafted and reviewed. - [x] Full description drafted and reviewed.
- [ ] Mentions key features: daily questions, question packs, spin wheel, date planning, bucket list, answer reveal. - [x] 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). - [x] Includes value/privacy angle (answers stay private, built for two people).
- [ ] No misleading claims or guarantees about relationships. - [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 ### 1.4 Keywords / tags
- [ ] Primary category selected (e.g., Lifestyle, Dating, Health & Fitness). - [ ] Primary category selected (e.g., Lifestyle, Dating, Health & Fitness).
@ -35,11 +58,13 @@
- [x] Android 13+ monochrome layer provided for themed icons. - [x] Android 13+ monochrome layer provided for themed icons.
- [ ] Icon tested on light and dark wallpapers. - [ ] Icon tested on light and dark wallpapers.
- [x] Round icon variant provided (`ic_launcher_round`). - [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 ### 2.2 Feature graphic
- [x] Feature graphic 1024 × 500 px (PNG, no alpha): `docs/store/feature-graphic-1024x500.png`. - [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] 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] 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 ### 2.3 Screenshots
- [x] Phone screenshots: 7 current captures at 1080 × 2400 in `docs/screenshots/`. - [x] Phone screenshots: 7 current captures at 1080 × 2400 in `docs/screenshots/`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -4,9 +4,10 @@
<feDropShadow dx="0" dy="18" stdDeviation="14" flood-color="#24122F" flood-opacity=".42"/> <feDropShadow dx="0" dy="18" stdDeviation="14" flood-color="#24122F" flood-opacity=".42"/>
</filter> </filter>
</defs> </defs>
<rect width="512" height="512" fill="#56306F"/> <rect width="512" height="512" fill="#4A235F"/>
<path d="M0 0h512v190C414 226 317 226 222 199 122 171 53 119 0 62Z" fill="#B98AF4" opacity=".28"/> <path d="M0 0h512v198C411 236 314 232 222 207 124 180 52 125 0 64Z" fill="#B98AF4" opacity=".24"/>
<path d="M0 355c92-52 192-45 296-15 93 27 157 37 216 1v171H0Z" fill="#180C20" opacity=".32"/> <path d="M0 354c92-52 192-45 296-15 93 27 157 37 216 1v172H0Z" fill="#24122F" opacity=".36"/>
<path d="M378 0h134v512H352c6-74-5-148-34-219-34-82-27-171 60-293Z" fill="#B98AF4" opacity=".10"/>
<g filter="url(#shadow)"> <g filter="url(#shadow)">
<path d="M256 402c-25-28-130-111-153-177-24-62 13-116 73-116 38 0 65 19 80 49Z" fill="#F7C8E4"/> <path d="M256 402c-25-28-130-111-153-177-24-62 13-116 73-116 38 0 65 19 80 49Z" fill="#F7C8E4"/>
<path d="M256 402c25-28 130-111 153-177 24-62-13-116-73-116-38 0-65 19-80 49Z" fill="#D9B8FF"/> <path d="M256 402c25-28 130-111 153-177 24-62-13-116-73-116-38 0-65 19-80 49Z" fill="#D9B8FF"/>

Before

Width:  |  Height:  |  Size: 999 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,24 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500" viewBox="0 0 1024 500"> <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500" viewBox="0 0 1024 500">
<defs> <defs>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="180%"> <filter id="markShadow" x="-30%" y="-30%" width="160%" height="180%">
<feDropShadow dx="0" dy="12" stdDeviation="12" flood-color="#180C20" flood-opacity=".45"/> <feDropShadow dx="0" dy="14" stdDeviation="14" flood-color="#180C20" flood-opacity=".45"/>
</filter>
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#180C20" flood-opacity=".22"/>
</filter> </filter>
</defs> </defs>
<rect width="1024" height="500" fill="#3B1D4B"/>
<circle cx="938" cy="62" r="210" fill="#B98AF4" opacity=".13"/> <rect width="1024" height="500" fill="#24122F"/>
<circle cx="855" cy="470" r="245" fill="#E7A2D1" opacity=".10"/> <path d="M0 0h1024v168C819 214 641 199 472 158 286 113 144 58 0 34Z" fill="#B98AF4" opacity=".14"/>
<path d="M0 396c134-81 273-73 418-29 127 39 230 43 307 6v127H0Z" fill="#180C20" opacity=".18"/> <path d="M0 372c144-62 292-57 449-14 142 39 259 43 358 7 70-25 142-33 217-13v148H0Z" fill="#180C20" opacity=".34"/>
<g transform="translate(92 96) scale(.72)" filter="url(#shadow)"> <path d="M900 0h124v500H820c31-76 34-154 8-236C800 174 812 88 900 0Z" fill="#F7C8E4" opacity=".08"/>
<path d="M256 402c-25-28-130-111-153-177-24-62 13-116 73-116 38 0 65 19 80 49Z" fill="#F7C8E4"/>
<path d="M256 402c25-28 130-111 153-177 24-62-13-116-73-116-38 0-65 19-80 49Z" fill="#D9B8FF"/> <g transform="translate(68 74)">
<path d="M122 199c7-45 36-69 78-69 27 0 46 11 56 28v28c-44-25-89-21-134 13Z" fill="#FFF4FA" opacity=".64"/> <rect x="0" y="0" width="312" height="312" rx="72" fill="#4A235F"/>
<path d="M256 158c14-18 38-28 67-28 42 0 70 24 77 69-49-34-97-38-144-13Z" fill="#F3E8FF" opacity=".50"/> <path d="M0 0h312v120C250 143 192 141 136 126 76 109 32 76 0 38Z" fill="#B98AF4" opacity=".24"/>
<path d="M0 216c56-32 117-28 181-9 57 16 96 23 131 1v104H0Z" fill="#180C20" opacity=".28"/>
<g transform="translate(0 -4)" filter="url(#markShadow)">
<path d="M156 246c-15-17-79-68-93-109-15-38 8-71 45-71 23 0 40 12 48 30Z" fill="#F7C8E4"/>
<path d="M156 246c15-17 79-68 93-109 15-38-8-71-45-71-23 0-40 12-48 30Z" fill="#D9B8FF"/>
<path d="M74 121c4-28 22-42 48-42 16 0 28 7 34 17v17c-27-15-54-13-82 8Z" fill="#FFF4FA" opacity=".68"/>
<path d="M156 96c9-11 23-17 41-17 26 0 43 14 47 42-30-21-59-23-88-8Z" fill="#F3E8FF" opacity=".52"/>
</g>
</g> </g>
<text x="500" y="193" fill="#FFF8FC" font-family="DejaVu Sans" font-size="76" font-weight="700">Closer</text>
<text x="504" y="254" fill="#F3E8FF" font-family="DejaVu Sans" font-size="32" font-weight="500">A private space for two.</text> <text x="408" y="142" fill="#FFF8FC" font-family="DejaVu Sans" font-size="72" font-weight="700">Closer</text>
<g transform="translate(504 296)"> <text x="412" y="198" fill="#F7C8E4" font-family="DejaVu Sans" font-size="26" font-weight="600">A private space for two.</text>
<rect width="448" height="56" rx="28" fill="#6B4A7C"/> <text x="412" y="236" fill="#F3E8FF" font-family="DejaVu Sans" font-size="18" font-weight="500">Private questions, reveals, and rituals for couples.</text>
<path d="M30 17l10 4v9c0 7-4 12-10 15-6-3-10-8-10-15v-9l10-4Zm0 6-5 2v5c0 4 2 7 5 9 3-2 5-5 5-9v-5l-5-2Z" fill="#F7C8E4"/>
<text x="56" y="36" fill="#FFF8FC" font-family="DejaVu Sans" font-size="18" font-weight="600">Private by design / Made for connection</text> <g transform="translate(408 274)" filter="url(#cardShadow)">
<rect width="176" height="116" rx="22" fill="#FFF8FC"/>
<circle cx="34" cy="36" r="16" fill="#F0DFFF"/>
<path d="M34 26l9 5v8c0 7-4 11-9 14-5-3-9-7-9-14v-8l9-5Z" fill="#56306F"/>
<text x="24" y="76" fill="#24122F" font-family="DejaVu Sans" font-size="18" font-weight="700">Private first</text>
<text x="24" y="98" fill="#6D5A75" font-family="DejaVu Sans" font-size="14">Reveal when ready</text>
</g>
<g transform="translate(606 274)" filter="url(#cardShadow)">
<rect width="176" height="116" rx="22" fill="#FFF8FC"/>
<circle cx="34" cy="36" r="16" fill="#FFE8F4"/>
<path d="M34 48c-4-5-20-17-23-27-3-8 2-15 11-15 5 0 9 3 12 8 3-5 7-8 12-8 9 0 14 7 11 15-3 10-19 22-23 27Z" fill="#9B1B5A"/>
<text x="24" y="76" fill="#24122F" font-family="DejaVu Sans" font-size="18" font-weight="700">For two</text>
<text x="24" y="98" fill="#6D5A75" font-family="DejaVu Sans" font-size="14">No public feed</text>
</g>
<g transform="translate(804 274)" filter="url(#cardShadow)">
<rect width="138" height="116" rx="22" fill="#FFF8FC"/>
<circle cx="34" cy="36" r="16" fill="#F4E8FF"/>
<path d="M24 36h20M34 26v20" stroke="#56306F" stroke-width="5" stroke-linecap="round"/>
<text x="24" y="76" fill="#24122F" font-family="DejaVu Sans" font-size="18" font-weight="700">Daily</text>
<text x="24" y="98" fill="#6D5A75" font-family="DejaVu Sans" font-size="14">Small rituals</text>
</g>
<g transform="translate(408 410)">
<rect width="430" height="42" rx="21" fill="#6B4A7C"/>
<path d="M25 12l10 4v8c0 7-4 12-10 15-6-3-10-8-10-15v-8l10-4Zm0 6-5 2v4c0 4 2 7 5 9 3-2 5-5 5-9v-4l-5-2Z" fill="#F7C8E4"/>
<text x="50" y="28" fill="#FFF8FC" font-family="DejaVu Sans" font-size="16" font-weight="600">Private by design - made for connection</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -296,10 +296,10 @@ describe("invites/{code}", () => {
await assertSucceeds(getDoc(doc(alice().firestore(), `invites/${INVITE_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 seedInvite();
await seedUser(UID_B); // user doc exists, no coupleId 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 () => { 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}`))); 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 seedInvite();
await seedUser(UID_B); await seedUser(UID_B);
await assertSucceeds( await assertFails(
updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), { updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), {
status: "accepted", status: "accepted",
acceptedByUserId: UID_B, acceptedByUserId: UID_B,

View File

@ -164,22 +164,11 @@ service cloud.firestore {
// Invite system with proper ownership, validation, and expiry checks. // Invite system with proper ownership, validation, and expiry checks.
match /invites/{code} { 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() allow read: if isSignedIn()
&& ( && request.auth.uid == resource.data.inviterUserId
// Inviter can always read && request.time < resource.data.expiresAt;
request.auth.uid == resource.data.inviterUserId
||
// Accepting user: not the inviter, invite is still pending, and user is unpaired
(
request.auth.uid != resource.data.inviterUserId
&& resource.data.status == 'pending'
&& !('coupleId' in resource.data)
&& isNotAlreadyPaired()
)
)
// Expired invites should not be readable by non-inviters
&& (request.auth.uid == resource.data.inviterUserId || request.time < resource.data.expiresAt);
// Create: ownership, code format, and required fields validation. // Create: ownership, code format, and required fields validation.
// hasOnly prevents injecting unrelated fields (e.g. coupleId) at creation. // 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', && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']); 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
// Update (accept): proper validation for changing status to accepted. // Update (accept): server-side / Cloud Function only.
// If coupleId is supplied, it must reference an existing couple where // Direct client updates to invites are denied. The Cloud Function uses the
// the acceptor is a member. (Server-side creation bypasses rules.) // Admin SDK, which bypasses these rules, to atomically create the couple,
allow update: if isSignedIn() // update user docs, and mark the invite accepted.
&& resource.data.status == 'pending' allow update: if false;
// 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
)
);
} }
// ── Couples ─────────────────────────────────────────────────────────────── // ── Couples ───────────────────────────────────────────────────────────────

View File

@ -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

View File

@ -0,0 +1 @@
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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 functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // 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; } }); Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } });
var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable"); var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable");
Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.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"); var onUserDelete_1 = require("./users/onUserDelete");
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } }); Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");

View File

@ -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"} {"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"}

View File

@ -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,
}
})

View File

@ -27,6 +27,7 @@ export {
export { onAnswerWritten } from './questions/onAnswerWritten' export { onAnswerWritten } from './questions/onAnswerWritten'
export { onCoupleLeave } from './couples/onCoupleLeave' export { onCoupleLeave } from './couples/onCoupleLeave'
export { leaveCoupleCallable } from './couples/leaveCoupleCallable' export { leaveCoupleCallable } from './couples/leaveCoupleCallable'
export { acceptInviteCallable } from './couples/acceptInviteCallable'
export { onUserDelete } from './users/onUserDelete' export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate } from './games/onGameSessionUpdate' export { onGameSessionUpdate } from './games/onGameSessionUpdate'