"+str(w.get("title") or w["id"])+"
"+str(w.get("description",""))+"
diff --git a/server.py b/server.py
index 3c29977..edaaa42 100644
--- a/server.py
+++ b/server.py
@@ -1,173 +1,109 @@
-"""Phast public landing and fail-closed owner control plane."""
from __future__ import annotations
-
-import os
+import json,os,re
from pathlib import Path
-
-import uvicorn
+import httpx,uvicorn
from fastmcp import FastMCP
+from fastmcp.server.dependencies import get_access_token
+from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
+from fastmcp.server.auth.oauth_proxy import OAuthProxy
from prefab_ui.app import PrefabApp
-from prefab_ui.components import Badge, Button, Card, Code, Column, Heading, Row, Separator, Text
-from starlette.requests import Request
-from starlette.responses import HTMLResponse, JSONResponse
-
-try:
- from .registry import AppRegistry, RegistryUnavailable, registry_from_runtime
-except ImportError:
- from registry import AppRegistry, RegistryUnavailable, registry_from_runtime
-
-OWNER_HOST = "my.phast.app"
-AUTH_BLOCKER = "Owner authentication is not configured; app mutations are disabled."
-
-try:
- registry: AppRegistry | None = registry_from_runtime()
- registry_blocker: str | None = None
-except RegistryUnavailable as error:
- registry = None
- registry_blocker = str(error)
-
-mcp = FastMCP(
- "My Phast",
- instructions="Management MCP for portable Phast tool belts and Prefab homes. Mutations fail closed until owner auth and durable provisioning are installed.",
-)
-
-def _require_registry() -> AppRegistry:
- if registry is None:
- raise RegistryUnavailable(registry_blocker or "durable registry is offline")
- return registry
-
-def _require_owner_auth() -> None:
- raise PermissionError(AUTH_BLOCKER)
-
-@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
-def phast_status() -> dict:
- return {
- "ok": True,
- "service": "phast",
- "url": "https://phast.app",
- "owner_url": "https://my.phast.app",
- "mcp": "https://my.phast.app/mcp",
- "registry_ready": registry is not None,
- "registry_blocker": registry_blocker,
- "mutations_ready": False,
- "auth_blocker": AUTH_BLOCKER,
- }
-
-@mcp.tool(name="phasts_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
-def phasts_list() -> dict:
- return {"apps": _require_registry().list()}
-
-@mcp.tool(name="phasts_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
-def phasts_get(slug: str) -> dict:
- result = _require_registry().get(slug)
- if result is None:
- raise KeyError(f"unknown app: {slug}")
- return result
-
-@mcp.tool(name="phasts_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False})
-def phasts_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict:
- _require_owner_auth()
- return _require_registry().create(slug=slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route)
-
-@mcp.tool(name="phasts_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True})
-def phasts_update(slug: str, name: str | None = None, base_url: str | None = None,
- human_route: str | None = None, mcp_route: str | None = None) -> dict:
- _require_owner_auth()
- return _require_registry().update(slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route)
-
-@mcp.tool(name="providers_search", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
-def providers_search(query: str) -> dict:
- """V0 seam: provider discovery belongs to Oracle, not a hard-coded Phast catalog."""
- return {"query": query, "source": "oracle", "status": "discovery-route-pending"}
-
-@mcp.tool(name="widgets_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
-def widgets_list(slug: str) -> dict:
- item = phasts_get(slug)
- return {"phast": slug, "widgets": item.get("prefab", {}).get("home", {}).get("widgets", [])}
-
-@mcp.tool(name="widgets_add", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False})
-def widgets_add(slug: str, widget: dict) -> dict:
- _require_owner_auth()
- raise NotImplementedError("durable phast.json widget persistence is not installed yet")
-
-@mcp.tool(name="widgets_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True})
-def widgets_update(slug: str, widget_id: str, widget: dict) -> dict:
- _require_owner_auth()
- raise NotImplementedError("durable phast.json widget persistence is not installed yet")
-
-@mcp.tool(name="widgets_remove", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True})
-def widgets_remove(slug: str, widget_id: str) -> dict:
- _require_owner_auth()
- raise NotImplementedError("durable phast.json widget persistence is not installed yet")
-
-def control_app() -> PrefabApp:
- apps = registry.list() if registry is not None else []
- with PrefabApp(title="My Phast", css_class="max-w-4xl mx-auto p-6", mode="light") as result:
- with Column(gap=5):
- with Row(gap=3, justify="between", align="center"):
- with Column(gap=1):
- Text("PHAST · OWNER CONTROL PLANE", code=[REDACTED]
- Heading("My Phast", level=1)
- Badge("read-only" if registry is not None else "setup required", variant="warning")
- Text("Your apps share one boring convention: a human surface at / and an agent surface at /mcp.")
- with Card(css_class="p-5"):
- with Column(gap=3):
- with Row(gap=2, justify="between", align="center"):
- Heading("Apps", level=2)
- Badge(str(len(apps)), variant="secondary")
- if apps:
- for item in apps:
- with Card(css_class="p-4"):
- with Row(gap=3, justify="between", align="center"):
- with Column(gap=1):
- Heading(item["name"], level=3)
- Text(item["base_url"], code=[REDACTED]
- Badge(item["runtime"], variant="outline")
- Text(f"human {item['human']['route']} · mcp {item['mcp']['route']}")
- else:
- Text("No durable app registry is available yet.")
- with Card(css_class="p-5"):
- with Column(gap=3):
- Heading("Control-plane readiness", level=2)
- Text(registry_blocker or "Durable SQLite registry is mounted and readable.")
- Text(AUTH_BLOCKER)
- Button("Create app", disabled=True)
- Separator()
- Text("Agent interface", bold=True)
- Code("https://my.phast.app/mcp", language="text")
- Text("Mutations stay disabled until both a Fly volume and accepted owner authentication exist.")
- return result
-
-@mcp.tool(name="my_phast_app", app=True, annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
-def my_phast_app() -> PrefabApp:
- return control_app()
-
-@mcp.custom_route("/", methods=["GET"])
-async def homepage(request: Request):
- host = request.headers.get("host", "").split(":", 1)[0].lower()
- if host == OWNER_HOST:
- return HTMLResponse(control_app().html(renderer_mode="bundled"))
- return HTMLResponse(Path(__file__).with_name("index.html").read_text())
-
-@mcp.custom_route("/healthz", methods=["GET"])
-async def health(_: Request):
- return JSONResponse({
- "ok": True,
- "service": "phast",
- "source_sha": os.environ.get("SOURCE_SHA", "local"),
- "control_plane": {
- "registry_ready": registry is not None,
- "mutations_ready": False,
- "blockers": [value for value in (registry_blocker, AUTH_BLOCKER) if value],
- },
- })
-
-app = mcp.http_app(
- path="/mcp",
- transport="streamable-http",
- stateless_http=True,
- allowed_hosts=["phast.app", "www.phast.app", "my.phast.app", "localhost", "testserver"],
-)
-
-if __name__ == "__main__":
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
\ No newline at end of file
+from prefab_ui.components import Badge,Card,Column,Heading,Row,Text
+from starlette.responses import HTMLResponse,JSONResponse
+DATA=Path(os.getenv("PHAST_DATA_DIR","/data/phast"));DATA.mkdir(parents=True,exist_ok=True)
+GITEA=os.getenv("GITEA_URL","https://teahub.xyz").rstrip("/");CID=os.environ["GITEA_OAUTH_CLIENT_ID"];SECRET=os.environ["GITEA_OAUTH_CLIENT_SECRET"];JWT=os.environ["JWT_SIGNING_KEY"]
+USERS={x.strip() for x in os.getenv("ALLOWED_GITEA_USERS","matcha,chai").split(",") if x.strip()};RX=re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
+class V(IntrospectionTokenVerifier):
+ async def verify_token(self,t):
+ a=await super().verify_token(t)
+ if not a:return None
+ async with httpx.AsyncClient(timeout=10) as c:r=await c.get(GITEA+"/login/oauth/userinfo",headers={"Authorization":"Bearer "+t})
+ if r.status_code!=200:return None
+ u=str(r.json().get("preferred_username") or "")
+ if u not in USERS:return None
+ a.claims={**(a.claims or {}),"login":u};return a
+def auth(base):
+ v=V(introspection_url=GITEA+"/login/oauth/introspect",client_id=CID,client_secret=SECRET,client_auth_method="client_secret_basic",required_scopes=["openid"])
+ return OAuthProxy(upstream_authorization_endpoint=GITEA+"/login/oauth/authorize",upstream_token_endpoint=GITEA+"/login/oauth/access_token",upstream_client_id=CID,upstream_client_secret=SECRET,token_verifier=v,base_url=base,resource_base_url=base,valid_scopes=["openid","profile","email"],forward_pkce=True,forward_resource=False,token_endpoint_auth_method="client_secret_basic",jwt_signing_key=JWT,require_authorization_consent="remember",enable_cimd=True)
+def actor():
+ a=get_access_token();u=str(((a.claims if a else {}) or {}).get("login") or "")
+ if u not in USERS:raise RuntimeError("actor not enabled")
+ return u
+def slug(s):
+ s=s.strip().lower()
+ if not RX.fullmatch(s) or s in {"my","www","mcp"}:raise ValueError("invalid slug")
+ return s
+def path(s):return DATA/slug(s)/"phast.json"
+def read(s):
+ p=path(s)
+ if not p.exists():raise KeyError("unknown Phast")
+ return json.loads(p.read_text())
+def write(s,d):
+ p=path(s);p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix(".tmp");q.write_text(json.dumps(d,indent=2,sort_keys=True)+"\n");q.replace(p);return d
+def mine(s):
+ d=read(s)
+ if d.get("owner")!=actor():raise PermissionError("not your Phast")
+ return d
+def create(s,name=""):
+ s=slug(s)
+ if path(s).exists():raise ValueError("already exists")
+ return write(s,{"schema_version":1,"kind":"phast","slug":s,"name":name.strip() or s,"owner":actor(),"host":s+".phast.app","surfaces":{"human":"/","mcp":"/mcp"},"tools":{"installed":[]},"connections":[],"prefab":{"version":1,"home":{"mode":"grid","edit":True,"widgets":[]}}})
+def prefab(d):
+ ws=d["prefab"]["home"]["widgets"]
+ with PrefabApp(title=d["name"],css_class="max-w-4xl mx-auto p-6",mode="light") as a:
+ with Column(gap=5):
+ with Row(justify="between",align="center"):Heading(d["name"],level=1);Badge(str(len(d["tools"]["installed"]))+" tools",variant="secondary")
+ Text("https://"+d["host"]+"/mcp",code=True)
+ if not ws:
+ with Card(css_class="p-5"):Heading("Blank home",level=2);Text("Add a provider or widget from Chat or my.phast.app.")
+ for w in ws:
+ with Card(css_class="p-5"):Heading(str(w.get("title") or w["id"]),level=2);Text(str(w.get("description","")))
+ return a
+def human(d):
+ ws=d["prefab"]["home"]["widgets"];cards="".join(" "+str(w.get("description",""))+" Add a widget from Chat or my.phast.app."+str(w.get("title") or w["id"])+"
Blank home
'+d["host"]+'/mcp