Specification: Phase 3.5 — BFF Routes
Implementation specification for Phase 3.5: BFF Routes. This document
defines what to build — server-side route modules, security utilities, and
the src/app/api/ thin re-exports — following the patterns described in the
BFF specification.
Follow the typescript-coding skill for TypeScript conventions and the
unit-tests-frontend skill for Jest test patterns. For the CloudFront
signer, the cdk-infrastructure skill provides context on the signing key
construct.
For project scope and success criteria, see goal.md. For the overall phase structure, see the implementation phasing.
Entry Criteria
Section titled “Entry Criteria”- Phase 3.1 complete:
@arda-cards/api-proxypublished withcreateImageUploadUrl()onItemProxy. - Phase 3.4 complete:
@arda-cards/design-systempublished (not strictly required for the BFF, but keeps the branch in a clean state before introducing new Next.js server code). arda-frontend-appbranch has Phase 3.0 legacy cleanup committed.
T-1: Directory Scaffolding and server-only Dependency
Section titled “T-1: Directory Scaffolding and server-only Dependency”Create the FD-02 server-only directory structure and install the
server-only package. This boundary enforces the constraint that no BFF
module is accidentally imported by client-side code.
Step 1 — Install the server-only package:
npm install server-onlyThis package is NOT currently in arda-frontend-app’s dependencies. It must
be added before creating any src/server/ files that import it.
Step 2 — Create directory layout (src/server/):
src/server/├── index.ts # import 'server-only'├── routes/│ ├── image-upload.ts│ └── storage/│ ├── check-url.ts│ ├── fetch-url.ts│ └── cdn-cookies.ts├── lib/│ ├── ssrf-validator.ts│ ├── rate-limiter.ts│ └── cloudfront-signer.ts└── __tests__/Step 3 — Create src/server/index.ts:
import 'server-only';All route and utility modules under src/server/ must import this barrel
(or import 'server-only' directly). This ensures that if any client-side
code accidentally imports from src/server/, the error is caught.
Enforcement mechanism: The server-only package causes a build-time
error during next build (webpack compilation) — not during tsc or
ESLint. If a client-side bundle transitively imports a module with
import 'server-only', the build fails with:
Error: This module cannot be imported from a Client Component module.It should only be used from a Server Component.This means npm run build is the check that catches violations. npx tsc --noEmit alone will not catch them. This is why the exit criteria require
a full npm run build pass, not just typecheck.
T-1b: AWS Naming Convention Utility (src/server/lib/aws-naming.ts)
Section titled “T-1b: AWS Naming Convention Utility (src/server/lib/aws-naming.ts)”Central place to resolve all AWS resource naming conventions from
NEXT_PUBLIC_INFRASTRUCTURE and NEXT_PUBLIC_PARTITION environment variables.
These env vars are set by
infrastructure#438.
The NEXT_PUBLIC_ prefix makes these values available to both server-side BFF
code and client-side SPA code. The non-prefixed fallback supports existing
Amplify deployments until the env var names are migrated. Infrastructure names
are non-sensitive (they identify the deployment, not secrets).
This utility is scoped to names needed by the image upload feature. Future secrets migration (#737) will extend it with additional secret name patterns.
Exported interface:
/** AWS resource naming derived from infrastructure + partition. */export interface AwsNaming { /** The infrastructure name (e.g., "Alpha002"). */ readonly infrastructure: string; /** The partition name (e.g., "dev"). */ readonly partition: string; /** Fully qualified name: "${Infrastructure}-${Partition}" (e.g., "Alpha002-dev"). */ readonly fqn: string; /** Subdomain: "${partition}.${infrastructure}" lowercase (e.g., "dev.alpha002"). */ readonly subdomain: string;
/** CDN host pattern for SSRF validation: "${partition}.${infrastructure}.assets.arda.cards". */ readonly cdnHost: string; /** CloudFront signing key secret name in Secrets Manager. */ readonly signingKeySecretName: string;
/** Cookie TTL in milliseconds. Default 30 minutes. */ readonly cookieTtlMs: number; /** Cookie refresh interval — 50% of TTL. For SPA refetchInterval. */ readonly cookieRefreshIntervalMs: number;}
/** * Resolve naming conventions from environment variables. * Reads NEXT_PUBLIC_INFRASTRUCTURE and NEXT_PUBLIC_PARTITION from process.env * (with fallback to non-prefixed names for backward compatibility). * Throws if either is missing (except in mock mode). */export function resolveAwsNaming(): AwsNaming;Implementation:
export function resolveAwsNaming(): AwsNaming { const infrastructure = process.env.NEXT_PUBLIC_INFRASTRUCTURE ?? process.env.INFRASTRUCTURE; const partition = process.env.NEXT_PUBLIC_PARTITION ?? process.env.PARTITION;
if (!infrastructure || !partition) { if (process.env.NEXT_PUBLIC_MOCK_MODE === 'true') { return { infrastructure: 'MockInfra', partition: 'mock', fqn: 'MockInfra-mock', subdomain: 'mock.mockinfra', cdnHost: 'mock.mockinfra.assets.arda.cards', signingKeySecretName: 'MockInfra-mock-ImageCdnSigningKey', cookieTtlMs: 30 * 60 * 1000, cookieRefreshIntervalMs: 15 * 60 * 1000, }; } throw new Error('INFRASTRUCTURE and PARTITION env vars must be set'); }
const fqn = `${infrastructure}-${partition}`; const subdomain = `${partition.toLowerCase()}.${infrastructure.toLowerCase()}`; const cookieTtlMs = Number(process.env.NEXT_PUBLIC_CDN_COOKIE_TTL_MS) || 30 * 60 * 1000; return { infrastructure, partition, fqn, subdomain, cdnHost: `${subdomain}.assets.arda.cards`, signingKeySecretName: `${fqn}-ImageCdnSigningKey`, cookieTtlMs, cookieRefreshIntervalMs: Math.floor(cookieTtlMs / 2), };}The cookie TTL and refresh interval are co-located in AwsNaming so that both
the server-side CloudFront signer (T-4) and the client-side useCdnCookies
hook (Phase 3.6 T-4) derive their values from the same source. The signer uses
cookieTtlMs for the CloudFront policy DateLessThan. The SPA uses
cookieRefreshIntervalMs for the TanStack Query refetchInterval.
Unit tests (src/server/__tests__/aws-naming.test.ts):
| Case | Input | Expected |
|---|---|---|
Standard naming (NEXT_PUBLIC_ vars) | NEXT_PUBLIC_INFRASTRUCTURE=Alpha002, NEXT_PUBLIC_PARTITION=dev | fqn: "Alpha002-dev", cdnHost: "dev.alpha002.assets.arda.cards", signingKeySecretName: "Alpha002-dev-ImageCdnSigningKey" |
| Fallback to non-prefixed vars | INFRASTRUCTURE=Alpha002, PARTITION=dev (no NEXT_PUBLIC_ set) | Same expected output as above |
| Different partition | NEXT_PUBLIC_INFRASTRUCTURE=Alpha001, NEXT_PUBLIC_PARTITION=prod | fqn: "Alpha001-prod", subdomain: "prod.alpha001" |
| Mock mode fallback | NEXT_PUBLIC_MOCK_MODE=true, no INFRASTRUCTURE/PARTITION | Returns mock values without throwing |
| Missing env vars (non-mock) | No INFRASTRUCTURE, no mock mode | Throws error |
T-1c: Generic Secrets Utility (src/server/lib/secrets.ts)
Section titled “T-1c: Generic Secrets Utility (src/server/lib/secrets.ts)”Reusable utility for fetching secrets from AWS Secrets Manager at runtime. Uses the Amplify SSR Compute Role credentials (automatic via AWS SDK). Future migration of other secrets (#737) will reuse this utility.
Dependencies: Add @aws-sdk/client-secrets-manager to package.json.
Exported interface:
/** * Fetch a secret value from AWS Secrets Manager. * Results are cached in memory; entries are evicted after `cacheTtlMs` * (default 8 hours). Accepts either a full secret ARN or a secret name. */export async function getSecret(secretId: string, cacheTtlMs?: number): Promise<string>;
/** * Clear the in-memory cache. Useful for testing. */export function clearSecretCache(): void;Default cacheTtlMs is 8 * 60 * 60 * 1000 (8 hours) if not provided.
Implementation:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});const cache = new Map<string, { value: string; fetchedAt: number }>();
const DEFAULT_CACHE_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours
export async function getSecret( secretId: string, cacheTtlMs: number = DEFAULT_CACHE_TTL_MS,): Promise<string> { const entry = cache.get(secretId); if (entry && Date.now() - entry.fetchedAt <= cacheTtlMs) { return entry.value; } // Evict stale entry (if any) and re-fetch cache.delete(secretId);
const response = await client.send( new GetSecretValueCommand({ SecretId: secretId }), ); if (!response.SecretString) { throw new Error(`Secret ${secretId} is empty or binary`); } cache.set(secretId, { value: response.SecretString, fetchedAt: Date.now() }); return response.SecretString;}
export function clearSecretCache(): void { cache.clear();}Unit tests (src/server/__tests__/secrets.test.ts):
Mock SecretsManagerClient via jest.mock('@aws-sdk/client-secrets-manager').
| Case | Expected |
|---|---|
| First call fetches from Secrets Manager | send called once, returns secret value |
| Second call uses cache | send NOT called again, same value returned |
| Different secretId fetches separately | send called for each distinct ID |
| Empty SecretString throws | Error: “Secret X is empty or binary” |
| SDK error propagates | SecretsManager error thrown to caller |
clearSecretCache resets | Next call after clear fetches from SDK again |
| Cache entry expired after TTL → re-fetches from SDK | After cacheTtlMs elapses, send is called again |
| Cache entry within TTL → returns cached value | send NOT called while cache entry is fresh |
T-2: SSRF Validator (src/server/lib/ssrf-validator.ts)
Section titled “T-2: SSRF Validator (src/server/lib/ssrf-validator.ts)”Validates target URLs before the BFF makes any outbound request. Required by BFF-FR-007 and BFF-FR-004.
Exported interface:
export function validateUrl(url: string): { valid: boolean; error?: string }Validation rules (applied in order):
- Parse the URL; reject any parse error.
- Accept only the
https:scheme. - Reject URLs whose hostname matches the CDN host pattern (managed storage
— the SPA should load these directly, not via the BFF). The CDN host
is derived from
resolveAwsNaming().cdnHost(T-1b). This is a string check, no DNS needed. - DNS resolution check: Resolve the hostname using
dns.resolve()(queries DNS directly, bypasses OS resolver and/etc/hosts). Since this runs on a server we control, the OS-level hosts file is not a relevant attack vector —dns.resolve()avoids it entirely, querying upstream DNS servers directly.- Resolve both A (IPv4) and AAAA (IPv6) records.
- Reject if any resolved address falls within a private IP range:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16, or::1. - Reject if DNS resolution fails (NXDOMAIN, SERVFAIL).
TOCTOU note: There is a time-of-check-to-time-of-use gap — DNS could
resolve to a safe IP at check time, then resolve to a private IP when
fetch() actually connects (DNS rebinding). The DNS check is a first line
of defense. To fully close the gap, the resolved IP would need to be pinned
and passed to fetch() directly — this is not implemented in this phase
but documented as a known limitation.
Exported interface (updated — async due to DNS resolution):
export async function validateUrl(url: string): Promise<{ valid: boolean; error?: string }>Unit tests (src/server/__tests__/ssrf-validator.test.ts):
Mock dns.resolve4 and dns.resolve6 for deterministic testing.
| Case | Input | DNS Mock | Expected |
|---|---|---|---|
| Valid HTTPS URL | https://example.com/image.jpg | ['93.184.216.34'] | { valid: true } |
| HTTP scheme | http://example.com/image.jpg | — (rejected before DNS) | { valid: false } |
DNS resolves to 10.x | https://evil.com/ | ['10.0.0.1'] | { valid: false } |
DNS resolves to 172.16.x | https://evil.com/ | ['172.16.0.1'] | { valid: false } |
DNS resolves to 192.168.x | https://evil.com/ | ['192.168.1.1'] | { valid: false } |
| DNS resolves to loopback | https://evil.com/ | ['127.0.0.1'] | { valid: false } |
| DNS resolves to link-local | https://evil.com/ | ['169.254.169.254'] | { valid: false } |
| DNS resolves to IPv6 loopback | https://evil.com/ | AAAA ['::1'] | { valid: false } |
| Multiple IPs, one private | https://evil.com/ | ['93.184.216.34', '10.0.0.1'] | { valid: false } |
| DNS resolution fails | https://nonexistent.example/ | NXDOMAIN error | { valid: false } |
| Localhost name (no DNS needed) | https://localhost/ | — | { valid: false } |
| IP literal in URL | https://10.0.0.1/ | — (no DNS, parsed directly) | { valid: false } |
| CDN host pattern | https://demo.alpha001.assets.arda.cards/... | — | { valid: false } |
T-3: Rate Limiter (src/server/lib/rate-limiter.ts)
Section titled “T-3: Rate Limiter (src/server/lib/rate-limiter.ts)”In-memory sliding-window per-tenant rate limiter. Required by BFF-FR-006.
The BFF runs as a persistent Next.js server on AWS Amplify Hosting (not a Lambda function), so in-process memory persists for the lifetime of the server instance. Each instance enforces limits independently; at the current single-instance scale, enforcement is exact.
Class interface:
export class RateLimiter { constructor(maxRequests: number, windowMs: number); check(tenantId: string): { allowed: boolean; retryAfterMs?: number };}Internal state: Map<tenantId, { count: number; windowStart: number }>.
On each call, if Date.now() - windowStart > windowMs, reset count and
windowStart. Increment count; return allowed: false with
retryAfterMs when count > maxRequests.
Default instantiation (used by check-url and fetch-url routes):
new RateLimiter(30, 60_000) — 30 requests per tenant per 60-second window.
429 response format: When rate limit is exceeded, the route handler must return:
- Status: 429 Too Many Requests
Retry-Afterheader: seconds until the rate limit window resets (value fromretryAfterMs / 1000, rounded up)- JSON body:
{ "code": "RATE_LIMITED", "retryAfterMs": <value from RateLimiter> }
The response uses a machine-readable code so the SPA is responsible for
formatting user-facing error messages from the code (e.g., displaying a
localized message in ImageDropZone).
Unit tests (src/server/__tests__/rate-limiter.test.ts):
| Case | Expected |
|---|---|
| First N requests within limit | All return { allowed: true } |
| N+1 request in same window | Returns { allowed: false, retryAfterMs: <positive> } |
| First request after window expires | Returns { allowed: true } (window slides) |
| Two distinct tenants — one at limit | Second tenant is unaffected |
retryAfterMs is positive and ≤ window size | Verify value is reasonable |
T-4: CloudFront Signer (src/server/lib/cloudfront-signer.ts)
Section titled “T-4: CloudFront Signer (src/server/lib/cloudfront-signer.ts)”Cookie signing utility for CDN access control. Required by BFF-FR-008 and BFF-FR-010.
Dependencies: @aws-sdk/client-secrets-manager already added in T-1c.
Infrastructure prerequisite:
infrastructure#438
must be deployed so the Amplify SSR Compute Role has
secretsmanager:GetSecretValue permission and the INFRASTRUCTURE,
PARTITION, and CLOUDFRONT_KEY_PAIR_ID environment variables are set.
Exported interface:
export interface CloudFrontCookies { policy: string; // CloudFront-Policy value signature: string; // CloudFront-Signature value keyPairId: string; // CloudFront-Key-Pair-Id value}
/** Sign CDN cookies for a tenant. Async because the first call fetches the private key from Secrets Manager (cached thereafter). */export async function sign(tenantId: string, ttlMs?: number): Promise<CloudFrontCookies>;Implementation:
-
Load the CloudFront private key from AWS Secrets Manager at runtime using the generic secrets utility (T-1c) and naming convention utility (T-1b):
import { getSecret } from './secrets';import { resolveAwsNaming } from './aws-naming';const naming = resolveAwsNaming();const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID;if (!keyPairId) throw new Error('CLOUDFRONT_KEY_PAIR_ID not set');async function getPrivateKey(): Promise<string> {// Secret name derived from naming convention — no ARN env var neededreturn getSecret(naming.signingKeySecretName);// getSecret() caches internally — subsequent calls are instant}The secret name is derived as
${Infrastructure}-${Partition}-ImageCdnSigningKeyvia the naming utility. TheCLOUDFRONT_SIGNING_KEY_SECRET_ARNenv var is no longer needed — the secret name is sufficient forSecretsManagerClient.GetSecretValue. TheCLOUDFRONT_KEY_PAIR_IDenv var is still needed because the key pair ID is a CloudFront-generated value with no derivable naming convention. -
Build a CloudFront custom policy JSON:
{"Statement": [{"Resource": "https://assets.*/<tenantId>/*","Condition": {"DateLessThan": { "AWS:EpochTime": <now + ttl in seconds> }}}]} -
Base64-encode the policy (CloudFront format: replace
+with-,/with~,=with_). -
Sign the encoded policy with RSA-SHA1 using the private key (Node.js
crypto.sign('sha1', ...)or equivalent). -
Return the three cookie values (including
keyPairIdfrom the env var).
Default TTL: 30 minutes (1_800_000 ms). Configurable via the ttlMs
parameter.
Unit tests (src/server/__tests__/cloudfront-signer.test.ts):
Mock SecretsManagerClient to return a test RSA 2048-bit key pair generated
at test setup. Set CLOUDFRONT_KEY_PAIR_ID and INFRASTRUCTURE/PARTITION
env vars in test setup. Mock getSecret() to return a test RSA 2048-bit key pair.
| Case | Assertion |
|---|---|
Policy Resource contains tenantId path | Resource equals https://assets.*/<testTenantId>/* |
DateLessThan within expected range | Epoch time is now + ttl (±5 s tolerance) |
| Different tenant IDs produce different policies | policy values differ |
| Default TTL is 30 minutes | DateLessThan is approximately now + 1800 |
| Custom TTL is respected | DateLessThan matches supplied ttlMs |
| Signature is valid for the test key pair | crypto.verify succeeds |
keyPairId matches env var | Returned value equals CLOUDFRONT_KEY_PAIR_ID |
Throws when CLOUDFRONT_KEY_PAIR_ID is unset | Error thrown |
| Throws when naming cannot resolve (no INFRASTRUCTURE/PARTITION) | Error thrown (non-mock mode) |
Private key fetched via getSecret() with derived secret name | getSecret called with ${fqn}-ImageCdnSigningKey |
Private key is cached by getSecret() | getSecret internal cache hit on second call |
| Secrets Manager failure propagates | SDK error is thrown (not swallowed) |
T-5: Image Upload Route (src/server/routes/image-upload.ts)
Section titled “T-5: Image Upload Route (src/server/routes/image-upload.ts)”BFF proxy for presigned POST credential generation. Implements
BFF-FR-001 for the
POST /api/image-upload path.
Imports:
import { ItemProxy } from '@arda-cards/api-proxy/reference/item';import { processJWTForArda } from '@/lib/jwt'; // TODO #734import { env } from '@/lib/env'; // TODO #734Handler logic:
- Call
processJWTForArda(request)to verify the session JWT and extractUserContext. Return401if the session is invalid. - Construct
ItemProxywith{ host: env.BASE_URL, apiKey: env.ARDA_API_KEY }. - Build
RequestContextfrom the authenticatedUserContext:const context: RequestContext = {author: userContext.email,tenantId: userContext.tenantId,userId: userContext.sub, // OIDC subject from JWT claims}; - Forward the request body to
proxy.createImageUploadUrl(request, { context }). TheHttpClient(Phase 3.1) automatically injectsAuthorization: Bearer,X-Author,X-Tenant-Id,X-oidc-subject, andX-Request-IDheaders from theRequestContextandProxyConfig. - Return the Backend response (
url,formFields,objectKey,cdnUrl) unmodified.
src/app/api/image-upload/route.ts (thin re-export):
export { POST } from '@/server/routes/image-upload';The src/app/api/ file contains no logic — it only re-exports the named HTTP
method handler from src/server/routes/. This pattern keeps all testable code
in src/server/ and away from Next.js routing boilerplate.
Unit tests (src/server/__tests__/image-upload.test.ts):
| Case | Expected |
|---|---|
| Valid session — proxy called with correct auth headers | Backend receives Authorization, X-Tenant-Id, X-Author, X-Request-ID, X-oidc-subject |
| Valid session — Backend response passed through | Response body unchanged |
| Missing or expired session JWT | Returns 401 |
| Backend returns non-2xx | Response status forwarded |
T-6: Check-URL Route (src/server/routes/storage/check-url.ts)
Section titled “T-6: Check-URL Route (src/server/routes/storage/check-url.ts)”URL reachability check with CORS fallback. Implements
BFF-FR-003,
BFF-FR-004,
BFF-FR-006, and
BFF-FR-007 for the
POST /api/storage/check-url path.
Handler logic:
- Verify session JWT. Return
401if invalid. - Parse
{ url }from the request body. - Call
validateUrl(url). Return400with the validation error if invalid. - Call
rateLimiter.check(tenantId). If exceeded, return429withRetry-Afterheader and user-facing error message (see T-3 429 response format). - Perform a HEAD request to
urlwith a 10-second timeout using nativefetchandAbortController. - If the HEAD request fails (network error, timeout) or returns no
Content-Type, attempt a GET with aRange: bytes=0-4095header. The GET response body is NOT read or returned — only the response headers (Content-Type,Content-Length) are inspected. The response body is immediately discarded viaAbortController.abort()after header inspection. This prevents the BFF from downloading or buffering potentially malicious content. - Validate that the response
Content-Typeheader matchesimage/*. If not, abort and return422with error message ‘URL does not point to an image’. - Return
{ reachable: true, contentType, contentLength }on success. - Return
422if the URL is unreachable after both attempts.
Thin re-export (src/app/api/storage/check-url/route.ts):
export { POST } from '@/server/routes/storage/check-url';Unit tests (src/server/__tests__/check-url.test.ts):
| Case | Expected |
|---|---|
| Valid HTTPS URL, HEAD succeeds | 200 with { reachable: true, contentType, contentLength } |
| Invalid URL (HTTP scheme) | 400 |
| SSRF — private IP | 400 |
| Rate limit exceeded | 429 with Retry-After header |
| HEAD fails, GET with Range succeeds | 200 with { reachable: true } |
| Both HEAD and GET fail | 422 |
| Timeout (10s exceeded) | 422 |
| No valid session | 401 |
| Non-image Content-Type → 422 | Response Content-Type of text/html returns 422 |
T-7: Fetch-URL Route (src/server/routes/storage/fetch-url.ts)
Section titled “T-7: Fetch-URL Route (src/server/routes/storage/fetch-url.ts)”Full image fetch proxy for CORS-blocked external URLs. Implements
BFF-FR-005 for the
POST /api/storage/fetch-url path.
Handler logic:
- Verify session JWT. Return
401if invalid. - Parse
{ url }from the request body. - Apply the same SSRF and rate-limit checks as check-url (steps 3–4).
Return
400or429(with user-facing message, see T-3) on failure. - Perform a GET request to
urlwith a 10-second timeout (AbortController) and a maximum response size of 10 MB. - If the response body exceeds 10 MB, abort and return
413. - Validate that the response
Content-Typeheader matchesimage/*. If not, abort and return422with error message ‘URL does not point to an image’. - Stream the response back to the SPA with the origin’s
Content-Typeheader.
Thin re-export (src/app/api/storage/fetch-url/route.ts):
export { POST } from '@/server/routes/storage/fetch-url';Unit tests (src/server/__tests__/fetch-url.test.ts):
| Case | Expected |
|---|---|
| Valid URL — response under 10 MB | 200 with image bytes and Content-Type |
| SSRF — private IP | 400 |
| Rate limit exceeded | 429 |
| Response exceeds 10 MB | 413 |
| Timeout (10s exceeded) | 422 |
| No valid session | 401 |
| Response Content-Type not image/* → 422 | Response Content-Type of text/html returns 422 |
T-8: CDN Cookies Route (src/server/routes/storage/cdn-cookies.ts)
Section titled “T-8: CDN Cookies Route (src/server/routes/storage/cdn-cookies.ts)”Issues CloudFront signed cookies scoped to the active tenant. Implements
BFF-FR-008 and
BFF-FR-009 for the
POST /api/storage/cdn-cookies path.
Handler logic:
- Verify session JWT. Return
401if invalid. - Extract
tenantIdfrom the authenticated session context. The tenant ID is never read from the request body or query parameters (BFF-FR-008: tenant scoping must be server-authoritative). - Call
sign(tenantId)fromcloudfront-signer.ts. Return500if the signing key is unavailable. - Set three
Set-Cookieheaders on the response using the values returned bysign():CloudFront-Policy=<value>CloudFront-Signature=<value>CloudFront-Key-Pair-Id=<value>
- All three cookies share the attributes:
Domain=.arda.cards; Path=/; Max-Age=1800; Secure; HttpOnly; SameSite=LaxMax-Ageis set to the same value as the CloudFront policy TTL (default 1800 seconds / 30 minutes) so the browser expires the cookies at the same time the CloudFront policy expires. - Return
204 No Content.
Thin re-export (src/app/api/storage/cdn-cookies/route.ts):
export { POST } from '@/server/routes/storage/cdn-cookies';Unit tests (src/server/__tests__/cdn-cookies.test.ts):
| Case | Expected |
|---|---|
| Valid session | 204, three Set-Cookie headers present |
Cookie Domain attribute | .arda.cards |
Cookie Path attribute | / |
| Cookie security attributes | Secure; HttpOnly; SameSite=Lax |
Cookie Max-Age attribute matches policy TTL | Max-Age=1800 when using default 30-minute TTL |
Tenant in cookie Resource | Policy Resource contains authenticated tenantId |
| Cookie body does not contain client-supplied tenant | Policy derived from session, not request |
| No valid session | 401 |
CLOUDFRONT_KEY_PAIR_ID unset | 500 |
T-9: Documentation checks and commit
Section titled “T-9: Documentation checks and commit”Update session log / byproducts in the documentation worktree.
# In the documentation worktreemake pr-checksCommit documentation changes referencing Phase 3.5.
Exit Criteria
Section titled “Exit Criteria”All tasks must be complete with passing unit tests before this phase is considered done. The full local check suite must pass cleanly with no regressions to pre-existing tests:
# arda-frontend-appnpm run lintnpx tsc --noEmitnpx jest --no-coverage --watchAll=false --forceExitnpm run build# documentationmake pr-checksDocumentation worktree changes committed.
STOP: Review BFF routes with the Principal Engineer before proceeding to Phase 3.6 (SPA integration).
Open Questions and Decisions
Section titled “Open Questions and Decisions”| # | Question | Options | Recommendation | Decision |
|---|---|---|---|---|
| 1 | CloudFront signing key loading: environment variable vs. AWS Secrets Manager | A: environment variable only; B: Secrets Manager at runtime via AWS SDK | B | Decided (FD-10): Use AWS SDK SecretsManagerClient.GetSecretValue() at runtime. The Amplify SSR Compute Role already has secretsmanager:GetSecretValue permission (Alpha001 demo; other envs via infrastructure#438). AWS explicitly recommends against storing private keys in environment variables. The secret ARN and key pair ID are passed as non-sensitive env vars; the actual PEM key stays in Secrets Manager. Cached in memory after first fetch. |
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved