fix(functions): add server-side throttle to gentle_reminders callable

This commit is contained in:
null 2026-06-20 23:27:54 -05:00
parent 71b230719b
commit 7898a4887f
1 changed files with 65 additions and 8 deletions

View File

@ -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(