fix(rules): add capsules + challenges member rules (D-001 P1) — Memory Lane/Challenges were broken

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 22:02:40 -05:00
parent efe0ddbf29
commit b05a72605e
2 changed files with 42 additions and 3 deletions

View File

@ -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 P0P2 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)

View File

@ -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} {