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
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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/`.
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 86 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||