refactor: update crypto, invite flow, and account screen patterns

This commit is contained in:
null 2026-06-20 18:09:46 -05:00
parent 09a2480359
commit 4dad0e774e
13 changed files with 180 additions and 110 deletions

View File

@ -49,13 +49,14 @@ class CoupleEncryptionManager @Inject constructor(
val handle = keyManager.newCoupleKeyset()
val wrapped = keyManager.wrap(handle, phrase)
keyStore.storeInviteKeyset(inviteCode, handle)
keyStore.storeInvitePhrase(inviteCode, phrase)
SetupResult(handle, wrapped, phrase)
}
/**
* Called by the acceptor during pairing, and on any device during recovery.
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
* locally under [coupleId].
* locally under [coupleId]. Also persists [phrase] so it can be shown in settings.
* Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong.
*/
suspend fun unwrapAndStore(
@ -66,9 +67,12 @@ class CoupleEncryptionManager @Inject constructor(
runCatching {
val handle = keyManager.unwrap(wrapped, phrase)
keyStore.storeKeyset(coupleId, handle)
keyStore.storeRecoveryPhrase(coupleId, phrase)
}
}
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
/**
* Called on app launch / Home load after the couple doc is resolved.
* Handles inviter reconciliation (flow B') transparently.

View File

@ -45,17 +45,34 @@ class CoupleKeyStore @Inject constructor(
prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
}
fun storeInvitePhrase(inviteCode: String, phrase: String) {
prefs.edit().putString(invitePhrasePrefKey(inviteCode), phrase).apply()
}
fun storeRecoveryPhrase(coupleId: String, phrase: String) {
prefs.edit().putString(recoveryPhraseKey(coupleId), phrase).apply()
}
fun loadRecoveryPhrase(coupleId: String): String? =
prefs.getString(recoveryPhraseKey(coupleId), null)
fun loadKeyset(coupleId: String): KeysetHandle? =
load(prefKey(coupleId))
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
load(invitePrefKey(inviteCode))
/** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */
/** Moves the invite-slot keyset (and phrase) to the coupleId slot and removes the invite slots. */
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
val handle = loadInviteKeyset(inviteCode) ?: return false
storeKeyset(coupleId, handle)
prefs.edit().remove(invitePrefKey(inviteCode)).apply()
prefs.edit().remove(invitePrefKey(inviteCode)).also { editor ->
val phrase = prefs.getString(invitePhrasePrefKey(inviteCode), null)
if (phrase != null) {
editor.putString(recoveryPhraseKey(coupleId), phrase)
editor.remove(invitePhrasePrefKey(inviteCode))
}
}.apply()
return true
}
@ -63,6 +80,7 @@ class CoupleKeyStore @Inject constructor(
prefs.edit()
.remove(prefKey(coupleId))
.remove(pendingPhraseKey(coupleId))
.remove(recoveryPhraseKey(coupleId))
.apply()
aeadCache.remove(coupleId)
}
@ -88,6 +106,8 @@ class CoupleKeyStore @Inject constructor(
private fun prefKey(coupleId: String) = "keyset_$coupleId"
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
private fun serialize(handle: KeysetHandle): String {

View File

@ -27,7 +27,8 @@ class FirestoreInviteDataSource @Inject constructor(
suspend fun createInvite(
code: String,
inviterUserId: String,
wrappedKey: RecoveryKeyManager.WrappedKey
wrappedKey: RecoveryKeyManager.WrappedKey,
recoveryPhrase: String
): Unit = suspendCancellableCoroutine { cont ->
val now = System.currentTimeMillis()
inviteRef(code).set(
@ -39,7 +40,8 @@ class FirestoreInviteDataSource @Inject constructor(
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params
"kdfParams" to wrappedKey.params,
"recoveryPhrase" to recoveryPhrase
)
)
.addOnSuccessListener { cont.resume(Unit) }
@ -79,14 +81,13 @@ class FirestoreInviteDataSource @Inject constructor(
* 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.
* The function reads the recovery phrase from the invite doc and returns it,
* so the acceptor never needs to type it manually.
*/
suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult {
suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult {
@Suppress("DEPRECATION")
val result = functions.getHttpsCallable("acceptInviteCallable")
.call(mapOf("code" to code, "recoveryPhrase" to recoveryPhrase))
.call(mapOf("code" to code))
.await()
val data = result.getData() as? Map<*, *>
?: throw IllegalStateException("Invalid response from acceptInviteCallable")
@ -101,6 +102,7 @@ class FirestoreInviteDataSource @Inject constructor(
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
val kdfParams = data["kdfParams"] as? String
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
val recoveryPhrase = data["recoveryPhrase"] as? String
return app.closer.domain.repository.AcceptInviteResult(
coupleId = coupleId,
@ -109,7 +111,8 @@ class FirestoreInviteDataSource @Inject constructor(
cipherB64 = wrappedCoupleKey,
saltB64 = kdfSalt,
params = kdfParams
)
),
recoveryPhrase = recoveryPhrase
)
}

View File

@ -18,7 +18,7 @@ class InviteRepositoryImpl @Inject constructor(
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
val code = dataSource.generateCode()
val setup = encryptionManager.setupForNewCouple(code)
dataSource.createInvite(code, inviterUserId, setup.wrapped)
dataSource.createInvite(code, inviterUserId, setup.wrapped, setup.recoveryPhrase)
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
}
@ -26,7 +26,7 @@ class InviteRepositoryImpl @Inject constructor(
dataSource.getInviteByCode(code)
}
override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult> = runCatching {
dataSource.acceptInvite(code, recoveryPhrase)
override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching {
dataSource.acceptInvite(code)
}
}

View File

@ -10,15 +10,18 @@ data class CreateInviteResult(val code: String, val recoveryPhrase: String)
*
* @property inviterUserId The UID of the partner who created the invite.
* @property wrappedKey The encrypted couple key the acceptor must unwrap.
* @property recoveryPhrase The phrase stored by the inviter; returned by the server so the acceptor
* never needs to type it manually.
*/
data class AcceptInviteResult(
val coupleId: String,
val inviterUserId: String,
val wrappedKey: RecoveryKeyManager.WrappedKey
val wrappedKey: RecoveryKeyManager.WrappedKey,
val recoveryPhrase: String?
)
interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult>
suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult>
}

View File

@ -25,8 +25,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -43,8 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -146,39 +142,7 @@ fun InviteConfirmScreen(
color = SettingsMuted
)
Spacer(Modifier.height(24.dp))
// Recovery phrase input — only shown for encrypted invites
if (state.isEncryptedInvite) {
OutlinedTextField(
value = state.recoveryPhrase,
onValueChange = viewModel::onPhraseChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text("Recovery phrase") },
placeholder = { Text("word word word word word word") },
supportingText = {
Text(
"Your partner sees this when they create the invite.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
},
singleLine = false,
minLines = 2,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = SettingsPrimaryDeep,
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
focusedLabelColor = SettingsPrimaryDeep,
unfocusedLabelColor = SettingsMuted,
cursorColor = SettingsPrimaryDeep
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
Spacer(Modifier.height(16.dp))
} else {
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(32.dp))
Button(
onClick = viewModel::confirmPairing,

View File

@ -20,11 +20,9 @@ import javax.inject.Inject
data class InviteConfirmUiState(
val isLoading: Boolean = true,
val inviterName: String? = null,
val recoveryPhrase: String = "",
val error: String? = null,
val navigateTo: String? = null,
val isConfirming: Boolean = false,
val isEncryptedInvite: Boolean = false
val isConfirming: Boolean = false
)
@HiltViewModel
@ -43,42 +41,33 @@ class InviteConfirmViewModel @Inject constructor(
init {
viewModelScope.launch {
// Invite details are no longer readable client-side. The Cloud Function
// will perform server-side validation and acceptance when the user confirms.
// We show a generic loading state and proceed to confirmation; the inviter name
// is fetched after a successful acceptance if needed.
_uiState.update {
it.copy(
isLoading = false,
inviterName = "your partner",
isEncryptedInvite = true
)
it.copy(isLoading = false, inviterName = "your partner")
}
}
}
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
fun confirmPairing() {
val acceptorId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(error = "Not signed in.") }
return
}
val phrase = _uiState.value.recoveryPhrase.trim()
_uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch {
inviteRepository.acceptInvite(inviteCode, acceptorId, phrase)
inviteRepository.acceptInvite(inviteCode, acceptorId)
.onSuccess { result ->
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
.onSuccess {
val inviterName = runCatching { userRepository.getUser(result.inviterUserId)?.displayName }
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
.getOrNull()
_uiState.update { it.copy(isConfirming = false, inviterName = inviterName ?: "your partner", navigateTo = AppRoute.HOME) }
}
.onFailure { e ->
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
}
val phrase = result.recoveryPhrase
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
.onSuccess {
navigateHome(result.inviterUserId)
}
.onFailure { e ->
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
}
} else {
navigateHome(result.inviterUserId)
}
}
.onFailure { e ->
_uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) }
@ -86,13 +75,26 @@ class InviteConfirmViewModel @Inject constructor(
}
}
private suspend fun navigateHome(inviterUserId: String) {
val inviterName = runCatching { userRepository.getUser(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
)
}
}
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."
"Couldn't unlock the couple key. Please try pairing again."
else -> e.message ?: "Couldn't unlock the couple key. Please try again."
}
@ -106,7 +108,6 @@ class InviteConfirmViewModel @Inject constructor(
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."
}

View File

@ -19,7 +19,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -33,16 +35,23 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -50,7 +59,10 @@ fun AccountScreen(
onNavigate: (String) -> Unit = {},
viewModel: EditProfileViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
@ -90,6 +102,71 @@ fun AccountScreen(
Spacer(Modifier.height(8.dp))
// Recovery phrase — only shown when the couple key is stored locally
val phrase = state.recoveryPhrase
if (phrase != null) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
Icons.Filled.Key,
contentDescription = null,
tint = SettingsMuted,
modifier = Modifier.size(20.dp)
)
Text(
text = "Recovery phrase",
style = MaterialTheme.typography.bodyLarge,
color = SettingsInk,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
clipboard.setText(AnnotatedString(phrase))
scope.launch { snackbar.showSnackbar("Recovery phrase copied") }
},
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = "Copy phrase",
tint = SettingsMuted,
modifier = Modifier.size(18.dp)
)
}
}
Text(
text = phrase,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = SettingsInk,
modifier = Modifier
.fillMaxWidth()
.background(
color = SettingsSoft,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 12.dp, vertical = 10.dp)
)
Text(
text = "Keep this safe. Either partner can use it to restore access on a new device.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
}
}
}
// Account lifecycle
Card(
modifier = Modifier.fillMaxWidth(),

View File

@ -3,8 +3,10 @@ package app.closer.ui.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.FirebaseStorageDataSource
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -27,14 +29,17 @@ data class EditProfileUiState(
val error: String? = null,
val success: Boolean = false,
val nameError: String? = null,
val sexError: String? = null
val sexError: String? = null,
val recoveryPhrase: String? = null
)
@HiltViewModel
class EditProfileViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
private val storageDataSource: FirebaseStorageDataSource
private val storageDataSource: FirebaseStorageDataSource,
private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(EditProfileUiState())
@ -51,6 +56,8 @@ class EditProfileViewModel @Inject constructor(
}
viewModelScope.launch {
val user = runCatching { userRepository.getUser(uid) }.getOrNull()
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
val phrase = coupleId?.let { encryptionManager.recoveryPhrase(it) }
_uiState.update {
it.copy(
isLoading = false,
@ -59,7 +66,8 @@ class EditProfileViewModel @Inject constructor(
photoUrl = user?.photoUrl ?: "",
email = authRepository.currentUserEmail ?: user?.email ?: "",
isAnonymous = authRepository.isAnonymous,
isGoogleAccount = authRepository.isGoogleAccount
isGoogleAccount = authRepository.isGoogleAccount,
recoveryPhrase = phrase
)
}
}

View File

@ -260,7 +260,7 @@ service cloud.firestore {
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']);
// Update (accept): server-side / Cloud Function only.
// Direct client updates to invites are denied. The Cloud Function uses the

View File

@ -43,11 +43,13 @@ const admin = __importStar(require("firebase-admin"));
* 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 }
* Request body: { code: 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 }
* The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually.
*
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
*
* Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired.
@ -68,10 +70,6 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
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();
@ -90,6 +88,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
const wrappedCoupleKey = invite.wrappedCoupleKey;
const kdfSalt = invite.kdfSalt;
const kdfParams = invite.kdfParams;
const recoveryPhrase = invite.recoveryPhrase;
if (status !== 'pending') {
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
}
@ -103,10 +102,6 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
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();
@ -137,6 +132,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
};
});
//# sourceMappingURL=acceptInviteCallable.js.map

View File

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

View File

@ -8,11 +8,13 @@ import * as admin from 'firebase-admin'
* 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 }
* Request body: { code: 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 }
* The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually.
*
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
*
* Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired.
@ -34,11 +36,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
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.
@ -61,6 +58,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
const kdfSalt = invite.kdfSalt as string | undefined
const kdfParams = invite.kdfParams as string | undefined
const recoveryPhrase = invite.recoveryPhrase as string | undefined
if (status !== 'pending') {
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
@ -79,11 +77,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
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)
@ -121,5 +114,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
wrappedCoupleKey: wrappedCoupleKey ?? null,
kdfSalt: kdfSalt ?? null,
kdfParams: kdfParams ?? null,
recoveryPhrase: recoveryPhrase ?? null,
}
})