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.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,19 +76,41 @@ 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 {

View File

@ -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<String> = 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<Unit> = runCatching {

View File

@ -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<Unit> = runCatching {
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
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<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?>
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))
// 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,

View File

@ -40,24 +40,12 @@ 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 ->
// 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)) }
}
}
.onFailure {
_uiState.update { it.copy(isLoading = false, error = "Couldn't find that code. Please try again.") }
}
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }

View File

@ -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,26 +46,18 @@ 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()
}
// 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 = inviterName ?: "your partner",
isEncryptedInvite = invite?.wrappedCoupleKey != null
inviterName = "your partner",
isEncryptedInvite = true
)
}
}
.onFailure {
_uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") }
}
}
}
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
@ -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 ->
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 = recoveryErrorMessage(e)) }
}
_uiState.update { it.copy(isConfirming = false, error = msg) }
}
.onFailure { e ->
_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"
}

View File

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

View File

@ -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/`.

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"/>
</filter>
</defs>
<rect width="512" height="512" fill="#56306F"/>
<path d="M0 0h512v190C414 226 317 226 222 199 122 171 53 119 0 62Z" fill="#B98AF4" opacity=".28"/>
<path d="M0 355c92-52 192-45 296-15 93 27 157 37 216 1v171H0Z" fill="#180C20" opacity=".32"/>
<rect width="512" height="512" fill="#4A235F"/>
<path d="M0 0h512v198C411 236 314 232 222 207 124 180 52 125 0 64Z" fill="#B98AF4" opacity=".24"/>
<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)">
<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"/>

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">
<defs>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="180%">
<feDropShadow dx="0" dy="12" stdDeviation="12" flood-color="#180C20" flood-opacity=".45"/>
<filter id="markShadow" x="-30%" y="-30%" width="160%" height="180%">
<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>
</defs>
<rect width="1024" height="500" fill="#3B1D4B"/>
<circle cx="938" cy="62" r="210" fill="#B98AF4" opacity=".13"/>
<circle cx="855" cy="470" r="245" fill="#E7A2D1" opacity=".10"/>
<path d="M0 396c134-81 273-73 418-29 127 39 230 43 307 6v127H0Z" fill="#180C20" opacity=".18"/>
<g transform="translate(92 96) scale(.72)" 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 402c25-28 130-111 153-177 24-62-13-116-73-116-38 0-65 19-80 49Z" fill="#D9B8FF"/>
<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"/>
<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"/>
<rect width="1024" height="500" fill="#24122F"/>
<path d="M0 0h1024v168C819 214 641 199 472 158 286 113 144 58 0 34Z" fill="#B98AF4" opacity=".14"/>
<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"/>
<path d="M900 0h124v500H820c31-76 34-154 8-236C800 174 812 88 900 0Z" fill="#F7C8E4" opacity=".08"/>
<g transform="translate(68 74)">
<rect x="0" y="0" width="312" height="312" rx="72" fill="#4A235F"/>
<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>
<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>
<g transform="translate(504 296)">
<rect width="448" height="56" rx="28" fill="#6B4A7C"/>
<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>
<text x="408" y="142" fill="#FFF8FC" font-family="DejaVu Sans" font-size="72" font-weight="700">Closer</text>
<text x="412" y="198" fill="#F7C8E4" font-family="DejaVu Sans" font-size="26" font-weight="600">A private space for two.</text>
<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>
<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>
</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}`)));
});
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,

View File

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

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 });
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");

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 { onCoupleLeave } from './couples/onCoupleLeave'
export { leaveCoupleCallable } from './couples/leaveCoupleCallable'
export { acceptInviteCallable } from './couples/acceptInviteCallable'
export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate } from './games/onGameSessionUpdate'