fix: rate limiter bump (20/day, 100/week), firestore rules for image messages, storage rules for chat_media, gitignore ClaudeReport

- NotificationRateLimiter: 20 partner/day, 100/week (was 2/4 — too tight for game activity)
- firestore.rules: messages create allows type=image with mediaUrl or type=text with ciphertext
- storage.rules: chat_media path with 15MB cap
- .gitignore: ClaudeReport.md, docs/img
This commit is contained in:
null 2026-06-24 15:20:44 -05:00
parent 608ddcfc5b
commit 4e2c3fdf0d
4 changed files with 36 additions and 8 deletions

4
.gitignore vendored
View File

@ -67,3 +67,7 @@ ios_encrypt.md
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md
gitleaks-after.json gitleaks-after.json
docs/img/qa.jpeg
docs/img/sam.jpg
ClaudeReport.md
ClaudeReport.md

View File

@ -8,10 +8,15 @@ import java.util.concurrent.TimeUnit
/** /**
* Persisted rate limiter for partner-trigger and reminder notifications. * Persisted rate limiter for partner-trigger and reminder notifications.
* *
* Limits: * Limits (sized for a couples app where partner activity is the core loop the old 2/day +
* - 2 partner-trigger notifications per day * 4/week caps suppressed legitimate game start/finish and partner-action notifications after a
* - 1 reminder notification per day * single game; these are anti-runaway ceilings, not gentle-nudge throttles):
* - 4 total notifications per week * - 20 partner-trigger notifications per day
* - 1 reminder notification per day (proactive nudges stay gentle)
* - 100 total notifications per week
*
* Note: chat messages are NOT throttled here foreground messages show the in-app bubble and
* backgrounded ones are displayed by the OS from the FCM notification block, both bypassing this.
* *
* Counts are stored in [SharedPreferences] and reset when a new day or week starts. * Counts are stored in [SharedPreferences] and reset when a new day or week starts.
*/ */
@ -30,9 +35,9 @@ class NotificationRateLimiter(context: Context) {
private const val KEY_REMINDER_COUNT = "reminder_count" private const val KEY_REMINDER_COUNT = "reminder_count"
private const val KEY_TOTAL_COUNT = "total_count" private const val KEY_TOTAL_COUNT = "total_count"
const val MAX_PARTNER_PER_DAY = 2 const val MAX_PARTNER_PER_DAY = 20
const val MAX_REMINDER_PER_DAY = 1 const val MAX_REMINDER_PER_DAY = 1
const val MAX_TOTAL_PER_WEEK = 4 const val MAX_TOTAL_PER_WEEK = 100
} }
/** /**

View File

@ -376,11 +376,20 @@ service cloud.firestore {
// Discussion messages: any couple member can read, but only the author can write/update/delete // Discussion messages: any couple member can read, but only the author can write/update/delete
match /messages/{messageId} { match /messages/{messageId} {
allow read: if isCouplesMember(coupleId); allow read: if isCouplesMember(coupleId);
// Text messages carry ciphertext in `text`; image messages carry only a `mediaUrl`
// pointing at the encrypted bytes in Storage (the photo itself is E2E-encrypted).
allow create: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId) && coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorUserId == request.auth.uid && request.resource.data.authorUserId == request.auth.uid
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt']) && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl'])
&& isCiphertext(request.resource.data.text); && (
(request.resource.data.get('type', 'text') == 'image'
&& request.resource.data.mediaUrl is string
&& request.resource.data.mediaUrl.size() > 0)
||
(request.resource.data.get('type', 'text') == 'text'
&& isCiphertext(request.resource.data.text))
);
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId) && coupleEncryptionEnabled(coupleId)
&& resource.data.authorUserId == request.auth.uid && resource.data.authorUserId == request.auth.uid

View File

@ -20,6 +20,16 @@ service firebase.storage {
allow read: if request.auth != null && request.auth.uid == uid; allow read: if request.auth != null && request.auth.uid == uid;
} }
// Encrypted chat media: the author writes under their own path (already E2E-encrypted
// ciphertext, so Storage never holds anything readable). The partner reads via the tokenized
// download URL, which bypasses these rules — same model as profile photos. 15 MB cap.
match /users/{uid}/chat_media/{file} {
allow write: if request.auth != null
&& request.auth.uid == uid
&& request.resource.size < 15 * 1024 * 1024;
allow read: if request.auth != null && request.auth.uid == uid;
}
// Deny all other paths by default. // Deny all other paths by default.
match /{allPaths=**} { match /{allPaths=**} {
allow read, write: if false; allow read, write: if false;