From b05a72605e55f043ac3809bd6f10ab46f32ed800 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 22:02:40 -0500 Subject: [PATCH] =?UTF-8?q?fix(rules):=20add=20capsules=20+=20challenges?= =?UTF-8?q?=20member=20rules=20(D-001=20P1)=20=E2=80=94=20Memory=20Lane/Ch?= =?UTF-8?q?allenges=20were=20broken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit couples/{id}/capsules and /challenges had NO rules -> default-deny -> Memory Lane hung on loader, Connection Challenges couldn't load (live PERMISSION_DENIED). Added member-read + ciphertext-enforcing capsules rule (title/content/promptUsed = enc:v1:) and a challenges rule (catalog-referenced progress). Deployed + verified live: both features load, 0 perm errors. Found during Round-2 re-verify of A-001 (Memory Lane couple-shared also confirmed). Co-Authored-By: Claude Opus 4.8 --- ClaudeReport.md | 8 +++++--- firestore.rules | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ClaudeReport.md b/ClaudeReport.md index 4ce9fa01..7bd07b86 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -15,9 +15,9 @@ _(Prior games/notifications QA from 2026-06-24 was completed + verified; superse | Severity | Open | Fixed | |---|---|---| | P0 | 0 | 0 | -| P1 | 0 | 1 | +| P1 | 0 | 2 | | P2 | 0 | 1 | -| P3 | 3 | 0 | +| P3 | 4 | 0 | **Round 1: all P0–P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2). Open = P3 cosmetics only (A-003 badge, B-001 stale-session guard, E-002 informational notif routing). Deferred for a clean "flawless" @@ -68,7 +68,9 @@ _Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh | ID | Area | Severity | Description | Status | |---|---|---|---|---| -| D-OBS | Rules / data load | **P2?** (investigate) | During Pass B, logcat showed `PERMISSION_DENIED` for client listeners on `couples/{id}/outcomes`, `couples/{id}/challenges (where status==active)`, and `couples/{id}/capsules`. Either a rules gap or queries firing for features the (free) user can't read → console errors / possibly broken Connection Challenges + Memory Lane data load. **Round 2: confirm whether these features load correctly + fix the rule or guard the query.** | Open | +| D-001 | Rules — missing subcollection rules | **P1** | `couples/{id}/capsules` and `couples/{id}/challenges` had **no `match` block** → default-deny → **Memory Lane hung on its loading heart** and **Connection Challenges** couldn't load (live `PERMISSION_DENIED` confirmed). Two premium features broken. | Sam premium, QA opens Memory Lane → stuck loading heart; logcat `Listen for Query(.../capsules) failed: PERMISSION_DENIED`. | **FIXED + DEPLOYED** — added member-read + ciphertext-enforcing `capsules` rule (title/content/promptUsed must be `enc:v1:`) and a `challenges` rule (catalog-referenced, progress-only). Re-verified live: Memory Lane shows empty state, Connection Challenges shows the series list, **0 permission errors**. | +| F-OBS | Resilience (UI) | **P3** | MemoryLaneScreen (and likely others) **hangs on the loading indicator forever** when a Firestore query fails, instead of showing an error/empty state. Masked the D-001 root cause. Add load-failure handling. | Was visible before D-001 fix (stuck heart). | Open (Pass F) | +| (outcomes) | Rules | — | The Round-1 `outcomes` list `PERMISSION_DENIED` is **by-design** — the rule restricts reads to specific dayKeys (`day_0/30/60/90`); a bare list query is correctly denied. Not a bug. | — | Closed (by-design) | ## Pass E — Notifications - **Copy carries no private content:** all function notification bodies are generic ("Tap to read and reply", "Answer together before it expires", etc.); `${title}` refers to public question/game titles, not user answers. ✓ (ties to D6) diff --git a/firestore.rules b/firestore.rules index 61457e48..2634a784 100644 --- a/firestore.rules +++ b/firestore.rules @@ -607,6 +607,43 @@ service cloud.firestore { allow delete: if false; } + // Memory Lane capsules: member-readable; author creates with ENCRYPTED content (title/content/ + // promptUsed are enc:v1:). status flips sealed→unlocked (client or the scheduled unlock fn). + match /capsules/{capsuleId} { + allow read: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && coupleEncryptionEnabled(coupleId) + && request.resource.data.authorId == request.auth.uid + && request.resource.data.keys().hasOnly( + ['authorId', 'title', 'content', 'promptUsed', 'unlockAt', 'createdAt', 'status']) + && isCiphertext(request.resource.data.title) + && isCiphertext(request.resource.data.content) + && (!('promptUsed' in request.resource.data) + || request.resource.data.promptUsed == null + || isCiphertext(request.resource.data.promptUsed)); + allow update: if isCouplesMember(coupleId) + && ( + // Author re-saves encrypted content before unlock + (request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'content', 'promptUsed', 'unlockAt']) + && isCiphertext(request.resource.data.title) + && isCiphertext(request.resource.data.content)) + || + // Status transition (e.g. sealed → unlocked) + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']) + ); + allow delete: if isCouplesMember(coupleId); + } + + // Connection Challenges: catalog-referenced (no free-text user content), members track progress. + match /challenges/{challengeId} { + allow read: if isCouplesMember(coupleId); + allow create: if isCouplesMember(coupleId) + && request.resource.data.keys().hasOnly(['challengeId', 'startedAt', 'status', 'completions']); + allow update: if isCouplesMember(coupleId) + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['completions', 'status']); + allow delete: if isCouplesMember(coupleId); + } + // Outcomes: couple-level 30/60/90 day check-ins. Both members can read. // Writes are server-side only via submitOutcomeCallable; direct client writes denied. match /outcomes/{dayKey} {