mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
[eric] publish: don't embed the install token in preview app JS; stub OUTPUT_* (run only when published)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user