[eric] publish: don't embed the install token in preview app JS; stub OUTPUT_* (run only when published)

This commit is contained in:
ciregenz
2026-06-19 17:47:25 -07:00
parent ecaad7c6bd
commit ef1c7a28e2
5 changed files with 27 additions and 74 deletions
+3 -1
View File
@@ -330,7 +330,9 @@ export const JOBS_LIST = '/api/jobs/list';
The FastAPI backend above runs in preview but is **not hosted when an app is
published** to the web. For features that should keep working on a published
`{slug}.openswarm.host` link, use these two runtime calls instead of a backend.
They behave the same in preview and when published.
They run on the published site (same-origin, no credentials). In the App Builder
**preview** they throw a clear "available once published" error, preview can't run
them without embedding a credential into your app, so test these by publishing.
**AI (Claude):** call `window.OUTPUT_LLM` with an Anthropic-style messages body.
The model is chosen for you (a cheap default), so don't pass one.
+14 -28
View File
@@ -54,32 +54,20 @@ def _validate_against_schema(data: dict, schema: dict) -> str | None:
return f"Schema validation failed at {path}: {exc.message}"
def _runtime_helpers_js(token: str, output_id: str | None) -> str:
"""OUTPUT_COMPUTE / OUTPUT_LLM: the same runtime API the published edge injects,
pointed at this install's backend so an app works in preview AND when published.
The install token rides in the header (the iframe already exposes it via the
relative-URL rewrite; this is the same local-only credential, not a cloud secret)."""
auth = json.dumps(f"Bearer {token}")
js = ""
if output_id:
oid = json.dumps(output_id)
js += (
" window.OUTPUT_COMPUTE = async function (input) {\n"
" var r = await fetch('/api/outputs/execute', {method:'POST', headers:{'Content-Type':'application/json','Authorization': " + auth + "}, body: JSON.stringify({output_id: " + oid + ", input_data: input || {}, force: true})});\n"
" var d = await r.json();\n"
" if (d.error) throw new Error(d.error);\n"
" return d.backend_result;\n"
" };\n"
)
js += (
" window.OUTPUT_LLM = async function (body) {\n"
" return fetch('/api/outputs/llm', {method:'POST', headers:{'Content-Type':'application/json','Authorization': " + auth + "}, body: JSON.stringify(body || {})});\n"
" };\n"
def _runtime_helpers_js() -> str:
"""OUTPUT_COMPUTE / OUTPUT_LLM only run for real on the published edge, where they
are same-origin and carry NO credentials. In the App Builder preview we
deliberately do NOT wire them to the authenticated backend: doing so would embed
this install's token into the app's own JS (the exact exposure SECURITY.md item A
is about). Preview defines readable stubs instead, the app degrades with a clear
message rather than crashing or leaking a credential."""
return (
" window.OUTPUT_COMPUTE = async function () { throw new Error('OUTPUT_COMPUTE runs once this app is published.'); };\n"
" window.OUTPUT_LLM = async function () { throw new Error('OUTPUT_LLM runs once this app is published.'); };\n"
)
return js
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null", runtime: dict | None = None) -> str:
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null", with_runtime: bool = False) -> str:
"""Build a <script> tag that sets OUTPUT_INPUT / OUTPUT_BACKEND_RESULT /
OUTPUT_BACKEND_URL, optionally wires OUTPUT_COMPUTE / OUTPUT_LLM, and listens
for postMessage updates.
@@ -88,9 +76,7 @@ def _build_data_injection(input_json: str, result_json: str, backend_url_json: s
process; otherwise it's `http://localhost:<port>` and app code can
`fetch(window.OUTPUT_BACKEND_URL + '/route')` to hit the persistent
backend's endpoints."""
helpers = ""
if runtime and runtime.get("token"):
helpers = _runtime_helpers_js(runtime["token"], runtime.get("output_id"))
helpers = _runtime_helpers_js() if with_runtime else ""
return (
"<script>\n"
"(function() {\n"
@@ -111,8 +97,8 @@ def _build_data_injection(input_json: str, result_json: str, backend_url_json: s
)
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null", runtime: dict | None = None) -> str:
injection = _build_data_injection(input_json, result_json, backend_url_json, runtime)
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null", with_runtime: bool = False) -> str:
injection = _build_data_injection(input_json, result_json, backend_url_json, with_runtime)
if "</head>" in html:
return html.replace("</head>", f"{injection}\n</head>", 1)
if "<body" in html:
-8
View File
@@ -235,11 +235,3 @@ class PublishResult(BaseModel):
blocked: bool = False
review: Optional[PublishReview] = None
error: Optional[str] = None
class AppLLMRequest(BaseModel):
# The runtime LLM call an app makes via window.OUTPUT_LLM. Anthropic-ish shape;
# the model is chosen server-side (the user's cheap tier), not by the app.
messages: list[dict[str, Any]] = Field(default_factory=list)
system: Optional[str] = None
max_tokens: int = 1024
+3 -30
View File
@@ -13,7 +13,7 @@ from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, WorkspaceSeedRequest,
PublishPreflightRequest, PublishRequest, PublishPreflightResponse,
PublishResult, PublishReview, AppLLMRequest,
PublishResult, PublishReview,
)
from backend.apps.outputs.executor import execute_backend_code, get_code_warnings
from backend.apps.outputs.publish import (
@@ -93,7 +93,7 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
if filepath == "index.html":
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
backend_url_json = _backend_url_for_workspace(workspace_id)
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, runtime={"token": get_auth_token()})
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
# parent's ?token= query string, so rewrite the HTML to put the token
# back on every relative URL; otherwise sub-resources 401.
@@ -114,7 +114,7 @@ async def serve_output_file(output_id: str, filepath: str, _d: str = ""):
if filepath == "index.html":
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
backend_url_json = _backend_url_for_workspace(output.workspace_id) if output.workspace_id else "null"
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, runtime={"token": get_auth_token(), "output_id": output.id})
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
content = _inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
@@ -807,33 +807,6 @@ async def publish_output(body: PublishRequest):
).model_dump()
@outputs.router.post("/llm")
async def app_llm(body: AppLLMRequest):
"""Runtime LLM for an app's window.OUTPUT_LLM call. Uses the user's configured
cheap tier (provider-agnostic), so an app's AI features work in the App Builder
preview the same way the published edge serves them via /__llm. Non-streaming:
the app reads `(await res.json()).content[0].text`."""
if not body.messages:
raise HTTPException(status_code=400, detail="messages is required")
settings = load_settings()
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.agents.core.aux_llm import _safe_resp_text
try:
model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
except Exception:
raise HTTPException(status_code=503, detail="No model is configured. Add a key or connect a plan in Settings.")
client = _get_anthropic_client(model)
kwargs: dict = {"model": model, "max_tokens": max(1, min(body.max_tokens, 4096)), "messages": body.messages}
if body.system:
kwargs["system"] = body.system
try:
resp = await client.messages.create(**kwargs)
except Exception:
logger.exception("app llm call failed")
raise HTTPException(status_code=502, detail="The model call failed.")
return {"content": [{"type": "text", "text": _safe_resp_text(resp)}], "model": model}
@outputs.router.post("/unpublish")
async def unpublish_output(body: PublishPreflightRequest):
"""Take the app offline and clear its publish state."""
+7 -7
View File
@@ -114,15 +114,15 @@ def test_runtime_injection():
from backend.apps.outputs.html_inject import _build_data_injection, _inject_data_into_html
base = _build_data_injection("{}", "null")
assert "OUTPUT_COMPUTE" not in base and "OUTPUT_LLM" not in base # no runtime -> no helpers
assert "OUTPUT_COMPUTE" not in base and "OUTPUT_LLM" not in base # off by default
full = _build_data_injection("{}", "null", "null", {"token": "tok123", "output_id": "abc"})
assert "OUTPUT_COMPUTE" in full and "OUTPUT_LLM" in full and "Bearer tok123" in full
rt = _build_data_injection("{}", "null", "null", with_runtime=True)
assert "OUTPUT_COMPUTE" in rt and "OUTPUT_LLM" in rt # preview stubs are defined
# Preview must NEVER embed the install token into app JS (SECURITY.md item A).
assert "Bearer" not in rt and "Authorization" not in rt
assert "once this app is published" in rt
llm_only = _build_data_injection("{}", "null", "null", {"token": "tok123"})
assert "OUTPUT_LLM" in llm_only and "OUTPUT_COMPUTE" not in llm_only # webapp: no output_id -> LLM only
html = _inject_data_into_html("<html><head></head><body>x</body></html>", "{}", "null", "null", {"token": "t"})
html = _inject_data_into_html("<html><head></head><body>x</body></html>", "{}", "null", "null", with_runtime=True)
assert "OUTPUT_LLM" in html and "</head>" in html