Repository navigation
feat(api): MCP OAuth client registration (CIMD and DCR), RFC-0044 phase 3a (#144) - #178
Merged
Merged
Conversation
…se 3a (#144) Remote MCP clients can now get a client_id themselves. - CIMD: @better-auth/cimd 1.7.18 with the MCP 2026-07-28 profile behind cimdFetch, which puts the media import's SSRF-safe safeFetch behind the plugin's fetch (nodeTransport on Node, workersTransport on Workers; https/443, DNS names only, public addresses, no redirects, 5 KB, 5 s, never the deployment's own host or a site domain). Fetch budget across isolates in rate_limits; optional CLOUDHAL_MCP_CLIENT_HOSTS allow-list. - DCR: Cloudhal's POST /api/auth/oauth2/register in front of the provider (public clients only, 10/address/hour and 300/day in rate_limits, 5 KB, a cap of 5,000, unknown fields dropped). The provider's own endpoint stays unreachable over HTTP. Unused clients are pruned by the hourly oauth.clients.prune job on both schedulers. CLOUDHAL_MCP_DCR=off and CLOUDHAL_MCP_CIMD=off switch them. - Redirect URIs: https, or loopback http on any port (RFC 8252), checked at registration and at every authorization. - The Phase 2 server-side test client (OauthServer.createClient) is removed; the suites register through DCR or CIMD. - set_embargo through an OAuth connection files the gate-only release for a person's approval. - AS metadata lists none and private_key_jwt as token auth methods. - Shared suite on sqlite and D1; hardening guide, self-host table, Docker compose variables, ADR-0045 Phase 3a section; changeset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…reen (#144) Review of #178, finding 6. A client name is self-asserted (a metadata document's or a registration's client_name), so: - consent info and the stored grant show it plain: control, bidi, zero-width, tag and other format characters removed, space runs collapsed, at most 100 code points (client-identity.ts, linear); - ConsentInfo.client gains kind (document, self-registered, server) and lookalike: a self-registered name containing Claude, Claude Code, ChatGPT, Codex, VS Code or Cursor; - Studio leads with a document client's host, in the title too ("Connect {client} from {host}"), labels DCR clients "Self-registered, not verified", and warns about look-alike names. Three new oauthConsent strings in en; the 18 other languages are machine drafts from pnpm i18n:import, awaiting review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…its (#144) Review of #178, finding 7. A set_embargo from an OAuth connection files an on-approval release, whose execute time is the moment it was filed, so the 60-minute window before it never matched once a millisecond had passed. approvalDue() makes an on-approval release awaiting approval due for as long as it waits. The test now reads notifications three hours after filing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…he host list and DCR switch at authorize (#144) Review of #178, findings 1-5. 1. Document clients are capped (5,000; 503 temporarily_unavailable for a new one beyond it) and pruned after a day with no connection and no fetch (pruneCimdClients); the hourly oauth.clients.prune job prunes DCR and document clients in alternate hours, on the :17 watermark. 2. cimd-discovery.ts wraps the plugin's discovery: a stored client validated within 60 min is served from its row with no fetch on every runtime; budgets are spent only before a real fetch (20/min per trusted caller address; 30/min per origin and 120/min in all only for documents not stored yet); a stored client is served from its row when a budget is spent. oauth-cimd-policy.ts is removed. 3. Private-use redirect schemes are accepted for native and public clients when reverse-domain or known (cursor, vscode, CLOUDHAL_MCP_REDIRECT_SCHEMES), matched exactly; dangerous schemes and http look-alikes are always refused; one bad entry refuses the whole registration. Cursor 3.14.27's observed DCR body registers. 4. validateRedirectUri applies CLOUDHAL_MCP_CLIENT_HOSTS to every client at every authorization; document hosts off the list resolve to none. 5. With CLOUDHAL_MCP_DCR=off, /oauth2/authorize refuses clients DCR stored earlier; their existing connections keep working. New shared suite testing-mcp-oauth-bounds.ts on sqlite and D1; each case fails on d363160. Hardening guide, self-host table, compose and .env.example, ADR-0045 and the changeset updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…ding (#144) Review round 2 of #178, item 7. approvalDue now reads the release's linked approval request: an on-approval release awaiting approval is due only while that request is pending and unexpired. After the request's 24 h TTL nobody can approve it, so nobody keeps a notification for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…und 2 (#144) Review round 2 of #178. 1. The plugin's own fetch windows are set out of reach and its cache is off, so the stored row is the only cache and Cloudhal's rate_limits budgets the only budgets, on Node's long-lived instance too. A declined plugin fetch serves a stored row younger than 24 h. 2. At the cap a new document evicts the oldest grantless client not fetched for 10 min (else 503). The host list and the well-known document hosts (claude.ai, claude.com, chatgpt.com, openai.com, vscode.dev, github.com) are exempt from the cap and from eviction. Grantless document clients are pruned after 3 h, in up to four batches of 50 per run; DCR and CIMD are pruned every run. 3. The caller's own budget comes first and answers 429, never a stale row. A failed refetch is recorded on the row and refuses the client until a fetch succeeds. 4. Migration 0032_oauth_client_kind_index on oauthClient (clientDiscoveryId, updatedAt). The cap count runs only for a new document, after the per-address hit. 5. CIMD-off keeps cutting existing document connections (conservative); documented next to DCR-off. 6. Consent screen: a private-use redirect reads as an app on this computer; the title puts the host first; at most two combining marks per character; look-alikes are compared after NFKC plus a small confusable map, and flagged only for the CIMD well-known names, and not when the client is a document on that client's real host. New strings in en; the other languages are machine drafts. 8. Test: application_type web with a private-use redirect is refused. Each fix has a test that fails on be319b8, except 8, which guards a provider rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
… hardening (#144) Review round 3 of #178. 1. Eviction matched exempt hosts with `clientId LIKE 'https://%.host/%'`. In SQLite `%` spans `/`, so https://junk.example/x.claude.ai/c.json passed for claude.ai and was never evicted, which brought the cap lockout back. evictCimdClient now reads the oldest grantless candidates and judges each by `new URL(clientId).hostname` with the same isExempt as admission. It deletes the pick in one atomic batch that re-checks the predicate (`clientId IN (?)`). 2. The plugin's cache size, which also bounds its per-origin and per-client fetch state, goes from 500 to 10,000. 3. oauthClientsPruneJob takes the deployment's limits (dcrUnusedDays, cimdUnusedHours). Test: 120 rows pruned in one run. 4. Test: eviction never removes a client with a live grant, nor a well-known host's row older than evictMinutes. 5. Look-alike names are folded with NFKD and their combining marks dropped (Cláude, Clȧude), with Armenian, Cherokee and Latin small capital look-alikes added to the map. A UTS #39 skeleton stays a follow-up. 6. Self-host guide: behind a reverse proxy without CLOUDHAL_BEHIND_PROXY=1, every client shares the proxy's rate-limit bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
This was referenced Nov 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RFC-0044 Phase 3a (#144): remote MCP clients can now get a
client_idthemselves, so they can connect by URL. Clients with a Client ID Metadata Document use CIMD (Claude Code, Codex, VS Code). Clients without one use dynamic client registration as a limited fallback (Cursor). The Phase 2 test-only pre-registered client is gone, andset_embargothrough an OAuth connection now files an approval request.Phase 2 invariants are unchanged: generation binding, provider cleanup on revoke and on member removal, narrowing by installation role, DPoP refused. The Phase 2 suites (
mcp-oauth-server.test.ts: server + hardening) now run against a DCR-registered client and pass unchanged, apart from the two assertions that encoded "no registration yet".Items → code → tests
cimd()from@better-auth/cimd1.7.18 (new catalog entry, pins test extended), MCP 2026-07-28 profile, cache of 500 entries revalidated within 60 minpackages/api/src/auth/oauth.ts(plugin),auth/cimd-fetch.ts(transport adapter),auth/oauth-cimd-policy.ts(allow-list + fetch budget)testing-mcp-oauth-registration.ts› "Client ID Metadata Documents"safeFetch(RFC-0004 §5.7): NodenodeTransport()(resolve once, every address public, pinned), WorkersworkersTransport()(DoH checks). Rules: https on 443 only, DNS names only (no IP literals), no internal names, no redirects, 5 KB, 5 s, GET onlypackages/cli/src/commands/studio.ts(studioOauth),packages/cloudflare/src/admin-worker.ts(adminAuth);core-tsSafeFetchResult.headers(the plugin reads cache headers)/mcp; wrongclient_id; a private or loopback DNS answer and an IP literal (nothing fetched); oversized (6 KB); a 302; a server that never answers (refused in < 5 s)blockedHosts, read lazily per fetch)client_id= document URL (the plugin's check);client_id_metadata_document_supportedadvertisedauth/oauth.tslocalhost/127.0.0.1/[::1]with any port (RFC 8252 §7.3, the provider's port-agnostic matching); at most 10; enforced at every authorization throughvalidateRedirectUriauth/redirect-policy.ts,auth/oauth.tsmyapp://…) is never used; linear-rule unit checks incl. 100k-char inputsPOST /api/auth/oauth2/register, mounted before Better Auth's catch-all. Checks the request, then calls the provider'sregisterOAuthClientserver-side with empty headers. The provider's own endpoint is unreachable over HTTP: the fence opens exactly that path only while DCR is on, and abeforehook refuses any HTTP call to itauth/dcr.ts,auth/dcr-server.ts,oauth-server.ts(mountClientRegistration,REGISTER_PATH),app.ts(mountAuthHandler),auth/instance.ts(hook)/register/,%72egister) intesting-mcp-oauth-server.tsCLOUDHAL_MCP_DCR=offremoves the route andregistration_endpointauth/oauth-registration.ts(oauthRegistrationFrom)rate_limits(#173): 10 per address per hour, 300 per installation per day, invalid attempts counted, a client-sentX-Forwarded-Forignoredauth/dcr.ts(StaffStore.hit)none; when absent it becomesnone, so no secret is ever minted);client_name1–100;grant_typesreduced toauthorization_code/refresh_token;response_types["code"]; 5 KB body (413)auth/dcr.ts(registrationBody)auth/oauth-registration.ts(countDcrClients)oauth.clients.prunesystem job on the admin Worker's tick and on Node'sauth/oauth-registration.ts(pruneDcrClients),oauth-prune-job.ts,cloudflare/src/scheduler.ts,cli/src/commands/scheduler-local.tsCLOUDHAL_MCP_CLIENT_HOSTS: hosts a CIMD document may come from and a DCR client may redirect to (localhostadmits loopback). A list that does not parse turns registration off and logs whyauth/oauth-registration.ts,auth/redirect-policy.ts,auth/cimd-discovery.tsOauthServer.createClientandauth/oauth-clients.tsare deleted;clientPrivileges/resourcePrivilegesnow admit nobody. The harness registers through DCR (registerClient)auth/types.ts,auth.ts,testing-oauth-harness.tsset_embargofrom a grant principal files the gate-only release as aschedule_releaserequest (on-approval, bound torl1:), so it shows up in Studio's Approvals and as aschedule-approval-duenotification. Approving puts the embargo live with the person's approval. Without out-of-band approvals the old "with next publish" answer staysmcp-server/src/schedule-tools.ts(gateRequest),gate-approval.ts(GRANT_GATE_PENDING); gate helpers moved toschedule-gates.ts(size ratchet)mcp-server/test/schedules.test.ts(request filed, release awaiting, no person approval);api/src/testing-schedules.ts"an OAuth connection's set_embargo waits for a person…" (notification, approve → scheduled withapproval.kind: person) on sqlite + D1packages/api/src/testing-mcp-oauth-registration.ts,packages/api/test/mcp-oauth-registration.test.tsBETTER_AUTH_SECRETrotation (it encrypts the signing key: delete thejwksrows after rotating; checked inbetter-auth1.7.18jwt/sign.mjs)docs/guides/cloudflare-hardening.md§ "Remote MCP clients (OAuth)";docs/guides/self-host.mdtable;deploy/docker/compose.yml+.env.examplepass the fourCLOUDHAL_MCP_*variables (CLOUDHAL_MCP_OAUTHwas not passed before)Also: ADR-0045 gains a "Phase 3a" section (decision 6 amended). The AS metadata now lists
token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]as RFC §2 says (the provider also listed the shared-secret methods). Size baseline tightened (schedule-tools.ts1579 → 1553 lines,createApi160 → 157). There is a patch changeset for@cloudhal/api,cloudflare,cli,mcp-serverandcore-ts. The playground deny list already covers/api/auth/*, so the new route is refused there.Deviations from the RFC (each the more conservative option, or forced by the platform)
none). RFC §3 also allowedprivate_key_jwt; the brief says public only. CIMD keepsnoneandprivate_key_jwt, as RFC §3 says.logo_uri,client_uri,tos_uri,policy_uri,contacts,jwks*andsoftware_*. The RFC said "stored but never rendered". Nothing self-asserted is stored.cursor,vscode,CLOUDHAL_MCP_REDIRECT_SCHEMES) only, matched exactly. The RFC's §3 DCR rule ("https or loopback") would have locked out Cursor, the client DCR exists for.rate_limits(as the brief asks), not Better Auth'srateLimit.customRules. The provider's own limiter never sees the server-side call.rate_limits(30 per origin and 120 in all per minute), because a Worker rebuilds the plugin, and so its budget, on every request.workersTransport()from@cloudhal/core-ts/media, not a new one inpackages/cloudflare. It has the same rules plus DoH address checks.CLOUDHAL_MCP_CIMD=off. The RFC had only the DCR switch; the brief's "when both CIMD and DCR are off" needs one.CLOUDHAL_MCP_CLIENT_HOSTSalso limits every client's redirect hosts, at every authorization. The RFC applied it to CIMD document hosts only. A list that does not parse turns registration off rather than failing every request.cloudhal mcp clients create(RFC: "Phase 3 if cheap"). RemovingcreateClientwas cheaper and leaves no server-side client path.set_embargothrough a connection files a request. It does not get a grant-boundautoapproval, which would be a contract change (the releaseautoapproval names akeyId).wrangler secret put, becausecloudhal deployrewrites the admin Worker's plain vars on every deploy. Acloudhal.jsonkey for them would be a public-interface change, left for a later RFC.Left for PR B, as planned: Studio "Connected agents", the
McpClientSetuptab (it will need/api/instanceto say whether CIMD and DCR are on),docs/guides/mcp-oauth.md, the docs site's clients page, the README auth section, and the e2e test.Review round 1 (commits 032397d, d6bb838, be319b8): the seven upheld findings
Each item has a test that fails on d363160. I checked by running the new tests against that commit, with a one-line shim for the new module.
temporarily_unavailable(503) beyond it.pruneCimdClientsdeletes a document client with no live connection and no fetch or connection activity for 1 day; the next authorize stores it again. The hourlyoauth.clients.prunejob prunes DCR clients in even UTC hours and document clients in odd hours, so it keeps a cost of 6. It now runs on the :17 watermark, so it always runs after the analytics rollup and the comments job (due jobs run by time, then name). It waits a minute when a free plan's tick is full; at :41 it could take the comments job's slot. Row helpers moved toauth/oauth-client-rows.tsgetClientauth/cimd-discovery.tswraps the plugin's discovery. A stored client whose row was validated within 60 min (updatedAt) is served with no fetch on every runtime. Budgets are spent only before a real fetch: 20/min per caller address (the trusted header, #165/#169) for every fetch; 30/min per origin and 120/min in all only for documents not stored yet. When a budget is spent, a stored client is served from its row (stale-if-exhausted). A failed refetch of a stored client is still refused.oauth-cimd-policy.tsis removedcursor,vscode, plusCLOUDHAL_MCP_REDIRECT_SCHEMES). Matched exactly (the provider relaxes only the loopback port). Always refused:javascript,vbscript,data,file,about,blob,filesystem,view-source,jar,intent,chrome,mailto,tel,sms,ftp,http…/wslook-alikes, and anything that is notscheme:/…. One bad entry refuses the registration; nothing is dropped. With a host list set, only known schemes passcursor://anysphere.cursor-mcp/oauth/callback,https://www.cursor.com/agents/mcp/oauth/callback,http://localhost:8787/callback,none, noapplication_type) registers and authorizes; ten dangerous or unknown schemes refused; reverse-domain andvscode://accepted; the operator list and the host-list rulevalidateRedirectUrinow applies the redirect rules andCLOUDHAL_MCP_CLIENT_HOSTSto every client at every authorization. Document hosts off the list resolve to no client, stored or notbeforehook on/oauth2/authorizerefuses a DCR-registered client (clientDiscoveryId,userIdandreferenceIdall null) whileCLOUDHAL_MCP_DCR=off. Tokens and refresh keep working until revoked or pruned. The guide says soauth/client-identity.ts: names shown plain (C0/C1 controls, bidi controls, zero-width, tag and other format characters removed, space runs collapsed) and at most 100 code points, in consent info and the stored grant.ConsentInfo.clientgainskind(document/self-registered/server) andlookalike(a self-registered name containing Claude, Claude Code, ChatGPT, Codex, VS Code, Visual Studio Code or Cursor). Studio: a document client's host is in the page title ("Connect {client} from {host}") and leads the identity card; DCR clients read "Self-registered, not verified", with an alert when the name is a look-alike. Three newoauthConsentstrings,enonly, thenpnpm i18n:export/pnpm i18n:import: the 18 other languages are machine drafts awaiting review (#112)apps/studio/test/oauth-consent.test.tsx(title with host; the self-registered label and the look-alike warning)set_embargorequest never notifiedapprovalDueinroutes/notifications.ts: anon-approvalrelease awaiting approval is due for as long as its request waitstesting-schedules.ts› "an OAuth connection's set_embargo waits…" now advances the clock 3 h between filing and readingDocs: the hardening guide's client section is rewritten for these rules. The self-host table, compose and
.env.exampleaddCLOUDHAL_MCP_REDIRECT_SCHEMES. ADR-0045 "Phase 3a" is updated. Changeset updated.Review round 2 (commits 6a45959, 06cd53f; verification of be319b8): two blocking items and six more
Each fix has a test that fails on be319b8. I checked this in a temporary worktree at that commit, with the shim
exportforguardedResolve. The exception is item 8, a guard test: the provider already refuses that case, and the test keeps a provider upgrade from widening it.guardedCimdsets the plugin'smetadataFetchPolicyout of reach and turns its cache off (metadataRevalidationInterval: 0). The stored row is the only cache, and Cloudhal'srate_limitsbudgets are the only budgets, on every runtime. If the plugin still declines to fetch (temporarily_unavailable), a stored client younger than 24 h is served from its rowevictCimdClient, one atomic batch), else 503. Hosts onCLOUDHAL_MCP_CLIENT_HOSTSand the well-known document hosts (claude.ai,claude.com,chatgpt.com,openai.com,vscode.dev,github.com, subdomains included) are exempt from the cap and from eviction. Grantless document clients are pruned after 3 hours (was a day), in up to 4 batches of 50 per hourly run while batches come back full. DCR and CIMD are pruned every run (cost 30, still on :17 so the job sorts after the comments job)markCimdFetchFailed: validation time cleared) and the client is refused, refresh included, until a fetch succeeds. A stale row is served only when the plugin declines and the row is undermaxStaleHours(24)redirect_uristake effect, the old one is refused; a 404 document refuses refresh, is recorded on the row, and stays refused after the caller drains its budget); bounds › "serves a stored row the plugin declines to refetch only within 24 hours, never after a failure" (unit: 2 h served, 25 h refused, a fetch failure marks and refuses); the budget test now expects 429 for the drained caller0032_oauth_client_kind_index:CREATE INDEX "oauthClient_clientDiscoveryId_updatedAt_idx" ON "oauthClient" ("clientDiscoveryId", "updatedAt"). 0031 is untouched. The count now runs only for a document not stored yet, after the per-address hitstorage/test/migrate.test.ts› "0032…":EXPLAIN QUERY PLANof the cap count uses the index; the migration name lists are extendedredirect.app(the scheme) and reads "Sends you back to an app on this computer (cursor:)", never as a web host. The title puts the host first: "{host}: connect {client}". At most 2 combining marks per base character. Look-alike checks compare names after NFKC plus a small confusable map (Cyrillic and Greek look-alikes of Latin,0/O,1/l/I; a full UTS #39 skeleton is a follow-up). The look-alike banner fires only for the CIMD well-known names (Claude, Claude Code, ChatGPT, Codex, VS Code), and not when the client is a document on that client's real host. Cursor's own name no longer triggers it. The banner text now says the return address is "shown above". Strings inen, thenpnpm i18n:export/pnpm i18n:import(machine drafts)Сlaude, fullwidth,C1aude,CIaude,ChatGРT,VS Сodeflagged; real Claude Code on claude.ai and DCR "Cursor" not; a "Claude" document on another host flagged; combining marks capped; thecursor:redirect); Studio › title with the host first, and the private-use redirect lineapprovalDuereads the linked request: an on-approval release is due only while its request ispendingand unexpiredtesting-schedules.ts› "stops telling deciders about an OAuth connection's embargo once its request expired" (present at +23 h, gone at +25 h)application_type: webwith a private-use redirectwebwithcursor://…and withcom.example.app:/cbis refused withinvalid_redirect_uri(guard test, passes on be319b8 by design)Also:
cimd-guard.ts(the guard's wiring) and theWhoIsAskingcomponent were extracted to stay under the size ratchet. ADR-0045 Phase 3a items 1 and 7 are rewritten. The changeset adds@cloudhal/storage.Review round 3 (commit 03f6b95; verification of 06cd53f)
clientId LIKE 'https://%.host/%'. In SQLite%spans/, sohttps://junk.example/x.claude.ai/c.jsoncounted toward the cap but was never evictedevictCimdClientreads up to 50 of the oldest grantless candidates and judges each bynew URL(clientId).hostnamewith the sameisExemptadmission uses. It then deletes the pick in one atomic batch that re-checks the predicate (clientId IN (?)). NoLIKEover the URL is lefthttps://junkN.example.com/x.claude.ai/c.jsonand a third-party client still connects (fails on 06cd53f)maxCacheEntries(500) and refuses at capacitymaxCacheEntries: 10_000cimdLimits.unusedHoursoauthClientsPruneJob(sql, log, { dcrUnusedDays, cimdUnusedHours }). The runtimes keep the defaults, since these limits have no environment variablefolded()applies NFKD and drops combining marks, soCláudeandClȧudematch. Armenian, Cherokee and Latin small-capital look-alikes are added to the map. Follow-up for #144: a full UTS #39 confusable skeletonCláude,Clȧude,ᴄʟᴀᴜᴅᴇ,Ꮯlaude,Clаսdeare flagged (fails on 06cd53f)CLOUDHAL_BEHIND_PROXY=1behind a reverse proxy, pointing to the hardening sectionVerification (local, one package at a time)
After review round 3 (HEAD 03f6b95): @cloudhal/api (the registration file runs 62 cases on better-sqlite3 and D1) and @cloudhal/studio re-run green, as are typecheck 20/20, lint, size check and design lint. The other packages are unchanged since round 2.
After review round 2 (HEAD 06cd53f):
turbo run test --concurrency=1, one package at a time, all green: @cloudhal/storage (migration 0032 on better-sqlite3 and D1), @cloudhal/api (better-sqlite3 and Miniflare D1; the registration test file runs 56 cases), @cloudhal/mcp-server, @cloudhal/studio, @cloudhal/cloudflare, @cloudhal/cli.SafeFetchResult.headerschanged, in round 0): 59 of 61 files green locally,fetcher.test.tsincluded.test/store.test.tsdrops Miniflare connections locally (connect EADDRNOTAVAIL; every sqlite case passes). Left to CI.turbo run typecheck --concurrency=1: 20/20.pnpm lint: no errors.pnpm size:check: ok.pnpm design:lint: clean.🤖 Generated with Claude Code