Skip to content

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.

  • Phase 3.1 complete: @arda-cards/api-proxy published with createImageUploadUrl() on ItemProxy.
  • Phase 3.4 complete: @arda-cards/design-system published (not strictly required for the BFF, but keeps the branch in a clean state before introducing new Next.js server code).
  • arda-frontend-app branch 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:

Terminal window
npm install server-only

This 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):

CaseInputExpected
Standard naming (NEXT_PUBLIC_ vars)NEXT_PUBLIC_INFRASTRUCTURE=Alpha002, NEXT_PUBLIC_PARTITION=devfqn: "Alpha002-dev", cdnHost: "dev.alpha002.assets.arda.cards", signingKeySecretName: "Alpha002-dev-ImageCdnSigningKey"
Fallback to non-prefixed varsINFRASTRUCTURE=Alpha002, PARTITION=dev (no NEXT_PUBLIC_ set)Same expected output as above
Different partitionNEXT_PUBLIC_INFRASTRUCTURE=Alpha001, NEXT_PUBLIC_PARTITION=prodfqn: "Alpha001-prod", subdomain: "prod.alpha001"
Mock mode fallbackNEXT_PUBLIC_MOCK_MODE=true, no INFRASTRUCTURE/PARTITIONReturns mock values without throwing
Missing env vars (non-mock)No INFRASTRUCTURE, no mock modeThrows 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').

CaseExpected
First call fetches from Secrets Managersend called once, returns secret value
Second call uses cachesend NOT called again, same value returned
Different secretId fetches separatelysend called for each distinct ID
Empty SecretString throwsError: “Secret X is empty or binary”
SDK error propagatesSecretsManager error thrown to caller
clearSecretCache resetsNext call after clear fetches from SDK again
Cache entry expired after TTL → re-fetches from SDKAfter cacheTtlMs elapses, send is called again
Cache entry within TTL → returns cached valuesend 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):

  1. Parse the URL; reject any parse error.
  2. Accept only the https: scheme.
  3. 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.
  4. 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.

CaseInputDNS MockExpected
Valid HTTPS URLhttps://example.com/image.jpg['93.184.216.34']{ valid: true }
HTTP schemehttp://example.com/image.jpg— (rejected before DNS){ valid: false }
DNS resolves to 10.xhttps://evil.com/['10.0.0.1']{ valid: false }
DNS resolves to 172.16.xhttps://evil.com/['172.16.0.1']{ valid: false }
DNS resolves to 192.168.xhttps://evil.com/['192.168.1.1']{ valid: false }
DNS resolves to loopbackhttps://evil.com/['127.0.0.1']{ valid: false }
DNS resolves to link-localhttps://evil.com/['169.254.169.254']{ valid: false }
DNS resolves to IPv6 loopbackhttps://evil.com/AAAA ['::1']{ valid: false }
Multiple IPs, one privatehttps://evil.com/['93.184.216.34', '10.0.0.1']{ valid: false }
DNS resolution failshttps://nonexistent.example/NXDOMAIN error{ valid: false }
Localhost name (no DNS needed)https://localhost/{ valid: false }
IP literal in URLhttps://10.0.0.1/— (no DNS, parsed directly){ valid: false }
CDN host patternhttps://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-After header: seconds until the rate limit window resets (value from retryAfterMs / 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):

CaseExpected
First N requests within limitAll return { allowed: true }
N+1 request in same windowReturns { allowed: false, retryAfterMs: <positive> }
First request after window expiresReturns { allowed: true } (window slides)
Two distinct tenants — one at limitSecond tenant is unaffected
retryAfterMs is positive and ≤ window sizeVerify 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:

  1. 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 needed
    return getSecret(naming.signingKeySecretName);
    // getSecret() caches internally — subsequent calls are instant
    }

    The secret name is derived as ${Infrastructure}-${Partition}-ImageCdnSigningKey via the naming utility. The CLOUDFRONT_SIGNING_KEY_SECRET_ARN env var is no longer needed — the secret name is sufficient for SecretsManagerClient.GetSecretValue. The CLOUDFRONT_KEY_PAIR_ID env var is still needed because the key pair ID is a CloudFront-generated value with no derivable naming convention.

  2. Build a CloudFront custom policy JSON:

    {
    "Statement": [{
    "Resource": "https://assets.*/<tenantId>/*",
    "Condition": {
    "DateLessThan": { "AWS:EpochTime": <now + ttl in seconds> }
    }
    }]
    }
  3. Base64-encode the policy (CloudFront format: replace + with -, / with ~, = with _).

  4. Sign the encoded policy with RSA-SHA1 using the private key (Node.js crypto.sign('sha1', ...) or equivalent).

  5. Return the three cookie values (including keyPairId from 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.

CaseAssertion
Policy Resource contains tenantId pathResource equals https://assets.*/<testTenantId>/*
DateLessThan within expected rangeEpoch time is now + ttl (±5 s tolerance)
Different tenant IDs produce different policiespolicy values differ
Default TTL is 30 minutesDateLessThan is approximately now + 1800
Custom TTL is respectedDateLessThan matches supplied ttlMs
Signature is valid for the test key paircrypto.verify succeeds
keyPairId matches env varReturned value equals CLOUDFRONT_KEY_PAIR_ID
Throws when CLOUDFRONT_KEY_PAIR_ID is unsetError thrown
Throws when naming cannot resolve (no INFRASTRUCTURE/PARTITION)Error thrown (non-mock mode)
Private key fetched via getSecret() with derived secret namegetSecret called with ${fqn}-ImageCdnSigningKey
Private key is cached by getSecret()getSecret internal cache hit on second call
Secrets Manager failure propagatesSDK 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 #734
import { env } from '@/lib/env'; // TODO #734

Handler logic:

  1. Call processJWTForArda(request) to verify the session JWT and extract UserContext. Return 401 if the session is invalid.
  2. Construct ItemProxy with { host: env.BASE_URL, apiKey: env.ARDA_API_KEY }.
  3. Build RequestContext from the authenticated UserContext:
    const context: RequestContext = {
    author: userContext.email,
    tenantId: userContext.tenantId,
    userId: userContext.sub, // OIDC subject from JWT claims
    };
  4. Forward the request body to proxy.createImageUploadUrl(request, { context }). The HttpClient (Phase 3.1) automatically injects Authorization: Bearer, X-Author, X-Tenant-Id, X-oidc-subject, and X-Request-ID headers from the RequestContext and ProxyConfig.
  5. 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):

CaseExpected
Valid session — proxy called with correct auth headersBackend receives Authorization, X-Tenant-Id, X-Author, X-Request-ID, X-oidc-subject
Valid session — Backend response passed throughResponse body unchanged
Missing or expired session JWTReturns 401
Backend returns non-2xxResponse 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:

  1. Verify session JWT. Return 401 if invalid.
  2. Parse { url } from the request body.
  3. Call validateUrl(url). Return 400 with the validation error if invalid.
  4. Call rateLimiter.check(tenantId). If exceeded, return 429 with Retry-After header and user-facing error message (see T-3 429 response format).
  5. Perform a HEAD request to url with a 10-second timeout using native fetch and AbortController.
  6. If the HEAD request fails (network error, timeout) or returns no Content-Type, attempt a GET with a Range: bytes=0-4095 header. 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 via AbortController.abort() after header inspection. This prevents the BFF from downloading or buffering potentially malicious content.
  7. Validate that the response Content-Type header matches image/*. If not, abort and return 422 with error message ‘URL does not point to an image’.
  8. Return { reachable: true, contentType, contentLength } on success.
  9. Return 422 if 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):

CaseExpected
Valid HTTPS URL, HEAD succeeds200 with { reachable: true, contentType, contentLength }
Invalid URL (HTTP scheme)400
SSRF — private IP400
Rate limit exceeded429 with Retry-After header
HEAD fails, GET with Range succeeds200 with { reachable: true }
Both HEAD and GET fail422
Timeout (10s exceeded)422
No valid session401
Non-image Content-Type → 422Response 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:

  1. Verify session JWT. Return 401 if invalid.
  2. Parse { url } from the request body.
  3. Apply the same SSRF and rate-limit checks as check-url (steps 3–4). Return 400 or 429 (with user-facing message, see T-3) on failure.
  4. Perform a GET request to url with a 10-second timeout (AbortController) and a maximum response size of 10 MB.
  5. If the response body exceeds 10 MB, abort and return 413.
  6. Validate that the response Content-Type header matches image/*. If not, abort and return 422 with error message ‘URL does not point to an image’.
  7. Stream the response back to the SPA with the origin’s Content-Type header.

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):

CaseExpected
Valid URL — response under 10 MB200 with image bytes and Content-Type
SSRF — private IP400
Rate limit exceeded429
Response exceeds 10 MB413
Timeout (10s exceeded)422
No valid session401
Response Content-Type not image/* → 422Response 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:

  1. Verify session JWT. Return 401 if invalid.
  2. Extract tenantId from 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).
  3. Call sign(tenantId) from cloudfront-signer.ts. Return 500 if the signing key is unavailable.
  4. Set three Set-Cookie headers on the response using the values returned by sign():
    • CloudFront-Policy=<value>
    • CloudFront-Signature=<value>
    • CloudFront-Key-Pair-Id=<value>
  5. All three cookies share the attributes: Domain=.arda.cards; Path=/; Max-Age=1800; Secure; HttpOnly; SameSite=Lax Max-Age is 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.
  6. 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):

CaseExpected
Valid session204, three Set-Cookie headers present
Cookie Domain attribute.arda.cards
Cookie Path attribute/
Cookie security attributesSecure; HttpOnly; SameSite=Lax
Cookie Max-Age attribute matches policy TTLMax-Age=1800 when using default 30-minute TTL
Tenant in cookie ResourcePolicy Resource contains authenticated tenantId
Cookie body does not contain client-supplied tenantPolicy derived from session, not request
No valid session401
CLOUDFRONT_KEY_PAIR_ID unset500

Update session log / byproducts in the documentation worktree.

Terminal window
# In the documentation worktree
make pr-checks

Commit documentation changes referencing Phase 3.5.

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:

Terminal window
# arda-frontend-app
npm run lint
npx tsc --noEmit
npx jest --no-coverage --watchAll=false --forceExit
npm run build
Terminal window
# documentation
make pr-checks

Documentation worktree changes committed.

STOP: Review BFF routes with the Principal Engineer before proceeding to Phase 3.6 (SPA integration).


#QuestionOptionsRecommendationDecision
1CloudFront signing key loading: environment variable vs. AWS Secrets ManagerA: environment variable only; B: Secrets Manager at runtime via AWS SDKBDecided (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