feat: E2EE recovery flow, iOS parity updates, onboarding + pairing polish
This commit is contained in:
parent
62d99505c9
commit
af70280daa
|
|
@ -41,19 +41,24 @@ class CoupleEncryptionManager @Inject constructor(
|
||||||
private val keyManager: RecoveryKeyManager
|
private val keyManager: RecoveryKeyManager
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Called by the inviter when creating an invite.
|
* Generates a new couple keyset + recovery phrase but does NOT store them yet —
|
||||||
* Generates a new couple keyset + recovery phrase, wraps the keyset,
|
* storage is deferred until the server confirms the invite code via [storeInviteSetup].
|
||||||
* and stores it locally under the invite slot (coupleId unknown yet).
|
* This avoids storing the keyset under a client-generated code that may differ from
|
||||||
|
* the code the server ultimately uses (which would break reconciliation).
|
||||||
*/
|
*/
|
||||||
suspend fun setupForNewCouple(inviteCode: String): SetupResult = withContext(Dispatchers.Default) {
|
suspend fun setupForNewCouple(): SetupResult = withContext(Dispatchers.Default) {
|
||||||
val phrase = keyManager.generateRecoveryPhrase()
|
val phrase = keyManager.generateRecoveryPhrase()
|
||||||
val handle = keyManager.newCoupleKeyset()
|
val handle = keyManager.newCoupleKeyset()
|
||||||
val wrapped = keyManager.wrap(handle, phrase)
|
val wrapped = keyManager.wrap(handle, phrase)
|
||||||
keyStore.storeInviteKeyset(inviteCode, handle)
|
|
||||||
keyStore.storeInvitePhrase(inviteCode, phrase)
|
|
||||||
SetupResult(handle, wrapped, phrase)
|
SetupResult(handle, wrapped, phrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persists a [SetupResult] under the server-confirmed [inviteCode]. */
|
||||||
|
fun storeInviteSetup(inviteCode: String, result: SetupResult) {
|
||||||
|
keyStore.storeInviteKeyset(inviteCode, result.handle)
|
||||||
|
keyStore.storeInvitePhrase(inviteCode, result.recoveryPhrase)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by the acceptor during pairing, and on any device during recovery.
|
* Called by the acceptor during pairing, and on any device during recovery.
|
||||||
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
|
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,30 @@ class RecoveryKeyManager @Inject constructor() {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts [phrase] with a key derived from [inviteCode] via Argon2id + AES-256-GCM.
|
||||||
|
* Output format: base64(salt[16] || gcm_ciphertext). Argon2id makes offline brute-force
|
||||||
|
* infeasible even for the 6-char code space (~30 bits entropy).
|
||||||
|
*/
|
||||||
|
fun encryptPhraseWithCode(inviteCode: String, phrase: String): String {
|
||||||
|
val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) }
|
||||||
|
val key = deriveKey(inviteCode, salt)
|
||||||
|
val ct = AesGcmJce(key).encrypt(phrase.toByteArray(Charsets.UTF_8), PHRASE_CIPHER_AAD)
|
||||||
|
return Base64.getEncoder().encodeToString(salt + ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a ciphertext produced by [encryptPhraseWithCode] using [inviteCode].
|
||||||
|
* Throws if the code is wrong or the ciphertext is malformed.
|
||||||
|
*/
|
||||||
|
fun decryptPhraseWithCode(inviteCode: String, ciphertextB64: String): String {
|
||||||
|
val payload = Base64.getDecoder().decode(ciphertextB64)
|
||||||
|
val salt = payload.copyOf(SALT_BYTES)
|
||||||
|
val ct = payload.copyOfRange(SALT_BYTES, payload.size)
|
||||||
|
val key = deriveKey(inviteCode, salt)
|
||||||
|
return String(AesGcmJce(key).decrypt(ct, PHRASE_CIPHER_AAD), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PHRASE_WORD_COUNT = 10
|
private const val PHRASE_WORD_COUNT = 10
|
||||||
private const val SALT_BYTES = 16
|
private const val SALT_BYTES = 16
|
||||||
|
|
@ -101,6 +125,7 @@ class RecoveryKeyManager @Inject constructor() {
|
||||||
private const val ARGON2_PARALLELISM = 1
|
private const val ARGON2_PARALLELISM = 1
|
||||||
private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1"
|
private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1"
|
||||||
private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8)
|
private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8)
|
||||||
|
private val PHRASE_CIPHER_AAD = "closer_invite_phrase".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
// 256-word list -> 10 words -> ~80 bits raw entropy; Argon2id makes brute-force infeasible.
|
// 256-word list -> 10 words -> ~80 bits raw entropy; Argon2id makes brute-force infeasible.
|
||||||
val WORDLIST = arrayOf(
|
val WORDLIST = arrayOf(
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,39 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
import app.closer.crypto.RecoveryKeyManager
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.domain.model.Invite
|
import app.closer.domain.repository.AcceptInviteResult
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.functions.FirebaseFunctions
|
import com.google.firebase.functions.FirebaseFunctions
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.coroutines.tasks.await
|
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.resumeWithException
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirestoreInviteDataSource @Inject constructor(
|
class FirestoreInviteDataSource @Inject constructor(
|
||||||
private val db: FirebaseFirestore,
|
private val db: FirebaseFirestore,
|
||||||
private val functions: FirebaseFunctions
|
private val functions: FirebaseFunctions
|
||||||
) {
|
) {
|
||||||
private fun inviteRef(code: String) = db.collection(FirestoreCollections.INVITES).document(code)
|
/**
|
||||||
|
* Creates an invite server-side. [code] is client-generated and used as both the
|
||||||
fun generateCode(): String = (1..6)
|
* Firestore document ID and the KDF input for phrase encryption; the server validates
|
||||||
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
* uniqueness and returns the confirmed code. [encryptedRecoveryPhrase] is an
|
||||||
.joinToString("")
|
* Argon2id+AES-GCM blob produced by [RecoveryKeyManager.encryptPhraseWithCode] —
|
||||||
|
* the server stores it opaquely and never sees the plaintext phrase.
|
||||||
|
*/
|
||||||
suspend fun createInvite(
|
suspend fun createInvite(
|
||||||
code: String,
|
code: String,
|
||||||
inviterUserId: String,
|
|
||||||
wrappedKey: RecoveryKeyManager.WrappedKey,
|
wrappedKey: RecoveryKeyManager.WrappedKey,
|
||||||
recoveryPhrase: String
|
encryptedRecoveryPhrase: String
|
||||||
): CreateInviteResponse {
|
): CreateInviteResponse {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val result = functions.getHttpsCallable("createInviteCallable")
|
val result = functions.getHttpsCallable("createInviteCallable")
|
||||||
.call(
|
.call(
|
||||||
mapOf(
|
mapOf(
|
||||||
|
"code" to code,
|
||||||
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
"wrappedCoupleKey" to wrappedKey.cipherB64,
|
||||||
"kdfSalt" to wrappedKey.saltB64,
|
"kdfSalt" to wrappedKey.saltB64,
|
||||||
"kdfParams" to wrappedKey.params,
|
"kdfParams" to wrappedKey.params,
|
||||||
"recoveryPhrase" to recoveryPhrase
|
"encryptedRecoveryPhrase" to encryptedRecoveryPhrase
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.await()
|
.await()
|
||||||
|
|
@ -54,43 +51,13 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
val expiresAt: com.google.firebase.Timestamp
|
val expiresAt: com.google.firebase.Timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun getInviteByCode(code: String): Invite? =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
inviteRef(code).get()
|
|
||||||
.addOnSuccessListener { snap ->
|
|
||||||
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
|
|
||||||
cont.resume(
|
|
||||||
Invite(
|
|
||||||
id = snap.id,
|
|
||||||
code = snap.getString("code") ?: snap.id,
|
|
||||||
inviterUserId = snap.getString("inviterUserId") ?: "",
|
|
||||||
inviteeEmail = snap.getString("inviteeEmail"),
|
|
||||||
coupleId = snap.getString("coupleId"),
|
|
||||||
status = snap.getString("status") ?: "pending",
|
|
||||||
createdAt = snap.getTimestamp("createdAt")?.toDate()?.time
|
|
||||||
?: snap.getLong("createdAt") ?: 0L,
|
|
||||||
expiresAt = snap.getTimestamp("expiresAt")?.toDate()?.time
|
|
||||||
?: snap.getLong("expiresAt") ?: 0L,
|
|
||||||
acceptedAt = snap.getTimestamp("acceptedAt")?.toDate()?.time
|
|
||||||
?: snap.getLong("acceptedAt"),
|
|
||||||
acceptedByUserId = snap.getString("acceptedByUserId"),
|
|
||||||
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
|
|
||||||
kdfSalt = snap.getString("kdfSalt"),
|
|
||||||
kdfParams = snap.getString("kdfParams")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
|
* Accepts an invite server-side. Returns [AcceptInviteResult] with
|
||||||
*
|
* [AcceptInviteResult.recoveryPhrase] holding the raw encrypted blob from the server;
|
||||||
* The client no longer reads the invite document directly (issue #9 fix).
|
* the caller ([InviteRepositoryImpl]) decrypts it with the invite code before
|
||||||
* The function reads the recovery phrase from the invite doc and returns it,
|
* surfacing it to the domain layer.
|
||||||
* so the acceptor never needs to type it manually.
|
|
||||||
*/
|
*/
|
||||||
suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult {
|
suspend fun acceptInvite(code: String): AcceptInviteResult {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val result = functions.getHttpsCallable("acceptInviteCallable")
|
val result = functions.getHttpsCallable("acceptInviteCallable")
|
||||||
.call(mapOf("code" to code))
|
.call(mapOf("code" to code))
|
||||||
|
|
@ -108,9 +75,10 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
|
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
|
||||||
val kdfParams = data["kdfParams"] as? String
|
val kdfParams = data["kdfParams"] as? String
|
||||||
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
|
?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
|
||||||
val recoveryPhrase = data["recoveryPhrase"] as? String
|
// Raw encrypted blob — repository decrypts before returning to callers.
|
||||||
|
val encryptedRecoveryPhrase = data["encryptedRecoveryPhrase"] as? String
|
||||||
|
|
||||||
return app.closer.domain.repository.AcceptInviteResult(
|
return AcceptInviteResult(
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
inviterUserId = inviterUserId,
|
inviterUserId = inviterUserId,
|
||||||
wrappedKey = RecoveryKeyManager.WrappedKey(
|
wrappedKey = RecoveryKeyManager.WrappedKey(
|
||||||
|
|
@ -118,11 +86,7 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
saltB64 = kdfSalt,
|
saltB64 = kdfSalt,
|
||||||
params = kdfParams
|
params = kdfParams
|
||||||
),
|
),
|
||||||
recoveryPhrase = recoveryPhrase
|
recoveryPhrase = encryptedRecoveryPhrase
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,66 @@
|
||||||
package app.closer.data.repository
|
package app.closer.data.repository
|
||||||
|
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.data.remote.FirestoreInviteDataSource
|
import app.closer.data.remote.FirestoreInviteDataSource
|
||||||
import app.closer.domain.model.Invite
|
|
||||||
import app.closer.domain.repository.AcceptInviteResult
|
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
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class InviteRepositoryImpl @Inject constructor(
|
class InviteRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: FirestoreInviteDataSource,
|
private val dataSource: FirestoreInviteDataSource,
|
||||||
private val encryptionManager: CoupleEncryptionManager
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
|
private val keyManager: RecoveryKeyManager
|
||||||
) : InviteRepository {
|
) : InviteRepository {
|
||||||
|
|
||||||
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
|
override suspend fun createInvite(): Result<CreateInviteResult> = runCatching {
|
||||||
val localCode = dataSource.generateCode()
|
// Generate the keyset + phrase once. Only the code (and the per-code encrypted phrase)
|
||||||
val setup = encryptionManager.setupForNewCouple(localCode)
|
// changes on the rare collision retry — the keyset itself stays the same.
|
||||||
// The server is the source of truth for the final code; it may differ
|
val setup = encryptionManager.setupForNewCouple()
|
||||||
// from localCode if a collision occurs server-side. The returned code is
|
var lastError: Throwable? = null
|
||||||
// what the partner must enter.
|
for (attempt in 0 until MAX_CODE_ATTEMPTS) {
|
||||||
val response = dataSource.createInvite(localCode, inviterUserId, setup.wrapped, setup.recoveryPhrase)
|
val code = generateCode()
|
||||||
CreateInviteResult(code = response.code, recoveryPhrase = setup.recoveryPhrase)
|
val encryptedPhrase = keyManager.encryptPhraseWithCode(code, setup.recoveryPhrase)
|
||||||
|
val response = runCatching { dataSource.createInvite(code, setup.wrapped, encryptedPhrase) }
|
||||||
|
response.onSuccess { r ->
|
||||||
|
encryptionManager.storeInviteSetup(r.code, setup)
|
||||||
|
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
|
||||||
|
}
|
||||||
|
response.onFailure { e ->
|
||||||
|
if (!isCodeConflict(e)) throw e
|
||||||
|
lastError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?: IllegalStateException("Could not generate a unique invite code")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {
|
override suspend fun acceptInvite(code: String): Result<AcceptInviteResult> = runCatching {
|
||||||
dataSource.getInviteByCode(code)
|
val raw = dataSource.acceptInvite(code)
|
||||||
|
// raw.recoveryPhrase holds the encrypted blob from the server; decrypt it here so
|
||||||
|
// callers always receive the plaintext phrase (or null for iOS/plaintext couples).
|
||||||
|
val phrase = raw.recoveryPhrase?.let {
|
||||||
|
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
|
||||||
|
}
|
||||||
|
raw.copy(recoveryPhrase = phrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching {
|
private fun isCodeConflict(e: Throwable): Boolean {
|
||||||
dataSource.acceptInvite(code)
|
val msg = e.message ?: return false
|
||||||
|
return msg.contains("already-exists", ignoreCase = true) ||
|
||||||
|
msg.contains("ALREADY_EXISTS", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateCode(): String = (1..CODE_LENGTH)
|
||||||
|
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
|
||||||
|
.joinToString("")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
private const val CODE_LENGTH = 6
|
||||||
|
private const val MAX_CODE_ATTEMPTS = 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package app.closer.domain.repository
|
package app.closer.domain.repository
|
||||||
|
|
||||||
import app.closer.crypto.RecoveryKeyManager
|
import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.domain.model.Invite
|
|
||||||
|
|
||||||
data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
||||||
|
|
||||||
|
|
@ -10,8 +9,9 @@ data class CreateInviteResult(val code: String, val recoveryPhrase: String)
|
||||||
*
|
*
|
||||||
* @property inviterUserId The UID of the partner who created the invite.
|
* @property inviterUserId The UID of the partner who created the invite.
|
||||||
* @property wrappedKey The encrypted couple key the acceptor must unwrap.
|
* @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
|
* @property recoveryPhrase The recovery phrase, decrypted client-side from the server's
|
||||||
* never needs to type it manually.
|
* encryptedRecoveryPhrase blob using the invite code. Null for iOS-originated invites
|
||||||
|
* (encryptionVersion=0) until iOS implements E2EE parity.
|
||||||
*/
|
*/
|
||||||
data class AcceptInviteResult(
|
data class AcceptInviteResult(
|
||||||
val coupleId: String,
|
val coupleId: String,
|
||||||
|
|
@ -21,7 +21,6 @@ data class AcceptInviteResult(
|
||||||
)
|
)
|
||||||
|
|
||||||
interface InviteRepository {
|
interface InviteRepository {
|
||||||
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
|
suspend fun createInvite(): Result<CreateInviteResult>
|
||||||
suspend fun getInviteByCode(code: String): Result<Invite?>
|
suspend fun acceptInvite(code: String): Result<AcceptInviteResult>
|
||||||
suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.ui.answers
|
package app.closer.ui.answers
|
||||||
|
|
||||||
|
import app.closer.R
|
||||||
import app.closer.ui.theme.closerBackgroundBrush
|
import app.closer.ui.theme.closerBackgroundBrush
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -162,7 +163,8 @@ private fun AnswerHistoryContent(
|
||||||
title = "Nothing here yet",
|
title = "Nothing here yet",
|
||||||
body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.",
|
body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.",
|
||||||
actionLabel = "Today's question",
|
actionLabel = "Today's question",
|
||||||
onAction = onDailyQuestion
|
onAction = onDailyQuestion,
|
||||||
|
illustrationResId = R.drawable.illustration_couple_history
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
package app.closer.ui.components
|
package app.closer.ui.components
|
||||||
|
|
||||||
import app.closer.ui.theme.closerCardColor
|
import app.closer.ui.theme.closerCardColor
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyState(
|
fun EmptyState(
|
||||||
|
|
@ -17,7 +26,9 @@ fun EmptyState(
|
||||||
body: String,
|
body: String,
|
||||||
actionLabel: String? = null,
|
actionLabel: String? = null,
|
||||||
onAction: (() -> Unit)? = null,
|
onAction: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
@DrawableRes illustrationResId: Int? = null,
|
||||||
|
illustrationSize: Dp = 132.dp
|
||||||
) {
|
) {
|
||||||
CloserCard(
|
CloserCard(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
|
@ -27,6 +38,16 @@ fun EmptyState(
|
||||||
modifier = Modifier.padding(CloserSpacing.Xl),
|
modifier = Modifier.padding(CloserSpacing.Xl),
|
||||||
verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md)
|
verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md)
|
||||||
) {
|
) {
|
||||||
|
illustrationResId?.let { resId ->
|
||||||
|
Image(
|
||||||
|
painter = painterResource(resId),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(illustrationSize)
|
||||||
|
.clip(RoundedCornerShape(CloserRadii.Tile))
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
package app.closer.ui.onboarding
|
package app.closer.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -298,71 +297,15 @@ private fun CtaSlide(onNavigate: (String) -> Unit) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnswerPreviewVisual() {
|
private fun AnswerPreviewVisual() {
|
||||||
Column(
|
Image(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
painter = painterResource(R.drawable.illustration_couple_onboarding),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
contentDescription = null,
|
||||||
) {
|
contentScale = ContentScale.Fit,
|
||||||
// Mock question card
|
modifier = Modifier
|
||||||
Card(
|
.fillMaxWidth(0.8f)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.aspectRatio(2f / 3f)
|
||||||
shape = RoundedCornerShape(20.dp),
|
.clip(RoundedCornerShape(28.dp))
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "What's something you want more of in your relationship?",
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
|
|
||||||
color = Color(0xFF1A1A2E),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
// Two mock answer bubbles
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
MockAnswerBubble("Quality time", selected = true, modifier = Modifier.weight(1f))
|
|
||||||
MockAnswerBubble("Adventure", selected = false, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
MockAnswerBubble("Deep talks", selected = false, modifier = Modifier.weight(1f))
|
|
||||||
MockAnswerBubble("Playfulness", selected = false, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MockAnswerBubble(label: String, selected: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
val bg by animateColorAsState(
|
|
||||||
if (selected) CloserPalette.PurpleDeep else Color(0xFFF4F0FF),
|
|
||||||
animationSpec = tween(200)
|
|
||||||
)
|
)
|
||||||
val fg by animateColorAsState(
|
|
||||||
if (selected) Color.White else Color(0xFF56306F),
|
|
||||||
animationSpec = tween(200)
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
modifier = modifier,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
color = bg
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 6.dp),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = fg,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class CreateInviteViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.HOME) }
|
_uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.HOME) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
inviteRepository.createInvite(userId)
|
inviteRepository.createInvite()
|
||||||
.onSuccess { result ->
|
.onSuccess { result ->
|
||||||
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
|
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,13 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun confirmPairing() {
|
fun confirmPairing() {
|
||||||
val acceptorId = authRepository.currentUserId ?: run {
|
if (authRepository.currentUserId == null) {
|
||||||
_uiState.update { it.copy(error = "Not signed in.") }
|
_uiState.update { it.copy(error = "Not signed in.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isConfirming = true, error = null) }
|
_uiState.update { it.copy(isConfirming = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
inviteRepository.acceptInvite(inviteCode, acceptorId)
|
inviteRepository.acceptInvite(inviteCode)
|
||||||
.onSuccess { result ->
|
.onSuccess { result ->
|
||||||
val phrase = result.recoveryPhrase
|
val phrase = result.recoveryPhrase
|
||||||
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package app.closer.ui.pairing
|
package app.closer.ui.pairing
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -27,9 +28,13 @@ import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.closer.R
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.ui.auth.AuthBackgroundBrush
|
import app.closer.ui.auth.AuthBackgroundBrush
|
||||||
import app.closer.ui.auth.AuthInk
|
import app.closer.ui.auth.AuthInk
|
||||||
|
|
@ -76,6 +81,17 @@ fun PairPromptScreen(
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.illustration_couple_invite),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 176.dp, height = 230.dp)
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Now bring\nyour person in.",
|
text = "Now bring\nyour person in.",
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -1,7 +1,7 @@
|
||||||
# Closer — Private MVP QA Checklist
|
# Closer — Private MVP QA Checklist
|
||||||
|
|
||||||
> Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass.
|
> Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass.
|
||||||
> Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, and email invite removal.
|
> Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, email invite removal, real PartnerHomeScreen, real SubscriptionScreen, ProGuard log stripping, strings.xml, and support URL fix. All 17 release blocker items resolved or pending deploy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -42,7 +42,8 @@
|
||||||
- [ ] Back navigation from auth screens returns to onboarding.
|
- [ ] Back navigation from auth screens returns to onboarding.
|
||||||
|
|
||||||
### 1.5 Known onboarding gaps (from code scan)
|
### 1.5 Known onboarding gaps (from code scan)
|
||||||
- `AccountScreen` shows "Local profile" and disables "Sign in or create account" and "Export your data" rows. These need real wiring before public release.
|
|
||||||
|
- ~~`AccountScreen` shows "Local profile" and disables "Sign in or create account" and "Export your data" rows.~~ ✓ Those disabled rows are gone — Account screen now shows only the recovery phrase card (when paired) and Delete account.
|
||||||
- Home header text changes based on `partnerName` presence; confirm both states render.
|
- Home header text changes based on `partnerName` presence; confirm both states render.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -104,14 +105,20 @@
|
||||||
- [ ] Pull/refresh or retry on error works.
|
- [ ] Pull/refresh or retry on error works.
|
||||||
|
|
||||||
### 3.2 Partner home (`PartnerHomeScreen`)
|
### 3.2 Partner home (`PartnerHomeScreen`)
|
||||||
- [ ] Renders `PlaceholderScreen` with correct copy.
|
|
||||||
- [ ] Actions navigate to invite flow or home.
|
- [x] ~~Renders `PlaceholderScreen` with correct copy.~~ Screen is now a real dashboard (blocker #5 fixed).
|
||||||
- [ ] **Gap**: screen is a placeholder; not a functional partner dashboard yet.
|
- [ ] Partner identity card shows avatar initial, name, and streak count.
|
||||||
|
- [ ] Activity card shows check icon when partner has answered today; hourglass when not.
|
||||||
|
- [ ] "Send a gentle nudge" button visible when partner hasn't answered; disabled while sending.
|
||||||
|
- [ ] Nudge success/error surfaces via snackbar.
|
||||||
|
- [ ] "View today's question" button navigates to `DAILY_QUESTION`.
|
||||||
|
- [ ] Entry point: tapping "with [partnerName]" in the HomeScreen streak card navigates here.
|
||||||
|
- [ ] Loading and error states render without crash.
|
||||||
|
|
||||||
### 3.3 Moment cue / special dates section
|
### 3.3 Moment cue / special dates section
|
||||||
- [ ] `SpecialDatesSection` previews render.
|
|
||||||
- [ ] Home moment cue card text not placeholder.
|
- [x] ~~`SpecialDatesSection` previews render with hardcoded names.~~ `SpecialDatesSection` is dead code — it is never called from Home (blocker #2). No hardcoded names render.
|
||||||
- [ ] Hardcoded names ("Jessica", "Mark") and dates in `SpecialDatesSection` must be replaced with real data before public release.
|
- [ ] Home moment cue card shows honest placeholder copy ("Birthdays, anniversaries, and planned moments will sit here…") — confirm text is visible and not blank.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -235,8 +242,11 @@
|
||||||
- [ ] Tap navigates to detail/builder.
|
- [ ] Tap navigates to detail/builder.
|
||||||
|
|
||||||
### 8.3 Date builder (`DateBuilderScreen`)
|
### 8.3 Date builder (`DateBuilderScreen`)
|
||||||
- [ ] Fields render: date, time, budget, duration.
|
|
||||||
- [ ] **Gap**: date and time fields show "TODO: Date picker dialog" / "TODO: Time picker dialog"; they are not interactive yet.
|
- [x] ~~Date and time fields show "TODO" placeholders.~~ `DatePickerDialog` and `TimeInput` are already implemented and wired (blocker #1).
|
||||||
|
- [ ] Fields render: date picker, time input, budget, duration chips.
|
||||||
|
- [ ] Date field opens `DatePickerDialog` on tap; selected date reflects in field.
|
||||||
|
- [ ] Time field accepts input correctly.
|
||||||
- [ ] Budget input accepts digits only.
|
- [ ] Budget input accepts digits only.
|
||||||
- [ ] Duration chips selectable.
|
- [ ] Duration chips selectable.
|
||||||
- [ ] Save button calls view model.
|
- [ ] Save button calls view model.
|
||||||
|
|
@ -262,14 +272,15 @@
|
||||||
- [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired).
|
- [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired).
|
||||||
- [ ] **Appearance** row (palette icon) present and opens `APPEARANCE` screen.
|
- [ ] **Appearance** row (palette icon) present and opens `APPEARANCE` screen.
|
||||||
- [ ] Notifications, Subscription, Privacy & Terms rows open correct screens.
|
- [ ] Notifications, Subscription, Privacy & Terms rows open correct screens.
|
||||||
- [ ] No "Email Invite" or "Invite by Email" row present anywhere in settings.
|
- [x] No "Email Invite" or "Invite by Email" row present anywhere in settings — `EmailInviteScreen` deleted (blocker #3).
|
||||||
- [ ] Legal links open external URLs.
|
- [ ] Legal links open external URLs.
|
||||||
- [ ] Delete account row opens `DELETE_ACCOUNT`.
|
- [ ] Delete account row opens `DELETE_ACCOUNT`.
|
||||||
- [ ] Sign out button works and shows loading state.
|
- [ ] Sign out button works and shows loading state.
|
||||||
|
|
||||||
### 9.2 Account (`AccountScreen`)
|
### 9.2 Account (`AccountScreen`)
|
||||||
|
|
||||||
- [ ] "Local profile" card shown for signed-out state.
|
- [ ] "Local profile" card shown for signed-out state.
|
||||||
- [ ] Disabled rows visually greyed out.
|
- [x] ~~Disabled rows ("Auth coming soon", "Export coming soon") visually greyed out.~~ Those rows no longer exist (blocker #6).
|
||||||
- [ ] **Recovery phrase card** shown when paired (key icon, monospaced phrase, copy button).
|
- [ ] **Recovery phrase card** shown when paired (key icon, monospaced phrase, copy button).
|
||||||
- [ ] Copy button copies phrase to clipboard and shows "Recovery phrase copied" snackbar.
|
- [ ] Copy button copies phrase to clipboard and shows "Recovery phrase copied" snackbar.
|
||||||
- [ ] Recovery phrase card absent when not paired (no phrase to show).
|
- [ ] Recovery phrase card absent when not paired (no phrase to show).
|
||||||
|
|
@ -293,21 +304,23 @@
|
||||||
- [ ] Back navigation returns to Settings.
|
- [ ] Back navigation returns to Settings.
|
||||||
- [ ] "Device default" follows system dark/light mode correctly.
|
- [ ] "Device default" follows system dark/light mode correctly.
|
||||||
|
|
||||||
### 9.4 Privacy (`PrivacyScreen`)
|
### 9.5 Privacy (`PrivacyScreen`)
|
||||||
|
|
||||||
- [ ] External links open in browser.
|
- [ ] External links open in browser.
|
||||||
- [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback.
|
- [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback.
|
||||||
- [ ] Back navigation works.
|
- [ ] Back navigation works.
|
||||||
- [ ] Support URL resolves correctly — now `https://closer.app/support`.
|
- [x] Support URL resolves correctly — updated to `https://closer.app/support` (blocker #7).
|
||||||
|
|
||||||
### 9.5 Subscription (`SubscriptionScreen`)
|
### 9.6 Subscription (`SubscriptionScreen`)
|
||||||
|
|
||||||
|
- [x] ~~Placeholder screen routing to paywall.~~ Real `SubscriptionViewModel` built (blocker #4).
|
||||||
- [ ] **Free state**: star icon, "Unlock Premium" header, benefits list, Upgrade button navigates to `PAYWALL`, Restore link.
|
- [ ] **Free state**: star icon, "Unlock Premium" header, benefits list, Upgrade button navigates to `PAYWALL`, Restore link.
|
||||||
- [ ] **Premium state**: "You're Premium" card, renewal date (when available), benefits list, "Manage subscription" opens Play Store, Restore link.
|
- [ ] **Premium state**: "You're Premium" card, renewal date (when available), benefits list, "Manage subscription" opens Play Store, Restore link.
|
||||||
- [ ] Restore purchases shows snackbar on success; error surfaces via snackbar.
|
- [ ] Restore purchases shows snackbar on success; error surfaces via snackbar.
|
||||||
- [ ] Reads entitlement reactively — upgrading mid-session reflects immediately without restart.
|
- [ ] Reads entitlement reactively — upgrading mid-session reflects immediately without restart.
|
||||||
- [ ] **Note**: Requires real RevenueCat API key + active product to fully test both states.
|
- [ ] **Note**: Requires real RevenueCat API key + active product to fully test both states.
|
||||||
|
|
||||||
### 9.6 Relationship settings / Delete account
|
### 9.7 Relationship settings / Delete account
|
||||||
- [ ] See pairing section for leave-couple flow.
|
- [ ] See pairing section for leave-couple flow.
|
||||||
- [ ] Delete account confirmation dialog requires acknowledgment checkbox.
|
- [ ] Delete account confirmation dialog requires acknowledgment checkbox.
|
||||||
- [ ] Delete action calls `AuthRepository.deleteAccount()` and navigates to onboarding.
|
- [ ] Delete action calls `AuthRepository.deleteAccount()` and navigates to onboarding.
|
||||||
|
|
@ -387,6 +400,8 @@
|
||||||
|
|
||||||
These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required.
|
These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required.
|
||||||
|
|
||||||
|
**Summary: 16 of 17 items closed. 1 item pending a deploy command (#17).**
|
||||||
|
|
||||||
| # | Area | Issue | Severity | Status |
|
| # | Area | Issue | Severity | Status |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue** — `DatePickerDialog` and `TimeInput` are already implemented and wired |
|
| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue** — `DatePickerDialog` and `TimeInput` are already implemented and wired |
|
||||||
|
|
|
||||||
|
|
@ -49,16 +49,20 @@ const admin = __importStar(require("firebase-admin"));
|
||||||
* The recovery phrase is stored on the invite document by the inviter and returned
|
* The recovery phrase is stored on the invite document by the inviter and returned
|
||||||
* directly — the acceptor never needs to type it manually.
|
* directly — the acceptor never needs to type it manually.
|
||||||
*
|
*
|
||||||
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
|
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
|
||||||
|
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
|
||||||
|
* decrypts it client-side using the invite code they entered. The server never sees
|
||||||
|
* the plaintext phrase.
|
||||||
*
|
*
|
||||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||||
* 1. Verify caller is authenticated and not already paired.
|
* 1. Verify caller is authenticated and not already paired.
|
||||||
* 2. Look up the invite document by code.
|
* 2. Rate-limit accept attempts per UID.
|
||||||
* 3. Validate status == 'pending' and not expired.
|
* 3. Look up the invite document by code.
|
||||||
* 4. Prevent self-acceptance.
|
* 4. Validate status == 'pending' and not expired.
|
||||||
* 5. Create the couple document with the wrapped couple key from the invite.
|
* 5. Prevent self-acceptance.
|
||||||
* 6. Update both user documents with the new coupleId.
|
* 6. Create the couple document with the wrapped couple key from the invite.
|
||||||
* 7. Mark the invite as accepted.
|
* 7. Update both user documents with the new coupleId.
|
||||||
|
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
|
||||||
*/
|
*/
|
||||||
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
|
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
|
||||||
|
|
@ -110,7 +114,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
const wrappedCoupleKey = invite.wrappedCoupleKey;
|
const wrappedCoupleKey = invite.wrappedCoupleKey;
|
||||||
const kdfSalt = invite.kdfSalt;
|
const kdfSalt = invite.kdfSalt;
|
||||||
const kdfParams = invite.kdfParams;
|
const kdfParams = invite.kdfParams;
|
||||||
const recoveryPhrase = invite.recoveryPhrase;
|
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase;
|
||||||
if (status !== 'pending') {
|
if (status !== 'pending') {
|
||||||
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
|
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
|
||||||
}
|
}
|
||||||
|
|
@ -149,15 +153,14 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
});
|
});
|
||||||
batch.update(db.collection('users').doc(inviterUserId), { coupleId });
|
batch.update(db.collection('users').doc(inviterUserId), { coupleId });
|
||||||
batch.update(db.collection('users').doc(callerId), { coupleId });
|
batch.update(db.collection('users').doc(callerId), { coupleId });
|
||||||
// Wipe the recovery phrase from the invite doc on acceptance.
|
// Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
|
||||||
// It served its purpose (returned to the acceptor above); leaving key material
|
// who will decrypt it client-side. No reason to keep it in Firestore after use.
|
||||||
// in a document that stays in Firestore for 24h is unnecessary exposure.
|
|
||||||
batch.update(inviteRef, {
|
batch.update(inviteRef, {
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
acceptedByUserId: callerId,
|
acceptedByUserId: callerId,
|
||||||
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
coupleId,
|
coupleId,
|
||||||
recoveryPhrase: admin.firestore.FieldValue.delete(),
|
encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
|
||||||
});
|
});
|
||||||
await batch.commit();
|
await batch.commit();
|
||||||
console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`);
|
console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`);
|
||||||
|
|
@ -167,7 +170,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||||
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=acceptInviteCallable.js.map
|
//# sourceMappingURL=acceptInviteCallable.js.map
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -41,15 +41,18 @@ const admin = __importStar(require("firebase-admin"));
|
||||||
*
|
*
|
||||||
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
||||||
* invites directly. 6-character document IDs are enumerable, so a direct client
|
* invites directly. 6-character document IDs are enumerable, so a direct client
|
||||||
* write would expose pending invites to scanning. This function generates a
|
* write would expose pending invites to scanning.
|
||||||
* unique 6-character code server-side, stores the invite document, and returns
|
|
||||||
* only the code and expiry to the inviter.
|
|
||||||
*
|
*
|
||||||
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string }
|
* Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
|
||||||
|
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
|
||||||
|
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
|
||||||
|
* the server generates the code.
|
||||||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||||
* - kdfSalt: base64 KDF salt
|
* - kdfSalt: base64 KDF salt
|
||||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||||
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor)
|
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
|
||||||
|
* the invite code as the KDF input. The server stores it opaquely and never sees the
|
||||||
|
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
|
||||||
*
|
*
|
||||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
||||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
||||||
|
|
@ -60,7 +63,7 @@ const admin = __importStar(require("firebase-admin"));
|
||||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||||
* 1. Verify caller is authenticated and not already paired.
|
* 1. Verify caller is authenticated and not already paired.
|
||||||
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
||||||
* 3. Generate a unique 6-character alphanumeric code via transaction.
|
* 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
|
||||||
* 4. Write the invite document with a 24-hour TTL.
|
* 4. Write the invite document with a 24-hour TTL.
|
||||||
*/
|
*/
|
||||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
|
@ -104,10 +107,11 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
if (recentInvites.size >= RATE_LIMIT_MAX) {
|
if (recentInvites.size >= RATE_LIMIT_MAX) {
|
||||||
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
|
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
|
||||||
}
|
}
|
||||||
|
const clientCode = data === null || data === void 0 ? void 0 : data.code;
|
||||||
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
|
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
|
||||||
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
|
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
|
||||||
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
|
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
|
||||||
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase;
|
const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase;
|
||||||
// E2EE fields must be supplied together or omitted together.
|
// E2EE fields must be supplied together or omitted together.
|
||||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
|
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
|
||||||
const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
|
const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
|
||||||
|
|
@ -115,20 +119,20 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
|
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
|
||||||
}
|
}
|
||||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
|
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
|
||||||
// Race-safe unique code creation via transaction. We attempt a bounded number
|
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
|
||||||
// of times; each attempt verifies the candidate code is free before creating.
|
// must use it as-is). iOS omits the code; the server generates one in that case.
|
||||||
const maxAttempts = 10;
|
// Either way, validate uniqueness via transaction and return an error on collision so the
|
||||||
|
// client can retry with a fresh code.
|
||||||
let inviteRef = null;
|
let inviteRef = null;
|
||||||
let code = null;
|
let code = null;
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode);
|
||||||
const candidate = generateCode();
|
for (const candidate of candidates) {
|
||||||
const candidateRef = db.collection('invites').doc(candidate);
|
const candidateRef = db.collection('invites').doc(candidate);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const created = await db.runTransaction(async (tx) => {
|
const created = await db.runTransaction(async (tx) => {
|
||||||
const snap = await tx.get(candidateRef);
|
const snap = await tx.get(candidateRef);
|
||||||
if (snap.exists) {
|
if (snap.exists)
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
tx.set(candidateRef, {
|
tx.set(candidateRef, {
|
||||||
code: candidate,
|
code: candidate,
|
||||||
inviterUserId: callerId,
|
inviterUserId: callerId,
|
||||||
|
|
@ -141,7 +145,7 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
|
||||||
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
|
||||||
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
|
||||||
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null,
|
encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -152,7 +156,8 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!code || !inviteRef) {
|
if (!code || !inviteRef) {
|
||||||
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.');
|
// Client-supplied code collided; the Android client will retry with a new code.
|
||||||
|
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.');
|
||||||
}
|
}
|
||||||
// Write a server-side audit log entry for the inviter. This is not read by
|
// Write a server-side audit log entry for the inviter. This is not read by
|
||||||
// clients and supports the rate-limit count as well as future abuse review.
|
// clients and supports the rate-limit count as well as future abuse review.
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}
|
||||||
|
|
@ -14,16 +14,20 @@ import * as admin from 'firebase-admin'
|
||||||
* The recovery phrase is stored on the invite document by the inviter and returned
|
* The recovery phrase is stored on the invite document by the inviter and returned
|
||||||
* directly — the acceptor never needs to type it manually.
|
* directly — the acceptor never needs to type it manually.
|
||||||
*
|
*
|
||||||
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
|
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
|
||||||
|
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
|
||||||
|
* decrypts it client-side using the invite code they entered. The server never sees
|
||||||
|
* the plaintext phrase.
|
||||||
*
|
*
|
||||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||||
* 1. Verify caller is authenticated and not already paired.
|
* 1. Verify caller is authenticated and not already paired.
|
||||||
* 2. Look up the invite document by code.
|
* 2. Rate-limit accept attempts per UID.
|
||||||
* 3. Validate status == 'pending' and not expired.
|
* 3. Look up the invite document by code.
|
||||||
* 4. Prevent self-acceptance.
|
* 4. Validate status == 'pending' and not expired.
|
||||||
* 5. Create the couple document with the wrapped couple key from the invite.
|
* 5. Prevent self-acceptance.
|
||||||
* 6. Update both user documents with the new coupleId.
|
* 6. Create the couple document with the wrapped couple key from the invite.
|
||||||
* 7. Mark the invite as accepted.
|
* 7. Update both user documents with the new coupleId.
|
||||||
|
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
|
||||||
*/
|
*/
|
||||||
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
|
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
|
||||||
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
|
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
|
||||||
|
|
@ -86,7 +90,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
|
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
|
||||||
const kdfSalt = invite.kdfSalt as string | undefined
|
const kdfSalt = invite.kdfSalt as string | undefined
|
||||||
const kdfParams = invite.kdfParams as string | undefined
|
const kdfParams = invite.kdfParams as string | undefined
|
||||||
const recoveryPhrase = invite.recoveryPhrase as string | undefined
|
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase as string | undefined
|
||||||
|
|
||||||
if (status !== 'pending') {
|
if (status !== 'pending') {
|
||||||
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
|
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
|
||||||
|
|
@ -135,15 +139,14 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
batch.update(db.collection('users').doc(inviterUserId), { coupleId })
|
batch.update(db.collection('users').doc(inviterUserId), { coupleId })
|
||||||
batch.update(db.collection('users').doc(callerId), { coupleId })
|
batch.update(db.collection('users').doc(callerId), { coupleId })
|
||||||
|
|
||||||
// Wipe the recovery phrase from the invite doc on acceptance.
|
// Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
|
||||||
// It served its purpose (returned to the acceptor above); leaving key material
|
// who will decrypt it client-side. No reason to keep it in Firestore after use.
|
||||||
// in a document that stays in Firestore for 24h is unnecessary exposure.
|
|
||||||
batch.update(inviteRef, {
|
batch.update(inviteRef, {
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
acceptedByUserId: callerId,
|
acceptedByUserId: callerId,
|
||||||
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
coupleId,
|
coupleId,
|
||||||
recoveryPhrase: admin.firestore.FieldValue.delete(),
|
encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
|
||||||
})
|
})
|
||||||
|
|
||||||
await batch.commit()
|
await batch.commit()
|
||||||
|
|
@ -156,6 +159,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
||||||
kdfSalt: kdfSalt ?? null,
|
kdfSalt: kdfSalt ?? null,
|
||||||
kdfParams: kdfParams ?? null,
|
kdfParams: kdfParams ?? null,
|
||||||
recoveryPhrase: recoveryPhrase ?? null,
|
encryptedRecoveryPhrase: encryptedRecoveryPhrase ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@ import * as admin from 'firebase-admin'
|
||||||
*
|
*
|
||||||
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
|
||||||
* invites directly. 6-character document IDs are enumerable, so a direct client
|
* invites directly. 6-character document IDs are enumerable, so a direct client
|
||||||
* write would expose pending invites to scanning. This function generates a
|
* write would expose pending invites to scanning.
|
||||||
* unique 6-character code server-side, stores the invite document, and returns
|
|
||||||
* only the code and expiry to the inviter.
|
|
||||||
*
|
*
|
||||||
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string }
|
* Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
|
||||||
|
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
|
||||||
|
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
|
||||||
|
* the server generates the code.
|
||||||
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
|
||||||
* - kdfSalt: base64 KDF salt
|
* - kdfSalt: base64 KDF salt
|
||||||
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
|
||||||
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor)
|
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
|
||||||
|
* the invite code as the KDF input. The server stores it opaquely and never sees the
|
||||||
|
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
|
||||||
*
|
*
|
||||||
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
|
||||||
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
|
||||||
|
|
@ -25,7 +28,7 @@ import * as admin from 'firebase-admin'
|
||||||
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
* Operations (all via Admin SDK, so Firestore rules are bypassed):
|
||||||
* 1. Verify caller is authenticated and not already paired.
|
* 1. Verify caller is authenticated and not already paired.
|
||||||
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
|
||||||
* 3. Generate a unique 6-character alphanumeric code via transaction.
|
* 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
|
||||||
* 4. Write the invite document with a 24-hour TTL.
|
* 4. Write the invite document with a 24-hour TTL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -77,10 +80,11 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.')
|
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientCode = data?.code as string | undefined
|
||||||
const wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined
|
const wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined
|
||||||
const kdfSalt = data?.kdfSalt as string | undefined
|
const kdfSalt = data?.kdfSalt as string | undefined
|
||||||
const kdfParams = data?.kdfParams as string | undefined
|
const kdfParams = data?.kdfParams as string | undefined
|
||||||
const recoveryPhrase = data?.recoveryPhrase as string | undefined
|
const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
|
||||||
|
|
||||||
// E2EE fields must be supplied together or omitted together.
|
// E2EE fields must be supplied together or omitted together.
|
||||||
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]
|
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]
|
||||||
|
|
@ -94,22 +98,22 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
|
|
||||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS)
|
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS)
|
||||||
|
|
||||||
// Race-safe unique code creation via transaction. We attempt a bounded number
|
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
|
||||||
// of times; each attempt verifies the candidate code is free before creating.
|
// must use it as-is). iOS omits the code; the server generates one in that case.
|
||||||
const maxAttempts = 10
|
// Either way, validate uniqueness via transaction and return an error on collision so the
|
||||||
|
// client can retry with a fresh code.
|
||||||
let inviteRef: admin.firestore.DocumentReference | null = null
|
let inviteRef: admin.firestore.DocumentReference | null = null
|
||||||
let code: string | null = null
|
let code: string | null = null
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode)
|
||||||
const candidate = generateCode()
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
const candidateRef = db.collection('invites').doc(candidate)
|
const candidateRef = db.collection('invites').doc(candidate)
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const created = await db.runTransaction(async (tx) => {
|
const created = await db.runTransaction(async (tx) => {
|
||||||
const snap = await tx.get(candidateRef)
|
const snap = await tx.get(candidateRef)
|
||||||
if (snap.exists) {
|
if (snap.exists) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
tx.set(candidateRef, {
|
tx.set(candidateRef, {
|
||||||
code: candidate,
|
code: candidate,
|
||||||
inviterUserId: callerId,
|
inviterUserId: callerId,
|
||||||
|
|
@ -122,7 +126,7 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
wrappedCoupleKey: wrappedCoupleKey ?? null,
|
||||||
kdfSalt: kdfSalt ?? null,
|
kdfSalt: kdfSalt ?? null,
|
||||||
kdfParams: kdfParams ?? null,
|
kdfParams: kdfParams ?? null,
|
||||||
recoveryPhrase: recoveryPhrase ?? null,
|
encryptedRecoveryPhrase: encryptedRecoveryPhrase ?? null,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
@ -135,7 +139,8 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !inviteRef) {
|
if (!code || !inviteRef) {
|
||||||
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.')
|
// Client-supplied code collided; the Android client will retry with a new code.
|
||||||
|
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write a server-side audit log entry for the inviter. This is not read by
|
// Write a server-side audit log entry for the inviter. This is not read by
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,18 @@ struct EmptyStateView: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
let title: String
|
let title: String
|
||||||
let message: String
|
let message: String
|
||||||
var action: (title: String, handler: () -> Void)?
|
var illustrationName: String? = nil
|
||||||
|
var action: (title: String, handler: () -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: CloserSpacing.lg) {
|
VStack(spacing: CloserSpacing.lg) {
|
||||||
Image(systemName: icon)
|
if let illustrationName {
|
||||||
.font(.system(size: 48))
|
CloserIllustrationView(imageName: illustrationName, size: 132)
|
||||||
.foregroundColor(.closerPrimary.opacity(0.6))
|
} else {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(.closerPrimary.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(CloserFont.title3)
|
.font(CloserFont.title3)
|
||||||
|
|
@ -84,6 +89,23 @@ struct EmptyStateView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Illustration View
|
||||||
|
|
||||||
|
struct CloserIllustrationView: View {
|
||||||
|
let imageName: String
|
||||||
|
var size: CGFloat = 220
|
||||||
|
var cornerRadius: CGFloat = CloserRadius.xlarge
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(imageName)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Partner Status Row
|
// MARK: - Partner Status Row
|
||||||
|
|
||||||
struct PartnerStatusRow: View {
|
struct PartnerStatusRow: View {
|
||||||
|
|
@ -287,4 +309,4 @@ struct PremiumGateView: View {
|
||||||
.closerShadow(level: .large)
|
.closerShadow(level: .large)
|
||||||
.padding(CloserSpacing.xl)
|
.padding(CloserSpacing.xl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,23 @@ struct OnboardingView: View {
|
||||||
@Binding var showSignUp: Bool
|
@Binding var showSignUp: Bool
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
|
|
||||||
let pages: [(icon: String, title: String, description: String)] = [
|
private let pages = [
|
||||||
("heart.fill", "Connect Deeper", "Daily questions, games, and shared experiences designed to bring you closer together."),
|
OnboardingPage(
|
||||||
("lock.fill", "Private & Secure", "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."),
|
icon: "heart.fill",
|
||||||
("sparkles", "Grow Together", "Build stronger habits, discover new things, and celebrate your journey as a couple.")
|
illustrationName: "illustration-couple-onboarding",
|
||||||
|
title: "Connect Deeper",
|
||||||
|
description: "Daily questions, games, and shared experiences designed to bring you closer together."
|
||||||
|
),
|
||||||
|
OnboardingPage(
|
||||||
|
icon: "lock.fill",
|
||||||
|
title: "Private & Secure",
|
||||||
|
description: "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."
|
||||||
|
),
|
||||||
|
OnboardingPage(
|
||||||
|
icon: "sparkles",
|
||||||
|
title: "Grow Together",
|
||||||
|
description: "Build stronger habits, discover new things, and celebrate your journey as a couple."
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -30,15 +43,13 @@ struct OnboardingView: View {
|
||||||
TabView(selection: $currentPage) {
|
TabView(selection: $currentPage) {
|
||||||
ForEach(pages.indices, id: \.self) { index in
|
ForEach(pages.indices, id: \.self) { index in
|
||||||
OnboardingPageView(
|
OnboardingPageView(
|
||||||
icon: pages[index].icon,
|
page: pages[index]
|
||||||
title: pages[index].title,
|
|
||||||
description: pages[index].description
|
|
||||||
)
|
)
|
||||||
.tag(index)
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||||
.frame(height: 260)
|
.frame(height: 330)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|
@ -67,22 +78,31 @@ struct OnboardingView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OnboardingPageView: View {
|
private struct OnboardingPage {
|
||||||
let icon: String
|
let icon: String
|
||||||
|
var illustrationName: String? = nil
|
||||||
let title: String
|
let title: String
|
||||||
let description: String
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OnboardingPageView: View {
|
||||||
|
let page: OnboardingPage
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: CloserSpacing.xl) {
|
VStack(spacing: CloserSpacing.lg) {
|
||||||
Image(systemName: icon)
|
if let illustrationName = page.illustrationName {
|
||||||
.font(.system(size: 44))
|
CloserIllustrationView(imageName: illustrationName, size: 170)
|
||||||
.foregroundColor(.closerPrimary)
|
} else {
|
||||||
|
Image(systemName: page.icon)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(.closerPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
Text(title)
|
Text(page.title)
|
||||||
.font(CloserFont.title2)
|
.font(CloserFont.title2)
|
||||||
.foregroundColor(.closerText)
|
.foregroundColor(.closerText)
|
||||||
|
|
||||||
Text(description)
|
Text(page.description)
|
||||||
.font(CloserFont.body)
|
.font(CloserFont.body)
|
||||||
.foregroundColor(.closerTextSecondary)
|
.foregroundColor(.closerTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
@ -513,4 +533,4 @@ struct CreateProfileView: View {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ struct PairPromptView: View {
|
||||||
VStack(spacing: CloserSpacing.xxl) {
|
VStack(spacing: CloserSpacing.xxl) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "link.circle.fill")
|
CloserIllustrationView(imageName: "illustration-couple-invite", size: 190)
|
||||||
.font(.system(size: 72))
|
|
||||||
.foregroundColor(.closerPrimary)
|
|
||||||
|
|
||||||
Text("Connect with Your Partner")
|
Text("Connect with Your Partner")
|
||||||
.font(CloserFont.title1)
|
.font(CloserFont.title1)
|
||||||
|
|
@ -334,4 +332,4 @@ struct EncryptionUpgradeView: View {
|
||||||
.background(Color.closerBackground)
|
.background(Color.closerBackground)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,8 @@ struct AnswerHistoryView: View {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "clock.arrow.circlepath",
|
icon: "clock.arrow.circlepath",
|
||||||
title: "No Answers Yet",
|
title: "No Answers Yet",
|
||||||
message: "Your answer history will appear here."
|
message: "Your answer history will appear here.",
|
||||||
|
illustrationName: "illustration-couple-history"
|
||||||
)
|
)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -573,4 +574,4 @@ let samplePacks: [QuestionPack] = [
|
||||||
QuestionPack(id: "fun", name: "Fun & Playful", description: "Lighthearted questions about playfulness, laughter, and shared activities.", categories: nil, isPremium: false),
|
QuestionPack(id: "fun", name: "Fun & Playful", description: "Lighthearted questions about playfulness, laughter, and shared activities.", categories: nil, isPremium: false),
|
||||||
QuestionPack(id: "date_night", name: "Date Night", description: "Questions designed for dates, meals, walks, or quiet time together.", categories: nil, isPremium: true),
|
QuestionPack(id: "date_night", name: "Date Night", description: "Questions designed for dates, meals, walks, or quiet time together.", categories: nil, isPremium: true),
|
||||||
QuestionPack(id: "trust", name: "Trust", description: "Questions about trust, repair, and rebuilding.", categories: nil, isPremium: true),
|
QuestionPack(id: "trust", name: "Trust", description: "Questions about trust, repair, and rebuilding.", categories: nil, isPremium: true),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -135,25 +135,24 @@ final class FirestoreService: @unchecked Sendable {
|
||||||
// MARK: - Callable Functions
|
// MARK: - Callable Functions
|
||||||
|
|
||||||
extension FirestoreService {
|
extension FirestoreService {
|
||||||
func acceptInviteCallable(code: String, recoveryPhrase: String? = nil) async throws -> String {
|
// TODO(iOS-E2EE): iOS does not yet generate E2EE keys or encrypt the recovery phrase,
|
||||||
var data: [String: Any] = ["code": code]
|
// so iOS-originated invites create plaintext couples (encryptionVersion=0). Cross-platform
|
||||||
if let phrase = recoveryPhrase {
|
// couples where the Android user invites must go through acceptInviteCallable on Android.
|
||||||
data["recoveryPhrase"] = phrase
|
// When iOS implements E2EE parity (CryptoKit keyset + Argon2id phrase cipher), update
|
||||||
}
|
// createInviteCallable to supply wrappedCoupleKey, kdfSalt, kdfParams, and
|
||||||
let result = try await functions.httpsCallable("acceptInviteCallable").call(data)
|
// encryptedRecoveryPhrase, and update acceptInviteCallable to decrypt the phrase.
|
||||||
|
|
||||||
|
func acceptInviteCallable(code: String) async throws -> String {
|
||||||
|
let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code])
|
||||||
guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else {
|
guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else {
|
||||||
throw FirestoreError.invalidResponse
|
throw FirestoreError.invalidResponse
|
||||||
}
|
}
|
||||||
return coupleId
|
return coupleId
|
||||||
}
|
}
|
||||||
|
|
||||||
func createInviteCallable(inviteCode: String? = nil) async throws -> (code: String, expiresAt: Date) {
|
func createInviteCallable() async throws -> (code: String, expiresAt: Date) {
|
||||||
var data: [String: Any] = [:]
|
// iOS MVP omits all E2EE fields; server writes nulls and sets encryptionVersion=0.
|
||||||
// iOS MVP skips E2EE; the server writes null for the wrapped couple key.
|
let data: [String: Any] = [:]
|
||||||
// When iOS E2EE parity lands, pass wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase here.
|
|
||||||
if let code = inviteCode {
|
|
||||||
data["preferredCode"] = code
|
|
||||||
}
|
|
||||||
let result = try await functions.httpsCallable("createInviteCallable").call(data)
|
let result = try await functions.httpsCallable("createInviteCallable").call(data)
|
||||||
guard let payload = result.data as? [String: Any],
|
guard let payload = result.data as? [String: Any],
|
||||||
let code = payload["code"] as? String,
|
let code = payload["code"] as? String,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ let package = Package(
|
||||||
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
|
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
|
||||||
],
|
],
|
||||||
path: "Closer",
|
path: "Closer",
|
||||||
exclude: ["Info.plist", "Closer.entitlements"]
|
exclude: ["Info.plist", "Closer.entitlements"],
|
||||||
|
resources: [
|
||||||
|
.process("Resources")
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "CloserTests",
|
name: "CloserTests",
|
||||||
|
|
@ -45,4 +48,4 @@ let package = Package(
|
||||||
path: "CloserUITests"
|
path: "CloserUITests"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue