diff --git a/.planning/phases/08-engagement-user-profile-new/08-01-SUMMARY.md b/.planning/phases/08-engagement-user-profile-new/08-01-SUMMARY.md new file mode 100644 index 0000000..5527523 --- /dev/null +++ b/.planning/phases/08-engagement-user-profile-new/08-01-SUMMARY.md @@ -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 ``. 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)