diff --git a/README.md b/README.md index 9bd2bac9..a51fa9cf 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 8744fa5b..e2128139 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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, diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 2c13556e..35d4e891 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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, diff --git a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt index 6aa35551..aa5ddb45 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt @@ -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( diff --git a/app/src/main/java/app/closer/ui/pairing/EmailInviteScreen.kt b/app/src/main/java/app/closer/ui/pairing/EmailInviteScreen.kt deleted file mode 100644 index f0a9716e..00000000 --- a/app/src/main/java/app/closer/ui/pairing/EmailInviteScreen.kt +++ /dev/null @@ -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() -} diff --git a/functions/dist/couples/acceptInviteCallable.js b/functions/dist/couples/acceptInviteCallable.js index 3a67a574..e7594812 100644 --- a/functions/dist/couples/acceptInviteCallable.js +++ b/functions/dist/couples/acceptInviteCallable.js @@ -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) { diff --git a/functions/dist/couples/acceptInviteCallable.js.map b/functions/dist/couples/acceptInviteCallable.js.map index b7a4b5f6..ba9597fc 100644 --- a/functions/dist/couples/acceptInviteCallable.js.map +++ b/functions/dist/couples/acceptInviteCallable.js.map @@ -1 +1 @@ -{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/couples/acceptInviteCallable.ts b/functions/src/couples/acceptInviteCallable.ts index 804142d7..968e2126 100644 --- a/functions/src/couples/acceptInviteCallable.ts +++ b/functions/src/couples/acceptInviteCallable.ts @@ -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) { diff --git a/iphone/Closer/Pairing/PairingViews.swift b/iphone/Closer/Pairing/PairingViews.swift index 4d9a8df7..ec6f8997 100644 --- a/iphone/Closer/Pairing/PairingViews.swift +++ b/iphone/Closer/Pairing/PairingViews.swift @@ -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 { diff --git a/iphone/Closer/Settings/SettingsViews.swift b/iphone/Closer/Settings/SettingsViews.swift index 1f6c6670..398a8e1f 100644 --- a/iphone/Closer/Settings/SettingsViews.swift +++ b/iphone/Closer/Settings/SettingsViews.swift @@ -57,9 +57,9 @@ struct SettingsView: View { } NavigationLink { - EmailInviteView() + CreateInviteView() } label: { - Label("Invite Partner", systemImage: "envelope") + Label("Invite Partner", systemImage: "square.and.arrow.up") } }