"""Catch the publish cliff before it ships: an app whose frontend calls its own FastAPI backend works in preview and breaks on its public URL. Publishing uploads a STATIC bundle. The edge serves that bundle plus exactly two runtime bridges (`/__compute`, which runs a single sandboxed `backend.py`, and `/__llm`); there is no `/api/*` route, so every `/api/...` fetch falls through to the static catch-all and 404s. Nothing else in the publish path notices, because `publish_scan` is a SECURITY scan. This module is the capability scan.""" from __future__ import annotations import os import re from typing import List from pydantic import BaseModel, ConfigDict from typeguard import typechecked from backend.apps.outputs.models import Output, PublishReview from backend.apps.outputs.publish_common import is_webapp, workspace_dir from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS 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(?:/|[\"'`]|$)") class PublishCapabilityReport(BaseModel): model_config = ConfigDict(validate_assignment=True) backend_enabled: bool = False backend_port: str = "" api_callers: List[str] = [] findings: List[str] = [] @typechecked def p_backend_port(root: str) -> str: """The workspace's BACKEND_PORT, or "" when the backend was never enabled.""" env_path = os.path.join(root, ".env") try: with open(env_path, "r", encoding="utf-8", errors="replace") as fh: for line in fh: key, _, value = line.partition("=") if key.strip() != "BACKEND_PORT": continue port = value.split("#", 1)[0].strip() return "" if port.upper() in ("", "NONE") else port except OSError: return "" return "" @typechecked def p_api_callers(root: str) -> List[str]: """Frontend files that reach for /api/..., relative to the workspace root.""" hits: List[str] = [] for base, dirs, fnames in os.walk(root): dirs[:] = [d for d in dirs if d not in WALK_SKIP_DIRS and d != "backend"] for fn in fnames: if not fn.lower().endswith(P_FRONTEND_EXTS): continue full = os.path.join(base, fn) if os.path.islink(full): continue try: 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()): hits.append(os.path.relpath(full, root)) except OSError: continue return sorted(hits) @typechecked def check_publish_capability(output: Output) -> PublishCapabilityReport: """Does this app depend on something publishing cannot carry?""" if not is_webapp(output): return PublishCapabilityReport() root = workspace_dir(output) port = p_backend_port(root) has_backend = bool(port) or os.path.isfile(os.path.join(root, "backend", "main.py")) if not has_backend: return PublishCapabilityReport() callers = p_api_callers(root) if not callers: return PublishCapabilityReport(backend_enabled=True, backend_port=port) shown = ", ".join(callers[:P_MAX_LISTED]) if len(callers) > P_MAX_LISTED: shown += f", and {len(callers) - P_MAX_LISTED} more" return PublishCapabilityReport( backend_enabled=True, backend_port=port, api_callers=callers, findings=[ "This app has a FastAPI backend, and publishing does not upload it. " f"{len(callers)} frontend file(s) call /api/... ({shown}); those requests " "will 404 on the published URL even though they work in preview.", "A published app gets static files plus two same-origin bridges: " "window.OUTPUT_COMPUTE(input), which runs a single sandboxed backend.py " "(pure compute, no network, no disk, 30s limit), and window.OUTPUT_LLM(body). " "Move the server-side logic into backend.py to use OUTPUT_COMPUTE, or keep " "this app local instead of publishing it.", ], ) @typechecked def merge_capability(output: Output, review: PublishReview) -> PublishReview: """Capability findings ride OUTSIDE the security memo, which is keyed on a source hash that never sees .env, so a backend_init.sh run would otherwise return a cached all-clear.""" report = check_publish_capability(output) if not report.findings: return review return PublishReview( verdict="block", findings=report.findings + review.findings, scanned_files=review.scanned_files, )