wulf-pulse/.planning/phases/08-engagement-user-profile-new/08-01-SUMMARY.md
lorentz b1a6e6a3c3 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)
2026-05-07 20:42:04 -04:00

4.8 KiB

phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics requirements_addressed
08-engagement-user-profile-new 01 mobile-engagement
mobile
engagement
msgraph
photo-proxy
api
requires provides affects
GET /api/mobile/engagement/user/[userId]/photo
MsGraphClient.getUserPhotoBytes(userId)
lib/services/msgraph-client.ts
app/api/mobile/engagement/user/[userId]/photo/route.ts
added patterns
Binary photo proxy via NextResponse with ArrayBuffer
Graph photo fetch via reused getToken() OAuth2 cache
modified created
lib/services/msgraph-client.ts
app/api/mobile/engagement/user/[userId]/photo/route.ts
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
duration_minutes completed_date tasks_completed files_modified files_created
2 2026-05-08 2 1 1
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)