API serializer audit (#844) - Test Plan + Findings Ledger โ
| Field | Value |
|---|---|
| Branch / PR | fix/offer-moderation-807 (PR #845, theme 3) |
| Environment | Static sweep + unit tests; post-deploy curl on dev |
| Tester | agent |
| Date | 2026-08-10 |
The audit standard (from #844): Cloud Run services read via Admin SDK, which bypasses firestore.rules, so every HTTP response serializer is the LAST privacy gate. Each endpoint must return an explicit field set appropriate to its audience. Baseline for "appropriate": what firestore.rules would let that same caller read directly.
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Sweep: all Cloud Run response paths classified | [x] pass | ledger below |
| 2 | Fix: admin email off the public events endpoint | [x] pass | allowlist-proof test 2/2 |
| 3 | Post-deploy: /analytics/events/custom carries no createdBy | [ ] pass [ ] fail [ ] blocked | SCHEDULED: 6-9pm close step (curl) |
Findings ledger (sweep of 2026-08-10) โ
Confirmed over-return, FIXED today:
- analytics: admin email published on an unauthenticated endpoint. The admin EventCreator auto-fills
createdBywith the creating admin's EMAIL;getCustomEventDefinitionsForClientincluded it in the "client-safe" mapping;/analytics/eventsis a public mount (deliberately: event schemas, not user data). Nothing client-side consumed the field. Fixed by dropping it from the client mapping;customEvents.service.test.jsnow proves the exact allowlisted key set, so any future field addition fails the test rather than shipping silently.
Clean (verified, no change):
- lanterns:
getVenueLanterns(the cross-user surface) pushes an explicit field set and resolves pins server-side so clients never learn a stranger's userId; the...dataspreads serve the caller's OWN lanterns (self-audience). Wave/connection payloads go to their own parties, matching rules visibility. - merchants: post-#807 serializers (this PR's theme 1); claim/redeem bodies are explicit literals; the public rotation path is allowlisted and integration-tested (offers run doc, scenario 7).
- venues: spreads of venue docs, a deliberately public collection (rules allow pre-signup browsing); no personal data.
- auth: user-path appeal status allowlists 4 fields;
check-bannedis deliberately{ banned: boolean }for its unauthenticated audience;banContextForallowlists; admin/merchant auth probes return fixed SAFE_RESPONSE shapes. - docs: explicit object literals, editor/admin audience.
- assistant: admin-gated with a collection allowlist (see follow-up below).
Structural follow-ups filed (not stretched into today):
- #846: appeal payloads send raw phoneHash to the moderator UI; strip if no portal flow needs it. (Appeal docs storing phoneHash + linkedUserId is user-initiated-appeal design, deliberately not a finding.)
- #847: assistant read_collection allowlist includes users/lanterns/waves whose full rows enter LLM context; trim or field-mask.
Noted in #844 (not filed as issues):
- serializeOffer's merchant path is a strip-blocklist (a future doc field defaults to exposed); invert to an allowlist next time that file is touched.
- The res.json-with-spread-doc lint heuristic is NOT cheap: most spreads are legitimate internal helpers, and the leak shape (a spread doc crossing res.json, possibly via a variable) needs dataflow, not a regex. Recorded in #844 rather than wiring a noisy check.
services/functionshas 3 spread sites (event-driven, not HTTP responses); out of the issue's Cloud Run scope, glance separately.
Scenario 3: post-deploy curl (session-close window) โ
- Steps:
curl -s https://<analytics-api-dev>/analytics/events/custom | jq '.events[0] // .[0]'and confirm nocreatedBykey in any definition. - Expected: definitions carry only the allowlisted shape.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up: