From 0dfa9284735cd0d5646888c88943adf370bf1765 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 3 Oct 2026 02:02:14 -0700 Subject: [PATCH] fix(ui): serve navigation payloads under the UI prefix --- .../test_ui_image_serves_offline.py | 48 +++++++++++++++++++ ui/nginx.conf | 17 ++----- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/tests/proxy_migration_tests/test_ui_image_serves_offline.py b/tests/proxy_migration_tests/test_ui_image_serves_offline.py index 5ff68effd7f..6f9f116c5bc 100644 --- a/tests/proxy_migration_tests/test_ui_image_serves_offline.py +++ b/tests/proxy_migration_tests/test_ui_image_serves_offline.py @@ -23,6 +23,9 @@ import subprocess import time import uuid from collections.abc import Iterator +from pathlib import PurePosixPath +from typing import Final +from urllib.parse import quote import pytest @@ -130,3 +133,48 @@ def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> f"GET {path} returned {page.stdout.strip()!r} as uid {ARBITRARY_UID}.\n" f"{_container_logs(container)}" ) + + +@pytest.mark.parametrize("route", ("", "teams", "mcp/oauth/callback")) +def test_ui_navigation_payloads_match_static_export(ui_container: tuple[str, str], route: str) -> None: + network, container = ui_container + export_root: Final = PurePosixPath("/usr/share/nginx/html") + payload_files: Final = _docker( + "exec", container, "find", str(export_root / route), "-maxdepth", "1", "-type", "f", "-name", "*.txt" + ).stdout.splitlines() + assert payload_files, f"no navigation payloads exported for {route!r}" + + for payload_file in payload_files: + relative_path: Final = PurePosixPath(payload_file).relative_to(export_root) + canonical_path: Final = f"/ui/{relative_path}" + request_paths: Final = ( + (canonical_path, f"/ui/{route}.txt" if route else "/ui.txt") + if relative_path.name == "index.txt" + else (canonical_path,) + ) + expected_payload: Final = _docker("exec", container, "cat", payload_file).stdout + for request_path in request_paths: + response: Final = _docker( + "run", + "--rm", + "--network", + network, + CURL_IMAGE, + "--silent", + "--show-error", + "--max-time", + "10", + "--write-out", + "\n%{http_code}\n%{content_type}", + f"http://{container}:{UI_PORT}{quote(request_path)}?_rsc=navigation", + ) + body, status, content_type = response.stdout.rsplit("\n", 2) + assert status == "200", f"GET {request_path} returned {status}: {body}" + assert content_type.startswith("text/plain"), f"GET {request_path} returned {content_type}" + assert body == expected_payload, f"GET {request_path} did not serve its exported navigation payload" + + +def test_ui_missing_navigation_payload_returns_404(ui_container: tuple[str, str]) -> None: + network, container = ui_container + response: Final = _probe(network, container, f"/ui/teams/{uuid.uuid4().hex}.txt?_rsc=navigation") + assert response.stdout.strip() == "404", response.stdout diff --git a/ui/nginx.conf b/ui/nginx.conf index 235cb9c501e..d4a414498c4 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -73,18 +73,11 @@ http { # Probe target — doesn't depend on disk. location = /healthz { default_type text/plain; return 200 "ok\n"; } - # Next.js App Router (output: "export") emits an RSC/flight payload - # as .txt next to .html, plus __next.*.txt segment - # data. The client router fetches these on soft navigation/prefetch - # (?_rsc=) — the query string is irrelevant, files resolve by - # $uri. These MUST be served from the export: if they fall through - # to the catch-all 404 below, client-side navigation never settles - # and the login flow spins in an infinite redirect loop - # (/ ⇄ /ui/login). Keep this BEFORE the /ui/ regex — ^/ui/(.+)$ is - # also a regex and nginx takes the first matching one, so a stray - # /ui/.txt would otherwise be rewritten to HTML and break RSC - # for nested routes. A genuinely missing payload must 404 (the - # router degrades to a hard navigation); never fall back to HTML. + location = /ui.txt { try_files /index.txt =404; } + location ~ ^/ui/(.+)\.txt$ { + try_files /$1.txt /$1/index.txt =404; + } + location ~ \.txt$ { try_files $uri =404; }