" + str(widget.get("title") or widget["id"]) + "
" + str(widget.get("description", "")) + "
from __future__ import annotations
import asyncio
import json
import os
import re
from pathlib import Path
from typing import Any
import httpx
import uvicorn
from fastmcp import FastMCP
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
from fastmcp.server.dependencies import get_access_token
from prefab_ui.app import PrefabApp
from prefab_ui.components import Badge, Card, Column, Heading, Row, Text
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, Response
DATA = Path(os.getenv("PHAST_DATA_DIR", "/data/phast"))
# The public pages are served by this app too: one source, no copies to drift.
HERE = Path(__file__).resolve().parent
PAGES = {"phast.app": HERE / "index.html", "my.phast.app": HERE / "my" / "index.html"}
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"]
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
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, token):
access = await super().verify_token(token)
if not access:
return None
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
GITEA + "/login/oauth/userinfo",
headers={"Authorization": "Bearer " + token},
)
if response.status_code != 200:
return None
login = str(response.json().get("preferred_username") or "")
if login not in USERS:
return None
access.claims = {**(access.claims or {}), "login": login}
return access
def auth(base: str, resource: str | None = None) -> OAuthProxy:
verifier = 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=verifier,
base_url=base,
resource_base_url=resource or 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() -> str:
access = get_access_token()
login = str(((access.claims if access else {}) or {}).get("login") or "")
if login not in USERS:
raise RuntimeError("actor not enabled")
return login
def slug(value: str) -> str:
value = value.strip().lower()
if not RX.fullmatch(value) or value in {"my", "www", "mcp"}:
raise ValueError("invalid slug")
return value
def path(value: str) -> Path:
return DATA / slug(value) / "phast.json"
def read(value: str) -> dict[str, Any]:
p = path(value)
if not p.exists():
raise KeyError("unknown Phast app")
return json.loads(p.read_text())
def write(value: str, data: dict[str, Any]) -> dict[str, Any]:
p = path(value)
p.parent.mkdir(parents=True, exist_ok=True)
try:
p.parent.chmod(0o700)
except OSError:
pass
tmp = p.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
tmp.chmod(0o600)
tmp.replace(p)
try:
p.chmod(0o600)
except OSError:
pass
return data
def mine(value: str) -> dict[str, Any]:
data = read(value)
if data.get("owner") != actor():
raise PermissionError("not your Phast app")
return data
def create(value: str, name: str = "") -> dict[str, Any]:
value = slug(value)
if path(value).exists():
raise ValueError("already exists")
# Provision auth before durable state so a failed OAuth mutation cannot leave
# a half-created personal MCP behind.
return write(
value,
{
"schema_version": 1,
"kind": "phast",
"slug": value,
"name": name.strip() or value,
"owner": actor(),
"host": value + ".phast.app",
"surfaces": {"human": "/", "mcp": "/mcp"},
"tools": {"installed": []},
"connections": [],
"prefab": {"version": 1, "home": {"mode": "grid", "edit": True, "widgets": []}},
},
)
def prefab(data: dict[str, Any]) -> PrefabApp:
widgets = data["prefab"]["home"]["widgets"]
with PrefabApp(title=data["name"], css_class="max-w-4xl mx-auto p-6", mode="light") as app:
with Column(gap=5):
with Row(justify="between", align="center"):
Heading(data["name"], level=1)
Badge(str(len(data["tools"]["installed"])) + " tools", variant="secondary")
Text("https://" + data["host"] + "/mcp", code=True)
if not widgets:
with Card(css_class="p-5"):
Heading("Blank home", level=2)
Text("Add a provider or widget from Chat or my.phast.app.")
for widget in widgets:
with Card(css_class="p-5"):
Heading(str(widget.get("title") or widget["id"]), level=2)
Text(str(widget.get("description", "")))
return app
def human(data: dict[str, Any]) -> str:
widgets = data["prefab"]["home"]["widgets"]
cards = "".join(
" "
+ str(widget.get("description", ""))
+ " Add a widget from Chat or my.phast.app."
+ str(widget.get("title") or widget["id"])
+ "
Blank home
"
+ data["host"]
+ "/mcp