Skip to content

API serializer audit (#844) - Test Plan + Findings Ledger โ€‹

FieldValue
Branch / PRfix/offer-moderation-807 (PR #845, theme 3)
EnvironmentStatic sweep + unit tests; post-deploy curl on dev
Testeragent
Date2026-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 โ€‹

#ScenarioResultNotes
1Sweep: all Cloud Run response paths classified[x] passledger below
2Fix: admin email off the public events endpoint[x] passallowlist-proof test 2/2
3Post-deploy: /analytics/events/custom carries no createdBy[ ] pass [ ] fail [ ] blockedSCHEDULED: 6-9pm close step (curl)

Findings ledger (sweep of 2026-08-10) โ€‹

Confirmed over-return, FIXED today:

  1. analytics: admin email published on an unauthenticated endpoint. The admin EventCreator auto-fills createdBy with the creating admin's EMAIL; getCustomEventDefinitionsForClient included it in the "client-safe" mapping; /analytics/events is 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.js now 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 ...data spreads 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-banned is deliberately { banned: boolean } for its unauthenticated audience; banContextFor allowlists; 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/functions has 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 no createdBy key in any definition.
  • Expected: definitions carry only the allowlisted shape.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

Built with VitePress