diff --git a/docs/decisions.md b/docs/decisions.md index 667ce01..40b7de5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -57,4 +57,4 @@ Format: date · decision · why. Add at the bottom. Never edit an old entry; sup - 2026-10-15 · **Prod runs Gitea 28.0.0, and its Fly config lives in this repo.** Prod went up on the floating `docker.gitea.com/gitea:1.24` tag (1.24.7) while the shell on hb-5 already ran 28.0.0, so the move off the old factory had gone backwards. At the operator's word prod now runs 28.0.0. First a rehearsal: a copy of prod's data (a sqlite backup plus the repos) ran under 28.0.0 in Docker on hb-5, migrations 321 to 355 went through in two seconds, and the Tea MCP's smoke test passed against it; the copy was deleted afterwards. Then Fly snapshot `vs_M9PaLKQVDwX0hZXq1q2MO` of `gitea_data` (5-day retention) and an on-volume copy, `/data/gitea/gitea.db.pre28-20261015`, before the deploy at 02:44 UTC. A migrated database won't start on 1.24, so going back means restoring one of those. Checked afterwards: version and health, the web UI and its GitHub sign-in, the Claude connector (still signed in), a smoke run on `fly-iad-1`, Time's next scheduled tick, a pages hook delivery, Phast and Boring. green-tea's `fly.toml` had lived only in hb-5 `~/green-tea`; it is now `gitea/fly.toml` here, pinned to the exact release, and the hb-5 copy is renamed beside a pointer. What 28 changes for us: workflows' `permissions:` blocks are enforced now (Time's ask for what they use; heartbeat and astra have none, and the default still lets the job token comment, checked in a throwaway repo that is deleted), `concurrency:` groups are honoured, finished runs are deleted after 400 days, webhooks go out under 28's egress rules (the pages hooks are public https, which they allow), and the run and job API exists. So Tea MCP 2.1 (`40706b8`) lists runs in `tea_actions` and reads job logs with `tea_action_logs`; `boring_teahub_deploy` shipped it. The steward's token routine (CLI mint, sqlite delete) works unchanged. Token 48 (`gitea28-20261015`, `write:repository`) pushed 2.1 and this line and is deleted by id right after; the table is checked. Why: the operator said the migration off the old factory had regressed Gitea and asked for 28. - 2026-10-15 · **The tapkey stays beside Taproot, and Orgchain shows every secret.** The operator likes the npub ↔ tapkey link (Nosft's model, from the Deezy thread) and wants Orgchain to be the full platform for running lineage LLCs, with secrets shown as on the key tree page. Both pages now list each npub's tapkey (its nostr key with the BIP-86 tweak) beside the Taproot and SegWit accounts, and the key tree page's keyring and `lineage.json` carry it. A node's inscription stays at its standard BIP-86 key: the tapkey is where anyone can send to an npub, while the key that carries an org's authority is not one that nostr clients hold. Orgchain v1.4 adds a Reveal panel to every node (the seed for holdings and orgs, the nsec, Taproot, SegWit and Tapkey descriptors with test-network WIF and BIP-380 checksums, and the leaves' private keys), hidden again on every selection. Its output matches the key tree page byte for byte (the tests pin hashes of the page's own output), BIP-84's WIF vector and BIP-380's checksum vector, and `ssh-keygen` reads its OpenSSH keys. The key tree page's descriptors gain checksums too. `lineage/orgchain` main `17b8692`, time's `gh-pages` `d481ba9`; 56 unit tests and Playwright 6/6. Token 50 (`lineage-secrets-20261015`, `write:repository`) did the pushes and is deleted right after; the table is checked. Why: Orgchain is meant to run the LLCs, not only draw them, and an npub should reach the chain on its own. - 2026-10-15 · **Orgchain layout: three columns, no gap above the timeline.** The operator's screenshot showed the timeline far below the tree: the act and inspect column, now long with keys, leaves and secrets, spanned both grid rows and stretched the first. Orgchain is now three columns that run on their own (holdings; the tree with its timeline; act and inspect) over a flexible last row, and the medium layout keeps the timeline under the tree too; checked at 1440, 1280, 1100, 900 and 390 px. The footer links the scure licenses. `lineage/orgchain` main `aeb11d3`, time's `gh-pages` `0b0f27a`; 56 unit tests and Playwright 6/6. Token 51 (`orgchain-layout-20261015`, `write:repository`) did the pushes and is deleted right after; the table is checked. Why: the operator asked for the formatting to be fixed. -- 2026-10-15 · **Prod looks like Teahub again: home page, dashboard, colours and cup, kept in this repo.** Prod's Gitea showed Gitea's own home page, name and blue. At the operator's word the old factory's look is back, ported to Gitea 28: `gitea/custom` has the signed-out home page (Teahub = Git + Tea + Time beside 🍵), the dashboard (the feed, and in the sidebar Domains | Repository | Organization above the contribution heatmap), green tea colours for Gitea's light, dark and auto themes (the old #2f9e44 and #58b96a with every shade Gitea uses; dark-mode buttons get near-black text, because white on that green reads at 2.5:1), the name beside the navbar's cup, and the 🍵 mark (SVGs in the reader's emoji font; PNGs for link previews, tabs and phones from Noto Emoji, Apache 2.0). `APP_NAME` is Teahub and the meta tags say Git + Tea + Time, as env in `gitea/fly.toml`. Rehearsed first on a scratch Gitea 28.0.0 with the same files, signed in, light and dark, desktop and phone width. `gitea/sync-custom.sh` copied the files onto the volume and a deploy at 03:50 UTC restarted Gitea with them (a restart signs everyone out, because sessions live in memory). Left behind with the old factory: the "Ask Tea" chat, the Codex and ceremony controls, the issue stream and the pages editor, which belonged to its agent. The Domains tab asks Tea MCP 2.2's `GET /domains` (`9551a86`), a port of the old factory's Cloudflare Registrar search: a live check of the exact names and Cloudflare's suggestions, with at-cost prices; nothing is registered or bought. A browser can't carry a teahub.xyz session to mcp.teahub.xyz, so unlike the old one it asks for no sign-in: only teahub.xyz may read it (CORS), each client gets 8 searches a minute and everyone together 20, and answers are kept ten minutes, so Cloudflare sees at most 40 calls a minute from the one machine. **Not live yet:** deploying 2.2 and giving green-tea-mcp the old factory's Cloudflare token (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, from the stopped `teahub-mcp` container) wait for the operator, because Claude's permission check refused the deploy; the secrets had been staged and were unstaged, so nothing goes live by accident. Until then the tab answers that domain search is unavailable. That token is wider than search needs (probed: it reads zones and registrar domains and lists Workers scripts; it can't read or write DNS or manage tokens), and neither it nor Boring's token can mint a narrower one, so a Registrar-read-only token from the operator would be better. Token 52 (`look-20261015`, `write:repository`) pushed `9551a86` and this line and is deleted by id right after; the table is checked. Why: the operator liked the old Teahub look, its colour and its domain search, and asked for the home and feed templates back. +- 2026-10-15 · **Prod looks like Teahub again: home page, dashboard, colours and cup, kept in this repo.** Prod's Gitea showed Gitea's own home page, name and blue. At the operator's word the old factory's look is back, ported to Gitea 28: `gitea/custom` has the signed-out home page (Teahub = Git + Tea + Time beside 🍵), the dashboard (the feed, and in the sidebar Domains | Repository | Organization above the contribution heatmap), green tea colours for Gitea's light, dark and auto themes (the old #2f9e44 and #58b96a with every shade Gitea uses; dark-mode buttons get near-black text, because white on that green reads at 2.5:1), the name beside the navbar's cup, and the 🍵 mark (SVGs in the reader's emoji font; PNGs for link previews, tabs and phones from Noto Emoji, Apache 2.0). `APP_NAME` is Teahub and the meta tags say Git + Tea + Time, as env in `gitea/fly.toml`. Rehearsed first on a scratch Gitea 28.0.0 with the same files, signed in, light and dark, desktop and phone width. `gitea/sync-custom.sh` copied the files onto the volume and a deploy at 03:50 UTC restarted Gitea with them (a restart signs everyone out, because sessions live in memory). Left behind with the old factory: the "Ask Tea" chat, the Codex and ceremony controls, the issue stream and the pages editor, which belonged to its agent. The Domains tab asks Tea MCP 2.2's `GET /domains` (`9551a86`), a port of the old factory's Cloudflare Registrar search: a live check of the exact names and Cloudflare's suggestions, with at-cost prices; nothing is registered or bought. Like the old one it is for people signed in, and the sign-in is the MCP's own, as the operator pointed out: the first search sends the browser once through `/domains/sign-in`, Tea MCP's Gitea OAuth with the "Green Tea MCP" app (which so lists `/domains/callback` as a second redirect; anyone who has connected Claude goes straight through), Gitea's token is used once to learn the name and dropped, and a signed, HttpOnly cookie on mcp.teahub.xyz keeps them for 30 days. teahub.xyz and mcp.teahub.xyz are one site, so the dashboard's requests carry it. Only teahub.xyz may read answers (CORS), each person gets 8 searches a minute and everyone together 20, and answers are kept ten minutes, so Cloudflare sees at most 40 calls a minute from the one machine. Tested end to end on hb-5 against a Gitea 28 and the real Cloudflare API: the search asks for the sign-in, Gitea's consent, back to the dashboard, and the waiting search runs. **Not live yet:** deploying 2.2, adding that redirect, restarting Gitea for the new script, and giving green-tea-mcp the old factory's Cloudflare token (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, from the stopped `teahub-mcp` container) wait for the operator, because Claude's permission check refused the first deploy; the secrets had been staged and were unstaged, so nothing goes live by accident. Until then the tab answers that domain search is unavailable. That token is wider than search needs (probed: it reads zones and registrar domains and lists Workers scripts; it can't read or write DNS or manage tokens), and neither it nor Boring's token can mint a narrower one, so a Registrar-read-only token from the operator would be better. Token 52 (`look-20261015`, `write:repository`) pushed `9551a86` and this line and is deleted by id right after; the table is checked. Token 53 (`signin-20261015`, `write:repository`) pushed the sign-in, likewise deleted. Why: the operator liked the old Teahub look, its colour and its domain search, and asked for the home and feed templates back. diff --git a/docs/rebuild.md b/docs/rebuild.md index 29e27e5..3952a7d 100644 --- a/docs/rebuild.md +++ b/docs/rebuild.md @@ -25,7 +25,7 @@ future rebuild should not have to rediscover. - IPs and cert as above. - First connect from claude.ai after a deploy sometimes fails ("Authentication failed", no `/token` call); the retry works. Unexplained. - `FASTMCP_LOG_LEVEL=DEBUG` logs tool arguments (file contents). Never leave it on. -- `GET /domains` (the dashboard's Domains tab) needs the secrets `CLOUDFLARE_API_TOKEN` (Registrar read) and `CLOUDFLARE_ACCOUNT_ID`; without them it answers 503. +- `GET /domains` (the dashboard's Domains tab) needs the secrets `CLOUDFLARE_API_TOKEN` (Registrar read) and `CLOUDFLARE_ACCOUNT_ID`; without them it answers 503. Its browser sign-in uses the same OAuth2 app, so the app also lists `PUBLIC_URL/domains/callback` as a redirect. ## Actions runner on Fly (app `green-tea-runner`, 2026-10-14) - Image `docker.gitea.com/act_runner:5.0.0-dind` (the `0.2.x` tags no longer exist; `-dind-rootless` never gets a daemon up in a Fly VM). Registration token: `gitea actions generate-runner-token` over `fly ssh`, set as `GITEA_RUNNER_REGISTRATION_TOKEN`. diff --git a/gitea/custom/public/assets/js/teahub.js b/gitea/custom/public/assets/js/teahub.js index 5e24b23..c0408f3 100644 --- a/gitea/custom/public/assets/js/teahub.js +++ b/gitea/custom/public/assets/js/teahub.js @@ -1,5 +1,6 @@ // Teahub on Gitea 28, loaded by templates/custom/footer.tmpl: the home page's title, the footer credit, and the -// dashboard's Domains tab, which searches Cloudflare Registrar through Tea MCP (data-domain-search on the script tag). +// dashboard's Domains tab, which searches Cloudflare Registrar through Tea MCP (data-domain-search on the script tag) +// once Tea MCP has signed the person in. (() => { const endpoint = document.currentScript?.dataset.domainSearch || "https://mcp.teahub.xyz/domains"; @@ -158,10 +159,21 @@ results.innerHTML = '
Searching Cloudflare...
'; try { const response = await fetch(`${endpoint}?${new URLSearchParams({q: query, limit: "24"})}`, - {mode: "cors", credentials: "omit"}); + {mode: "cors", credentials: "include"}); const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || "Domain search is unavailable just now."); if (mine !== asked) return; + if (response.status === 401) { + const signIn = `${endpoint}/sign-in?${new URLSearchParams({next: location.href})}`; + results.innerHTML = '
Domain search uses Tea MCP, which needs you to sign in ' + + `once. Sign in
`; + results.querySelector("a").addEventListener("click", () => { + try { + sessionStorage.setItem("teahub-domain-query", query); + } catch {} + }); + return; + } + if (!response.ok) throw new Error(data.error || "Domain search is unavailable just now."); exact = Array.isArray(data.exact_domains) ? data.exact_domains : []; suggested = Array.isArray(data.domains) ? data.domains : []; sort = "price-asc"; @@ -178,6 +190,17 @@ event.preventDefault(); search(); }); + + // Back from signing in: run the search that asked for it. + let pending = ""; + try { + pending = sessionStorage.getItem("teahub-domain-query") || ""; + sessionStorage.removeItem("teahub-domain-query"); + } catch {} + if (pending) { + input.value = pending; + search(); + } }; if (!mount()) { diff --git a/gitea/custom/templates/custom/footer.tmpl b/gitea/custom/templates/custom/footer.tmpl index 744c9e1..c302e60 100644 --- a/gitea/custom/templates/custom/footer.tmpl +++ b/gitea/custom/templates/custom/footer.tmpl @@ -1 +1 @@ - + diff --git a/mcp/README.md b/mcp/README.md index 43ba36c..9945774 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -11,7 +11,7 @@ Tools (30), all acting as you. Their parameters are typed, so a bad argument is - Files: `tea_tree`, `tea_file_get`, `tea_file_change`, `tea_commit` (up to 50 files, text or base64, 3 MB in all), `tea_branch_create`. - Actions: `tea_actions` (recent runs), `tea_action_logs` (a run's jobs, steps and log tails), `tea_workflow_dispatch`, `tea_secret_set` (never echoed), `tea_variable_set`. -Beside the tools, `GET /domains?q=` answers the Domains tab of Teahub's dashboard: Cloudflare Registrar's live check of the exact names and its suggestions, with prices. Only `GITEA_URL` may read it from a browser (CORS); each client gets 8 searches a minute and everyone together 20, and answers are kept ten minutes. It needs the secrets `CLOUDFLARE_API_TOKEN` (Registrar read) and `CLOUDFLARE_ACCOUNT_ID`, and says it is not set up without them. Nothing is registered or bought. +Beside the tools, `GET /domains?q=` answers the Domains tab of Teahub's dashboard: Cloudflare Registrar's live check of the exact names and its suggestions, with prices. It is for people signed in: the tab sends them once through `/domains/sign-in`, which is this server's own Gitea OAuth (the app Claude's connector uses, so it needs `PUBLIC_URL/domains/callback` as a second redirect), and a signed, HttpOnly cookie on this host keeps them for 30 days; Gitea's token is used once to learn the name and dropped. Only `GITEA_URL` may read answers from a browser (CORS); each person gets 8 searches a minute and everyone together 20, and answers are kept ten minutes. It needs the secrets `CLOUDFLARE_API_TOKEN` (Registrar read) and `CLOUDFLARE_ACCOUNT_ID`, and says it is not set up without them. Nothing is registered or bought. New repositories are private unless asked otherwise, and labels must already exist. The Actions tools need Gitea 1.25 or later (prod runs 28). diff --git a/mcp/tea_mcp/domains.py b/mcp/tea_mcp/domains.py index b63b36e..08b65e6 100644 --- a/mcp/tea_mcp/domains.py +++ b/mcp/tea_mcp/domains.py @@ -1,33 +1,45 @@ -"""GET /domains: Cloudflare Registrar search for the Domains tab of Teahub's dashboard. +"""GET /domains: Cloudflare Registrar search for the Domains tab of Teahub's dashboard, for people signed in. -The dashboard on GITEA_URL asks with ?q=. Exact names (the domain as given, or the -name under each of ENDINGS) are checked live with Cloudflare's domain-check; suggestions come from its domain-search. -Each comes with availability and Cloudflare's at-cost price. Nothing is registered or bought here. The Cloudflare -token stays on this server and anyone may ask, so each client gets a few searches a minute, everyone together a few -more, and answers are kept for ten minutes. +Signing in is the MCP's own sign-in, done in the browser. /domains/sign-in sends the browser to Gitea's OAuth with this +server's OAuth app (the one Claude's connector uses, so anyone who has connected is let straight through), and +/domains/callback learns from Gitea who it is and remembers them for 30 days in a signed, HttpOnly cookie on this host. +The dashboard on GITEA_URL then asks with ?q= and that cookie (teahub.xyz and +mcp.teahub.xyz are one site, so the browser sends it). Exact names (the domain as given, or the name under each of +ENDINGS) are checked live with Cloudflare's domain-check; suggestions come from its domain-search, each with +availability and Cloudflare's at-cost price. Nothing is registered or bought. Each person gets a few searches a minute, +everyone together a few more, and answers are kept for ten minutes. """ from __future__ import annotations import asyncio +import base64 +import binascii +import hashlib +import hmac +import json import os import re +import secrets import time from collections import OrderedDict, deque from typing import Any +from urllib.parse import urlencode import httpx from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse, Response CLOUDFLARE = "https://api.cloudflare.com/client/v4" QUERY = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}") ENDINGS = ("com", "net", "org", "co", "io", "xyz", "app", "dev", "ai", "me") -PER_CLIENT, IN_ALL, WINDOW = 8, 20, 60.0 # searches per WINDOW seconds that go to Cloudflare +PER_PERSON, IN_ALL, WINDOW = 8, 20, 60.0 # searches per WINDOW seconds that go to Cloudflare KEEP_SECONDS, KEEP_ANSWERS = 600.0, 256 +SIGNED_IN, SIGNING_IN = "tea_domains", "tea_domains_signin" # cookies, both scoped to /domains on this host +SIGNED_IN_SECONDS, SIGNING_IN_SECONDS = 30 * 86400, 600 -transport: httpx.AsyncBaseTransport | None = None # the tests' fake Cloudflare +transport: httpx.AsyncBaseTransport | None = None # the tests' fake Cloudflare and Gitea _kept: OrderedDict[tuple[str, int], tuple[float, dict[str, Any]]] = OrderedDict() -_by_client: dict[str, deque[float]] = {} +_by_person: dict[str, deque[float]] = {} _by_all: deque[float] = deque() @@ -35,6 +47,44 @@ class Unanswered(Exception): """Cloudflare gave no usable answer.""" +def _url(name: str) -> str: + return os.getenv(name, "").strip().rstrip("/") + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _mac(body: str) -> str: + key = hmac.new(os.environ["JWT_SIGNING_KEY"].encode(), b"teahub domains cookie", hashlib.sha256).digest() + return _b64(hmac.new(key, body.encode(), hashlib.sha256).digest()) + + +def seal(data: dict[str, Any], seconds: int) -> str: + """`data` and an expiry, signed: readable by anyone, made only here.""" + body = _b64(json.dumps({**data, "exp": int(time.time()) + seconds}, separators=(",", ":")).encode()) + return f"{body}.{_mac(body)}" + + +def unseal(value: str | None) -> dict[str, Any] | None: + body, _, mac = (value or "").partition(".") + if not body or not hmac.compare_digest(mac.encode(), _mac(body).encode()): + return None + try: + data = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) + except (ValueError, binascii.Error): + return None + return data if isinstance(data, dict) and isinstance(data.get("exp"), int) and data["exp"] > time.time() else None + + +def _json(response: httpx.Response) -> dict[str, Any]: + try: + body = response.json() + except ValueError: + return {} + return body if isinstance(body, dict) else {} + + def exact_names(query: str) -> list[str]: """A domain is checked as given; a name or phrase becomes one label under each of ENDINGS.""" value = query.strip().lower() @@ -47,13 +97,10 @@ def exact_names(query: str) -> list[str]: def _rows(answer: httpx.Response | BaseException, *, invalid_ok: bool = False) -> list[dict[str, Any]]: if isinstance(answer, BaseException): raise Unanswered(type(answer).__name__) - try: - body = answer.json() - except ValueError: - body = {} if answer.status_code == 400 and invalid_ok: return [] # none of the names is a domain Cloudflare could register (code 1008) - if answer.status_code >= 400 or not isinstance(body, dict) or body.get("success") is False: + body = _json(answer) + if answer.status_code >= 400 or body.get("success") is False: raise Unanswered(f"HTTP {answer.status_code}") rows = [] for item in (body.get("result") or {}).get("domains") or []: @@ -81,32 +128,36 @@ async def search(query: str, limit: int, token: str, account: str) -> dict[str, "domains": [row for row in found if str(row["name"]).lower() not in names]} -def _allowed(client: str, now: float) -> bool: - mine = _by_client.setdefault(client, deque()) +def _allowed(person: str, now: float) -> bool: + mine = _by_person.setdefault(person, deque()) for times in (_by_all, mine): while times and now - times[0] > WINDOW: times.popleft() - if len(mine) >= PER_CLIENT or len(_by_all) >= IN_ALL: + if len(mine) >= PER_PERSON or len(_by_all) >= IN_ALL: return False mine.append(now) _by_all.append(now) - if len(_by_client) > 2048: - for name in [name for name, times in _by_client.items() if not times or now - times[-1] > WINDOW]: - del _by_client[name] + if len(_by_person) > 2048: + for name in [name for name, times in _by_person.items() if not times or now - times[-1] > WINDOW]: + del _by_person[name] return True -async def domains_http(request: Request) -> Response: - origin = os.getenv("GITEA_URL", "").rstrip("/") - cors = {"Vary": "Origin"} +def _cors(request: Request) -> dict[str, str]: + origin = _url("GITEA_URL") if origin and request.headers.get("origin") == origin: - cors["Access-Control-Allow-Origin"] = origin + return {"Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Vary": "Origin"} + return {"Vary": "Origin"} + + +async def domains_http(request: Request) -> Response: + cors = _cors(request) if request.method == "OPTIONS": return Response(status_code=204, headers={**cors, "Access-Control-Allow-Methods": "GET", "Access-Control-Max-Age": "86400"}) def answer(body: dict[str, Any], status: int = 200) -> JSONResponse: - keep = "public, max-age=300" if status == 200 else "no-store" + keep = "private, max-age=300" if status == 200 else "no-store" return JSONResponse(body, status, headers={**cors, "Cache-Control": keep}) query = (request.query_params.get("q") or "").strip() @@ -122,13 +173,15 @@ async def domains_http(request: Request) -> Response: account = os.getenv("CLOUDFLARE_ACCOUNT_ID", "").strip() if not (token and account): return answer({"error": "Domain search is not set up here."}, 503) + person = (unseal(request.cookies.get(SIGNED_IN)) or {}).get("u") + if not person: + return answer({"error": "Sign in to search domains.", "sign_in": f"{_url('PUBLIC_URL')}/domains/sign-in"}, 401) key, now = (query.lower(), limit), time.monotonic() kept = _kept.get(key) if kept and kept[0] > now: return answer(kept[1]) - client = request.headers.get("fly-client-ip") or (request.client.host if request.client else "") - if not _allowed(client, now): + if not _allowed(person, now): return answer({"error": "Too many searches. Try again in a minute."}, 429) try: result = await search(query, limit, token, account) @@ -139,3 +192,52 @@ async def domains_http(request: Request) -> Response: while len(_kept) > KEEP_ANSWERS: _kept.popitem(last=False) return answer(result) + + +async def sign_in_http(request: Request) -> Response: + """Off to Gitea's OAuth with this server's app; `next`, a page on GITEA_URL, is where the browser comes back to.""" + gitea, public = _url("GITEA_URL"), _url("PUBLIC_URL") + back = request.query_params.get("next") or "" + if not back.startswith(f"{gitea}/"): + back = f"{gitea}/" + state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(48) + to = f"{gitea}/login/oauth/authorize?" + urlencode({ + "client_id": os.environ["GITEA_OAUTH_CLIENT_ID"], "redirect_uri": f"{public}/domains/callback", + "response_type": "code", "scope": "openid profile", "state": state, + "code_challenge": _b64(hashlib.sha256(verifier.encode()).digest()), "code_challenge_method": "S256"}) + response = RedirectResponse(to, 302) + response.set_cookie(SIGNING_IN, seal({"s": state, "v": verifier, "n": back}, SIGNING_IN_SECONDS), + max_age=SIGNING_IN_SECONDS, path="/domains", secure=True, httponly=True, samesite="lax") + return response + + +async def callback_http(request: Request) -> Response: + """Gitea's answer: the code becomes a Gitea token, the token says who signed in, and only the name is kept.""" + pending = unseal(request.cookies.get(SIGNING_IN)) + code, state = request.query_params.get("code", ""), request.query_params.get("state", "") + if not pending or not code or not hmac.compare_digest(state.encode(), str(pending.get("s", "")).encode()): + return PlainTextResponse("That sign-in has expired or was not started here. Start again from the Domains tab.", + 400) + gitea, public = _url("GITEA_URL"), _url("PUBLIC_URL") + person = None + try: + async with httpx.AsyncClient(timeout=12, transport=transport) as client: + granted = await client.post(f"{gitea}/login/oauth/access_token", headers={"Accept": "application/json"}, + data={"grant_type": "authorization_code", "code": code, + "redirect_uri": f"{public}/domains/callback", + "client_id": os.environ["GITEA_OAUTH_CLIENT_ID"], + "client_secret": os.environ["GITEA_OAUTH_CLIENT_SECRET"], + "code_verifier": str(pending.get("v", ""))}) + access = _json(granted).get("access_token") if granted.status_code == 200 else None + if access: + me = await client.get(f"{gitea}/login/oauth/userinfo", headers={"Authorization": f"Bearer {access}"}) + person = _json(me).get("preferred_username") if me.status_code == 200 else None + except httpx.HTTPError: + person = None + if not person or not isinstance(person, str): + return PlainTextResponse("Teahub could not sign you in just now. Try again from the Domains tab.", 502) + response = RedirectResponse(str(pending.get("n") or f"{gitea}/"), 303) + response.set_cookie(SIGNED_IN, seal({"u": person}, SIGNED_IN_SECONDS), max_age=SIGNED_IN_SECONDS, + path="/domains", secure=True, httponly=True, samesite="lax") + response.delete_cookie(SIGNING_IN, path="/domains", secure=True, httponly=True, samesite="lax") + return response diff --git a/mcp/tea_mcp/server.py b/mcp/tea_mcp/server.py index 788f043..0586b2b 100644 --- a/mcp/tea_mcp/server.py +++ b/mcp/tea_mcp/server.py @@ -28,7 +28,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route -from .domains import domains_http +from .domains import callback_http, domains_http, sign_in_http def required_env(name: str) -> str: @@ -673,7 +673,10 @@ async def healthz(_: Request) -> JSONResponse: app = mcp.http_app(path="/mcp", transport="streamable-http", stateless_http=True, json_response=True, allowed_hosts=ALLOWED_HOSTS) app.routes.append(Route("/healthz", healthz, methods=["GET"])) -app.routes.append(Route("/domains", domains_http, methods=["GET", "OPTIONS"])) # the dashboard's Domains tab +# The dashboard's Domains tab: its search, and the browser sign-in it needs (this server's Gitea OAuth app). +app.routes.append(Route("/domains", domains_http, methods=["GET", "OPTIONS"])) +app.routes.append(Route("/domains/sign-in", sign_in_http, methods=["GET"])) +app.routes.append(Route("/domains/callback", callback_http, methods=["GET"])) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080")), proxy_headers=True, forwarded_allow_ips="*") diff --git a/mcp/tests/test_domains.py b/mcp/tests/test_domains.py index cc33b5b..e43a43d 100644 --- a/mcp/tests/test_domains.py +++ b/mcp/tests/test_domains.py @@ -1,11 +1,14 @@ -"""GET /domains against a fake Cloudflare. From mcp/: python -m unittest discover -s tests""" +"""GET /domains and its sign-in, against a fake Cloudflare and a fake Gitea. From mcp/: python -m unittest discover -s tests""" from __future__ import annotations +import base64 +import hashlib import json import os import sys import unittest from unittest import mock +from urllib.parse import parse_qs, urlsplit sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -15,7 +18,7 @@ from starlette.routing import Route # noqa: E402 from tea_mcp import domains # noqa: E402 -ORIGIN = "https://teahub.test" +ORIGIN, MCP = "https://teahub.test", "https://mcp.test" def offer(name: str, registrable: bool = True, cost: str = "10.46") -> dict: @@ -25,13 +28,18 @@ def offer(name: str, registrable: bool = True, cost: str = "10.46") -> dict: "pricing": {"currency": "USD", "registration_cost": cost, "renewal_cost": cost}} -class FakeCloudflare: +class Fake: + """Cloudflare's Registrar and Gitea's OAuth, as far as /domains uses them.""" + def __init__(self): self.calls: list[httpx.Request] = [] self.fail = 0 + self.codes: dict[str, tuple[str, str]] = {} # code -> (its PKCE challenge, who signed in) def __call__(self, request: httpx.Request) -> httpx.Response: self.calls.append(request) + if request.url.host == "teahub.test": + return self.gitea(request) if request.headers.get("authorization") != "Bearer cf-token" or "/accounts/acct/" not in request.url.path: return httpx.Response(403, json={"success": False, "errors": [{"code": 10000}]}) if self.fail: @@ -45,87 +53,160 @@ class FakeCloudflare: return httpx.Response(200, json={"success": True, "result": {"domains": [ offer(name, registrable=not name.endswith(".com")) for name in names]}}) + def gitea(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/login/oauth/access_token": + form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} + challenge, person = self.codes.get(form.get("code", ""), ("", "")) + proof = base64.urlsafe_b64encode(hashlib.sha256(form.get("code_verifier", "").encode()).digest()) + if not person or proof.decode().rstrip("=") != challenge or form.get("client_secret") != "not-a-secret" \ + or form.get("redirect_uri") != f"{MCP}/domains/callback": + return httpx.Response(400, json={"error": "invalid_grant"}) + return httpx.Response(200, json={"access_token": f"at-{person}", "token_type": "bearer"}) + if request.url.path == "/login/oauth/userinfo": + person = request.headers.get("authorization", "").removeprefix("Bearer at-") + return httpx.Response(200, json={"sub": "2", "preferred_username": person}) + return httpx.Response(404) + class Domains(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - env = mock.patch.dict(os.environ, {"GITEA_URL": ORIGIN, "CLOUDFLARE_API_TOKEN": "cf-token", - "CLOUDFLARE_ACCOUNT_ID": "acct"}) + env = mock.patch.dict(os.environ, { + "GITEA_URL": ORIGIN, "PUBLIC_URL": MCP, "GITEA_OAUTH_CLIENT_ID": "id", + "GITEA_OAUTH_CLIENT_SECRET": "not-a-secret", "JWT_SIGNING_KEY": "k" * 32, + "CLOUDFLARE_API_TOKEN": "cf-token", "CLOUDFLARE_ACCOUNT_ID": "acct"}) env.start() self.addCleanup(env.stop) - self.cf = FakeCloudflare() - domains.transport = httpx.MockTransport(self.cf) + self.fake = Fake() + domains.transport = httpx.MockTransport(self.fake) self.addCleanup(setattr, domains, "transport", None) - for kept in (domains._kept, domains._by_client, domains._by_all): + for kept in (domains._kept, domains._by_person, domains._by_all): kept.clear() - app = Starlette(routes=[Route("/domains", domains.domains_http, methods=["GET", "OPTIONS"])]) - self.web = httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://mcp.test") - self.addAsyncCleanup(self.web.aclose) + self.app = Starlette(routes=[Route("/domains", domains.domains_http, methods=["GET", "OPTIONS"]), + Route("/domains/sign-in", domains.sign_in_http), + Route("/domains/callback", domains.callback_http)]) - async def ask(self, q: str, origin: str = ORIGIN, ip: str = "203.0.113.7", **params) -> httpx.Response: - return await self.web.get("/domains", params={"q": q, **params}, - headers={"origin": origin, "fly-client-ip": ip}) + def browser(self) -> httpx.AsyncClient: + web = httpx.AsyncClient(transport=httpx.ASGITransport(app=self.app), base_url=MCP) + self.addAsyncCleanup(web.aclose) + return web + + async def start(self, web: httpx.AsyncClient, back: str = f"{ORIGIN}/dashboard") -> tuple[httpx.Response, dict]: + started = await web.get("/domains/sign-in", params={"next": back}) + return started, {k: v[0] for k, v in parse_qs(urlsplit(started.headers["location"]).query).items()} + + async def signed_in(self, person: str = "matcha") -> httpx.AsyncClient: + web = self.browser() + _, asked = await self.start(web) + self.fake.codes[f"code-{person}"] = (asked["code_challenge"], person) + back = await web.get("/domains/callback", params={"code": f"code-{person}", "state": asked["state"]}) + self.assertEqual(back.status_code, 303) + return web + + async def ask(self, web: httpx.AsyncClient, q: str, origin: str = ORIGIN, **params) -> httpx.Response: + return await web.get("/domains", params={"q": q, **params}, headers={"origin": origin}) + + async def test_signing_in_goes_through_gitea_and_comes_back(self): + web = self.browser() + started, asked = await self.start(web) + self.assertEqual(started.status_code, 302) + self.assertTrue(started.headers["location"].startswith(f"{ORIGIN}/login/oauth/authorize?")) + self.assertEqual((asked["client_id"], asked["redirect_uri"], asked["code_challenge_method"], asked["scope"]), + ("id", f"{MCP}/domains/callback", "S256", "openid profile")) + self.fake.codes["c1"] = (asked["code_challenge"], "matcha") + back = await web.get("/domains/callback", params={"code": "c1", "state": asked["state"]}) + self.assertEqual((back.status_code, back.headers["location"]), (303, f"{ORIGIN}/dashboard")) + cookie = next(v for k, v in back.headers.multi_items() if k == "set-cookie" and v.startswith("tea_domains=")) + for part in ("HttpOnly", "Secure", "Path=/domains", "SameSite=lax"): + self.assertIn(part, cookie) + self.assertNotIn("at-matcha", str(back.headers)) # Gitea's token is used once and dropped + self.assertEqual(domains.unseal(web.cookies["tea_domains"])["u"], "matcha") + self.assertEqual((await self.ask(web, "teahub")).status_code, 200) + + async def test_searching_needs_a_sign_in(self): + r = await self.ask(self.browser(), "teahub") + self.assertEqual((r.status_code, r.json()["sign_in"]), (401, f"{MCP}/domains/sign-in")) + self.assertEqual((r.headers["access-control-allow-origin"], r.headers["access-control-allow-credentials"]), + (ORIGIN, "true")) + forged = domains._b64(json.dumps({"u": "root", "exp": 2**40}).encode()) + for cookie in (f"{forged}.{domains._mac(forged)}x", f"{forged}.", domains.seal({"u": "matcha"}, -1)): + r = await self.browser().get("/domains", params={"q": "teahub"}, headers={"cookie": f"tea_domains={cookie}"}) + self.assertEqual(r.status_code, 401) + self.assertEqual(self.fake.calls, []) + + async def test_a_sign_in_must_start_here_and_come_back_to_teahub(self): + web = self.browser() + _, asked = await self.start(web, back="https://elsewhere.test/") + self.fake.codes["c2"] = (asked["code_challenge"], "matcha") + stranger = await self.browser().get("/domains/callback", params={"code": "c2", "state": asked["state"]}) + forged = await web.get("/domains/callback", params={"code": "c2", "state": "forged"}) + refused = await web.get("/domains/callback", params={"code": "unknown", "state": asked["state"]}) + self.assertEqual((stranger.status_code, forged.status_code, refused.status_code), (400, 400, 502)) + self.assertNotIn("tea_domains", web.cookies) + back = await web.get("/domains/callback", params={"code": "c2", "state": asked["state"]}) + self.assertEqual(back.headers["location"], f"{ORIGIN}/") async def test_a_name_is_checked_under_ten_endings_and_suggestions_skip_those(self): - r = await self.ask("Tea Hub") + r = await self.ask(await self.signed_in(), "Tea Hub") body = r.json() - self.assertEqual(r.status_code, 200) self.assertEqual([d["name"] for d in body["exact_domains"]], [f"teahub.{e}" for e in domains.ENDINGS]) com, net = body["exact_domains"][:2] self.assertEqual((com["registrable"], com["registration_cost"], com["reason"]), (False, None, "domain_unavailable")) self.assertEqual((net["registrable"], net["registration_cost"], net["currency"]), (True, "10.46", "USD")) self.assertEqual([d["name"] for d in body["domains"]], ["teahubusa.com", "teahub.uk"]) - self.assertEqual((r.headers["access-control-allow-origin"], r.headers["vary"]), (ORIGIN, "Origin")) - search = self.cf.calls[0] + search = next(c for c in self.fake.calls if c.url.path.endswith("/domain-search")) self.assertEqual((search.url.params["q"], search.url.params["limit"]), ("Tea Hub", "24")) - async def test_a_domain_is_checked_as_given(self): - body = (await self.ask("Tea-Hub.dev", limit=5)).json() - self.assertEqual([d["name"] for d in body["exact_domains"]], ["tea-hub.dev"]) - self.assertEqual(self.cf.calls[0].url.params["limit"], "5") - - async def test_names_cloudflare_cannot_register_leave_exact_empty(self): - body = (await self.ask("a.b")).json() + async def test_a_domain_is_checked_as_given_and_one_cloudflare_cannot_sell_is_no_match(self): + web = await self.signed_in() + self.assertEqual([d["name"] for d in (await self.ask(web, "Tea-Hub.dev")).json()["exact_domains"]], + ["tea-hub.dev"]) + body = (await self.ask(web, "a.b")).json() self.assertEqual((body["exact_domains"], len(body["domains"])), ([], 3)) async def test_only_teahub_may_read_the_answer(self): - r = await self.ask("teahub", origin="https://elsewhere.test") + r = await self.ask(await self.signed_in(), "teahub", origin="https://elsewhere.test") self.assertEqual(r.status_code, 200) self.assertNotIn("access-control-allow-origin", r.headers) - preflight = await self.web.options("/domains", headers={"origin": ORIGIN, - "access-control-request-method": "GET"}) - self.assertEqual((preflight.status_code, preflight.headers["access-control-allow-origin"], - preflight.headers["access-control-allow-methods"]), (204, ORIGIN, "GET")) + preflight = await self.browser().options("/domains", headers={"origin": ORIGIN, + "access-control-request-method": "GET"}) + self.assertEqual((preflight.status_code, preflight.headers["access-control-allow-origin"]), (204, ORIGIN)) async def test_bad_searches_never_reach_cloudflare(self): + web = await self.signed_in() + self.fake.calls.clear() for q, params in (("", {}), ("-tea", {}), ("t" * 101, {}), ("tea/hub", {}), ("tea", {"limit": "0"}), ("tea", {"limit": "51"}), ("tea", {"limit": "many"})): - r = await self.ask(q, **params) - self.assertEqual(r.status_code, 400, (q, params)) - self.assertEqual(r.headers["cache-control"], "no-store") - self.assertEqual(self.cf.calls, []) + r = await self.ask(web, q, **params) + self.assertEqual((r.status_code, r.headers["cache-control"]), (400, "no-store"), (q, params)) + self.assertEqual(self.fake.calls, []) async def test_answers_are_kept_and_a_failure_is_not(self): - self.cf.fail = 500 - failed = await self.ask("teahub") + web = await self.signed_in() + self.fake.calls.clear() + self.fake.fail = 500 + failed = await self.ask(web, "teahub") self.assertEqual((failed.status_code, failed.json()["error"]), (502, "Cloudflare could not answer just now.")) - self.cf.fail = 0 - first, again = await self.ask("teahub"), await self.ask("TeaHub") + self.fake.fail = 0 + first, again = await self.ask(web, "teahub"), await self.ask(web, "TeaHub") self.assertEqual((first.status_code, again.json()), (200, first.json())) - self.assertEqual(len(self.cf.calls), 4) # the failed pair, then one search and one check + self.assertEqual(len(self.fake.calls), 4) # the failed pair, then one search and one check - async def test_each_client_and_everyone_are_held_to_a_few_a_minute(self): - for n in range(domains.PER_CLIENT): - self.assertEqual((await self.ask(f"tea{n}")).status_code, 200) - limited = await self.ask("one-more") + async def test_each_person_and_everyone_get_a_few_a_minute(self): + a, b, c = await self.signed_in("a"), await self.signed_in("b"), await self.signed_in("c") + for n in range(domains.PER_PERSON): + self.assertEqual((await self.ask(a, f"a{n}")).status_code, 200) + limited = await self.ask(a, "a-more") self.assertEqual((limited.status_code, limited.json()["error"]), (429, "Too many searches. Try again in a minute.")) - self.assertEqual((await self.ask("tea0")).status_code, 200) # a kept answer costs nothing - for n in range(domains.IN_ALL - domains.PER_CLIENT): - self.assertEqual((await self.ask(f"other{n}", ip=f"198.51.100.{n}")).status_code, 200) - self.assertEqual((await self.ask("fresh", ip="192.0.2.1")).status_code, 429) + self.assertEqual((await self.ask(a, "a0")).status_code, 200) # a kept answer costs nothing + for n in range(domains.PER_PERSON): + self.assertEqual((await self.ask(b, f"b{n}")).status_code, 200) + for n in range(domains.IN_ALL - 2 * domains.PER_PERSON): + self.assertEqual((await self.ask(c, f"c{n}")).status_code, 200) + self.assertEqual((await self.ask(c, "c-more")).status_code, 429) async def test_without_cloudflare_settings_it_says_so(self): with mock.patch.dict(os.environ, {"CLOUDFLARE_API_TOKEN": ""}): - r = await self.ask("teahub") + r = await self.ask(self.browser(), "teahub") self.assertEqual((r.status_code, r.json()["error"]), (503, "Domain search is not set up here.")) diff --git a/mcp/tests/test_server.py b/mcp/tests/test_server.py index dd447c6..77c192e 100644 --- a/mcp/tests/test_server.py +++ b/mcp/tests/test_server.py @@ -438,8 +438,10 @@ class App(unittest.IsolatedAsyncioTestCase): headers={"Accept": "application/json, text/event-stream"}) metadata = await web.get("/.well-known/oauth-authorization-server") domains = await web.get("/domains", params={"q": "teahub"}, headers={"origin": "https://gitea.test"}) + sign_in = await web.get("/domains/sign-in") self.assertEqual((health.json()["version"], anonymous.status_code), (server.VERSION, 401)) self.assertEqual((domains.status_code, domains.headers["access-control-allow-origin"]), (503, "https://gitea.test")) + self.assertTrue(sign_in.headers["location"].startswith("https://gitea.test/login/oauth/authorize?client_id=id&")) self.assertEqual(metadata.json()["issuer"].rstrip("/"), "https://mcp.test")