From 7898a4887fe84d8f675ea8bfa53294fa40384d0d Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 23:27:54 -0500 Subject: [PATCH] fix(functions): add server-side throttle to gentle_reminders callable --- .../sendGentleReminderCallable.ts | 73 +++++++++++++++++-- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/functions/src/notifications/sendGentleReminderCallable.ts b/functions/src/notifications/sendGentleReminderCallable.ts index a39c3a11..a77ec542 100644 --- a/functions/src/notifications/sendGentleReminderCallable.ts +++ b/functions/src/notifications/sendGentleReminderCallable.ts @@ -1,13 +1,21 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +const GENTLE_REMINDER_MAX_PER_HOUR = 5 +const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000 // 1 hour + /** * Sends a gentle nudge from one partner to the other when the caller has * already answered today's question but the partner hasn't. * - * Rate limit: one reminder per couple per calendar day (UTC). The lock is - * stored in couples/{coupleId}/gentle_reminders/{date} so it survives - * function restarts and is visible to both partners. + * Rate limits: + * - Per-user: max 5 gentle reminders per rolling hour. Guarded by a server-side + * transaction on `rate_limits/{uid}_gentle_reminder` so malicious clients + * cannot bypass it by calling the callable in a loop. The Android-side + * NotificationRateLimiter remains for UX but is not the authoritative guard. + * - Per-couple: one reminder per couple per calendar day (UTC). The lock is + * stored in couples/{coupleId}/gentle_reminders/{date} so it survives + * function restarts and is visible to both partners. * * The notification is both an FCM push (for the system tray) and an entry in * the partner's notification_queue (for in-app display). @@ -39,7 +47,56 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c throw new functions.https.HttpsError('failed-precondition', 'No partner found.') } - // ── 2. Rate limit: one per couple per day ──────────────────────────────── + // ── 2. Server-side per-user throttle: 5 per hour (rolling window) ──────── + + const now = admin.firestore.Timestamp.now() + const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`) + + const throttleResult = await db.runTransaction(async (tx) => { + const snap = await tx.get(rateLimitRef) + const data = snap.data() + + let windowStart: admin.firestore.Timestamp + let count: number + + if (!snap.exists || !data) { + windowStart = now + count = 0 + } else { + windowStart = data.windowStart as admin.firestore.Timestamp + count = (typeof data.count === 'number' ? data.count : 0) + + const elapsedMs = now.toMillis() - windowStart.toMillis() + if (elapsedMs >= GENTLE_REMINDER_WINDOW_MS) { + // Rolling window has expired; start a fresh one. + windowStart = now + count = 0 + } + } + + if (count >= GENTLE_REMINDER_MAX_PER_HOUR) { + const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis()) + const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60_000)) + return { allowed: false, retryAfterMinutes } + } + + tx.set(rateLimitRef, { + count: count + 1, + windowStart, + updatedAt: now, + }, { merge: true }) + + return { allowed: true, count: count + 1, windowStart } + }) + + if (!throttleResult.allowed) { + throw new functions.https.HttpsError( + 'resource-exhausted', + `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.` + ) + } + + // ── 3. Rate limit: one per couple per day ──────────────────────────────── const today = new Date().toISOString().slice(0, 10) // e.g. "2026-06-19" const lockRef = db @@ -53,7 +110,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c return { sent: false, reason: 'already_sent_today' } } - // ── 3. Collect partner FCM tokens ──────────────────────────────────────── + // ── 4. Collect partner FCM tokens ──────────────────────────────────────── const tokens: string[] = [] const partnerDoc = await db.collection('users').doc(partnerId).get() @@ -76,7 +133,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c } }) - // ── 4. Write in-app notification record ────────────────────────────────── + // ── 5. Write in-app notification record ────────────────────────────────── await db .collection('users') @@ -91,14 +148,14 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c createdAt: admin.firestore.FieldValue.serverTimestamp(), }) - // ── 5. Claim the daily rate-limit lock ─────────────────────────────────── + // ── 6. Claim the daily rate-limit lock ─────────────────────────────────── await lockRef.set({ sentBy: callerId, sentAt: admin.firestore.FieldValue.serverTimestamp(), }) - // ── 6. Send FCM push ───────────────────────────────────────────────────── + // ── 7. Send FCM push ───────────────────────────────────────────────────── if (tokens.length > 0) { const sendResults = await Promise.allSettled(