Skip to content

Run 5: BFF Routes — Project Plan

Author: Technical Writer / Team Lead Date: 2026-04-07 Status: Planning Phase: 3.5 — BFF Routes Branch: jmpicnic/image-upload-frontend

Implement the server-side BFF route modules, security utilities, and the src/app/api/ thin re-exports in arda-frontend-app. This run produces the src/server/ directory consumed by Run 6 (SPA integration). All code targets the Next.js server runtime only — the server-only package enforces the client/server boundary at build time.

CriterionVerification
Run 1 exit gate passed: @arda-cards/api-proxy published with createImageUploadUrl() on ItemProxynpm view @arda-cards/api-proxy versions --registry=https://npm.pkg.github.com shows a published version
Phase 3.0 legacy cleanup committed to arda-frontend-app branchgit -C /Users/jmp/code/arda/projects/image-upload-frontend-worktrees/arda-frontend-app log --oneline -5 — cleanup commit present
Branch jmpicnic/image-upload-frontend is clean and up to dategit -C /Users/jmp/code/arda/projects/image-upload-frontend-worktrees/arda-frontend-app status — clean
#TaskPersonaDepends OnStatusAcceptance Criteria
T-1Directory scaffolding and server-only dependencyfront-end-engineerEntry criteriaPendingnpm install server-only succeeds; src/server/ directory created with index.ts, routes/image-upload.ts, routes/storage/check-url.ts, routes/storage/fetch-url.ts, routes/storage/cdn-cookies.ts, lib/ssrf-validator.ts, lib/rate-limiter.ts, lib/cloudfront-signer.ts, __tests__/; src/server/index.ts contains import 'server-only'
T-1bAWS naming utility (src/server/lib/aws-naming.ts) with NEXT_PUBLIC_ env varsfront-end-engineerT-1PendingExports AwsNaming interface and resolveAwsNaming(); reads NEXT_PUBLIC_INFRASTRUCTURE and NEXT_PUBLIC_PARTITION with non-prefixed fallback; cookieTtlMs defaults to 30 min; cookieRefreshIntervalMs = 50% of TTL; unit tests pass for all 5 cases in specification
T-1cGeneric secrets utility (src/server/lib/secrets.ts) with 8-hour cache TTLfront-end-engineerT-1PendingExports getSecret(secretId, cacheTtlMs?) and clearSecretCache(); default TTL is 8 hours; in-memory cache with eviction; unit tests pass for all 8 cases in specification
T-2SSRF validator (src/server/lib/ssrf-validator.ts) with DNS resolution checkfront-end-engineerT-1bPendingExports async validateUrl(url): Promise<{valid, error?}>; validates HTTPS scheme; checks CDN host pattern; performs DNS resolution via dns.resolve() (not OS resolver); rejects private IP ranges; unit tests pass for all 13 cases in specification
T-3Rate limiter (src/server/lib/rate-limiter.ts) with machine-readable error codes (FD-12)front-end-engineerT-1PendingRateLimiter class with check(tenantId) returning {allowed, retryAfterMs?}; sliding window per tenant; default new RateLimiter(30, 60_000); 429 response body uses { "code": "RATE_LIMITED", "retryAfterMs": <value> }; unit tests pass for all 5 cases
T-4CloudFront signer (src/server/lib/cloudfront-signer.ts) using Secrets Manager (FD-10)front-end-engineerT-1b, T-1cPendingExports sign(tenantId, ttlMs?) returning CloudFrontCookies; fetches private key from Secrets Manager using getSecret(naming.signingKeySecretName); CLOUDFRONT_SIGNING_KEY_SECRET_ARN env var not required (name derived from naming convention); default TTL 30 min; unit tests pass for all 12 cases
T-5Image upload route (src/server/routes/image-upload.ts) using RequestContext (FD-13, REQ-FE-028)front-end-engineerT-4PendingHandler verifies JWT, constructs RequestContext with author, tenantId, userId; calls proxy.createImageUploadUrl(request, { context }); returns backend response unmodified; thin re-export at src/app/api/image-upload/route.ts; unit tests pass for all 4 cases
T-6Check-URL route (src/server/routes/storage/check-url.ts) with Content-Type validation (REQ-FE-024, REQ-FE-027)front-end-engineerT-2, T-3PendingHEAD then GET-with-Range fallback; response body discarded after header inspection; validates Content-Type: image/*; 422 on non-image content type; thin re-export at src/app/api/storage/check-url/route.ts; unit tests pass for all 9 cases
T-7Fetch-URL route (src/server/routes/storage/fetch-url.ts) with Content-Type validation (REQ-FE-024)front-end-engineerT-2, T-3PendingGET with 10s timeout and 10 MB max; validates Content-Type: image/*; 413 on body exceeding 10 MB; streams response; thin re-export at src/app/api/storage/fetch-url/route.ts; unit tests pass for all 7 cases
T-8CDN cookies route (src/server/routes/storage/cdn-cookies.ts) with Max-Age matching policy TTL (REQ-FE-026)front-end-engineerT-4PendingtenantId extracted from session only (never from request body); three Set-Cookie headers with Domain=.arda.cards; Path=/; Max-Age=1800; Secure; HttpOnly; SameSite=Lax; returns 204; thin re-export at src/app/api/storage/cdn-cookies/route.ts; unit tests pass for all 10 cases
T-9Documentation commitfront-end-engineerT-1 through T-8Pendingmake pr-checks passes in documentation worktree; changes committed referencing Phase 3.5
DecisionSummary
FD-02 (Hybrid code organization)All server logic in src/server/; src/app/api/ contains only thin re-exports
FD-10 (Secrets Manager for CloudFront key)Private key fetched at runtime via SecretsManagerClient; cached in memory; no key in env vars
FD-12 (Machine-readable error codes)Rate limit 429 body uses { "code": "RATE_LIMITED", "retryAfterMs": <n> }; SPA formats user message
FD-13 (Type re-exports from api-proxy)RequestContext imported from @arda-cards/api-proxy; used in image-upload route handler
REQ-FE-028 (RequestContext)Route handler constructs RequestContext from JWT claims; passed to proxy
CriterionVerification
All routes and utilities implemented with passing unit testsnpx jest --no-coverage --watchAll=false --forceExit — all tests green
Lint passesnpm run lint — no errors
TypeScript typecheck passesnpx tsc --noEmit — no errors
Full Next.js build passes (catches server-only violations)npm run build — succeeds with no errors
Documentation committed with make pr-checks passinggit -C /Users/jmp/code/arda/projects/image-upload-frontend-worktrees/documentation log --oneline -3
ArtifactSourceProduced By
@arda-cards/api-proxy published with createImageUploadUrl() on ItemProxyGitHub PackagesRun 1
Phase 3.0 legacy cleanup committed to arda-frontend-apparda-frontend-app worktreeRun 1 preparation / Phase 3.0
ArtifactLocationConsumed By
src/server/ directory with 4 BFF routes (image-upload, check-url, fetch-url, cdn-cookies) and 3 utilities (aws-naming, secrets, ssrf-validator, rate-limiter, cloudfront-signer)arda-frontend-app worktreeRun 6 (SPA hooks call these routes via fetch())
src/app/api/ thin re-exports (4 route files)arda-frontend-app worktreeNext.js router (maps URL paths to handlers)
Documentation commit referencing Phase 3.5documentation worktreeRun 7 (documentation PR)

Use the following prompt to spawn the front-end-engineer agent for this run. Paste the full text as the Task tool input.

You are a front-end-engineer working in the arda-frontend-app worktree for the
image-upload-frontend project.
**Primary worktree**: /Users/jmp/code/arda/projects/image-upload-frontend-worktrees/arda-frontend-app
**Branch**: jmpicnic/image-upload-frontend
**Documentation worktree**: /Users/jmp/code/arda/projects/image-upload-frontend-worktrees/documentation
Load the following skills before starting:
- typescript-coding
- unit-tests-frontend
- path-conventions
- document-writing
Read the specification before beginning:
/Users/jmp/code/arda/projects/image-upload-frontend-worktrees/documentation/src/content/docs/roadmap/completed/item-image-upload/3-frontend-implementation/3.5-bff-routes/specification.md
Your task is to execute Run 5 of the image-upload-frontend project (Phase 3.5 —
BFF Routes). Complete the following tasks in order:
T-1: Install `server-only` via `npm install server-only`. Create `src/server/`
directory layout per the specification (§T-1). Create `src/server/index.ts`
with `import 'server-only'`.
T-1b: Create `src/server/lib/aws-naming.ts` with `AwsNaming` interface and
`resolveAwsNaming()` function. Reads `NEXT_PUBLIC_INFRASTRUCTURE` and
`NEXT_PUBLIC_PARTITION` (with non-prefixed fallback). Write unit tests in
`src/server/__tests__/aws-naming.test.ts` covering all 5 cases.
T-1c: Create `src/server/lib/secrets.ts` with `getSecret()` and
`clearSecretCache()`. Default 8-hour cache TTL. Write unit tests in
`src/server/__tests__/secrets.test.ts` covering all 8 cases.
T-2: Create `src/server/lib/ssrf-validator.ts` with async `validateUrl()`.
Use `dns.resolve4` and `dns.resolve6` (not OS resolver). Reject private
IP ranges, HTTP scheme, CDN host pattern, and DNS failures. Write unit
tests covering all 13 cases.
T-3: Create `src/server/lib/rate-limiter.ts` with `RateLimiter` class.
Sliding window per tenant. 429 body: `{ "code": "RATE_LIMITED",
"retryAfterMs": <value> }` with `Retry-After` header in seconds.
Write unit tests covering all 5 cases.
T-4: Create `src/server/lib/cloudfront-signer.ts`. Load private key from
Secrets Manager using `getSecret(naming.signingKeySecretName)`. Build
CloudFront custom policy JSON, base64-encode, sign with RSA-SHA1.
Default TTL 30 min. Write unit tests covering all 12 cases.
T-5: Create `src/server/routes/image-upload.ts`. Verify JWT, construct
RequestContext (author, tenantId, userId from JWT claims), call
`proxy.createImageUploadUrl(request, { context })`. Create thin
re-export at `src/app/api/image-upload/route.ts`. Write unit tests
covering all 4 cases.
T-6: Create `src/server/routes/storage/check-url.ts`. HEAD then GET-with-Range
fallback; discard body after header inspection; validate Content-Type
matches `image/*`. Create thin re-export. Write unit tests covering all
9 cases.
T-7: Create `src/server/routes/storage/fetch-url.ts`. GET with 10s timeout
and 10 MB max; validate Content-Type matches `image/*`; stream response.
Create thin re-export. Write unit tests covering all 7 cases.
T-8: Create `src/server/routes/storage/cdn-cookies.ts`. Extract tenantId
from session only. Set three CloudFront cookies with
`Domain=.arda.cards; Path=/; Max-Age=1800; Secure; HttpOnly; SameSite=Lax`.
Return 204. Create thin re-export. Write unit tests covering all 10 cases.
T-9: Run the full check suite:
npm run lint
npx tsc --noEmit
npx jest --no-coverage --watchAll=false --forceExit
npm run build
All must pass. Then in the documentation worktree run `make pr-checks`
and commit changes referencing Phase 3.5.
All tool calls must use absolute paths. Never use `cd` — use absolute paths
or `git -C <path>` for git commands.
Report completion with: (a) a summary of all files created, (b) test pass
counts, and (c) confirmation that `npm run build` and `make pr-checks` passed.

Copyright: (c) Arda Systems 2025-2026, All rights reserved