diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e8bbae..923d9a4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: fi if [ -n "$changed" ]; then printf 'Changed files:\n%s\n' "$changed" - grep -qE '^(services/gateway/|packages/realtime-gateway/|packages/game/|packages/core/|packages/api/|scripts/(nginx-trusted-edge-acceptance|lib/wait-for-health)\.mjs|docker/web/nginx\.conf\.template|package(-lock)?\.json|\.github/workflows/)' <<<"$changed" && gateway=true || gateway=false + grep -qE '^(services/gateway/|packages/realtime-gateway/|packages/game/|packages/core/|packages/api/|packages/web/|scripts/(nginx-trusted-edge-acceptance|nginx-web-delivery-acceptance|lib/wait-for-health)\.mjs|docker/web/nginx\.conf\.template|package(-lock)?\.json|\.github/workflows/)' <<<"$changed" && gateway=true || gateway=false grep -qE '^(packages/web/|packages/e2e-harness/|packages/api/|packages/game/|packages/realtime-gateway/|packages/core/|package(-lock)?\.json|\.github/workflows/)' <<<"$changed" && web=true || web=false grep -qE '^(deploy/|\.github/workflows/)' <<<"$changed" && deploy=true || deploy=false # Only what changes how an image is *built*. Application code is already compiled by @@ -609,6 +609,12 @@ jobs: env: REQUIRE_DOCKER: '1' + - name: Test web delivery caching and compression through real Nginx + run: npm run test:web-delivery + working-directory: services/gateway + env: + REQUIRE_DOCKER: '1' + # M6 acceptance gate: Playwright e2e (full game vs bot + vs human) + # Lighthouse a11y audit (score must be >= 95). Runs the e2e harness # (in-memory API + gateway + bot) alongside vite preview so the specs diff --git a/docker/web/nginx.conf.template b/docker/web/nginx.conf.template index c0e09bfa..d60f33fb 100644 --- a/docker/web/nginx.conf.template +++ b/docker/web/nginx.conf.template @@ -8,6 +8,51 @@ server { root /usr/share/nginx/html; index index.html; + # Compression: enable gzip for sufficiently large compressible assets. + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + application/json + application/javascript + text/javascript + application/xml + image/svg+xml + application/manifest+json; + + # Only Vite-style content-hashed assets are safe for long-lived immutable caching. + # Regex locations take precedence over the /assets/ prefix fallback below. + location ~ "^/assets/(?:.*/)?[^/]+-[A-Za-z0-9_-]{8}\.[^/]+$" { + add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=()" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Existing unhashed assets remain reachable, but must revalidate and must never fall through + # to the SPA shell. Missing assets return 404 without an immutable Cache-Control header. + location /assets/ { + add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=()" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + # SPA fallback: serve index.html for any route not matching a static file location / { add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always; @@ -17,6 +62,7 @@ server { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; + add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; } @@ -45,6 +91,7 @@ server { # literal at config load, so a wrong value is a startup failure ("host not found in # upstream"), not a runtime 502 — see ADR-0075. location /v1/ { + gzip off; proxy_pass http://${API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 91d18f46..d453abac 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -6,7 +6,11 @@ > to read **only this file** and continue immediately. Updated after every > milestone and every significant architectural step. -_Last updated: 2026-09-15 — M15 Increment 58: kubelet probe NetworkPolicy contract._ +_Last updated: 2026-09-22 — M15 Increment 60: hash-aware immutable asset delivery contract._ + +Prior: _Last updated: 2026-09-16 — M15 Increment 59: production web delivery caching and compression contract._ + +Prior: _Last updated: 2026-09-15 — M15 Increment 58: kubelet probe NetworkPolicy contract._ Prior: _Last updated: 2026-09-15 — M15 Increment 57: search-indexer API NetworkPolicy reachability._ @@ -4236,11 +4240,15 @@ Per package: `cd packages/ && npm install && npm run build && npm test`. - Reserve backup files exclusively and clean up only resources created by the drill. Preserve restore and cleanup errors together while continuing other cleanup. Redact connection secrets from diagnostics, including CLI argument errors. - Verify append-only protection and valid, ready HNSW indexes on their specific public-schema relations. Added database-boundary and disposable-file regressions for native/Docker custom/plain orchestration and failure paths; live integration remains opt-in and was not run against an existing database. +## M15 Increment 59 — Production web delivery caching and compression contract (2026-09-16) +- Hardened production web delivery in `docker/web/nginx.conf.template` to implement optimized HTTP compression and safe caching headers. +- **Compression**: Enabled `gzip on;`, `gzip_vary on;`, `gzip_proxied any;`, `gzip_comp_level 6;`, `gzip_min_length 1024;`, and MIME types for text/plain, text/css, text/xml, application/json, application/javascript, text/javascript, application/xml, image/svg+xml, and application/manifest+json. +- **Content-Hashed Assets**: Route `/assets/*` enforces long-lived immutable caching (`Cache-Control: public, max-age=31536000, immutable`), returns 404 for missing assets without attaching immutable headers, and preserves all 7 security headers with `always`. +- **SPA Shell & Static Mutable Files**: Root route `/` enforces safe revalidation (`Cache-Control: no-cache`), ensuring `index.html` and SPA deep-link fallback routes (`try_files $uri $uri/ /index.html;`) cannot permanently pin clients to stale asset references after deployments. +- **Real Nginx Acceptance Suite**: Added `scripts/nginx-web-delivery-acceptance.mjs` and `npm run test:web-delivery` in `services/gateway`, validating all 10 delivery assertions (hashed asset caching, SPA shell freshness, deep-link fallback, gzip encoding, Vary header, API proxy safety, WebSocket upgrades, security headers preservation, 404 error routes, and uncompressed representation) through real Nginx containers in CI and local runners with zero test skips. +## M15 Increment 60 — Hash-aware immutable asset delivery contract (2026-09-22) - - - - - +- Restricted one-year immutable caching to Vite-style content-hashed filenames under `/assets/`; existing unhashed assets now use `Cache-Control: no-cache`, and both hashed and unhashed missing assets remain strict 404 responses without immutable headers. +- Extended the real-Nginx acceptance suite with a deterministic unhashed `/assets/runtime-config.json` fixture, hashed JS and CSS cache assertions, root static-file revalidation checks, both hashed/unhashed 404 paths, and shared security-header assertions while preserving gzip, SPA, REST, and WebSocket coverage. diff --git a/scripts/ci-local.mjs b/scripts/ci-local.mjs index bad7d22e..fa79445e 100644 --- a/scripts/ci-local.mjs +++ b/scripts/ci-local.mjs @@ -104,6 +104,7 @@ const SERVICE_JOBS = [ env: { REQUIRE_DOCKER: '1' }, steps: [ ['trusted edge through real Nginx', 'npm run test:trusted-edge'], + ['web delivery caching and compression through real Nginx', 'npm run test:web-delivery'], ], }, ]; diff --git a/scripts/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs new file mode 100644 index 00000000..a4105f56 --- /dev/null +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -0,0 +1,532 @@ +import assert from 'node:assert/strict'; +import { test, describe, before, after } from 'node:test'; +import { spawn, execSync } from 'node:child_process'; +import { request as httpRequest } from 'node:http'; +import { createServer } from 'node:net'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { readdirSync, readFileSync, existsSync, statSync, writeFileSync, rmSync } from 'node:fs'; +import { gunzipSync } from 'node:zlib'; +import WebSocket from 'ws'; +import { waitForHealth } from './lib/wait-for-health.mjs'; + +import { + createApiServer, + createInMemoryRepositories, + resolveConfig, + ScryptPasswordHasher, + AccessTokenService, + ManualClock, + uuidv7Generator, + InMemoryRateLimiter, + NullLogger, + InMemoryMetrics, + NullTracer, +} from '../packages/api/dist/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = resolve(__dirname, '..'); +const webDistPath = resolve(repoRoot, 'packages/web/dist'); + +/** + * Probes whether the Docker daemon is reachable and responding. + */ +function isDockerAvailable() { + try { + execSync('docker info', { stdio: 'ignore', timeout: 4000 }); + return true; + } catch { + return false; + } +} + +/** + * Allocates an available ephemeral TCP port on loopback (127.0.0.1). + */ +async function getFreePort() { + return new Promise((resolvePort, reject) => { + const srv = createServer(); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (!addr || typeof addr === 'string') { + srv.close(() => reject(new Error('Failed to get port'))); + return; + } + const port = addr.port; + srv.close(() => resolvePort(port)); + }); + }); +} + +const dockerAvailable = isDockerAvailable(); +if (!dockerAvailable && process.env['REQUIRE_DOCKER'] === '1') { + throw new Error('Docker is required for the web-delivery acceptance gate but is unavailable'); +} + +/** Resolve once a WebSocket opens, failing if it closes or exceeds the deadline first. */ +async function waitForOpen(ws, timeoutMs = 5_000) { + if (ws.readyState === WebSocket.OPEN) return; + + return new Promise((resolveOpen, reject) => { + const cleanup = () => { + clearTimeout(timer); + ws.off('open', onOpen); + ws.off('close', onClose); + ws.off('error', onError); + }; + const onOpen = () => { + cleanup(); + resolveOpen(); + }; + const onClose = (code) => { + cleanup(); + reject(new Error(`WebSocket closed with ${code} before opening`)); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error('WebSocket did not open before the deadline')); + }, timeoutMs); + + ws.once('open', onOpen); + ws.once('close', onClose); + ws.once('error', onError); + }); +} + +/** Assert the production security-header contract for either successful or error responses. */ +function assertSecurityHeaders(headers, context) { + assert.equal( + headers.get('content-security-policy'), + "frame-ancestors 'none'; object-src 'none'; base-uri 'self'", + `Content-Security-Policy missing or wrong on ${context}`, + ); + assert.equal(headers.get('cross-origin-resource-policy'), 'same-origin', `Cross-Origin-Resource-Policy missing or wrong on ${context}`); + assert.equal( + headers.get('permissions-policy'), + 'camera=(), geolocation=(), microphone=(), payment=()', + `Permissions-Policy missing or wrong on ${context}`, + ); + assert.equal(headers.get('referrer-policy'), 'no-referrer', `Referrer-Policy missing or wrong on ${context}`); + assert.equal( + headers.get('strict-transport-security'), + 'max-age=31536000; includeSubDomains', + `Strict-Transport-Security missing or wrong on ${context}`, + ); + assert.equal(headers.get('x-content-type-options'), 'nosniff', `X-Content-Type-Options missing or wrong on ${context}`); + assert.equal(headers.get('x-frame-options'), 'DENY', `X-Frame-Options missing or wrong on ${context}`); +} + +describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contract', { skip: !dockerAvailable }, () => { + let apiPort; + let gwPort; + let gwHealthPort; + let nginxPort; + + let httpServer; + let gwProc; + let nginxContainerName; + let nginxProc; + + let hashedJsFile; + let hashedCssFile; + const unhashedAssetFile = 'runtime-config.json'; + const unhashedAssetContent = '{"cachePolicy":"must-revalidate"}\n'; + const unhashedAssetPath = join(webDistPath, 'assets', unhashedAssetFile); + + const secret = 'test-secret-at-least-32-bytes-long-1234567890'; + + before(async () => { + // Verify web dist exists before starting tests + if (!existsSync(webDistPath) || !existsSync(join(webDistPath, 'index.html'))) { + throw new Error(`packages/web/dist does not exist or missing index.html at ${webDistPath}. Run npm run build:web first.`); + } + + const assetsDir = join(webDistPath, 'assets'); + if (!existsSync(assetsDir)) { + throw new Error(`assets directory missing at ${assetsDir}`); + } + + const assetEntries = readdirSync(assetsDir); + // Select a content-hashed JS asset meeting or exceeding Nginx's gzip minimum compression threshold (1024 bytes) + hashedJsFile = assetEntries.find((f) => { + if (!f.endsWith('.js') || f.endsWith('.map') || !/-[a-zA-Z0-9_-]{6,}\.js$/.test(f)) return false; + const stat = statSync(join(assetsDir, f)); + return stat.size >= 1024; + }); + // Select a content-hashed CSS asset meeting or exceeding Nginx's gzip minimum compression threshold (1024 bytes) + hashedCssFile = assetEntries.find((f) => { + if (!f.endsWith('.css') || f.endsWith('.map') || !/-[a-zA-Z0-9_-]{6,}\.css$/.test(f)) return false; + const stat = statSync(join(assetsDir, f)); + return stat.size >= 1024; + }); + + if (!hashedJsFile) { + throw new Error( + `No compressible hashed JS asset (>= 1024 bytes) found in packages/web/dist/assets (found: ${assetEntries.join(', ')})` + ); + } + if (!hashedCssFile) { + throw new Error( + `No compressible hashed CSS asset (>= 1024 bytes) found in packages/web/dist/assets (found: ${assetEntries.join(', ')})` + ); + } + + // Deterministically exercise the runtime fallback for an existing unhashed /assets/ file. + writeFileSync(unhashedAssetPath, unhashedAssetContent, 'utf8'); + + apiPort = await getFreePort(); + gwPort = await getFreePort(); + gwHealthPort = await getFreePort(); + nginxPort = await getFreePort(); + + // 1. Start API server on 0.0.0.0:apiPort + const clock = new ManualClock(Date.now()); + const repos = createInMemoryRepositories(clock); + const tokens = new AccessTokenService({ secret, ttlSec: 900, clock, ids: uuidv7Generator }); + const passwordHasher = new ScryptPasswordHasher({ N: 1024 }); + const rateLimiter = new InMemoryRateLimiter(clock); + const config = resolveConfig({ + port: apiPort, + accessTokenSecret: secret, + trustProxy: 1, + }); + const apiServer = createApiServer({ + repos, + tokens, + hasher: passwordHasher, + clock, + ids: uuidv7Generator, + config, + logger: new NullLogger(), + metrics: new InMemoryMetrics(), + tracer: new NullTracer(), + rateLimiter, + }); + httpServer = await apiServer.listen(apiPort, '0.0.0.0'); + await waitForHealth(`http://127.0.0.1:${apiPort}/v1/health`, 'API', { timeoutMs: 15_000 }); + + // 2. Start Gateway server on 0.0.0.0:gwPort + const gatewayDir = resolve(repoRoot, 'services/gateway'); + const serveScript = resolve(gatewayDir, 'dist/serve.js'); + gwProc = spawn(process.execPath, [serveScript], { + cwd: gatewayDir, + env: { + ...process.env, + PORT: String(gwPort), + HEALTH_PORT: String(gwHealthPort), + HOST: '0.0.0.0', + ACCESS_TOKEN_SECRET: secret, + WS_MAX_CONNECTIONS_PER_IP: '20', + TRUST_PROXY: '1', + DATABASE_URL: '', + REDIS_URL: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + gwProc.on('error', () => {}); + await waitForHealth(`http://127.0.0.1:${gwHealthPort}/health`, 'gateway', { timeoutMs: 15_000 }); + + // 3. Start real Nginx container mounting docker/web/nginx.conf.template and packages/web/dist + nginxContainerName = `gambit-test-web-${randomUUID().slice(0, 8)}`; + const templatePath = resolve(repoRoot, 'docker/web/nginx.conf.template'); + + const dockerArgs = [ + 'run', '--rm', + '--name', nginxContainerName, + '--add-host', 'host.docker.internal:host-gateway', + '-p', `127.0.0.1:${nginxPort}:8080`, + '-e', `API_UPSTREAM=host.docker.internal:${apiPort}`, + '-e', `GATEWAY_UPSTREAM=host.docker.internal:${gwPort}`, + '-v', `${templatePath}:/etc/nginx/templates/default.conf.template:ro`, + '-v', `${webDistPath}:/usr/share/nginx/html:ro`, + 'nginxinc/nginx-unprivileged:alpine', + ]; + + nginxProc = spawn('docker', dockerArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + nginxProc.on('error', () => {}); + + // Wait for Nginx to proxy /v1/health + await waitForHealth(`http://127.0.0.1:${nginxPort}/v1/health`, 'nginx web edge', { timeoutMs: 15_000 }); + }); + + after(async () => { + rmSync(unhashedAssetPath, { force: true }); + if (nginxContainerName) { + try { + execSync(`docker rm -f ${nginxContainerName}`, { stdio: 'ignore' }); + } catch { + // ignore + } + } + if (nginxProc) { + nginxProc.kill('SIGTERM'); + } + if (gwProc) { + gwProc.kill('SIGTERM'); + } + if (httpServer) { + await new Promise((r) => httpServer.close(r)); + } + }); + + test('1. Content-hashed static asset: HTTP 200, long-lived Cache-Control with immutable', async () => { + for (const assetFile of [hashedJsFile, hashedCssFile]) { + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${assetFile}`); + assert.equal(res.status, 200, `Hashed asset ${assetFile} must return 200 OK`); + + const cacheControl = res.headers.get('cache-control'); + assert.ok(cacheControl, `Cache-Control header must be present on hashed asset ${assetFile}`); + assert.match(cacheControl, /public/, `Cache-Control must declare public for ${assetFile}`); + assert.match(cacheControl, /max-age=31536000/, `Cache-Control must set a one-year max-age for ${assetFile}`); + assert.match(cacheControl, /immutable/, `Cache-Control must include immutable for ${assetFile}`); + } + }); + + test('2. Existing unhashed /assets/ files revalidate and never receive immutable caching', async () => { + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${unhashedAssetFile}`); + assert.equal(res.status, 200, 'Existing unhashed asset must remain reachable'); + assert.equal(await res.text(), unhashedAssetContent, 'Unhashed asset body must be served directly, not as the SPA shell'); + + const cacheControl = res.headers.get('cache-control'); + assert.ok(cacheControl, 'Unhashed asset must receive an explicit cache policy'); + assert.match(cacheControl, /no-cache/, 'Unhashed asset must require revalidation'); + assert.doesNotMatch(cacheControl, /immutable/, 'Unhashed asset must not receive immutable caching'); + assert.doesNotMatch(cacheControl, /max-age=31536000/, 'Unhashed asset must not receive a one-year max-age'); + assertSecurityHeaders(res.headers, `/assets/${unhashedAssetFile}`); + }); + + test('3. index.html, SPA shell, and root static files use safe freshness/revalidation', async () => { + const resIndex = await fetch(`http://127.0.0.1:${nginxPort}/index.html`); + assert.equal(resIndex.status, 200, 'index.html must return 200 OK'); + + const cacheControlIndex = resIndex.headers.get('cache-control'); + assert.ok(cacheControlIndex, 'Cache-Control header must be present on index.html'); + assert.doesNotMatch(cacheControlIndex, /immutable/, 'index.html MUST NOT be immutable'); + assert.doesNotMatch(cacheControlIndex, /max-age=31536000/, 'index.html MUST NOT have year-long max-age'); + assert.match(cacheControlIndex, /no-cache/, 'index.html must specify no-cache revalidation policy'); + + const resRoot = await fetch(`http://127.0.0.1:${nginxPort}/`); + assert.equal(resRoot.status, 200, 'root route / must return 200 OK'); + const cacheControlRoot = resRoot.headers.get('cache-control'); + assert.ok(cacheControlRoot, 'Cache-Control header must be present on /'); + assert.doesNotMatch(cacheControlRoot, /immutable/, 'root route / MUST NOT be immutable'); + assert.match(cacheControlRoot, /no-cache/, 'root route / must specify no-cache revalidation policy'); + + for (const staticPath of ['/icon.svg', '/manifest.webmanifest', '/sw.js']) { + const staticRes = await fetch(`http://127.0.0.1:${nginxPort}${staticPath}`); + assert.equal(staticRes.status, 200, `${staticPath} must return 200 OK`); + const staticCache = staticRes.headers.get('cache-control'); + assert.ok(staticCache, `Cache-Control header must be present on ${staticPath}`); + assert.match(staticCache, /no-cache/, `${staticPath} must require revalidation`); + assert.doesNotMatch(staticCache, /immutable/, `${staticPath} must not be immutable`); + } + }); + + test('4. SPA deep-link fallback: returns shell correctly without inheriting immutable asset policy', async () => { + const res = await fetch(`http://127.0.0.1:${nginxPort}/play`); + assert.equal(res.status, 200, 'Deep link /play must resolve to 200 OK via SPA fallback'); + + const body = await res.text(); + assert.match(body, / { + for (const assetFile of [hashedJsFile, hashedCssFile]) { + // 4a. Verify fetch sees Content-Encoding: gzip + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${assetFile}`, { + headers: { + 'Accept-Encoding': 'gzip', + }, + }); + assert.equal(res.status, 200); + assert.equal( + res.headers.get('content-encoding'), + 'gzip', + `Compressible asset ${assetFile} must be served with Content-Encoding: gzip`, + ); + + // 4b. Fetch raw wire bytes via http.request to prove wire compression and decompress with gunzipSync + const { statusCode, headers, rawWireBody } = await new Promise((resolveReq, rejectReq) => { + const req = httpRequest({ + hostname: '127.0.0.1', + port: nginxPort, + path: `/assets/${assetFile}`, + method: 'GET', + headers: { + 'Accept-Encoding': 'gzip', + }, + }, (incoming) => { + const chunks = []; + incoming.on('data', (c) => chunks.push(c)); + incoming.on('end', () => resolveReq({ + statusCode: incoming.statusCode, + headers: incoming.headers, + rawWireBody: Buffer.concat(chunks), + })); + }); + req.on('error', rejectReq); + req.end(); + }); + + assert.equal(statusCode, 200); + assert.equal(headers['content-encoding'], 'gzip', `Wire response for ${assetFile} must declare content-encoding: gzip`); + + // Gzip magic bytes check (0x1f, 0x8b) + assert.equal(rawWireBody[0], 0x1f, `First magic byte of gzip header for ${assetFile} must be 0x1f`); + assert.equal(rawWireBody[1], 0x8b, `Second magic byte of gzip header for ${assetFile} must be 0x8b`); + + const diskContent = readFileSync(join(webDistPath, 'assets', assetFile), 'utf8'); + assert.ok( + rawWireBody.length < Buffer.byteLength(diskContent), + `Compressed wire size for ${assetFile} (${rawWireBody.length} bytes) must be smaller than raw size (${Buffer.byteLength(diskContent)} bytes)`, + ); + + const decompressed = gunzipSync(rawWireBody).toString('utf8'); + assert.equal(decompressed, diskContent, `Decompressed wire bytes for ${assetFile} must match original disk content`); + } + }); + + test('6. Vary: response includes Accept-Encoding variation', async () => { + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`, { + headers: { + 'Accept-Encoding': 'gzip', + }, + }); + assert.equal(res.status, 200); + const vary = res.headers.get('vary'); + assert.ok(vary, 'Vary header must be present'); + assert.match(vary, /Accept-Encoding/i, 'Vary header must include Accept-Encoding'); + }); + + test('7. API proxy: continues routing correctly and avoids accidental public immutable caching', async () => { + const res = await fetch(`http://127.0.0.1:${nginxPort}/v1/health`); + assert.equal(res.status, 200, '/v1/health must proxy successfully'); + + assert.equal( + res.headers.get('content-encoding'), + null, + 'API upstream response must not receive nginx gzip compression (gzip off)', + ); + + const cacheControl = res.headers.get('cache-control'); + if (cacheControl) { + assert.doesNotMatch(cacheControl, /immutable/, 'API response must not have immutable caching'); + assert.doesNotMatch(cacheControl, /31536000/, 'API response must not have long-lived static max-age'); + } + }); + + test('8. WebSocket proxy: Upgrade and connection behavior not regressed', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${nginxPort}/ws`, { + origin: `http://127.0.0.1:${nginxPort}`, + }); + try { + await waitForOpen(ws); + assert.equal(ws.readyState, WebSocket.OPEN, 'WebSocket must connect successfully through Nginx'); + } finally { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.terminate(); + } + } + }); + + test('9. Security headers: all existing security headers preserved on responses', async () => { + const paths = ['/', `/assets/${hashedJsFile}`, `/assets/${unhashedAssetFile}`]; + + for (const path of paths) { + const res = await fetch(`http://127.0.0.1:${nginxPort}${path}`); + assert.equal(res.status, 200, `${path} must return 200 OK`); + + assertSecurityHeaders(res.headers, path); + } + }); + + test('10. Missing hashed and unhashed assets return 404 without immutable caching', async () => { + for (const missingPath of ['/assets/nonexistent-AbCd1234.js', '/assets/nonexistent.js']) { + const resMissingAsset = await fetch(`http://127.0.0.1:${nginxPort}${missingPath}`); + assert.equal(resMissingAsset.status, 404, `${missingPath} must return 404`); + const missingAssetCache = resMissingAsset.headers.get('cache-control'); + if (missingAssetCache) { + assert.doesNotMatch(missingAssetCache, /immutable/, `${missingPath} must not have immutable cache-control`); + assert.doesNotMatch(missingAssetCache, /max-age=31536000/, `${missingPath} must not have a one-year max-age`); + } + assertSecurityHeaders(resMissingAsset.headers, `${missingPath} 404`); + } + }); + + test('11. Hashed asset without gzip Accept-Encoding: returns valid original uncompressed representation with Vary header', async () => { + for (const assetFile of [hashedJsFile, hashedCssFile]) { + const diskContent = readFileSync(join(webDistPath, 'assets', assetFile), 'utf8'); + + // 10a. Explicit identity encoding + const resIdentity = await fetch(`http://127.0.0.1:${nginxPort}/assets/${assetFile}`, { + headers: { + 'Accept-Encoding': 'identity', + }, + }); + assert.equal(resIdentity.status, 200); + assert.equal(resIdentity.headers.get('content-encoding'), null, `Identity request for ${assetFile} must not receive Content-Encoding`); + assert.match( + resIdentity.headers.get('vary') || '', + /Accept-Encoding/i, + `Identity request for ${assetFile} must include Vary: Accept-Encoding (gzip_vary on)`, + ); + + const identityText = await resIdentity.text(); + assert.equal(identityText, diskContent, `Uncompressed response body for ${assetFile} must exactly match disk content`); + + // 10b. Omitted Accept-Encoding header (using httpRequest to avoid fetch's automatic Accept-Encoding header) + const { statusCode, headers, body } = await new Promise((resolveReq, rejectReq) => { + const req = httpRequest({ + hostname: '127.0.0.1', + port: nginxPort, + path: `/assets/${assetFile}`, + method: 'GET', + headers: {}, + }, (incoming) => { + const chunks = []; + incoming.on('data', (c) => chunks.push(c)); + incoming.on('end', () => resolveReq({ + statusCode: incoming.statusCode, + headers: incoming.headers, + body: Buffer.concat(chunks).toString('utf8'), + })); + }); + req.on('error', rejectReq); + req.end(); + }); + + assert.equal(statusCode, 200); + assert.equal(headers['content-encoding'], undefined, `Omitted encoding request for ${assetFile} must not receive Content-Encoding`); + assert.match( + headers['vary'] || '', + /Accept-Encoding/i, + `Omitted encoding request for ${assetFile} must include Vary: Accept-Encoding (gzip_vary on)`, + ); + assert.equal(body, diskContent, `Uncompressed response body for ${assetFile} must exactly match disk content`); + } + + // 10c. index.html also includes Vary: Accept-Encoding + const resIndex = await fetch(`http://127.0.0.1:${nginxPort}/index.html`, { + headers: { + 'Accept-Encoding': 'identity', + }, + }); + assert.equal(resIndex.status, 200); + assert.match( + resIndex.headers.get('vary') || '', + /Accept-Encoding/i, + 'index.html must include Vary: Accept-Encoding (gzip_vary on)', + ); + }); +}); diff --git a/services/gateway/package.json b/services/gateway/package.json index 95ec67db..3bbfa5dd 100644 --- a/services/gateway/package.json +++ b/services/gateway/package.json @@ -10,6 +10,7 @@ "start": "node dist/serve.js", "test": "tsc -p tsconfig.test.json && node --test \"dist-test/test/**/*.test.js\"", "test:trusted-edge": "npm --prefix ../.. run build:server && npm run build && node --test ../../scripts/nginx-trusted-edge-acceptance.mjs", + "test:web-delivery": "npm --prefix ../.. run build:web && npm --prefix ../.. run build:server && npm run build && node --test ../../scripts/nginx-web-delivery-acceptance.mjs", "lint": "tsc -p tsconfig.json --noEmit", "clean": "rm -rf dist dist-test" },