docs(08-01): complete photo proxy plan — SUMMARY

- MsGraphClient.getUserPhotoBytes() added (3f6b135)
- /api/mobile/engagement/user/[userId]/photo route added (4978780)
- Type-check and build both pass
- All threat mitigations verified (T-08-01 through T-08-08)
This commit is contained in:
lorentz 2026-05-07 20:42:04 -04:00
parent 4978780962
commit b1a6e6a3c3

View file

@ -0,0 +1,99 @@
---
phase: 08-engagement-user-profile-new
plan: "01"
subsystem: mobile-engagement
tags: [mobile, engagement, msgraph, photo-proxy, api]
dependency_graph:
requires: []
provides:
- GET /api/mobile/engagement/user/[userId]/photo
- MsGraphClient.getUserPhotoBytes(userId)
affects:
- lib/services/msgraph-client.ts
- app/api/mobile/engagement/user/[userId]/photo/route.ts
tech_stack:
added: []
patterns:
- Binary photo proxy via NextResponse with ArrayBuffer
- Graph photo fetch via reused getToken() OAuth2 cache
key_files:
modified:
- lib/services/msgraph-client.ts
created:
- app/api/mobile/engagement/user/[userId]/photo/route.ts
decisions:
- "userId validation is permissive (length + denylist) not GUID-strict — graph_users.id is VARCHAR(255) and accepts UPN-style identifiers per migration 041"
- "Cache-Control: private, max-age=3600 on 200 — private because response is per-authenticated-user even though photo is keyed on Graph userId"
- "502 (not 503) for Graph upstream errors — 503 is reserved for the unconfigured-MSGRAPH case (D-26)"
- "No retry in getUserPhotoBytes — photo fetches are best-effort per D-26; fetchJson's 429-retry is overkill for binary media"
metrics:
duration_minutes: 2
completed_date: "2026-05-08"
tasks_completed: 2
files_modified: 1
files_created: 1
requirements_addressed: [ENG-06]
---
# Phase 8 Plan 01: MS Graph Photo Proxy Summary
**One-liner:** Server-side photo proxy at `/api/mobile/engagement/user/[userId]/photo` backed by a new `MsGraphClient.getUserPhotoBytes()` method — returns JPEG/PNG bytes or neutral 404/503, gated by `requireAuth()`.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Add getUserPhotoBytes() to MsGraphClient | 3f6b135 | lib/services/msgraph-client.ts |
| 2 | Add /api/mobile/engagement/user/[userId]/photo route | 4978780 | app/api/mobile/engagement/user/[userId]/photo/route.ts |
## What Was Built
**Task 1 — `MsGraphClient.getUserPhotoBytes(userId)`** (`lib/services/msgraph-client.ts`)
New public async method inserted as a sibling of the user-scoped methods (before `getUserCalendarEvents`). It:
- Calls `this.getToken()` to reuse the existing OAuth2 client_credentials token cache (no per-request token churn)
- Issues a bare `fetch` to `https://graph.microsoft.com/v1.0/users/{encodeURIComponent(userId)}/photo/$value` with only the `Authorization: Bearer` header (no `Accept: application/json` — the endpoint returns binary)
- Returns `null` on 404 (user has no photo — normal outcome)
- Throws `Error(Graph photo error ${status}...)` on other non-2xx for the caller to map to 502
- Returns `{ bytes: ArrayBuffer, contentType: string }` on 2xx (defaults content-type to `image/jpeg` if Graph omits the header)
- No retry logic — best-effort per D-26
**Task 2 — `/api/mobile/engagement/user/[userId]/photo` route** (`app/api/mobile/engagement/user/[userId]/photo/route.ts`)
New GET handler that:
1. Calls `requireAuth()` first (mandatory — middleware whitelists `/api/mobile/*` without pre-gating)
2. Returns 503 if `isMsgraphConfigured()` is false (D-26)
3. Validates `userId` with a permissive denylist (rejects `/`, `?`, `#`, `..`, whitespace, empty, >128 chars) — NOT a strict GUID regex (per migration 041 `graph_users.id VARCHAR(255)` which accepts UPN-form IDs)
4. Calls `client.getUserPhotoBytes(userId)` and returns binary bytes with `Content-Type` + `Cache-Control: private, max-age=3600` on success (D-25)
5. Returns neutral 404 when `getUserPhotoBytes` returns null
6. Returns neutral 502 on any thrown error (no userId or token echoed in response body)
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None — this plan delivers a complete, wired endpoint with no placeholder data.
## Threat Flags
No new security surface beyond what is catalogued in the plan's `<threat_model>`. All high-severity threats mitigated:
- T-08-06 (Spoofing/unauthenticated): `requireAuth()` is first call in handler — verified at line 10, before any Graph interaction.
- T-08-03 (Path traversal): userId denylist + `encodeURIComponent` in `getUserPhotoBytes`.
- T-08-02 (DoS/rate amplification): `Cache-Control: private, max-age=3600` + early 400 on invalid userId.
- T-08-04 (Info disclosure oracle): 404 and 502 responses use neutral copy with no userId echo.
- T-08-05 (Token leak via logs): `console.error` logs the Error object (status text only), not the bearer token.
## Self-Check: PASSED
Files exist:
- FOUND: lib/services/msgraph-client.ts (modified)
- FOUND: app/api/mobile/engagement/user/[userId]/photo/route.ts (created)
Commits exist:
- FOUND: 3f6b135 — feat(08-01): add getUserPhotoBytes() to MsGraphClient
- FOUND: 4978780 — feat(08-01): add /api/mobile/engagement/user/[userId]/photo proxy route
Type-check: PASSED (npx tsc --noEmit --pretty exits 0)
Build: PASSED (npm run build exits 0)