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:
parent
4978780962
commit
b1a6e6a3c3
1 changed files with 99 additions and 0 deletions
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue