diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84d250b..1cb5365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,16 +122,27 @@ jobs: *) echo "expected the inbox or a redirect to /login"; docker logs og; exit 1 ;; esac - - name: The landing page is public + - name: The landing page is public and actually has a body run: | - # / must render for a signed-out visitor, not redirect to /login. - code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/) - echo "GET / -> $code" + # --compressed matters: plain curl sends no Accept-Encoding, but every + # browser does. A proxy that forwards the child's content-encoding + # while fetch has already decompressed the body returns a 200 with + # zero bytes — which shipped once, and which a bare curl cannot see. + code=$(curl -s --compressed -o /tmp/landing.html -w '%{http_code}' http://127.0.0.1:8080/) + bytes=$(wc -c < /tmp/landing.html) + echo "GET / -> $code, $bytes bytes" + [ "$code" = "200" ] || { echo "the landing page must be public"; docker logs og; exit 1; } + [ "$bytes" -gt 1000 ] || { echo "landing page body is empty or truncated"; docker logs og; exit 1; } - curl -s http://127.0.0.1:8080/ | grep -q "warm conversations" \ + grep -q "warm conversations" /tmp/landing.html \ || { echo "landing page did not render its content"; exit 1; } + # The signed-in shell must survive compression too. + login_bytes=$(curl -s --compressed http://127.0.0.1:8080/login | wc -c) + echo "GET /login -> $login_bytes bytes" + [ "$login_bytes" -gt 500 ] || { echo "/login body is empty"; docker logs og; exit 1; } + - name: Auth is enforced run: | code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/v1/recommendations) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index cc96fb6..69687d3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -113,10 +113,19 @@ async function proxyToWeb(request: Request): Promise { const incoming = new URL(request.url); const target = new URL(incoming.pathname + incoming.search, `http://127.0.0.1:${WEB_PORT}`); + // Ask the child for identity encoding. `fetch` transparently decompresses + // what it receives, so a forwarded `content-encoding: gzip` would describe a + // body that is no longer compressed — the client then fails to decode it and + // renders nothing. Not requesting compression over loopback avoids the + // mismatch entirely and costs nothing. + const headers = new Headers(request.headers); + headers.set('accept-encoding', 'identity'); + + let response: Response; try { - return await fetch(target, { + response = await fetch(target, { method: request.method, - headers: request.headers, + headers, body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body, redirect: 'manual', // Streaming bodies require this; without it Bun buffers and rejects. @@ -126,6 +135,21 @@ async function proxyToWeb(request: Request): Promise { console.error('web proxy failed', error); return new Response('the web application is not responding', { status: 502 }); } + + // Strip hop-by-hop and body-framing headers. Whatever the child said about + // length or encoding describes its own wire format, not the one this + // response will be sent with. + const outbound = new Headers(response.headers); + outbound.delete('content-encoding'); + outbound.delete('content-length'); + outbound.delete('transfer-encoding'); + outbound.delete('connection'); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: outbound, + }); } // ------------------------------------------------------------------- server