22 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | requirements_addressed | user_setup | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-engagement-user-profile-new | 01 | execute | 1 |
|
true |
|
|
|
This endpoint is a hard dependency of the Phase 8 profile header avatar (D-05, D-25, D-26). The client fallback to initials happens in Plan 02 by treating any non-200 as "use initials".
Purpose: Establish the photo-fetch foundation in Wave 1 so Plan 02 can reference the
URL directly in the <img> tag without further coordination.
Output:
- New public method on
MsGraphClient:getUserPhotoBytes(userId) - New route handler at
app/api/mobile/engagement/user/[userId]/photo/route.ts
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/08-engagement-user-profile-new/08-CONTEXT.md @.planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md @CLAUDE.mdFrom lib/services/msgraph-factory.ts (existing, unchanged):
export function isMsgraphConfigured(): boolean;
export function getMsgraphClient(): MsGraphClient; // throws if env missing
From lib/services/msgraph-client.ts (existing — class structure, NOT all members):
export class MsGraphClient {
private config: MsGraphClientConfig;
private accessToken: string | null = null;
private tokenExpiry: number = 0;
// Existing — keep untouched:
private async getToken(): Promise<string>; // OAuth2 client_credentials
private async fetchJson<T>(path: string, retryCount?: number): Promise<T>;
async getUsers(): Promise<GraphUser[]>;
// ... other existing public methods (getTeamsActivity, getEmailActivity, …)
// NEW (this plan adds this — see Task 1):
// async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null>;
}
The existing fetchJson is JSON-only (calls res.json() internally) and so cannot
be reused for binary photo bytes. The new method must do its own fetch against
https://graph.microsoft.com/v1.0/users/{id}/photo/$value with the bearer token
from await this.getToken() and call res.arrayBuffer().
Microsoft Graph contract for photo endpoint (verified against current docs):
- 200 OK +
Content-Type: image/jpeg(most common) on success - 404 Not Found when the user exists but has no photo
- Other 4xx/5xx on upstream errors
From lib/auth-utils.ts (existing, unchanged):
export async function requireAuth(): Promise<{
session: Session | null;
error: NextResponse | null;
}>;
Pattern (from app/api/mobile/engagement/summary/route.ts and others):
const { session, error: authError } = await requireAuth();
if (authError) return authError;
Existing /api/mobile route precedent (from app/api/mobile/engagement/summary/route.ts):
- File at
app/api/mobile/<path>/route.ts - Exports
async function GET(...)(or POST etc.) - First call inside try is
requireAuth() - Errors return
NextResponse.json({ error, message }, { status })per CLAUDE.md
graph_users.id schema verified at planning time (migration 041 line 4):
id VARCHAR(255) PRIMARY KEY -- Azure AD object ID
The column is TEXT/VARCHAR (NOT UUID). It typically holds GUID-like strings
(Azure AD object IDs, e.g. "abc12345-de67-89ab-cdef-1234567890ab"), but the
schema permits any string up to 255 chars (e.g. UPN-style identifiers).
Therefore the route handler's userId regex MUST remain permissive (bounded by
length + denylist of dangerous characters), NOT a strict GUID-only check.
@lib/services/msgraph-client.ts @lib/services/msgraph-factory.ts @lib/auth-utils.ts @app/api/mobile/engagement/summary/route.ts @middleware.ts
Task 1: Add getUserPhotoBytes() to MsGraphClient lib/services/msgraph-client.ts - lib/services/msgraph-client.ts (read in full — understand existing class shape, getToken() and fetchJson() signatures, where to insert the new method) - lib/services/msgraph-factory.ts (confirm how the singleton is constructed — no changes needed here) Add a new public async method `getUserPhotoBytes(userId: string)` to the `MsGraphClient` class in `lib/services/msgraph-client.ts`. Insert it as a sibling of the existing public methods (anywhere after `fetchJson` is fine — group with other `users/`-scoped methods like `getUserMessages` for code locality).The method MUST:
- Call
await this.getToken()to reuse the existing OAuth2 token cache. - Issue a
fetchtohttps://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$valuewith headerAuthorization: Bearer ${token}(noAccept: application/jsonheader — let Graph return image bytes). - On
res.status === 404, returnnull(user has no photo). This is a normal outcome, not an error. - On
res.status === 401or403, throwError(\Graph photo auth failed: ${res.status}`)` so the upstream caller can map to 503. - On any other non-2xx, throw
Error(\Graph photo error ${res.status} for user ${userId}`)`. - On 2xx, read
res.arrayBuffer()and return{ bytes, contentType: res.headers.get('content-type') ?? 'image/jpeg' }. - Do NOT add retry logic for this method (photo fetches are best-effort per D-26;
the analyzer-style 429 retry in
fetchJsonis overkill here).
Exact TypeScript signature to add:
async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null> {
const token = await this.getToken();
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$value`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Graph photo error ${res.status} for user ${userId}: ${text}`);
}
const bytes = await res.arrayBuffer();
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
return { bytes, contentType };
}
Do NOT modify getToken, fetchJson, or any other existing method. Do NOT
change the class's exports beyond adding this one method. Do NOT add new
module-level interfaces — the inline return type is sufficient.
grep -c "async getUserPhotoBytes" /opt/stacks/pulse/lib/services/msgraph-client.ts
<acceptance_criteria>
- File lib/services/msgraph-client.ts exists (was modified, not created).
- grep -c "async getUserPhotoBytes" lib/services/msgraph-client.ts returns exactly 1.
- grep -c "users/\${encodeURIComponent(userId)}/photo/\\\$value" lib/services/msgraph-client.ts returns at least 1.
- grep -c "res.status === 404" lib/services/msgraph-client.ts returns at least 1 (the no-photo branch).
- grep -c "res.arrayBuffer()" lib/services/msgraph-client.ts returns at least 1.
- grep -c "private async getToken" lib/services/msgraph-client.ts still returns 1 (existing method untouched).
- grep -c "async getUsers" lib/services/msgraph-client.ts still returns 1 (existing method untouched).
- npx tsc --noEmit --pretty exits 0.
</acceptance_criteria>
MsGraphClient.getUserPhotoBytes(userId) is callable, returns
{ bytes, contentType } on 200, null on 404, and throws on other non-2xx.
Existing methods unchanged. Type-check passes.
This handler proxies a Microsoft Graph user-photo fetch and is gated by
requireAuth(). Behaviour matches CONTEXT.md D-25 / D-26.
File contents (this is the complete file — do not add a POST handler, do not add a config export, do not add Zod):
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { getMsgraphClient, isMsgraphConfigured } from '@/lib/services/msgraph-factory';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
// 1. Auth gate (middleware whitelists /api/mobile/* — handler MUST gate itself)
const { error: authError } = await requireAuth();
if (authError) return authError;
// 2. MS Graph configuration gate (D-26)
if (!isMsgraphConfigured()) {
return NextResponse.json(
{ error: 'msgraph_not_configured', message: 'Microsoft Graph credentials are not configured' },
{ status: 503 }
);
}
const { userId } = await params;
// 3. Defensive userId shape check — prevents path traversal and malformed
// requests from reaching MS Graph. graph_users.id is VARCHAR(255)
// (verified in migration 041 line 4) — it typically holds Azure AD GUID
// object IDs but the column also permits UPN-style identifiers, so this
// check is permissive: reject anything containing '/', '?', '#', '..',
// or whitespace, or that is empty / longer than 128 chars. Do NOT
// tighten to a strict GUID regex — that would lock out valid UPN-form
// rows the schema explicitly allows.
if (!userId || userId.length > 128 || /[\s/?#]|\.\./.test(userId)) {
return NextResponse.json(
{ error: 'invalid_user_id', message: 'Invalid user id' },
{ status: 400 }
);
}
try {
const client = getMsgraphClient();
const photo = await client.getUserPhotoBytes(userId);
if (!photo) {
// No photo on Graph (whether the user exists or not — neutral 404)
return NextResponse.json(
{ error: 'no_photo', message: 'No photo available' },
{ status: 404 }
);
}
return new NextResponse(photo.bytes, {
status: 200,
headers: {
'Content-Type': photo.contentType,
'Cache-Control': 'private, max-age=3600',
},
});
} catch (error) {
console.error('[ENGAGEMENT-USER-PHOTO] Error:', error);
// Neutral error response — do not leak whether the user exists or whether
// the failure was auth/network/upstream. Always 502 for "couldn't reach
// Graph for any reason other than no-photo".
return NextResponse.json(
{ error: 'photo_fetch_failed', message: 'Failed to fetch photo' },
{ status: 502 }
);
}
}
Notes:
- Cache-Control is
private, max-age=3600per D-25.privateis correct here because the response is per-authenticated-user (the photo is keyed on the Graph user id but the request itself is authenticated, so shared caches must not store it). - The
_requestparameter prefix tells ESLint it is intentionally unused. - Do NOT add CORS headers — same-origin only.
- Do NOT add a logger import; use
console.errorper CLAUDE.md convention. - Do NOT echo the userId in error messages (timing/info-disclosure neutrality).
test -f /opt/stacks/pulse/app/api/mobile/engagement/user/[userId]/photo/route.ts && grep -c "requireAuth" /opt/stacks/pulse/app/api/mobile/engagement/user/[userId]/photo/route.ts
<acceptance_criteria>
- File
app/api/mobile/engagement/user/[userId]/photo/route.tsexists. grep -c "export async function GET" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns 1.grep -c "requireAuth" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 2 (import + call).grep -c "isMsgraphConfigured" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 2 (import + call).grep -c "getMsgraphClient" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 2 (import + call).grep -c "getUserPhotoBytes" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 1.grep -c "Cache-Control" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 1, AND that line contains the literal stringprivate, max-age=3600.grep -c "status: 503" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 1 (the unconfigured branch).grep -c "status: 404" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 1 (the no-photo branch).grep -c "status: 400" app/api/mobile/engagement/user/[userId]/photo/route.tsreturns at least 1 (the invalid-id branch).npx tsc --noEmit --prettyexits 0.npm run buildexits 0. </acceptance_criteria> Hitting GET /api/mobile/engagement/user/{validId}/photo as an authenticated user returns either binary image bytes (200) or 404. As an unauthenticated user it returns 401. With MSGRAPH_* env unset it returns 503. With a malformed userId it returns 400. Type-check and build both pass.
- File
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| browser → /api/mobile/engagement/user/[userId]/photo | Authenticated user requests an arbitrary Graph user id (path param). Untrusted input crosses here. |
| /api/mobile/.../photo → Microsoft Graph | Server-side outbound to https://graph.microsoft.com using the MSGRAPH_* client_credentials token. Outbound trust boundary. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-08-01 | Information Disclosure (IDOR) | photo route handler | low | accept | Any authenticated Pulse user can request any tenant user's photo. This matches the existing /api/engagement/user/[userId] endpoint behavior (which already exposes name, job title, hours, recent meetings to any authed user) — Engagement is an internal admin-overview surface, not a per-user-isolation surface. Consistent with sibling endpoints. Documented as accepted residual risk; revisit if Pulse adds an external-user role. |
| T-08-02 | Denial of Service (rate amplification) | photo route handler | medium | mitigate | Endpoint sets Cache-Control: private, max-age=3600, so each photo is fetched at most once per hour per browser. The handler also early-rejects malformed userIds (400) before spending a Graph token call, preventing trivial path-fuzzing amplification. Mitigation implemented in app/api/mobile/engagement/user/[userId]/photo/route.ts. |
| T-08-03 | Tampering (path traversal via userId) | photo route handler | medium | mitigate | The userId param is interpolated into a Graph URL via encodeURIComponent. Additionally, the handler rejects any userId containing /, ?, #, .., whitespace, or longer than 128 chars before the Graph call. Implemented in route handler. |
| T-08-04 | Information Disclosure (oracle on bad userId) | photo route handler / data endpoint | low | mitigate | Both 404 (Graph returns no photo) and 502 (Graph error) responses use neutral copy that does not echo the userId or distinguish "user does not exist in Graph" from "user exists but has no photo". The Graph endpoint returns 404 in both cases at the upstream level. Verified by reading the action: error responses contain only { error: 'no_photo' | 'photo_fetch_failed', message: '...' }. |
| T-08-05 | Information Disclosure (token leak via logs) | MsGraphClient.getUserPhotoBytes | low | mitigate | The new method uses the existing this.getToken() and never logs the token. The handler console.errors the caught Error object, which contains the upstream status text but NOT the bearer token. CLAUDE.md "no echoing secrets" rule respected. |
| T-08-06 | Spoofing (request from unauthenticated user) | photo route handler | high | mitigate | requireAuth() is the FIRST call inside GET, before getMsgraphClient() is even invoked. Verified by acceptance criterion: grep -c "requireAuth" returns ≥2. Middleware whitelists /api/mobile/*, so this in-handler gate is mandatory. |
| T-08-07 | Repudiation | photo route handler | low | accept | No mutation occurs; read-only photo proxy. No audit log needed. |
| T-08-08 | Elevation of Privilege | photo route handler | low | accept | Any authenticated user (user/admin/super-admin) may call this endpoint. No role gate required because the underlying /api/engagement/user/[userId] data endpoint has the same permission level. Consistent posture. |
Block-on-high check: T-08-06 is the only high severity threat and it is mitigated
by requireAuth() at the top of the handler. No unmitigated highs remain.
</threat_model>
Wave-1 complete when:
lib/services/msgraph-client.tscontainsasync getUserPhotoBytes(userId: string)(grep)app/api/mobile/engagement/user/[userId]/photo/route.tsexists with all required imports and the GET handlerrequireAuthis the first call inside GET (positional grep + visual verification)Cache-Control: private, max-age=3600is set on 200 responses (grep)- 503 returned when
isMsgraphConfigured()is false (grep) - 404 returned when
getUserPhotoBytesreturns null (grep) - 400 returned for malformed userId (grep)
- No new public exports in
msgraph-client.tsbeyondgetUserPhotoBytes(theMsGraphClientclass is the only export already) npx tsc --noEmit --prettyexits 0npm run buildexits 0
<success_criteria> After this plan:
- The Phase 8 profile page (Plan 02) can reference
<img src="/api/mobile/engagement/user/{userId}/photo" />without further coordination - Authenticated browsers receive cached photo bytes on success, neutral 404/503 on
absence/missing-config, and
<img onError>falls back to initials in Plan 02 - The MS Graph token cache is reused (
getToken()) — no per-request token churn </success_criteria>