diff --git a/docs/decisions.md b/docs/decisions.md
index 78b5f86..37bf76e 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -57,7 +57,7 @@ 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. 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.
+- 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. Who may search is settled by where the search is: the tab is only on the signed-in dashboard, and in chat the same search is Tea MCP's `tea_domains` tool, behind the connector's sign-in. The address the tab calls can't see a teahub.xyz sign-in (another host), so it stays open and bounded: 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. A browser sign-in through the MCP's own Gitea OAuth was built and tested end to end (`9fece6d`) and then taken out at the operator's word: being signed in to the site, or connected in chat, is the gate. **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 (Claude's permission check refused the first deploy, and then the operator said not now); 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. Token 57 (`domains-tool-20261015`) pushed the simpler version, 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.
- 2026-10-15 · **Orgchain v1.5: beats pay the holding, and test BTC moves down the tree.** The operator asked to simulate transactions inside an organization, the way Super Testnet's mining pays its wallet: the seed org gets each beat's funds and can send to subsidiaries and agents. Each beat now pays its 50 test BTC block reward to the holding being worked in, at the holding's SegWit (BIP-84) address; a closed holding, or one that predates lab keys, leaves it to the lab wallet. **Send** moves test BTC from any active node to another in its holding, signed by the sender's BIP-84 key, with the toy chain's fixed 500-sat fee: at most two coins in and one block reward out. Sends land like acts (at once, cued, or on *Beat now*, which now mines even when nothing is cued). A graduation moves each re-keyed node's money to its new address with its inscription, so the parent's line can no longer spend it. Balances follow the slider; the tree shows them rounded (≈), the timeline lists every reward and send, and Inspect has exact amounts and the raw transaction. Inscription fees still come from the lab wallet. The engine's one local change is in `src/adapters/chain.js`: the toy chain also takes P2WPKH outputs and SIGHASH_ALL spends (BIP-143, low-S DER), recorded in PROVENANCE; Super Testnet's tests pass unchanged. `lineage/orgchain` main `6d5806d`, time's `gh-pages` `1e59e5d`; 62 unit tests and Playwright 8/8. Token 54 (`orgchain-money-20261015`, `write:repository`) did the pushes and is deleted by id right after; the table is checked. Why: transactions inside an org come before orgs trading with each other.
- 2026-10-15 · **Orgchain v1.6: holdings share the mining, every act pays its gas, and money crosses holdings.** The operator asked whether mining splits when a lab has several holdings (it didn't: the holding being worked in took each beat), said they'd been thinking equal with hashrate as an interesting option, that the last-act rule is an interesting multiplayer mechanic (like an idle clicker), and that there "should have gas on every transaction". So mining is now a lab setting. *Split* (the default) pays every active holding a share of each beat, pro rata by a hashrate dial from 0 to 9 on each holding (1 unless set, so equal), one coinbase output per holding. *Last act* gives the whole beat to the holding that acts, and *Beat now* pays the one being worked in. Every act is paid by the acting node's own keys: its inscription's fees and postage come out of its gas tank (the plain coins at its Taproot key), which tops up from its own money 0.0001 test BTC at a time. Each act is checked before anything is signed, so a node with no money can't act until someone sends it some, which is the anti-spam point. A graduating org pays for the claim on its new root out of its own money, and the lab wallet now pays only for foundings and for holdings that predate lab keys. Sends cross holdings (one-way payments), and the timeline shows gas top-ups. The engine's second local change is in `src/adapters/chain.js`: a coinbase may split the reward across up to 64 outputs (`mineTo`), recorded in PROVENANCE. Chain keys are now derived once per page (gas checks ask for them often). `lineage/orgchain` main `2354947`, time's `gh-pages` `69eb5a3`; 62 unit tests and Playwright 8/8. Token 55 (`orgchain-gas-20261015`, `write:repository`) did the pushes and is deleted by id right after; the table is checked. Why: the operator's questions about several holdings mining, and gas on every act.
- 2026-10-15 · **Orgchain v1.6.1: under *Last act*, a timed beat goes to whoever acted last.** v1.6 paid a timed or manual beat to the holding being worked in. Now it goes to the holding behind the newest cued act or send (with nothing cued, *Beat now* still pays the one being worked in), so at a timed tempo holdings race for the next beat. `lineage/orgchain` main `32e8131`, time's `gh-pages` `54750a7`; 62 unit tests and Playwright 8/8. Token 56 (`orgchain-race-20261015`, `write:repository`) did the pushes and is deleted by id right after; the table is checked. Why: the operator's idle-clicker point, made true at every tempo.
diff --git a/docs/rebuild.md b/docs/rebuild.md
index 3952a7d..2a9878c 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. Its browser sign-in uses the same OAuth2 app, so the app also lists `PUBLIC_URL/domains/callback` as a redirect.
+- `tea_domains` and `GET /domains` (the dashboard's Domains tab) need the secrets `CLOUDFLARE_API_TOKEN` (Registrar read) and `CLOUDFLARE_ACCOUNT_ID`; without them they say domain search is not set up.
## 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 c0408f3..5e24b23 100644
--- a/gitea/custom/public/assets/js/teahub.js
+++ b/gitea/custom/public/assets/js/teahub.js
@@ -1,6 +1,5 @@
// 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)
-// once Tea MCP has signed the person in.
+// dashboard's Domains tab, which searches Cloudflare Registrar through Tea MCP (data-domain-search on the script tag).
(() => {
const endpoint = document.currentScript?.dataset.domainSearch || "https://mcp.teahub.xyz/domains";
@@ -159,21 +158,10 @@
results.innerHTML = '
Searching Cloudflare...
';
try {
const response = await fetch(`${endpoint}?${new URLSearchParams({q: query, limit: "24"})}`,
- {mode: "cors", credentials: "include"});
+ {mode: "cors", credentials: "omit"});
const data = await response.json().catch(() => ({}));
- 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.");
+ if (mine !== asked) return;
exact = Array.isArray(data.exact_domains) ? data.exact_domains : [];
suggested = Array.isArray(data.domains) ? data.domains : [];
sort = "price-asc";
@@ -190,17 +178,6 @@
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 c302e60..744c9e1 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 9945774..7882c91 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -4,14 +4,15 @@ Teahub's MCP (Fly app `green-tea-mcp`, `https://mcp.teahub.xyz/mcp`) against the
Env: `GITEA_URL`, `PUBLIC_URL`, `ALLOWED_HOSTS`; secrets `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET` (an OAuth2 application on the Gitea with redirect `PUBLIC_URL/auth/callback`), `JWT_SIGNING_KEY`. State: `/data` (FastMCP's OAuth store).
-Tools (30), all acting as you. Their parameters are typed, so a bad argument is refused before Gitea is asked anything.
+Tools (31), all acting as you. Their parameters are typed, so a bad argument is refused before Gitea is asked anything.
- Issues and pull requests: `tea_issues`, `tea_issue_get/create/edit`, `tea_issue_comments/comment`, `tea_labels`, `tea_label` (add or remove by name), `tea_pull_get` (branches, mergeability, checks, diff), `tea_pull_create`, `tea_pull_review`, `tea_pull_merge` (keeps the head branch).
- Repositories and organisations: `tea_whoami`, `tea_repo`, `tea_repo_edit` (description, website, visibility, default branch, archived), `tea_repo_create`, `tea_org_create`, `tea_org_repo_create`, `tea_org_add_member` (name the team), `tea_hook_create` (one hook per URL).
- 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`.
+- Domains: `tea_domains` (Cloudflare Registrar: exact names checked live, and suggestions, with prices).
- 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. 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.
+`tea_domains` and `GET /domains?q=` (the Domains tab of Teahub's dashboard) are one search: Cloudflare Registrar's live check of the exact names and its suggestions, with prices; nothing is registered or bought. The tool is behind this server's sign-in like the others, and the tab is only on the signed-in dashboard. The address the tab calls can't see a teahub.xyz sign-in, so it is open and bounded: 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. Both need the secrets `CLOUDFLARE_API_TOKEN` (Registrar read) and `CLOUDFLARE_ACCOUNT_ID` and say it is not set up without them.
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 08b65e6..234df47 100644
--- a/mcp/tea_mcp/domains.py
+++ b/mcp/tea_mcp/domains.py
@@ -1,45 +1,34 @@
-"""GET /domains: Cloudflare Registrar search for the Domains tab of Teahub's dashboard, for people signed in.
+"""Domain search with Cloudflare Registrar, for the Domains tab of Teahub's dashboard and the tea_domains tool.
-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.
+A domain as given, or a name or phrase under each of ENDINGS, is 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. Who may search is settled by where the search is: the tab is only on the signed-in dashboard, and the tool is
+behind this server's sign-in. GET /domains, which the tab calls, can't see a teahub.xyz sign-in (another host), so it is
+open and bounded: only GITEA_URL may read it from a browser, each client 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, PlainTextResponse, RedirectResponse, Response
+from starlette.responses import JSONResponse, 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_PERSON, IN_ALL, WINDOW = 8, 20, 60.0 # searches per WINDOW seconds that go to Cloudflare
+PER_CLIENT, 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 and Gitea
+transport: httpx.AsyncBaseTransport | None = None # the tests' fake Cloudflare
_kept: OrderedDict[tuple[str, int], tuple[float, dict[str, Any]]] = OrderedDict()
-_by_person: dict[str, deque[float]] = {}
+_by_client: dict[str, deque[float]] = {}
_by_all: deque[float] = deque()
@@ -47,44 +36,6 @@ 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()
@@ -97,10 +48,13 @@ 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)
- body = _json(answer)
- if answer.status_code >= 400 or body.get("success") is False:
+ if answer.status_code >= 400 or not isinstance(body, dict) or body.get("success") is False:
raise Unanswered(f"HTTP {answer.status_code}")
rows = []
for item in (body.get("result") or {}).get("domains") or []:
@@ -128,116 +82,73 @@ 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(person: str, now: float) -> bool:
- mine = _by_person.setdefault(person, deque())
+def _allowed(client: str, now: float) -> bool:
+ mine = _by_client.setdefault(client, deque())
for times in (_by_all, mine):
while times and now - times[0] > WINDOW:
times.popleft()
- if len(mine) >= PER_PERSON or len(_by_all) >= IN_ALL:
+ if len(mine) >= PER_CLIENT or len(_by_all) >= IN_ALL:
return False
mine.append(now)
_by_all.append(now)
- 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]
+ 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]
return True
-def _cors(request: Request) -> dict[str, str]:
- origin = _url("GITEA_URL")
- if origin and request.headers.get("origin") == origin:
- return {"Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Vary": "Origin"}
- return {"Vary": "Origin"}
+class Refused(Exception):
+ """A search turned away: what to tell the person, and the HTTP status that goes with it."""
+
+ def __init__(self, message: str, status: int):
+ super().__init__(message)
+ self.status = status
-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 = "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()
+async def look_up(query: str, limit: int, who: str) -> dict[str, Any]:
+ """One search for the tab or the tool: checked, kept ten minutes, and paced per `who` and for everyone."""
+ query = query.strip()
if not QUERY.fullmatch(query):
- return answer({"error": "Search for 1 to 100 letters, digits, spaces, dots, dashes or underscores."}, 400)
- try:
- limit = int(request.query_params.get("limit") or 24)
- except ValueError:
- limit = 0
+ raise Refused("Search for 1 to 100 letters, digits, spaces, dots, dashes or underscores.", 400)
if not 1 <= limit <= 50:
- return answer({"error": "limit is 1 to 50."}, 400)
+ raise Refused("limit is 1 to 50.", 400)
token = os.getenv("CLOUDFLARE_API_TOKEN", "").strip()
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)
-
+ raise Refused("Domain search is not set up here.", 503)
key, now = (query.lower(), limit), time.monotonic()
kept = _kept.get(key)
if kept and kept[0] > now:
- return answer(kept[1])
- if not _allowed(person, now):
- return answer({"error": "Too many searches. Try again in a minute."}, 429)
+ return kept[1]
+ if not _allowed(who, now):
+ raise Refused("Too many searches. Try again in a minute.", 429)
try:
result = await search(query, limit, token, account)
except Unanswered:
- return answer({"error": "Cloudflare could not answer just now."}, 502)
+ raise Refused("Cloudflare could not answer just now.", 502) from None
_kept[key] = (now + KEEP_SECONDS, result)
_kept.move_to_end(key)
while len(_kept) > KEEP_ANSWERS:
_kept.popitem(last=False)
- return answer(result)
+ return 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
+async def domains_http(request: Request) -> Response:
+ origin = os.getenv("GITEA_URL", "").rstrip("/")
+ cors = {"Vary": "Origin"}
+ if origin and request.headers.get("origin") == origin:
+ cors["Access-Control-Allow-Origin"] = origin
+ if request.method == "OPTIONS":
+ return Response(status_code=204, headers={**cors, "Access-Control-Allow-Methods": "GET",
+ "Access-Control-Max-Age": "86400"})
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
+ limit = int(request.query_params.get("limit") or 24)
+ except ValueError:
+ limit = 0
+ client = request.headers.get("fly-client-ip") or (request.client.host if request.client else "")
+ try:
+ result, status = await look_up(request.query_params.get("q") or "", limit, client), 200
+ except Refused as no:
+ result, status = {"error": str(no)}, no.status
+ keep = "public, max-age=300" if status == 200 else "no-store"
+ return JSONResponse(result, status, headers={**cors, "Cache-Control": keep})
diff --git a/mcp/tea_mcp/server.py b/mcp/tea_mcp/server.py
index 0586b2b..0029d26 100644
--- a/mcp/tea_mcp/server.py
+++ b/mcp/tea_mcp/server.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import base64
import binascii
+import hashlib
import os
from typing import Annotated, Any, Literal
from urllib.parse import quote
@@ -28,7 +29,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
-from .domains import callback_http, domains_http, sign_in_http
+from .domains import Refused, domains_http, look_up
def required_env(name: str) -> str:
@@ -106,8 +107,9 @@ mcp = FastMCP(
website_url=GITEA_URL,
instructions=("Teahub tools against " + GITEA_URL + ". Every tool acts as you, with your own Gitea permissions: "
"issues, pull requests and reviews, labels, repositories, files and commits, branches, Actions "
- "(runs, logs, dispatch, secrets, variables), hooks and organisations. Reads and writes are bounded and no "
- "tool deletes. Repositories are named owner/repo."),
+ "(runs, logs, dispatch, secrets, variables), hooks and organisations, and domain search with "
+ "Cloudflare Registrar. Reads and writes are bounded and no tool deletes. Repositories are named "
+ "owner/repo."),
auth=auth,
mask_error_details=True,
)
@@ -663,6 +665,23 @@ async def tea_variable_set(repo: Repo, name: VarName, value: Value) -> dict[str,
return {"repo": repo, "variable": name.upper(), "created": not exists}
+# ---------- domains ----------
+
+
+@mcp.tool(description="Look up domain names with Cloudflare Registrar: a domain as given, or a name or phrase under "
+ "common endings (com, net, org, co, io, xyz, app, dev, ai, me), checked live, plus suggestions, "
+ "each with availability and Cloudflare's at-cost price. Nothing is registered or bought.",
+ annotations=READ)
+async def tea_domains(search: Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$",
+ description="a name, a phrase or a domain")],
+ limit: Limit = 24) -> dict[str, Any]:
+ who = "mcp:" + hashlib.sha256(actor_token().encode()).hexdigest()[:16]
+ try:
+ return await look_up(search, limit, who)
+ except Refused as no:
+ raise ToolError(str(no)) from None
+
+
# ---------- app ----------
@@ -673,10 +692,7 @@ 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"]))
-# 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"]))
+app.routes.append(Route("/domains", domains_http, methods=["GET", "OPTIONS"])) # the dashboard's Domains tab
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 e43a43d..cc33b5b 100644
--- a/mcp/tests/test_domains.py
+++ b/mcp/tests/test_domains.py
@@ -1,14 +1,11 @@
-"""GET /domains and its sign-in, against a fake Cloudflare and a fake Gitea. From mcp/: python -m unittest discover -s tests"""
+"""GET /domains against a fake Cloudflare. 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__))))
@@ -18,7 +15,7 @@ from starlette.routing import Route # noqa: E402
from tea_mcp import domains # noqa: E402
-ORIGIN, MCP = "https://teahub.test", "https://mcp.test"
+ORIGIN = "https://teahub.test"
def offer(name: str, registrable: bool = True, cost: str = "10.46") -> dict:
@@ -28,18 +25,13 @@ def offer(name: str, registrable: bool = True, cost: str = "10.46") -> dict:
"pricing": {"currency": "USD", "registration_cost": cost, "renewal_cost": cost}}
-class Fake:
- """Cloudflare's Registrar and Gitea's OAuth, as far as /domains uses them."""
-
+class FakeCloudflare:
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:
@@ -53,160 +45,87 @@ class Fake:
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, "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 = mock.patch.dict(os.environ, {"GITEA_URL": ORIGIN, "CLOUDFLARE_API_TOKEN": "cf-token",
+ "CLOUDFLARE_ACCOUNT_ID": "acct"})
env.start()
self.addCleanup(env.stop)
- self.fake = Fake()
- domains.transport = httpx.MockTransport(self.fake)
+ self.cf = FakeCloudflare()
+ domains.transport = httpx.MockTransport(self.cf)
self.addCleanup(setattr, domains, "transport", None)
- for kept in (domains._kept, domains._by_person, domains._by_all):
+ for kept in (domains._kept, domains._by_client, domains._by_all):
kept.clear()
- 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)])
+ 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)
- 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 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})
async def test_a_name_is_checked_under_ten_endings_and_suggestions_skip_those(self):
- r = await self.ask(await self.signed_in(), "Tea Hub")
+ r = await self.ask("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"])
- search = next(c for c in self.fake.calls if c.url.path.endswith("/domain-search"))
+ self.assertEqual((r.headers["access-control-allow-origin"], r.headers["vary"]), (ORIGIN, "Origin"))
+ search = self.cf.calls[0]
self.assertEqual((search.url.params["q"], search.url.params["limit"]), ("Tea Hub", "24"))
- 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()
+ 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()
self.assertEqual((body["exact_domains"], len(body["domains"])), ([], 3))
async def test_only_teahub_may_read_the_answer(self):
- r = await self.ask(await self.signed_in(), "teahub", origin="https://elsewhere.test")
+ r = await self.ask("teahub", origin="https://elsewhere.test")
self.assertEqual(r.status_code, 200)
self.assertNotIn("access-control-allow-origin", r.headers)
- 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))
+ 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"))
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(web, q, **params)
- self.assertEqual((r.status_code, r.headers["cache-control"]), (400, "no-store"), (q, params))
- self.assertEqual(self.fake.calls, [])
+ 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, [])
async def test_answers_are_kept_and_a_failure_is_not(self):
- web = await self.signed_in()
- self.fake.calls.clear()
- self.fake.fail = 500
- failed = await self.ask(web, "teahub")
+ self.cf.fail = 500
+ failed = await self.ask("teahub")
self.assertEqual((failed.status_code, failed.json()["error"]), (502, "Cloudflare could not answer just now."))
- self.fake.fail = 0
- first, again = await self.ask(web, "teahub"), await self.ask(web, "TeaHub")
+ self.cf.fail = 0
+ first, again = await self.ask("teahub"), await self.ask("TeaHub")
self.assertEqual((first.status_code, again.json()), (200, first.json()))
- self.assertEqual(len(self.fake.calls), 4) # the failed pair, then one search and one check
+ self.assertEqual(len(self.cf.calls), 4) # the failed pair, then one search and one check
- 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")
+ 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")
self.assertEqual((limited.status_code, limited.json()["error"]), (429, "Too many searches. Try again in a minute."))
- 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)
+ 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)
async def test_without_cloudflare_settings_it_says_so(self):
with mock.patch.dict(os.environ, {"CLOUDFLARE_API_TOKEN": ""}):
- r = await self.ask(self.browser(), "teahub")
+ r = await self.ask("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 77c192e..d35160a 100644
--- a/mcp/tests/test_server.py
+++ b/mcp/tests/test_server.py
@@ -7,6 +7,7 @@ import os
import sys
import tempfile
import unittest
+from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.update({"GITEA_URL": "https://gitea.test", "PUBLIC_URL": "https://mcp.test", "GITEA_OAUTH_CLIENT_ID": "id",
@@ -21,7 +22,7 @@ from tea_mcp import server # noqa: E402
R = "repos/o/r"
READS = {"tea_whoami", "tea_issues", "tea_issue_get", "tea_issue_comments", "tea_labels", "tea_pull_get", "tea_repo",
- "tea_tree", "tea_file_get", "tea_actions", "tea_action_logs"}
+ "tea_tree", "tea_file_get", "tea_actions", "tea_action_logs", "tea_domains"}
WRITES = {"tea_issue_create", "tea_issue_edit", "tea_issue_comment", "tea_label", "tea_pull_create", "tea_pull_review",
"tea_pull_merge", "tea_repo_edit", "tea_repo_create", "tea_org_repo_create", "tea_org_create",
"tea_org_add_member", "tea_hook_create", "tea_file_change", "tea_commit", "tea_branch_create",
@@ -414,6 +415,27 @@ class TeaMCP(unittest.IsolatedAsyncioTestCase):
self.assertEqual((out["archived"], out["description"]), (True, "old"))
self.assertIn("nothing to change", await self.refused("tea_repo_edit", repo="o/r"))
+ async def test_domains_are_looked_up_with_cloudflare(self):
+ from tea_mcp import domains
+
+ def cloudflare(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/domain-search"):
+ return httpx.Response(200, json={"success": True, "result": {"domains": [
+ {"name": "teahub.uk", "registrable": True, "tier": "standard",
+ "pricing": {"currency": "USD", "registration_cost": "5.30", "renewal_cost": "5.30"}}]}})
+ return httpx.Response(200, json={"success": True, "result": {"domains": [
+ {"name": name, "registrable": False, "reason": "domain_unavailable"}
+ for name in json.loads(request.content)["domains"]]}})
+
+ domains.transport = httpx.MockTransport(cloudflare)
+ self.addCleanup(setattr, domains, "transport", None)
+ for kept in (domains._kept, domains._by_client, domains._by_all):
+ kept.clear()
+ self.assertIn("not set up", await self.refused("tea_domains", search="teahub"))
+ with mock.patch.dict(os.environ, {"CLOUDFLARE_API_TOKEN": "t", "CLOUDFLARE_ACCOUNT_ID": "a"}):
+ found = await self.call("tea_domains", search="teahub")
+ self.assertEqual((len(found["exact_domains"]), found["domains"][0]["name"]), (10, "teahub.uk"))
+
async def test_an_unreachable_gitea_is_named(self):
def flaky(request):
if request.url.path.endswith((".diff", "/contents")):
@@ -438,10 +460,8 @@ 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")