From e347f887bc815730894d47a6944fe18b0324dfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:34:43 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20AWS=20Lambda=20de?= =?UTF-8?q?ployment=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lambda.ts, a serverless entry point for running the admin panel as a single AWS Lambda behind a Function URL or API Gateway HTTP API (payload format 2.0). The handler serves both server routes and the static client assets itself, so no CDN or S3 bucket is required. - `build:lambda` bundles the handler + client into `dist/lambda/` - README documents the serverless deployment path - `GET /health` returns 200 for load-balancer health checks Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +++ lambda.ts | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + tsconfig.json | 2 +- 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 lambda.ts diff --git a/README.md b/README.md index 508c60d..2da92f2 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,13 @@ docker run -p 3000:3000 \ -e VITE_BASE_PATH=/adminpanel \ librechat-admin-panel ``` + +### Serverless (AWS Lambda) + +The panel can also run as a single AWS Lambda behind a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or an API Gateway HTTP API (payload format 2.0). The handler in `lambda.ts` serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. + +```bash +bun run build:lambda # outputs dist/lambda/{index.mjs, client/} +``` + +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for load-balancer health checks. diff --git a/lambda.ts b/lambda.ts new file mode 100644 index 0000000..d23062e --- /dev/null +++ b/lambda.ts @@ -0,0 +1,186 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join, sep } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +/** + * AWS Lambda entry point for running the admin panel serverlessly behind a + * Lambda Function URL or an API Gateway HTTP API (payload format 2.0). + * + * The same Lambda also serves the static client assets (`dist/client`, bundled + * next to the handler as `client/`), so no CDN or S3 bucket is required — the + * deployed zip is self-contained. Build it with `bun run build:lambda`. + */ + +type LambdaEvent = { + rawPath: string; + rawQueryString: string; + cookies?: string[]; + headers?: Record; + requestContext: { http: { method: string } }; + body?: string; + isBase64Encoded?: boolean; +}; + +type LambdaResult = { + statusCode: number; + headers: Record; + cookies?: string[]; + body: string; + isBase64Encoded: boolean; +}; + +type FetchHandler = { default: { fetch: (request: Request) => Promise } }; + +const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; + +const CLIENT_DIR = join(fileURLToPath(new URL('.', import.meta.url)), 'client'); +const NO_CACHE = 'no-cache, no-store, must-revalidate'; +const IMMUTABLE = 'public, max-age=31536000, immutable'; +const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); + +const CONTENT_TYPES: Record = { + js: 'text/javascript', + mjs: 'text/javascript', + css: 'text/css', + html: 'text/html; charset=utf-8', + json: 'application/json', + svg: 'image/svg+xml', + ico: 'image/x-icon', + png: 'image/png', + jpg: 'image/jpeg', + webp: 'image/webp', + woff: 'font/woff', + woff2: 'font/woff2', + txt: 'text/plain', + map: 'application/json', +}; + +function contentType(pathname: string): string { + const ext = pathname.split('.').pop()?.toLowerCase() ?? ''; + return CONTENT_TYPES[ext] ?? 'application/octet-stream'; +} + +function isStaticPath(pathname: string): boolean { + return ( + pathname.startsWith('/assets/') || + pathname.endsWith('.svg') || + pathname === '/favicon.ico' || + NEVER_CACHE.has(pathname) + ); +} + +function acceptsGzip(event: LambdaEvent): boolean { + return (event.headers?.['accept-encoding'] ?? '').includes('gzip'); +} + +function isCompressible(contentType: string): boolean { + return /javascript|css|json|html|svg|text|xml|manifest/.test(contentType); +} + +// Lambda response payloads are capped at 6 MB. Gzipping compressible assets +// keeps large chunks (e.g. the icons bundle) well under it and cuts transfer. +function encodeBody( + buffer: Buffer, + contentType: string, + gzip: boolean, +): { body: string; contentEncoding?: string } { + if (gzip && isCompressible(contentType) && buffer.length > 1024) { + return { body: gzipSync(buffer).toString('base64'), contentEncoding: 'gzip' }; + } + return { body: buffer.toString('base64') }; +} + +function buildRequest(event: LambdaEvent): Request { + const headers = new Headers(); + if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); + + const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost'; + const proto = headers.get('x-forwarded-proto') ?? 'https'; + const query = event.rawQueryString ? `?${event.rawQueryString}` : ''; + const url = `${proto}://${host}${event.rawPath}${query}`; + + const method = event.requestContext.http.method; + const hasBody = event.body != null && method !== 'GET' && method !== 'HEAD'; + const body = hasBody + ? event.isBase64Encoded + ? Buffer.from(event.body as string, 'base64') + : event.body + : undefined; + + return new Request(url, { method, headers, body }); +} + +async function serveStatic(pathname: string, gzip: boolean): Promise { + const filePath = join(CLIENT_DIR, pathname); + if (filePath !== CLIENT_DIR && !filePath.startsWith(CLIENT_DIR + sep)) return null; + try { + const data = await readFile(filePath); + const ct = contentType(pathname); + const cache = pathname.startsWith('/assets/') ? IMMUTABLE : NEVER_CACHE.has(pathname) ? NO_CACHE : ''; + const encoded = encodeBody(data, ct, gzip); + const headers: Record = { 'content-type': ct }; + if (cache) headers['cache-control'] = cache; + if (encoded.contentEncoding) { + headers['content-encoding'] = encoded.contentEncoding; + headers['vary'] = 'accept-encoding'; + } + return { statusCode: 200, headers, body: encoded.body, isBase64Encoded: true }; + } catch { + return null; + } +} + +async function toLambdaResult(response: Response, gzip: boolean): Promise { + const headers: Record = {}; + response.headers.forEach((value, key) => { + if (key === 'set-cookie') return; + headers[key] = value; + }); + if (!headers['cache-control']) headers['cache-control'] = NO_CACHE; + + const cookies = + typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []; + + const buffer = Buffer.from(await response.arrayBuffer()); + const ct = headers['content-type'] ?? ''; + const alreadyEncoded = 'content-encoding' in headers; + const encoded = encodeBody(buffer, ct, gzip && !alreadyEncoded); + if (encoded.contentEncoding) { + headers['content-encoding'] = encoded.contentEncoding; + headers['vary'] = headers['vary'] ? `${headers['vary']}, accept-encoding` : 'accept-encoding'; + delete headers['content-length']; + } + + return { + statusCode: response.status, + headers, + cookies: cookies.length ? cookies : undefined, + body: encoded.body, + isBase64Encoded: true, + }; +} + +export async function handler(event: LambdaEvent): Promise { + if (event.rawPath === '/health') { + return { + statusCode: 200, + headers: { 'content-type': 'text/plain' }, + body: 'ok', + isBase64Encoded: false, + }; + } + + const gzip = acceptsGzip(event); + + if (isStaticPath(event.rawPath)) { + const asset = await serveStatic(event.rawPath, gzip); + if (asset) return asset; + } + + const response = await app.fetch(buildRequest(event)); + return toLambdaResult(response, gzip); +} diff --git a/package.json b/package.json index 7c4ca0b..8976ac2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "postinstall": "patch-package", "dev": "vite dev --port 3000", "build": "vite build", + "build:lambda": "vite build && bun build ./lambda.ts --target=node --format=esm --outfile=dist/lambda/index.mjs && rm -rf dist/lambda/client && cp -r dist/client dist/lambda/client", "start": "bun server.ts", "preview": "vite preview", "test": "vitest run", diff --git a/tsconfig.json b/tsconfig.json index 3dd5492..7074f53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["scripts", "server.ts", "tools"], + "exclude": ["scripts", "server.ts", "lambda.ts", "tools"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx", From 7364204c023e48e4d12e104b57b4b093a53a4795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:42:46 +0000 Subject: [PATCH 2/6] feat: support ALB Lambda targets alongside Function URL / API Gateway Detect the incoming event shape and reply in the matching format: ELB event (statusDescription + multiValueHeaders) for ALB targets, and payload format 2.0 (headers + cookies[]) for Function URLs / HTTP API. Document both deployment paths in the README, including the ALB target group's multi-value-headers requirement and 1 MB response cap. Co-Authored-By: Claude Opus 4.8 --- README.md | 14 ++++- lambda.ts | 185 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 161 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 2da92f2..de60d91 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,20 @@ docker run -p 3000:3000 \ ### Serverless (AWS Lambda) -The panel can also run as a single AWS Lambda behind a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or an API Gateway HTTP API (payload format 2.0). The handler in `lambda.ts` serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. +The panel can also run as a single AWS Lambda. The handler in `lambda.ts` detects the incoming event shape and supports both: + +- a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or API Gateway HTTP API (payload format 2.0) +- an [Application Load Balancer target](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html) (`target_type = lambda`) + +The Lambda serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. ```bash bun run build:lambda # outputs dist/lambda/{index.mjs, client/} ``` -Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for load-balancer health checks. +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for health checks. + +> **ALB targets:** enable multi-value headers on the target group so multiple +> `Set-Cookie` headers (required for the session/PKCE flow) survive. ALB caps +> Lambda responses at 1 MB; the handler gzips compressible assets to stay under +> it. With plain-HTTP listeners, also set `SESSION_COOKIE_SECURE=false` (see above). diff --git a/lambda.ts b/lambda.ts index d23062e..0553133 100644 --- a/lambda.ts +++ b/lambda.ts @@ -4,25 +4,54 @@ import { join, sep } from 'node:path'; import { gzipSync } from 'node:zlib'; /** - * AWS Lambda entry point for running the admin panel serverlessly behind a - * Lambda Function URL or an API Gateway HTTP API (payload format 2.0). + * AWS Lambda entry point for running the admin panel serverlessly. It supports + * both event shapes a Lambda can receive from an HTTP front end: * - * The same Lambda also serves the static client assets (`dist/client`, bundled - * next to the handler as `client/`), so no CDN or S3 bucket is required — the - * deployed zip is self-contained. Build it with `bun run build:lambda`. + * - Lambda Function URL / API Gateway HTTP API (payload format 2.0) + * - Application Load Balancer target (`target_type = lambda`) + * + * The handler detects the shape from the event and replies in the matching + * format. The same Lambda also serves the static client assets (`dist/client`, + * bundled next to the handler as `client/`), so no CDN or S3 bucket is required + * — the deployed zip is self-contained. Build it with `bun run build:lambda`. + * + * For ALB targets, enable multi-value headers on the target group so multiple + * `Set-Cookie` headers survive. */ -type LambdaEvent = { +type AlbEvent = { + requestContext: { elb: { targetGroupArn: string } }; + httpMethod: string; + path: string; + queryStringParameters?: Record; + multiValueQueryStringParameters?: Record; + headers?: Record; + multiValueHeaders?: Record; + body?: string; + isBase64Encoded?: boolean; +}; + +type HttpEvent = { + requestContext: { http: { method: string } }; rawPath: string; rawQueryString: string; cookies?: string[]; headers?: Record; - requestContext: { http: { method: string } }; body?: string; isBase64Encoded?: boolean; }; -type LambdaResult = { +type LambdaEvent = AlbEvent | HttpEvent; + +type AlbResult = { + statusCode: number; + statusDescription: string; + multiValueHeaders: Record; + body: string; + isBase64Encoded: boolean; +}; + +type HttpResult = { statusCode: number; headers: Record; cookies?: string[]; @@ -30,6 +59,15 @@ type LambdaResult = { isBase64Encoded: boolean; }; +type Result = { + status: number; + statusText?: string; + headers: Record; + cookies: string[]; + body: string; + isBase64Encoded: boolean; +}; + type FetchHandler = { default: { fetch: (request: Request) => Promise } }; const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; @@ -39,6 +77,27 @@ const NO_CACHE = 'no-cache, no-store, must-revalidate'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); +// ALB requires statusDescription to be a " " line; a bare code +// (e.g. "307") makes the ALB return 502 Bad Gateway. +const REASON_PHRASES: Record = { + 200: 'OK', + 204: 'No Content', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', +}; + const CONTENT_TYPES: Record = { js: 'text/javascript', mjs: 'text/javascript', @@ -56,6 +115,10 @@ const CONTENT_TYPES: Record = { map: 'application/json', }; +function isAlb(event: LambdaEvent): event is AlbEvent { + return 'elb' in event.requestContext; +} + function contentType(pathname: string): string { const ext = pathname.split('.').pop()?.toLowerCase() ?? ''; return CONTENT_TYPES[ext] ?? 'application/octet-stream'; @@ -70,16 +133,13 @@ function isStaticPath(pathname: string): boolean { ); } -function acceptsGzip(event: LambdaEvent): boolean { - return (event.headers?.['accept-encoding'] ?? '').includes('gzip'); -} - function isCompressible(contentType: string): boolean { return /javascript|css|json|html|svg|text|xml|manifest/.test(contentType); } -// Lambda response payloads are capped at 6 MB. Gzipping compressible assets -// keeps large chunks (e.g. the icons bundle) well under it and cuts transfer. +// ALB Lambda targets cap the response at 1 MB (Function URLs and API Gateway +// allow ~6 MB). Gzipping compressible assets keeps large chunks (e.g. the icons +// bundle) under the cap and cuts transfer overall. function encodeBody( buffer: Buffer, contentType: string, @@ -91,19 +151,43 @@ function encodeBody( return { body: buffer.toString('base64') }; } +function albQueryString(event: AlbEvent): string { + const params = new URLSearchParams(); + if (event.multiValueQueryStringParameters) { + for (const [key, values] of Object.entries(event.multiValueQueryStringParameters)) { + for (const value of values) params.append(key, value); + } + } else if (event.queryStringParameters) { + for (const [key, value] of Object.entries(event.queryStringParameters)) params.append(key, value); + } + return params.toString(); +} + function buildRequest(event: LambdaEvent): Request { const headers = new Headers(); - if (event.headers) { - for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + if (isAlb(event)) { + if (event.multiValueHeaders) { + for (const [name, values] of Object.entries(event.multiValueHeaders)) { + for (const value of values) headers.append(name, value); + } + } else if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + } else { + if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); } - if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); + + const method = isAlb(event) ? event.httpMethod : event.requestContext.http.method; + const path = isAlb(event) ? event.path : event.rawPath; + const rawQuery = isAlb(event) ? albQueryString(event) : event.rawQueryString; const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost'; const proto = headers.get('x-forwarded-proto') ?? 'https'; - const query = event.rawQueryString ? `?${event.rawQueryString}` : ''; - const url = `${proto}://${host}${event.rawPath}${query}`; + const url = `${proto}://${host}${path}${rawQuery ? `?${rawQuery}` : ''}`; - const method = event.requestContext.http.method; const hasBody = event.body != null && method !== 'GET' && method !== 'HEAD'; const body = hasBody ? event.isBase64Encoded @@ -114,7 +198,7 @@ function buildRequest(event: LambdaEvent): Request { return new Request(url, { method, headers, body }); } -async function serveStatic(pathname: string, gzip: boolean): Promise { +async function serveStatic(pathname: string, gzip: boolean): Promise { const filePath = join(CLIENT_DIR, pathname); if (filePath !== CLIENT_DIR && !filePath.startsWith(CLIENT_DIR + sep)) return null; try { @@ -128,17 +212,16 @@ async function serveStatic(pathname: string, gzip: boolean): Promise { +async function toResult(response: Response, gzip: boolean): Promise { const headers: Record = {}; response.headers.forEach((value, key) => { - if (key === 'set-cookie') return; - headers[key] = value; + if (key !== 'set-cookie') headers[key] = value; }); if (!headers['cache-control']) headers['cache-control'] = NO_CACHE; @@ -156,31 +239,61 @@ async function toLambdaResult(response: Response, gzip: boolean): Promise { - if (event.rawPath === '/health') { +async function route(path: string, request: Request, gzip: boolean): Promise { + if (path === '/health') { return { - statusCode: 200, + status: 200, headers: { 'content-type': 'text/plain' }, + cookies: [], body: 'ok', isBase64Encoded: false, }; } - const gzip = acceptsGzip(event); - - if (isStaticPath(event.rawPath)) { - const asset = await serveStatic(event.rawPath, gzip); + if (isStaticPath(path)) { + const asset = await serveStatic(path, gzip); if (asset) return asset; } - const response = await app.fetch(buildRequest(event)); - return toLambdaResult(response, gzip); + return toResult(await app.fetch(request), gzip); +} + +function toAlbResult(result: Result): AlbResult { + const multiValueHeaders: Record = {}; + for (const [name, value] of Object.entries(result.headers)) multiValueHeaders[name] = [value]; + if (result.cookies.length) multiValueHeaders['set-cookie'] = result.cookies; + return { + statusCode: result.status, + statusDescription: `${result.status} ${result.statusText || REASON_PHRASES[result.status] || 'OK'}`, + multiValueHeaders, + body: result.body, + isBase64Encoded: result.isBase64Encoded, + }; +} + +function toHttpResult(result: Result): HttpResult { + return { + statusCode: result.status, + headers: result.headers, + cookies: result.cookies.length ? result.cookies : undefined, + body: result.body, + isBase64Encoded: result.isBase64Encoded, + }; +} + +export async function handler(event: LambdaEvent): Promise { + const path = isAlb(event) ? event.path : event.rawPath; + const request = buildRequest(event); + const gzip = (request.headers.get('accept-encoding') ?? '').includes('gzip'); + const result = await route(path, request, gzip); + return isAlb(event) ? toAlbResult(result) : toHttpResult(result); } From def571c3ea7aa857f152882bd6ebbd6eb5970aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:45:19 +0000 Subject: [PATCH 3/6] fix: serve static assets under VITE_BASE_PATH in the Lambda handler Mirror server.ts: assets live on disk without the base prefix, so strip VITE_BASE_PATH before the static lookup while still handing the app the original request (its router is configured with the base path). Without this, //assets/* requests skipped the static branch and 404'd. Co-Authored-By: Claude Opus 4.8 --- lambda.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lambda.ts b/lambda.ts index 0553133..e015dc5 100644 --- a/lambda.ts +++ b/lambda.ts @@ -73,6 +73,7 @@ type FetchHandler = { default: { fetch: (request: Request) => Promise const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; const CLIENT_DIR = join(fileURLToPath(new URL('.', import.meta.url)), 'client'); +const BASE_PATH = (process.env.VITE_BASE_PATH || '').replace(/\/$/, ''); const NO_CACHE = 'no-cache, no-store, must-revalidate'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); @@ -259,8 +260,16 @@ async function route(path: string, request: Request, gzip: boolean): Promise Date: Tue, 23 Jun 2026 12:47:12 +0000 Subject: [PATCH 4/6] docs: note that Lambda static-file serving is for simplicity, not production-ideal Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index de60d91..8471618 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,11 @@ The panel can also run as a single AWS Lambda. The handler in `lambda.ts` detect The Lambda serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. +> **Note:** Returning static files from a Lambda is not what Lambdas are designed +> for — in production you would normally put a CDN (or S3 + CloudFront) in front +> and let it serve `dist/client` directly. The handler does it inline purely for +> simplicity, so a single self-contained zip deploys with no extra infrastructure. + ```bash bun run build:lambda # outputs dist/lambda/{index.mjs, client/} ``` From 03c76529e981c4ec0c9645a70d3d13055947ea11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 12:55:42 +0000 Subject: [PATCH 5/6] docs: clarify Prometheus /metrics is not exposed by the Lambda The pull-model /metrics endpoint is a server.ts feature; in-process counters don't fit Lambda's ephemeral instances. Narrow the env-var list to the ones that actually apply and point to CloudWatch for Lambda metrics. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8471618..9481266 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,12 @@ The Lambda serves both the server routes and the static client assets, so no CDN bun run build:lambda # outputs dist/lambda/{index.mjs, client/} ``` -Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the same environment variables as the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, etc.). A `GET /health` route returns `200 ok` for health checks. +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the runtime environment variables used by the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, `ADMIN_SSO_*`, `SESSION_COOKIE_SECURE`, etc.). A `GET /health` route returns `200 ok` for health checks. + +> **Note:** The Prometheus `/metrics` endpoint (and `ADMIN_PANEL_METRICS_SECRET`) +> is specific to the long-running `server.ts` deployment and is **not** exposed by +> the Lambda — in-process pull-model metrics don't fit Lambda's ephemeral, horizontally +> scaled instances. Use CloudWatch (or another push-based exporter) for Lambda metrics. > **ALB targets:** enable multi-value headers on the target group so multiple > `Set-Cookie` headers (required for the session/PKCE flow) survive. ALB caps From eadbdf046e7307aa0dc932db5dfa6ab8c6ecb053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 23 Jun 2026 13:05:36 +0000 Subject: [PATCH 6/6] fix: match server.ts security headers and base-path behavior in Lambda Bring the Lambda handler to parity with the long-running server.ts: - apply security headers (CSP report-only/enforced, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS in prod) to text/html responses - only serve static assets under VITE_BASE_PATH (not at the unprefixed path) when a base path is configured - redirect the bare base path to its trailing-slash form (302) Co-Authored-By: Claude Opus 4.8 --- lambda.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/lambda.ts b/lambda.ts index e015dc5..1b4221f 100644 --- a/lambda.ts +++ b/lambda.ts @@ -78,6 +78,37 @@ const NO_CACHE = 'no-cache, no-store, must-revalidate'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); +// Mirrors server.ts. Shipped report-only by default because TanStack Start's SSR +// injects an inline boot script; set ADMIN_PANEL_CSP_ENFORCE=true to enforce once +// a per-request nonce is wired up. +const CSP_VALUE = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); +const CSP_HEADER_NAME = + process.env.ADMIN_PANEL_CSP_ENFORCE === 'true' + ? 'content-security-policy' + : 'content-security-policy-report-only'; + +function applySecurityHeaders(headers: Record): void { + if (!(headers['content-type'] ?? '').toLowerCase().startsWith('text/html')) return; + headers[CSP_HEADER_NAME] = CSP_VALUE; + headers['x-content-type-options'] = 'nosniff'; + headers['referrer-policy'] = 'strict-origin-when-cross-origin'; + headers['x-frame-options'] = 'DENY'; + if (process.env.NODE_ENV === 'production') { + headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'; + } +} + // ALB requires statusDescription to be a " " line; a bare code // (e.g. "307") makes the ALB return 502 Bad Gateway. const REASON_PHRASES: Record = { @@ -260,15 +291,25 @@ async function route(path: string, request: Request, gzip: boolean): Promise