diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..976a37f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# AGENTS.md + +## Scope + +Build and maintain Phast app as a deliberately small Python + Prefab + MCP application platform. + +## Product model + +- `my.phast.app` is the owner control plane. +- Each `app.phast.app` is independently useful and independently connectable. +- `/` is the human Prefab UI by default. +- `/mcp` is the agent interface by default. +- A Prephab is a cloneable Python + Prefab + MCP application shape. + +## Working rules + +- Prefer boring, explicit Python over framework sprawl. +- Keep one source of truth for app state and capabilities. +- Do not fake auth or security; mark seams explicitly. +- No delete path until it is deliberately designed. +- Commit small coherent changes to `main` and leave the system simpler. diff --git a/Dockerfile b/Dockerfile index 1ba7ab4..093f3c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -COPY server.py registry.py index.html phast.json ./ +COPY server.py registry.py index.html phast.json start.sh ./ +COPY my/index.html my/index.html ARG SOURCE_SHA=unknown ENV SOURCE_SHA=$SOURCE_SHA -RUN useradd --create-home --uid 10001 phast -USER phast +RUN useradd --create-home --uid 10001 phast && chmod +x start.sh EXPOSE 8080 -CMD ["python", "server.py"] +CMD ["./start.sh"] diff --git a/README.md b/README.md index 19f414e..1e80fd8 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,73 @@ -# Phast runtime +# Phast app -This directory is the canonical source for the existing `phast-app` Fly app, -serving `phast.app` and `my.phast.app`. It remains one Python/FastMCP runtime. +Canonical source of truth for Phast app. -## App convention +> **Connect your tools once. Bring your tool belt anywhere.** -`phast.json` is the minimal Prephab manifest. Schema version 1 requires a -stable slug and name, an HTTPS origin, Python as the runtime, `/` as the default -human route, `/mcp` as the default MCP route, and Prephab shape version 1. -Registry tools return this same shape. +Phast app gives a person one portable MCP plus a programmable Prefab home. Connect `my.phast.app/mcp` to an agent to create and manage Phast apps. A created Phast app has two matching surfaces: -`my.phast.app` renders its human surface with the installed Prefab UI package. -Its MCP exposes `apps_list`, `apps_get`, `apps_create`, and `apps_update`, plus -bounded status and Prefab-app tools. There is intentionally no delete. +- `user.phast.app` — human / Prefab home +- `user.phast.app/mcp` — the user's portable MCP tool belt -## Production blockers +## V0 contract -The current Fly app has no volume and Phast has no accepted owner-auth pattern. -The registry therefore refuses to treat the image filesystem as durable, and -create/update always fail before writing. Do not deploy this as an enabled -control plane until both seams are closed: +`phast.json` is deliberately boring. It is mostly: -1. create and attach one Fly volume at `/data`, then set - `PHAST_DATA_DIR=/data`; the runtime verifies that the directory is a mount; -2. reuse an accepted FastMCP owner identity provider and replace the explicit - mutation guard with caller identity/authorization checks. +1. the MCP tool list and provider connections; +2. references to delegated credential authority (Persimmons, never raw secrets); +3. Prefab home/widget settings. -Do not substitute a shared secret in the UI or an environment flag for owner -authentication. Once both prerequisites exist, exercise list/get/create/update -through `my.phast.app/mcp`, verify persistence across a Machine replacement, -then deploy through the existing Phast launch path. +The browser and chat are two editors for the same declaration. -## Local validation +### Browser flow -Run `python -m unittest tests.test_phast`. Tests use a temporary SQLite file; -they do not weaken the production mount or authentication guards. +Home → Edit Home → Add Widget → provider search → connect if necessary → place/configure widget. -## On Teahub prod (2026-10-14) -Ported from the old factory (`tea-mcp/src/phast`, plus `phastacp` as `acp/`) as it was. Phast is a client of Teahub: -this org owns the code, the site (`phast/site`) and phast.app. The Fly app named in `fly.toml` (`phast-app`) does not -exist yet; the production blockers above still stand (volume at /data, owner auth via Gitea OAuth). +### Agent flow + +Connect once to `my.phast.app/mcp` → create/manage `user.phast.app` → search providers → add tools/widgets → stream/taste-test Prefab in chat → persist accepted UI to the same `phast.json`. + +## Boundaries + +- **Phast app composes.** It owns the portable MCP/tool belt and Prefab composition. +- **Oracle discovers.** Provider/MCP/OAuth discovery comes from the capability graph, not a hard-coded Phast app directory. +- **Persimmons holds secrets.** Phast app stores connection/authority references, not secret values. +- **Factory builds/operates.** Provisioning uses bounded deterministic infrastructure routes. +- **Clients are replaceable.** ChatGPT, Claude, Codex, or another MCP client should be able to use the same Phast app without recollecting the user's tools. + +V0 is intentionally not arbitrary app generation. Prove the portable-tool-belt + editable-home loop first. + +## Naming and copy + +Use **Phast app** when naming the product or one of its instances; use +**Phast apps** for the plural. Write “your Phast app,” “create a Phast app,” +and “My Phast app” for the management connector. Existing all-caps display +styles may use “PHAST APP.” Apply this to page titles, metadata, navigation, +buttons, examples, generated homes, MCP display text, and documentation. + +Keep deliberate substitutions for “fast”: “make something phast,” +“built phast,” and “make MCP apps phast.” Do not rename literal domains, +URLs, `phast.json`, environment variables, CSS classes, tool names, or +schema keys/values as part of a copy edit. Preserve user-chosen app names. + +The public copy lives in `index.html` (phast.app), `my/index.html` +(my.phast.app), and `server.py` (generated personal homes and MCP display +text). `phast.json` supplies the management display name. Review these +surfaces together when changing product naming. + +Chair review: the standalone “PHAST.” wordmark and the “My Phast” connector +label could be separate brand treatments. This change proposes “PHAST APP.” +and “My Phast app” consistently; the chair should confirm those treatments. +The phrase “your Phast” names an app instance, so it becomes “your Phast app.” + +## Running + +Prod since 2026-10-14: Fly app `phast-app` (this `fly.toml`), one machine, volume `phast_data` at `/data` +(`PHAST_DATA_DIR=/data/phast`, `FASTMCP_HOME=/data/fastmcp`). It serves phast.app, my.phast.app and +`*.phast.app`; the two pages are this repo's `index.html` and `my/index.html`, not copies. Sign-in is +Teahub OAuth (app "Phast app" owned by `teahubxyz`, redirect `https://my.phast.app/auth/callback`); +`GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET` and `JWT_SIGNING_KEY` exist only as Fly secrets. +`ALLOWED_GITEA_USERS` says who may use it. + +Deploy: `fly deploy --build-arg SOURCE_SHA=$(git rev-parse HEAD)`; `GET /healthz` reports the SHA. +History before 2026-10-14 is `matcha/phast` from the old factory, merged in. diff --git a/__init__.py b/__init__.py index ef13255..67015ff 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1 @@ -"""Phast application runtime.""" +"""Phast app runtime.""" diff --git a/fly.toml b/fly.toml index 7564318..a1f1072 100644 --- a/fly.toml +++ b/fly.toml @@ -4,20 +4,31 @@ primary_region = "iad" [build] dockerfile = "Dockerfile" +[env] + GITEA_URL = "https://teahub.xyz" + PHAST_DATA_DIR = "/data/phast" + FASTMCP_HOME = "/data/fastmcp" + ALLOWED_GITEA_USERS = "matcha,chai" + PORT = "8080" + PHAST_HOST = "0.0.0.0" # asyncio binds :: as IPv6-only; the Fly proxy arrives over IPv4 + [http_service] internal_port = 8080 force_https = true - auto_stop_machines = "stop" - auto_start_machines = true + auto_stop_machines = "off" min_machines_running = 1 [[http_service.checks]] - grace_period = "15s" - interval = "15s" + grace_period = "20s" + interval = "30s" method = "GET" path = "/healthz" timeout = "5s" +[mounts] + source = "phast_data" + destination = "/data" + [[vm]] memory = "512mb" cpu_kind = "shared" diff --git a/index.html b/index.html index 99fa4ef..6c29397 100644 --- a/index.html +++ b/index.html @@ -1,73 +1,46 @@ - - - - - phast - - - - + + + + +Phast app + + + + -
-
- phast. - -
-
-
- -

make something.
phast.

-
-
- Connect your agent -

Bring the intelligence. Phast turns it into something you can use.

-
-
- -
-
copied
- - - \ No newline at end of file +
+
phast.app
+
+
+

MCP APPS // ZERO TO USEFUL

+

MAKE SOMETHINGPHAST

+
+
01 CONNECTadd my.phast.app/mcp
+
02 MAKEcreate your Phast app
+
03 TUNEtools + prefab widgets
+
04 RUNuse it from any agent
+
+
+
+

Connect your tools once.
Bring your tool belt anywhere. ChatGPT, Claude, Codex — same Phast app, same tools, same home.

+BUILD YOUR PHAST APP→ +

one connector // many personal MCPs

+
+
+ +
+
MCP COPIED
+ + \ No newline at end of file diff --git a/my/index.html b/my/index.html new file mode 100644 index 0000000..d27de41 --- /dev/null +++ b/my/index.html @@ -0,0 +1,46 @@ + + + + +my.phast.app — your Phast app + + + + + +
+
PHAST APP.MY PHAST APP // CONTROL MCP
+
+

START HERE // ONE CONNECTION

YOUR MCP.BUILT PHAST.

+

Add My Phast app to your agent once. Then create personal MCPs, add tools, stream Prefab, and keep what works.

https://my.phast.app/mcp
+
+
+

ZERO → PERSONAL MCP

CHAT IS THE SETUP UI
+
+
01 // CONNECT

ADD MY PHAST APP

Connect the management MCP in ChatGPT, Claude, Codex, or another MCP client. Authenticate once with Teahub.

MCP my.phast.app/mcp
+
02 // CREATE

TELL YOUR AGENT

No project wizard. Name it in chat. Wildcard DNS, TLS, home, and MCP are already waiting.

YOU create me a Phast app called sunday
+
03 // TUNE

BUILD THE BELT

Add providers, tools, and Prefab widgets. Your agent and your web home edit the same Phast app.

YOU add github + a PR widget
+
+
+
+
WHAT YOU GET

YOURNAME.PHAST.APP

A fast personal web home for the human side of your composition. Widgets are saved views of the same capabilities your agent can use.

/ human homePrefab widgetsedit by chat
+
TAKE IT ANYWHERE

/MCP

Your portable tool belt. Connect yourname.phast.app/mcp to another agent without recollecting every tool.

ChatGPTClaudeCodexwhatever's next
+
+ +
+
MCP COPIED
+ + diff --git a/phast-source.json b/phast-source.json deleted file mode 100644 index fbaa31b..0000000 --- a/phast-source.json +++ /dev/null @@ -1 +0,0 @@ -{"repository":"matcha/phast","commit":"830ab44d8ff0a8eb95fbff1bdc76d6680486daca","server_sha256":"1279780a42744d71b974f283d6693ad053b3e50c782836f7c15894352f6345da"} diff --git a/phast.json b/phast.json index 36d2302..fda1061 100644 --- a/phast.json +++ b/phast.json @@ -1,11 +1,17 @@ { "schema_version": 1, - "kind": "phast.app", - "slug": "my-phast", - "name": "My Phast", - "runtime": "python", - "base_url": "https://my.phast.app", - "human": {"route": "/"}, - "mcp": {"route": "/mcp"}, - "prephab": {"version": 1} + "kind": "phast", + "slug": "my", + "name": "My Phast app", + "host": "my.phast.app", + "surfaces": {"human": "/", "mcp": "/mcp"}, + "tools": { + "management": ["phasts_list","phasts_get","phasts_create","phasts_update","providers_search","widgets_list","widgets_add","widgets_update","widgets_remove"], + "installed": [] + }, + "connections": [], + "prefab": { + "version": 1, + "home": {"mode": "grid", "edit": true, "widgets": []} + } } diff --git a/registry.py b/registry.py index 3ab9d22..eff6a8b 100644 --- a/registry.py +++ b/registry.py @@ -13,29 +13,24 @@ from urllib.parse import urlsplit SCHEMA_VERSION = 1 _SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") - class RegistryUnavailable(RuntimeError): - """Raised when the production persistence contract is not satisfied.""" - + pass def _now() -> str: return datetime.now(timezone.utc).isoformat() - def _validate_slug(value: str) -> str: value = value.strip() if not _SLUG.fullmatch(value): raise ValueError("slug must be 1-63 lowercase letters, numbers, or hyphens") return value - def _validate_name(value: str) -> str: value = value.strip() if not value or len(value) > 100: raise ValueError("name must be 1-100 characters") return value - def _validate_base_url(value: str) -> str: value = value.strip().rstrip("/") parsed = urlsplit(value) @@ -43,14 +38,12 @@ def _validate_base_url(value: str) -> str: raise ValueError("base_url must be an HTTPS origin without a path") return value - def _validate_route(value: str, field: str) -> str: value = value.strip() if not value.startswith("/") or value.startswith("//") or "?" in value or "#" in value: raise ValueError(f"{field} must be an absolute path without query or fragment") return value - def _manifest(row: sqlite3.Row) -> dict: return { "schema_version": SCHEMA_VERSION, @@ -66,10 +59,7 @@ def _manifest(row: sqlite3.Row) -> dict: "updated_at": row["updated_at"], } - class AppRegistry: - """Small SQLite-backed registry; one connection is used per operation.""" - def __init__(self, database: str | Path): self.database = Path(database) self.database.parent.mkdir(parents=True, exist_ok=True) @@ -93,8 +83,7 @@ class AppRegistry: def _initialize(self) -> None: with self._connection() as connection: connection.execute("PRAGMA journal_mode = WAL") - connection.execute( - """ + connection.execute(""" CREATE TABLE IF NOT EXISTS apps ( slug TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -104,8 +93,7 @@ class AppRegistry: created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) - """ - ) + """) def list(self) -> list[dict]: with self._connection() as connection: @@ -127,12 +115,12 @@ class AppRegistry: try: with self._connection() as connection: connection.execute( - "INSERT INTO apps (slug, name, base_url, human_route, mcp_route, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", (*values, timestamp, timestamp), + "INSERT INTO apps (slug, name, base_url, human_route, mcp_route, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (*values, timestamp, timestamp), ) except sqlite3.IntegrityError as error: raise ValueError("an app with this slug or base_url already exists") from error - return self.get(values[0]) # type: ignore[return-value] + return self.get(values[0]) def update(self, slug: str, *, name: str | None = None, base_url: str | None = None, human_route: str | None = None, mcp_route: str | None = None) -> dict: @@ -157,11 +145,9 @@ class AppRegistry: raise KeyError(f"unknown app: {slug}") except sqlite3.IntegrityError as error: raise ValueError("an app with this base_url already exists") from error - return self.get(slug) # type: ignore[return-value] - + return self.get(slug) def registry_from_runtime() -> AppRegistry: - """Open production state only when its configured directory is a real mount.""" raw = os.environ.get("PHAST_DATA_DIR", "").strip() if not raw: raise RegistryUnavailable("PHAST_DATA_DIR is not configured; durable registry is offline") diff --git a/requirements.txt b/requirements.txt index 7bf1a11..3b7d227 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastmcp==4.0.10 prefab-ui==0.20.2 mcp==2.2.0 +httpx==0.28.1 uvicorn==0.37.0 diff --git a/server.py b/server.py index 222f239..abb766f 100644 --- a/server.py +++ b/server.py @@ -1,174 +1,425 @@ -"""Phast public landing and fail-closed owner control plane.""" 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, Button, Card, Code, Column, Heading, Row, Separator, Text -from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse +from prefab_ui.components import Badge, Card, Column, Heading, Row, Text +from starlette.responses import FileResponse, HTMLResponse, JSONResponse, Response -try: - from .registry import AppRegistry, RegistryUnavailable, registry_from_runtime -except ImportError: # Docker runs this file directly from /app. - from registry import AppRegistry, RegistryUnavailable, registry_from_runtime +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])?$") -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) +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 -mcp = FastMCP( - "My Phast", - instructions="Owner control plane for Phast app manifests. Mutations fail closed until owner auth is installed.", + +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("title") or widget["id"]) + + "

" + + str(widget.get("description", "")) + + "

" + for widget in widgets + ) or "

Blank home

Add a widget from Chat or my.phast.app.

" + return ( + '' + + "" + + data["name"] + + " - Phast app" + + "" + + "
Phast app." + + data["host"] + + "/mcp

" + + data["name"] + + "

" + + cards + + "
" + ) + + +m = FastMCP( + "My Phast app", + version="0.2.0", + instructions="Create and manage portable Phast app MCP tool belts and Prefab homes.", + auth=auth("https://my.phast.app"), + mask_error_details=True, ) -def _require_registry() -> AppRegistry: - if registry is None: - raise RegistryUnavailable(registry_blocker or "durable registry is offline") - return registry - - -def _require_owner_auth() -> None: - # Deliberately not an environment-secret pseudo-auth scheme. Replace this - # only with an accepted reusable FastMCP owner identity provider. - raise PermissionError(AUTH_BLOCKER) - - -@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) +@m.tool() def phast_status() -> dict: - """Get public Phast endpoints and control-plane readiness.""" + return {"ok": True, "actor": actor(), "version": "0.2.0"} + + +@m.tool() +def phasts_list() -> dict: + current = actor() + rows = [] + for p in DATA.glob("*/phast.json"): + try: + data = json.loads(p.read_text()) + except Exception: + continue + if data.get("owner") == current: + rows.append(data) + return {"phasts": sorted(rows, key=lambda item: item["slug"])} + + +@m.tool() +def phasts_get(slug: str) -> dict: + return mine(slug) + + +@m.tool() +def phasts_create(slug: str, name: str = "") -> dict: + data = create(slug, name) 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, + "phast": data, + "human_url": "https://" + data["host"] + "/", + "mcp_url": "https://" + data["host"] + "/mcp", } -@mcp.tool(name="apps_list", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def apps_list() -> dict: - """List the owner's registered Phast app manifests.""" - return {"apps": _require_registry().list()} +@m.tool() +def phasts_update(slug: str, name: str) -> dict: + data = mine(slug) + clean = name.strip() + if not clean: + raise ValueError("name is required") + data["name"] = clean + return write(slug, data) -@mcp.tool(name="apps_get", annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}) -def apps_get(slug: str) -> dict: - """Get one registered Phast app manifest by slug.""" - result = _require_registry().get(slug) - if result is None: - raise KeyError(f"unknown app: {slug}") - return result +@m.tool() +def providers_search(query: str) -> dict: + return {"query": query, "source": "oracle", "status": "adapter-next"} -@mcp.tool(name="apps_create", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}) -def apps_create(slug: str, name: str, base_url: str, human_route: str = "/", mcp_route: str = "/mcp") -> dict: - """Create a Phast app manifest; disabled until owner auth is installed.""" - _require_owner_auth() - return _require_registry().create( - slug=slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route +@m.tool() +def widgets_list(slug: str) -> dict: + return {"widgets": list(mine(slug)["prefab"]["home"]["widgets"])} + + +@m.tool() +def widgets_add(slug: str, widget_id: str, title: str, description: str = "") -> dict: + data = mine(slug) + widgets = data["prefab"]["home"]["widgets"] + if any(widget["id"] == widget_id for widget in widgets): + raise ValueError("widget exists") + widget = {"id": widget_id, "title": title, "kind": "prefab", "description": description} + widgets.append(widget) + write(slug, data) + return widget + + +@m.tool() +def widgets_update(slug: str, widget_id: str, title: str = "", description: str = "") -> dict: + data = mine(slug) + for widget in data["prefab"]["home"]["widgets"]: + if widget["id"] == widget_id: + if title.strip(): + widget["title"] = title.strip() + if description: + widget["description"] = description + write(slug, data) + return widget + raise KeyError("unknown widget") + + +@m.tool() +def widgets_remove(slug: str, widget_id: str) -> dict: + data = mine(slug) + widgets = data["prefab"]["home"]["widgets"] + kept = [widget for widget in widgets if widget["id"] != widget_id] + if len(kept) == len(widgets): + raise KeyError("unknown widget") + data["prefab"]["home"]["widgets"] = kept + write(slug, data) + return {"removed": widget_id} + + +@m.tool(app=True) +def phast_preview(slug: str) -> PrefabApp: + return prefab(mine(slug)) + + +mgmt = m.http_app(path="/mcp", transport="streamable-http", stateless_http=True) +cache: dict[str, Any] = {} +started: dict[str, Any] = {} +locks: dict[str, asyncio.Lock] = {} + + +def inst(value: str): + value = slug(value) + if value in cache: + return cache[value] + data = read(value) + personal = FastMCP( + data["name"], + version="0.2.0", + instructions="Portable personal Phast app MCP.", + auth=auth("https://my.phast.app", "https://" + data["host"]), + mask_error_details=True, ) - -@mcp.tool(name="apps_update", annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True}) -def apps_update(slug: str, name: str | None = None, base_url: str | None = None, - human_route: str | None = None, mcp_route: str | None = None) -> dict: - """Update a Phast app manifest; disabled until owner auth is installed.""" - _require_owner_auth() - return _require_registry().update( - slug, name=name, base_url=base_url, human_route=human_route, mcp_route=mcp_route - ) - - -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=True) - 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=True) - 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: - """Open the read-only My Phast Prefab control surface.""" - 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( - { + @personal.tool() + def phast_status() -> dict: + owned = mine(value) + return { "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], - }, + "slug": value, + "owner": owned["owner"], + "tools": len(owned["tools"]["installed"]), + "widgets": len(owned["prefab"]["home"]["widgets"]), } - ) + + @personal.tool() + def phast_manifest() -> dict: + return mine(value) + + @personal.tool(app=True) + def home_app() -> PrefabApp: + return prefab(mine(value)) + + cache[value] = personal.http_app(path="/mcp", transport="streamable-http", stateless_http=True) + return cache[value] -app = mcp.http_app( - path="/mcp", - transport="streamable-http", - stateless_http=True, - allowed_hosts=["phast.app", "www.phast.app", "my.phast.app", "phast-app.fly.dev", "localhost", "testserver"], -) +async def ready_inst(value: str): + value = slug(value) + app = inst(value) + if value in started: + return app + lock = locks.setdefault(value, asyncio.Lock()) + async with lock: + if value not in started: + cm = app.router.lifespan_context(app) + await cm.__aenter__() + started[value] = cm + return app + + +class Router: + async def __call__(self, scope, receive, send): + if scope.get("type") == "lifespan": + try: + await mgmt(scope, receive, send) + finally: + for cm in reversed(list(started.values())): + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + return + + host = dict(scope.get("headers", [])).get(b"host", b"").decode().split(":")[0].lower() + request_path = scope.get("path", "/") + method = scope.get("method", "GET") + + if request_path == "/healthz": + await JSONResponse({"ok": True, "service": "phast", "source_sha": os.getenv("SOURCE_SHA", "local")})(scope, receive, send) + return + + page = PAGES.get(host) + if page and request_path == "/" and method in {"GET", "HEAD"}: + await FileResponse(page, media_type="text/html", headers={"Cache-Control": "public, max-age=60"})(scope, receive, send) + return + + if host == "phast.app": + await JSONResponse({"error": "not found"}, status_code=404)(scope, receive, send) + return + + if host == "my.phast.app": + await mgmt(scope, receive, send) + return + + if host.endswith(".phast.app"): + value = host[:-10] + try: + data = read(value) + except Exception: + await JSONResponse({"error": "unknown Phast app"}, status_code=404)(scope, receive, send) + return + + if request_path == "/" and method in {"GET", "HEAD"}: + body = human(data) + if method == "HEAD": + await Response(content=b"", media_type="text/html", status_code=200)(scope, receive, send) + else: + await HTMLResponse(body)(scope, receive, send) + return + + personal = await ready_inst(value) + await personal(scope, receive, send) + return + + await JSONResponse({"error": "unknown host"}, status_code=404)(scope, receive, send) + + +app = Router() if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) + uvicorn.run(app, host=os.getenv("PHAST_HOST", "::"), port=int(os.getenv("PORT", "8081"))) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..ade64b8 --- /dev/null +++ b/start.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# The Fly volume mounts root-owned at /data: make our dirs, hand them to the app user, drop root. +set -e +for d in "${PHAST_DATA_DIR:-/data/phast}" "${FASTMCP_HOME:-/data/fastmcp}"; do + mkdir -p "$d" && chown -R 10001:10001 "$d" && chmod 700 "$d" +done +exec env HOME=/home/phast setpriv --reuid=10001 --regid=10001 --clear-groups python server.py