From 1a3b9601f08d2b26c71989ff85624ecc60745023 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 16 Sep 2026 21:42:36 +0300 Subject: [PATCH 1/7] perf(web): harden production caching and compression --- .github/workflows/ci.yml | 8 +- docker/web/nginx.conf.template | 31 ++ docs/PROJECT_STATE.md | 17 +- scripts/ci-local.mjs | 1 + scripts/nginx-web-delivery-acceptance.mjs | 425 ++++++++++++++++++++++ services/gateway/package.json | 1 + 6 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 scripts/nginx-web-delivery-acceptance.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e8bbae..c52f603a 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/|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..5ebff2cd 100644 --- a/docker/web/nginx.conf.template +++ b/docker/web/nginx.conf.template @@ -8,6 +8,36 @@ 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; + + # Content-hashed static assets: safe for long-lived immutable caching. + 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 "public, max-age=31536000, immutable"; + 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 +47,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; } diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 91d18f46..95088fbc 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -6,7 +6,9 @@ > 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-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 +4238,10 @@ 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. 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..f8b6df65 --- /dev/null +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -0,0 +1,425 @@ +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 } 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); + }); +} + +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 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); + hashedJsFile = assetEntries.find((f) => f.endsWith('.js') && !f.endsWith('.map')); + hashedCssFile = assetEntries.find((f) => f.endsWith('.css')); + + if (!hashedJsFile) { + throw new Error('No hashed JS asset found in packages/web/dist/assets'); + } + + 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 () => { + 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 () => { + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`); + assert.equal(res.status, 200, 'Hashed asset must return 200 OK'); + + const cacheControl = res.headers.get('cache-control'); + assert.ok(cacheControl, 'Cache-Control header must be present on hashed asset'); + assert.match(cacheControl, /public/, 'Cache-Control must declare public'); + assert.match(cacheControl, /max-age=31536000/, 'Cache-Control must set 1-year max-age (31536000)'); + assert.match(cacheControl, /immutable/, 'Cache-Control must include immutable'); + }); + + test('2. index.html / SPA shell: NOT year-long immutable, explicit safe freshness/revalidation policy', 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'); + }); + + test('3. 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, / { + // 4a. Verify fetch sees Content-Encoding: gzip + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`, { + headers: { + 'Accept-Encoding': 'gzip', + }, + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-encoding'), 'gzip', 'Compressible asset 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/${hashedJsFile}`, + 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 must declare content-encoding: gzip'); + + // Gzip magic bytes check (0x1f, 0x8b) + assert.equal(rawWireBody[0], 0x1f, 'First magic byte of gzip header must be 0x1f'); + assert.equal(rawWireBody[1], 0x8b, 'Second magic byte of gzip header must be 0x8b'); + + const diskContent = readFileSync(join(webDistPath, 'assets', hashedJsFile), 'utf8'); + assert.ok( + rawWireBody.length < Buffer.byteLength(diskContent), + `Compressed wire size (${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 must match original disk content'); + }); + + test('5. 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('6. 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'); + + 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('7. WebSocket proxy: Upgrade and connection behavior not regressed', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${nginxPort}/ws`); + 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('8. Security headers: all existing security headers preserved on responses', async () => { + const paths = ['/', `/assets/${hashedJsFile}`]; + + 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`); + + assert.equal( + res.headers.get('content-security-policy'), + "frame-ancestors 'none'; object-src 'none'; base-uri 'self'", + `Content-Security-Policy missing or wrong on ${path}`, + ); + assert.equal( + res.headers.get('cross-origin-resource-policy'), + 'same-origin', + `Cross-Origin-Resource-Policy missing or wrong on ${path}`, + ); + assert.equal( + res.headers.get('permissions-policy'), + 'camera=(), geolocation=(), microphone=(), payment=()', + `Permissions-Policy missing or wrong on ${path}`, + ); + assert.equal( + res.headers.get('referrer-policy'), + 'no-referrer', + `Referrer-Policy missing or wrong on ${path}`, + ); + assert.equal( + res.headers.get('strict-transport-security'), + 'max-age=31536000; includeSubDomains', + `Strict-Transport-Security missing or wrong on ${path}`, + ); + assert.equal( + res.headers.get('x-content-type-options'), + 'nosniff', + `X-Content-Type-Options missing or wrong on ${path}`, + ); + assert.equal( + res.headers.get('x-frame-options'), + 'DENY', + `X-Frame-Options missing or wrong on ${path}`, + ); + } + }); + + test('9. Nonexistent route: does not receive misleading immutable caching', async () => { + const resMissingAsset = await fetch(`http://127.0.0.1:${nginxPort}/assets/nonexistent-hash-file.js`); + assert.equal(resMissingAsset.status, 404, 'Missing asset under /assets/ must return 404'); + const missingAssetCache = resMissingAsset.headers.get('cache-control'); + if (missingAssetCache) { + assert.doesNotMatch(missingAssetCache, /immutable/, '404 asset must NOT have immutable cache-control'); + } + }); + + test('10. Hashed asset without gzip Accept-Encoding: returns valid original uncompressed representation', async () => { + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`, { + headers: { + 'Accept-Encoding': 'identity', + }, + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-encoding'), null, 'Identity request must not receive Content-Encoding'); + + const text = await res.text(); + const diskContent = readFileSync(join(webDistPath, 'assets', hashedJsFile), 'utf8'); + assert.equal(text, diskContent, 'Uncompressed response body must exactly match disk content'); + }); +}); 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" }, From ef381d88114834dc6f85de88e2ec5e8fb163a27b Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 16 Sep 2026 21:57:15 +0300 Subject: [PATCH 2/7] fix(ci): trigger gateway on web changes and harden acceptance asset selection --- .github/workflows/ci.yml | 2 +- scripts/nginx-web-delivery-acceptance.mjs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c52f603a..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|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 '^(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 diff --git a/scripts/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs index f8b6df65..45e1fd3f 100644 --- a/scripts/nginx-web-delivery-acceptance.mjs +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -6,7 +6,7 @@ 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 } from 'node:fs'; +import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'; import { gunzipSync } from 'node:zlib'; import WebSocket from 'ws'; import { waitForHealth } from './lib/wait-for-health.mjs'; @@ -127,11 +127,18 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr } const assetEntries = readdirSync(assetsDir); - hashedJsFile = assetEntries.find((f) => f.endsWith('.js') && !f.endsWith('.map')); + // Select a 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')) return false; + const stat = statSync(join(assetsDir, f)); + return stat.size >= 1024; + }); hashedCssFile = assetEntries.find((f) => f.endsWith('.css')); if (!hashedJsFile) { - throw new Error('No hashed JS asset found in packages/web/dist/assets'); + throw new Error( + `No compressible hashed JS asset (>= 1024 bytes) found in packages/web/dist/assets (found: ${assetEntries.join(', ')})` + ); } apiPort = await getFreePort(); From 7a505f859b32c7a7394f9e7fb3abf5d455ea2492 Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 16 Sep 2026 22:10:56 +0300 Subject: [PATCH 3/7] test(gateway): provide explicit origin header for websocket acceptance test --- scripts/nginx-web-delivery-acceptance.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs index 45e1fd3f..a3536538 100644 --- a/scripts/nginx-web-delivery-acceptance.mjs +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -351,7 +351,9 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr }); test('7. WebSocket proxy: Upgrade and connection behavior not regressed', async () => { - const ws = new WebSocket(`ws://127.0.0.1:${nginxPort}/ws`); + 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'); From f3346efcc8c078df1ae75976f1dd4123f1b9a85e Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 16 Sep 2026 22:25:43 +0300 Subject: [PATCH 4/7] test(gateway): exercise CSS asset in gzip and identity delivery checks --- scripts/nginx-web-delivery-acceptance.mjs | 118 +++++++++++++--------- 1 file changed, 68 insertions(+), 50 deletions(-) diff --git a/scripts/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs index a3536538..df381cfe 100644 --- a/scripts/nginx-web-delivery-acceptance.mjs +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -133,13 +133,23 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr const stat = statSync(join(assetsDir, f)); return stat.size >= 1024; }); - hashedCssFile = assetEntries.find((f) => f.endsWith('.css')); + // Select a 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')) 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(', ')})` + ); + } apiPort = await getFreePort(); gwPort = await getFreePort(); @@ -278,53 +288,59 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr }); test('4. gzip: compressible production resource with Accept-Encoding: gzip demonstrates Content-Encoding: gzip', async () => { - // 4a. Verify fetch sees Content-Encoding: gzip - const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`, { - headers: { - 'Accept-Encoding': 'gzip', - }, - }); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-encoding'), 'gzip', 'Compressible asset 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/${hashedJsFile}`, - method: 'GET', + 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', }, - }, (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(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 must declare content-encoding: gzip'); + 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 must be 0x1f'); - assert.equal(rawWireBody[1], 0x8b, 'Second magic byte of gzip header must be 0x8b'); + // 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', hashedJsFile), 'utf8'); - assert.ok( - rawWireBody.length < Buffer.byteLength(diskContent), - `Compressed wire size (${rawWireBody.length} bytes) must be smaller than raw size (${Buffer.byteLength(diskContent)} bytes)`, - ); + 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 must match original disk content'); + const decompressed = gunzipSync(rawWireBody).toString('utf8'); + assert.equal(decompressed, diskContent, `Decompressed wire bytes for ${assetFile} must match original disk content`); + } }); test('5. Vary: response includes Accept-Encoding variation', async () => { @@ -419,16 +435,18 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr }); test('10. Hashed asset without gzip Accept-Encoding: returns valid original uncompressed representation', async () => { - const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`, { - headers: { - 'Accept-Encoding': 'identity', - }, - }); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-encoding'), null, 'Identity request must not receive Content-Encoding'); + for (const assetFile of [hashedJsFile, hashedCssFile]) { + const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${assetFile}`, { + headers: { + 'Accept-Encoding': 'identity', + }, + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-encoding'), null, `Identity request for ${assetFile} must not receive Content-Encoding`); - const text = await res.text(); - const diskContent = readFileSync(join(webDistPath, 'assets', hashedJsFile), 'utf8'); - assert.equal(text, diskContent, 'Uncompressed response body must exactly match disk content'); + const text = await res.text(); + const diskContent = readFileSync(join(webDistPath, 'assets', assetFile), 'utf8'); + assert.equal(text, diskContent, `Uncompressed response body for ${assetFile} must exactly match disk content`); + } }); }); From ba7cc27df08efdc3d293998b53a399075e894e9a Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Wed, 16 Sep 2026 22:37:42 +0300 Subject: [PATCH 5/7] test(gateway): enforce content-hash format on discovered acceptance assets --- scripts/nginx-web-delivery-acceptance.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs index df381cfe..162b9462 100644 --- a/scripts/nginx-web-delivery-acceptance.mjs +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -127,15 +127,15 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr } const assetEntries = readdirSync(assetsDir); - // Select a hashed JS asset meeting or exceeding Nginx's gzip minimum compression threshold (1024 bytes) + // 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')) return false; + 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 hashed CSS asset meeting or exceeding Nginx's gzip minimum compression threshold (1024 bytes) + // 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')) return false; + 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; }); From 49eb3ba17385b21bec5756214057d8d6dee6771b Mon Sep 17 00:00:00 2001 From: Hussein Mohamed Date: Sun, 20 Sep 2026 13:04:06 +0300 Subject: [PATCH 6/7] fix(nginx): disable gzip on API proxy path and verify error security headers and Vary contract --- docker/web/nginx.conf.template | 1 + scripts/nginx-web-delivery-acceptance.mjs | 109 ++++++++++++++++++++-- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/docker/web/nginx.conf.template b/docker/web/nginx.conf.template index 5ebff2cd..8bc8d2a7 100644 --- a/docker/web/nginx.conf.template +++ b/docker/web/nginx.conf.template @@ -76,6 +76,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/scripts/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs index 162b9462..782cc09e 100644 --- a/scripts/nginx-web-delivery-acceptance.mjs +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -359,6 +359,12 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr 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'); @@ -425,28 +431,115 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr } }); - test('9. Nonexistent route: does not receive misleading immutable caching', async () => { + test('9. Nonexistent route: does not receive misleading immutable caching and preserves security headers', async () => { const resMissingAsset = await fetch(`http://127.0.0.1:${nginxPort}/assets/nonexistent-hash-file.js`); assert.equal(resMissingAsset.status, 404, 'Missing asset under /assets/ must return 404'); const missingAssetCache = resMissingAsset.headers.get('cache-control'); if (missingAssetCache) { assert.doesNotMatch(missingAssetCache, /immutable/, '404 asset must NOT have immutable cache-control'); } + + // Verify all 7 security headers survive 404 responses via 'always' directive + assert.equal( + resMissingAsset.headers.get('content-security-policy'), + "frame-ancestors 'none'; object-src 'none'; base-uri 'self'", + 'Content-Security-Policy must be present on 404 via always', + ); + assert.equal( + resMissingAsset.headers.get('cross-origin-resource-policy'), + 'same-origin', + 'Cross-Origin-Resource-Policy must be present on 404 via always', + ); + assert.equal( + resMissingAsset.headers.get('permissions-policy'), + 'camera=(), geolocation=(), microphone=(), payment=()', + 'Permissions-Policy must be present on 404 via always', + ); + assert.equal( + resMissingAsset.headers.get('referrer-policy'), + 'no-referrer', + 'Referrer-Policy must be present on 404 via always', + ); + assert.equal( + resMissingAsset.headers.get('strict-transport-security'), + 'max-age=31536000; includeSubDomains', + 'Strict-Transport-Security must be present on 404 via always', + ); + assert.equal( + resMissingAsset.headers.get('x-content-type-options'), + 'nosniff', + 'X-Content-Type-Options must be present on 404 via always', + ); + assert.equal( + resMissingAsset.headers.get('x-frame-options'), + 'DENY', + 'X-Frame-Options must be present on 404 via always', + ); }); - test('10. Hashed asset without gzip Accept-Encoding: returns valid original uncompressed representation', async () => { + test('10. Hashed asset without gzip Accept-Encoding: returns valid original uncompressed representation with Vary header', async () => { for (const assetFile of [hashedJsFile, hashedCssFile]) { - const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${assetFile}`, { + 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(res.status, 200); - assert.equal(res.headers.get('content-encoding'), null, `Identity request for ${assetFile} must not receive Content-Encoding`); + 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 text = await res.text(); - const diskContent = readFileSync(join(webDistPath, 'assets', assetFile), 'utf8'); - assert.equal(text, diskContent, `Uncompressed response body for ${assetFile} must exactly match disk content`); + 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)', + ); }); }); From 70801433c70ec5bacbc7a9ed234db8f5f603d60d Mon Sep 17 00:00:00 2001 From: sayed710 Date: Tue, 22 Sep 2026 13:04:13 +0300 Subject: [PATCH 7/7] fix(web): restrict immutable caching to hashed assets --- docker/web/nginx.conf.template | 19 ++- docs/PROJECT_STATE.md | 9 +- scripts/nginx-web-delivery-acceptance.mjs | 175 ++++++++++------------ 3 files changed, 106 insertions(+), 97 deletions(-) diff --git a/docker/web/nginx.conf.template b/docker/web/nginx.conf.template index 8bc8d2a7..d60f33fb 100644 --- a/docker/web/nginx.conf.template +++ b/docker/web/nginx.conf.template @@ -25,8 +25,9 @@ server { image/svg+xml application/manifest+json; - # Content-hashed static assets: safe for long-lived immutable caching. - location /assets/ { + # 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; @@ -38,6 +39,20 @@ server { 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; diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 95088fbc..d453abac 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -6,7 +6,9 @@ > to read **only this file** and continue immediately. Updated after every > milestone and every significant architectural step. -_Last updated: 2026-09-16 — M15 Increment 59: production web delivery caching and compression 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._ @@ -4245,3 +4247,8 @@ Per package: `cd packages/ && npm install && npm run build && npm test`. - **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/nginx-web-delivery-acceptance.mjs b/scripts/nginx-web-delivery-acceptance.mjs index 782cc09e..a4105f56 100644 --- a/scripts/nginx-web-delivery-acceptance.mjs +++ b/scripts/nginx-web-delivery-acceptance.mjs @@ -6,7 +6,7 @@ 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 } from 'node:fs'; +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'; @@ -99,6 +99,29 @@ async function waitForOpen(ws, timeoutMs = 5_000) { }); } +/** 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; @@ -112,6 +135,9 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr 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'; @@ -151,6 +177,9 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr ); } + // Deterministically exercise the runtime fallback for an existing unhashed /assets/ file. + writeFileSync(unhashedAssetPath, unhashedAssetContent, 'utf8'); + apiPort = await getFreePort(); gwPort = await getFreePort(); gwHealthPort = await getFreePort(); @@ -227,6 +256,7 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr }); after(async () => { + rmSync(unhashedAssetPath, { force: true }); if (nginxContainerName) { try { execSync(`docker rm -f ${nginxContainerName}`, { stdio: 'ignore' }); @@ -246,17 +276,32 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr }); test('1. Content-hashed static asset: HTTP 200, long-lived Cache-Control with immutable', async () => { - const res = await fetch(`http://127.0.0.1:${nginxPort}/assets/${hashedJsFile}`); - assert.equal(res.status, 200, 'Hashed asset must return 200 OK'); + 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, 'Cache-Control header must be present on hashed asset'); - assert.match(cacheControl, /public/, 'Cache-Control must declare public'); - assert.match(cacheControl, /max-age=31536000/, 'Cache-Control must set 1-year max-age (31536000)'); - assert.match(cacheControl, /immutable/, 'Cache-Control must include immutable'); + 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('2. index.html / SPA shell: NOT year-long immutable, explicit safe freshness/revalidation policy', async () => { + 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'); @@ -272,9 +317,18 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr 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('3. SPA deep-link fallback: returns shell correctly without inheriting immutable asset policy', async () => { + 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'); @@ -287,7 +341,7 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr assert.match(cacheControl, /no-cache/, 'SPA fallback must use safe revalidation policy'); }); - test('4. gzip: compressible production resource with Accept-Encoding: gzip demonstrates Content-Encoding: gzip', async () => { + test('5. gzip: compressible production resource with Accept-Encoding: gzip demonstrates Content-Encoding: gzip', async () => { 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}`, { @@ -343,7 +397,7 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr } }); - test('5. Vary: response includes Accept-Encoding variation', async () => { + 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', @@ -355,7 +409,7 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr assert.match(vary, /Accept-Encoding/i, 'Vary header must include Accept-Encoding'); }); - test('6. API proxy: continues routing correctly and avoids accidental public immutable caching', async () => { + 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'); @@ -372,7 +426,7 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr } }); - test('7. WebSocket proxy: Upgrade and connection behavior not regressed', async () => { + 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}`, }); @@ -386,98 +440,31 @@ describe('Real Nginx Path Acceptance: Web Delivery Caching and Compression Contr } }); - test('8. Security headers: all existing security headers preserved on responses', async () => { - const paths = ['/', `/assets/${hashedJsFile}`]; + 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`); - assert.equal( - res.headers.get('content-security-policy'), - "frame-ancestors 'none'; object-src 'none'; base-uri 'self'", - `Content-Security-Policy missing or wrong on ${path}`, - ); - assert.equal( - res.headers.get('cross-origin-resource-policy'), - 'same-origin', - `Cross-Origin-Resource-Policy missing or wrong on ${path}`, - ); - assert.equal( - res.headers.get('permissions-policy'), - 'camera=(), geolocation=(), microphone=(), payment=()', - `Permissions-Policy missing or wrong on ${path}`, - ); - assert.equal( - res.headers.get('referrer-policy'), - 'no-referrer', - `Referrer-Policy missing or wrong on ${path}`, - ); - assert.equal( - res.headers.get('strict-transport-security'), - 'max-age=31536000; includeSubDomains', - `Strict-Transport-Security missing or wrong on ${path}`, - ); - assert.equal( - res.headers.get('x-content-type-options'), - 'nosniff', - `X-Content-Type-Options missing or wrong on ${path}`, - ); - assert.equal( - res.headers.get('x-frame-options'), - 'DENY', - `X-Frame-Options missing or wrong on ${path}`, - ); + assertSecurityHeaders(res.headers, path); } }); - test('9. Nonexistent route: does not receive misleading immutable caching and preserves security headers', async () => { - const resMissingAsset = await fetch(`http://127.0.0.1:${nginxPort}/assets/nonexistent-hash-file.js`); - assert.equal(resMissingAsset.status, 404, 'Missing asset under /assets/ must return 404'); - const missingAssetCache = resMissingAsset.headers.get('cache-control'); - if (missingAssetCache) { - assert.doesNotMatch(missingAssetCache, /immutable/, '404 asset must NOT have immutable cache-control'); + 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`); } - - // Verify all 7 security headers survive 404 responses via 'always' directive - assert.equal( - resMissingAsset.headers.get('content-security-policy'), - "frame-ancestors 'none'; object-src 'none'; base-uri 'self'", - 'Content-Security-Policy must be present on 404 via always', - ); - assert.equal( - resMissingAsset.headers.get('cross-origin-resource-policy'), - 'same-origin', - 'Cross-Origin-Resource-Policy must be present on 404 via always', - ); - assert.equal( - resMissingAsset.headers.get('permissions-policy'), - 'camera=(), geolocation=(), microphone=(), payment=()', - 'Permissions-Policy must be present on 404 via always', - ); - assert.equal( - resMissingAsset.headers.get('referrer-policy'), - 'no-referrer', - 'Referrer-Policy must be present on 404 via always', - ); - assert.equal( - resMissingAsset.headers.get('strict-transport-security'), - 'max-age=31536000; includeSubDomains', - 'Strict-Transport-Security must be present on 404 via always', - ); - assert.equal( - resMissingAsset.headers.get('x-content-type-options'), - 'nosniff', - 'X-Content-Type-Options must be present on 404 via always', - ); - assert.equal( - resMissingAsset.headers.get('x-frame-options'), - 'DENY', - 'X-Frame-Options must be present on 404 via always', - ); }); - test('10. Hashed asset without gzip Accept-Encoding: returns valid original uncompressed representation with Vary header', async () => { + 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');