From 8f6160bf8d05f31ccd89bf6cadfbb2c70185891d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 13 Aug 2026 00:44:28 -0700 Subject: [PATCH] [eric] publish: the capability gate detects any unserved backend call, not just the literal /api (ENG-293) --- backend/apps/outputs/publish_capability.py | 34 ++++++- backend/tests/test_publish_capability.py | 10 +- ...ish_capability_detects_any_backend_call.py | 94 +++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_publish_capability_detects_any_backend_call.py diff --git a/backend/apps/outputs/publish_capability.py b/backend/apps/outputs/publish_capability.py index eee88477..162a5b6f 100644 --- a/backend/apps/outputs/publish_capability.py +++ b/backend/apps/outputs/publish_capability.py @@ -23,7 +23,37 @@ P_FRONTEND_EXTS = (".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".html") P_MAX_FILE_BYTES = 512 * 1024 P_MAX_LISTED = 8 # Matches /api/foo, "/api", '/api' and `/api` but not /apiary or /rapid. -P_API_CALL = re.compile(r"/api(?:/|[\"'`]|$)") +# A published app is served static files plus exactly two same-origin bridges, so ANY other +# same-origin call is the cliff. Matching the literal "/api" caught 1 of 6 real shapes: a base-URL +# constant, an env var, a proxy prefix, an absolute localhost URL and an axios baseURL all shipped +# silently broken. Spelling is the agent's choice, so the detector cannot depend on it. +P_BRIDGES = ("/__compute", "/__llm") +P_STATIC_EXT = re.compile(r"\.[a-z0-9]{2,5}([\"'`?#]|$)", re.I) +P_BACKEND_SIGNALS = ( + re.compile(r"/api(?:/|[\"'`]|$)"), + # const API_BASE = '/backend' / baseURL: "/server" + re.compile(r"(?:api[_-]?base|base[_-]?url|apiurl|api[_-]?url)\s*[:=]\s*[\"'`](/[^\"'`]*)", re.I), + # import.meta.env.VITE_API_URL, process.env.REACT_APP_API_BASE + re.compile(r"env\.[A-Z0-9_]*API[A-Z0-9_]*", re.I), + # a backend the published host cannot reach at all + re.compile(r"https?://(?:localhost|127\.0\.0\.1)(?::\d+)?"), +) +# fetch('/server/v1/items') with no file extension is an endpoint, not an asset. +P_ROOTED_FETCH = re.compile(r"fetch\(\s*[\"'`](/[^\"'`]*)") + + +def p_reaches_unserved_backend(text: str) -> bool: + """True when the source calls something the published deploy does not serve.""" + for pattern in P_BACKEND_SIGNALS: + if pattern.search(text): + return True + for path in P_ROOTED_FETCH.findall(text): + if path.startswith(P_BRIDGES): + continue + if P_STATIC_EXT.search(path): + continue + return True + return False class PublishCapabilityReport(BaseModel): @@ -68,7 +98,7 @@ def p_api_callers(root: str) -> List[str]: if os.path.getsize(full) > P_MAX_FILE_BYTES: continue with open(full, "r", encoding="utf-8", errors="replace") as fh: - if P_API_CALL.search(fh.read()): + if p_reaches_unserved_backend(fh.read()): hits.append(os.path.relpath(full, root)) except OSError: continue diff --git a/backend/tests/test_publish_capability.py b/backend/tests/test_publish_capability.py index 21683f36..d74202ae 100644 --- a/backend/tests/test_publish_capability.py +++ b/backend/tests/test_publish_capability.py @@ -100,11 +100,17 @@ def test_backend_dir_without_port_still_counts(p_ws_root): def test_apiary_is_not_an_api_call(p_ws_root): - """Prefix matching would flag /apiary and /rapid; the boundary is load-bearing.""" + """Prefix matching would flag /apiary and /rapid; the boundary is load-bearing. + + The paths carry a file extension on purpose. ENG-293 widened the gate from "does this + say /api" to "does this call something the published deploy cannot serve", and an + extension-less rooted fetch really does 404 up there. What must stay true is the + original point: the letters "api" inside a longer word are not a backend call. + """ out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", - frontend="fetch('/apiary/bees'); fetch('/rapid');\n", backend_main=True, + frontend="fetch('/apiary/bees.json'); fetch('/rapid.png');\n", backend_main=True, ) assert check_publish_capability(out).findings == [] diff --git a/backend/tests/test_publish_capability_detects_any_backend_call.py b/backend/tests/test_publish_capability_detects_any_backend_call.py new file mode 100644 index 00000000..f298c7cf --- /dev/null +++ b/backend/tests/test_publish_capability_detects_any_backend_call.py @@ -0,0 +1,94 @@ +"""The publish capability gate must catch a backend call however it is spelled (ENG-293). + +A published app gets static files plus two same-origin bridges (`/__compute`, one +sandboxed backend.py with no network, no disk and a 30s cap, and `/__llm`). There is +no `/api/*` route, so a frontend that calls its own FastAPI backend works in preview +and 404s the moment it has a public URL. + +The gate that warns about this matched the literal string `/api`. An agent that wrote +`const API_BASE = '/backend'` and then fetched `${API_BASE}/items` sailed past it and +shipped a broken app with no warning at all, which is the load-bearing-string-match +shape this codebase treats as a bug in its own right. + +These cases are the class, not one string: a base-URL constant, an env var, a proxy +prefix, and a rewritten route. The clean block underneath is the other half of the bar, +because a detector that flags a plain static app just trains people to hit Publish +Anyway. + +Run: + backend/.venv/bin/python -m pytest backend/tests/test_publish_capability_detects_any_backend_call.py -v +""" + +import uuid +from typing import Any, List, Tuple + +import pytest + +from backend.apps.outputs import publish_common +from backend.apps.outputs.models import Output +from backend.apps.outputs.publish_capability import check_publish_capability + + +# (name, frontend source) pairs that all reach a backend this deploy cannot serve. +P_REACHES_BACKEND: List[Tuple[str, str]] = [ + ("literal /api", "fetch('/api/items').then(r => r.json())"), + ("base-url constant", "const API_BASE = '/backend';\nfetch(`${API_BASE}/items`)"), + ("env var base", "const base = import.meta.env.VITE_API_URL;\nfetch(base + '/items')"), + ("proxy prefix", "await fetch('/server/v1/items')"), + ("absolute localhost", "fetch('http://localhost:8000/items')"), + ("axios instance", "axios.create({ baseURL: '/backend' })"), +] + +# Static apps that must NOT be flagged, or the warning becomes noise. +P_CLEAN: List[Tuple[str, str]] = [ + ("pure static", "document.querySelector('#app').textContent = 'hi'"), + ("uses the supported bridge", "const out = await window.OUTPUT_COMPUTE({ x: 1 })"), + ("uses the llm bridge", "const r = await window.OUTPUT_LLM({ prompt: 'hi' })"), + ("external api, not ours", "fetch('https://api.github.com/repos/x/y')"), +] + + +@pytest.fixture +def p_ws_root(tmp_path: Any, monkeypatch: Any) -> Any: + root = tmp_path / "ws" + root.mkdir() + monkeypatch.setattr(publish_common, "OUTPUTS_WORKSPACE_DIR", str(root)) + return root + + +def p_seeded_app(ws_root: Any, source: str) -> Output: + """A workspace with a real backend and one frontend file, through the public entry point.""" + wsid = uuid.uuid4().hex + (ws_root / wsid).mkdir() + out = Output(name="Demo", description="", files={}, workspace_id=wsid) + root = ws_root / wsid + (root / ".env").write_text("BACKEND_PORT=8123\n") + (root / "backend").mkdir() + (root / "backend" / "main.py").write_text("app = 1\n") + fe = root / "frontend" / "src" + fe.mkdir(parents=True) + (fe / "api.ts").write_text(source) + return out + + +@pytest.mark.parametrize("name,source", P_REACHES_BACKEND, ids=[n for n, _ in P_REACHES_BACKEND]) +def test_every_way_of_calling_a_backend_is_caught(p_ws_root: Any, name: str, source: str) -> None: + out = p_seeded_app(p_ws_root, source) + hits = check_publish_capability(out).api_callers + assert hits, ( + f"{name}: this app calls a backend the published deploy cannot serve, and the gate " + "said nothing. It will 404 in production having worked in preview." + ) + + +@pytest.mark.parametrize("name,source", P_CLEAN, ids=[n for n, _ in P_CLEAN]) +def test_a_static_app_is_not_flagged(p_ws_root: Any, name: str, source: str) -> None: + out = p_seeded_app(p_ws_root, source) + hits = check_publish_capability(out).api_callers + assert not hits, f"{name}: flagged an app that publishes fine, which trains users to ignore the warning" + + +def test_the_enumeration_did_not_shrink() -> None: + """A case list that quietly loses entries is a check that quietly stops checking.""" + assert len(P_REACHES_BACKEND) >= 6 + assert len(P_CLEAN) >= 4