feat: remove email invite screen, add accept-invite rate limiting, clean up iOS pairing (v0.2.2)

This commit is contained in:
null 2026-06-21 08:55:43 -05:00
parent 9b22e6d135
commit dc34462f0b
10 changed files with 63 additions and 214 deletions

View File

@ -57,7 +57,7 @@ Closer gives couples a private, shared space for guided connection:
- **Question packs** — 22+ curated categories spanning 6,000+ bundled prompts (communication, conflict, trust, intimacy, parenting, marriage, money, stress, date night, and more).
- **Answer history** — review past questions and answers, delete controls, and partner reveal support.
- **Discussion threads** — question-specific conversation threads and reactions for follow-up.
- **Partner pairing** — 6-character invite code, email invite flow, and partner-aware home states.
- **Partner pairing** — 6-character invite code with copy and share, and partner-aware home states.
- **Spin the wheel** — category-based random questions for date nights, road trips, and low-pressure moments.
- **Date tools** — swipe-to-match date ideas, date planning preferences, and a shared bucket list.
- **Settings & privacy** — account, notifications, appearance, subscription, relationship, privacy, and account deletion flows.
@ -99,7 +99,7 @@ The Android app is the **reference implementation** — the iOS port is built to
All of this is free, forever. No credits, no daily limits that magically shrink after a week.
- Anonymous onboarding → email or Google sign-up
- 6-character invite code pairing (with email invite fallback)
- 6-character invite code pairing (copy or share via any app)
- Daily question with full answer modes (text, scale, multiple choice, this-or-that)
- Private answer reveal flow once both partners have answered
- Question packs — 6,000+ bundled prompts across 22 categories (most packs included free)

View File

@ -41,7 +41,6 @@ import app.closer.ui.onboarding.CreateProfileScreen
import app.closer.ui.onboarding.OnboardingScreen
import app.closer.ui.pairing.AcceptInviteScreen
import app.closer.ui.pairing.CreateInviteScreen
import app.closer.ui.pairing.EmailInviteScreen
import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.pairing.PairPromptScreen
import app.closer.ui.pairing.RecoveryScreen
@ -275,9 +274,6 @@ fun AppNavigation(
onBack = { navController.popBackStack() }
)
}
composable(route = AppRoute.EMAIL_INVITE) {
EmailInviteScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.ACCEPT_INVITE) {
AcceptInviteScreen(
onNavigate = navigateRoute,

View File

@ -19,7 +19,6 @@ object AppRoute {
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
const val ANSWER_HISTORY = "answer_history"
const val CREATE_INVITE = "create_invite"
const val EMAIL_INVITE = "email_invite"
const val ACCEPT_INVITE = "accept_invite"
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
const val CATEGORY_PICKER = "category_picker"
@ -82,7 +81,6 @@ object AppRoute {
Definition(ANSWER_REVEAL, "Reveal", "answers"),
Definition(ANSWER_HISTORY, "Answer History", "answers"),
Definition(CREATE_INVITE, "Create Invite", "pairing"),
Definition(EMAIL_INVITE, "Email Invite", "pairing"),
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
@ -135,7 +133,6 @@ object AppRoute {
val modalLikeRoutes = setOf(
CREATE_INVITE,
EMAIL_INVITE,
ACCEPT_INVITE,
INVITE_CONFIRM,
PAYWALL,

View File

@ -3,7 +3,6 @@ package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.Timestamp
import com.google.firebase.functions.FirebaseFunctions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
@ -24,82 +23,30 @@ class FirestoreInviteDataSource @Inject constructor(
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
.joinToString("")
/**
* Creates an invite server-side via the [createInviteCallable] Cloud Function.
*
* The client no longer writes invites directly (review2.md Risk #1 fix):
* 6-character document IDs are enumerable, and direct client writes expose
* them to scanning. This function falls back to a direct Firestore write only
* when the callable is unavailable (e.g. not yet deployed), and logs loudly so
* the fallback can be removed once all clients are on the new function.
*
* @return The created invite code and expiry timestamp from the server.
*/
@Suppress("DEPRECATION")
suspend fun createInvite(
code: String,
inviterUserId: String,
wrappedKey: RecoveryKeyManager.WrappedKey,
recoveryPhrase: String
): CreateInviteResponse {
// Primary path: server-side callable.
val callableResult = runCatching {
val result = functions.getHttpsCallable("createInviteCallable")
.call(
mapOf(
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params,
"recoveryPhrase" to recoveryPhrase
)
@Suppress("DEPRECATION")
val result = functions.getHttpsCallable("createInviteCallable")
.call(
mapOf(
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params,
"recoveryPhrase" to recoveryPhrase
)
.await()
val data = result.getData() as? Map<*, *>
?: throw IllegalStateException("Invalid response from createInviteCallable")
val returnedCode = data["code"] as? String
?: throw IllegalStateException("Missing code in createInviteCallable response")
val expiresAt = data["expiresAt"] as? com.google.firebase.Timestamp
?: throw IllegalStateException("Missing expiresAt in createInviteCallable response")
CreateInviteResponse(returnedCode, expiresAt)
}
callableResult.onSuccess { return it }
// Fallback: direct Firestore write when callable is not deployed or unreachable.
// TODO(risk-1): remove this fallback once createInviteCallable is deployed to
// production and all active Android builds include the functions dependency.
android.util.Log.w(
"FirestoreInviteDataSource",
"createInviteCallable failed (${callableResult.exceptionOrNull()?.message}); falling back to direct Firestore write"
)
return createInviteDirect(code, inviterUserId, wrappedKey, recoveryPhrase)
}
/**
* Legacy direct-write path. Kept only as a deployment-transition fallback.
* Will be removed once createInviteCallable is universally available.
*/
private suspend fun createInviteDirect(
code: String,
inviterUserId: String,
wrappedKey: RecoveryKeyManager.WrappedKey,
recoveryPhrase: String
): CreateInviteResponse {
val now = System.currentTimeMillis()
inviteRef(code).set(
mapOf(
"code" to code,
"inviterUserId" to inviterUserId,
"status" to "pending",
"createdAt" to Timestamp.now(),
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params,
"recoveryPhrase" to recoveryPhrase
)
).await()
return CreateInviteResponse(code, Timestamp(now / 1000 + 24 * 60 * 60, 0))
.await()
val data = result.getData() as? Map<*, *>
?: throw IllegalStateException("Invalid response from createInviteCallable")
val returnedCode = data["code"] as? String
?: throw IllegalStateException("Missing code in createInviteCallable response")
val expiresAt = data["expiresAt"] as? com.google.firebase.Timestamp
?: throw IllegalStateException("Missing expiresAt in createInviteCallable response")
return CreateInviteResponse(returnedCode, expiresAt)
}
data class CreateInviteResponse(

View File

@ -1,35 +0,0 @@
package app.closer.ui.pairing
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.closer.core.navigation.AppRoute
import app.closer.ui.components.FinishedEmptyStateAction
import app.closer.ui.components.FinishedEmptyStateScreen
import app.closer.ui.theme.CloserPalette
@Composable
fun EmailInviteScreen(
onNavigate: (String) -> Unit = {}
) {
FinishedEmptyStateScreen(
eyebrow = "Pairing",
title = "Send an invite that is easy to follow",
body = "Create an invite code first, then share it with your partner in the channel you both already use.",
glyphCategoryId = "communication",
primaryAction = FinishedEmptyStateAction("Create invite", AppRoute.CREATE_INVITE),
secondaryAction = FinishedEmptyStateAction("Enter a code", AppRoute.ACCEPT_INVITE),
accent = CloserPalette.PurpleDeep,
details = listOf(
"Use one code instead of a temporary sample invite.",
"Keep the message short enough to send anywhere.",
"Give your partner a single next step."
),
onNavigate = onNavigate
)
}
@Preview
@Composable
fun EmailInviteScreenPreview() {
EmailInviteScreen()
}

View File

@ -60,6 +60,8 @@ const admin = __importStar(require("firebase-admin"));
* 6. Update both user documents with the new coupleId.
* 7. Mark the invite as accepted.
*/
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
@ -71,6 +73,22 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
}
const db = admin.firestore();
// Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes.
const windowStart = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() - ACCEPT_RATE_LIMIT_WINDOW_MS);
const recentAttempts = await db
.collection('users').doc(callerId)
.collection('invite_attempts')
.where('attemptedAt', '>=', windowStart)
.count()
.get();
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.');
}
// Record this attempt before doing any work, so failures also count.
await db.collection('users').doc(callerId).collection('invite_attempts').add({
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
code,
});
// Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get();
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) {

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

@ -25,6 +25,9 @@ import * as admin from 'firebase-admin'
* 6. Update both user documents with the new coupleId.
* 7. Mark the invite as accepted.
*/
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
export const acceptInviteCallable = functions.https.onCall(async (data: any, context) => {
const callerId = context.auth?.uid
if (!callerId) {
@ -38,6 +41,25 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
const db = admin.firestore()
// Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes.
const windowStart = admin.firestore.Timestamp.fromMillis(
admin.firestore.Timestamp.now().toMillis() - ACCEPT_RATE_LIMIT_WINDOW_MS
)
const recentAttempts = await db
.collection('users').doc(callerId)
.collection('invite_attempts')
.where('attemptedAt', '>=', windowStart)
.count()
.get()
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.')
}
// Record this attempt before doing any work, so failures also count.
await db.collection('users').doc(callerId).collection('invite_attempts').add({
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
code,
})
// Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get()
if (callerDoc.exists && callerDoc.data()?.coupleId != null) {

View File

@ -5,7 +5,6 @@ import SwiftUI
struct PairPromptView: View {
@State private var showCreateInvite = false
@State private var showAcceptInvite = false
@State private var showEmailInvite = false
var body: some View {
NavigationStack {
@ -29,17 +28,12 @@ struct PairPromptView: View {
VStack(spacing: CloserSpacing.lg) {
Button(action: { showCreateInvite = true }) {
Label("Create Invite Code", systemImage: "plus.circle.fill")
Label("Invite my partner", systemImage: "plus.circle.fill")
}
.buttonStyle(PrimaryButtonStyle())
Button(action: { showAcceptInvite = true }) {
Label("Enter Partner's Code", systemImage: "key.fill")
}
.buttonStyle(SecondaryButtonStyle())
Button(action: { showEmailInvite = true }) {
Label("Invite by Email", systemImage: "envelope.fill")
Label("Enter a code", systemImage: "key.fill")
}
.buttonStyle(SecondaryButtonStyle())
}
@ -54,9 +48,6 @@ struct PairPromptView: View {
.navigationDestination(isPresented: $showAcceptInvite) {
AcceptInviteView()
}
.navigationDestination(isPresented: $showEmailInvite) {
EmailInviteView()
}
}
}
}
@ -252,93 +243,6 @@ struct AcceptInviteView: View {
}
}
// MARK: - Email Invite
struct EmailInviteView: View {
@EnvironmentObject var appState: AppState
@State private var email = ""
@State private var isLoading = false
@State private var errorMessage: String?
@State private var successMessage: String?
var body: some View {
ScrollView {
VStack(spacing: CloserSpacing.xxl) {
VStack(spacing: CloserSpacing.sm) {
Image(systemName: "envelope.fill")
.font(.system(size: 44))
.foregroundColor(.closerPrimary)
Text("Invite by Email")
.font(CloserFont.title1)
.foregroundColor(.closerText)
Text("Send an invitation to your partner's email")
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
}
.padding(.top, CloserSpacing.xxl)
VStack(spacing: CloserSpacing.md) {
TextField("partner@example.com", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
.padding()
.background(Color.closerSurface)
.cornerRadius(CloserRadius.medium)
if let success = successMessage {
Text(success)
.font(CloserFont.callout)
.foregroundColor(.closerSuccess)
}
if let error = errorMessage {
Text(error)
.font(CloserFont.caption)
.foregroundColor(.closerDanger)
}
Button(action: sendInvite) {
if isLoading {
ProgressView().tint(.white)
} else {
Text("Send Invitation")
}
}
.buttonStyle(PrimaryButtonStyle(isDisabled: isLoading || email.isEmpty))
.disabled(isLoading || email.isEmpty)
}
}
.closerPadding()
}
.background(Color.closerBackground)
.navigationBarTitleDisplayMode(.inline)
}
private func sendInvite() {
// Email invite sends via Cloud Function or mail service
// For MVP, generate invite code and share system share sheet
guard !email.isEmpty else { return }
isLoading = true
errorMessage = nil
Task {
do {
let (code, _) = try await FirestoreService.shared.createInviteCallable()
successMessage = "Invitation sent! Share this code: \(code)"
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
private func generateSixCharCode() -> String {
let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
return String((0..<6).map { _ in chars.randomElement()! })
}
}
// MARK: - Invite Confirm
struct InviteConfirmView: View {

View File

@ -57,9 +57,9 @@ struct SettingsView: View {
}
NavigationLink {
EmailInviteView()
CreateInviteView()
} label: {
Label("Invite Partner", systemImage: "envelope")
Label("Invite Partner", systemImage: "square.and.arrow.up")
}
}