"+str(w.get("title") or w["id"])+"
"+str(w.get("description",""))+"
from __future__ import annotations
import json,os,re
from pathlib import Path
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,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