diff --git a/.changeset/mcp-oauth-client-registration.md b/.changeset/mcp-oauth-client-registration.md
new file mode 100644
index 00000000..8b502219
--- /dev/null
+++ b/.changeset/mcp-oauth-client-registration.md
@@ -0,0 +1,10 @@
+---
+"@cloudhal/storage": patch
+"@cloudhal/api": patch
+"@cloudhal/cloudflare": patch
+"@cloudhal/cli": patch
+"@cloudhal/mcp-server": patch
+"@cloudhal/core-ts": patch
+---
+
+Remote MCP clients can now register themselves (RFC-0044 Phase 3a, ADR-0045). Clients that name an HTTPS Client ID Metadata Document as their `client_id` (Claude Code, Codex, VS Code) are accepted through `@better-auth/cimd`, fetched with the media import's SSRF-safe transport (pinned on Node, DoH-checked on Workers; https only, public addresses only, no redirects, 5 KB, 5 s). Clients without one (Cursor) use dynamic client registration at `/api/auth/oauth2/register`: public clients only, rate-limited per address and per day, capped at 5,000, and pruned by the scheduler after 30 unused days. Document clients are fetched at most hourly per client, with budgets in Cloudhal's own counters that never lock out stored clients, a cap of 5,000 that evicts unused clients (the well-known clients' hosts are exempt), pruning after three hours unused, and a failed refetch that refuses the client; migration `0032_oauth_client_kind_index` indexes them. Redirect URIs must be https, loopback http (any port) or a native app's private-use scheme (`cursor`, `vscode`, reverse-domain, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), and the client host list applies at every authorization. With DCR off, the clients it registered earlier cannot start new authorizations. The consent screen shows client names plain and short, a document client's host first, and marks self-registered clients as unverified. `CLOUDHAL_MCP_CIMD=off`, `CLOUDHAL_MCP_DCR=off`, `CLOUDHAL_MCP_CLIENT_HOSTS` and `CLOUDHAL_MCP_REDIRECT_SCHEMES` control it on both runtimes, and the Docker compose file passes them through. The Phase 2 server-side test client (`OauthServer.createClient`) is removed. `set_embargo` through an OAuth connection now files the gate for a person's approval instead of leaving it unapplied, and the request is notified while it waits. `safeFetch` results carry the response headers.
diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts
index 8857c12c..0b755231 100644
--- a/apps/studio/src/i18n/generated/params.ts
+++ b/apps/studio/src/i18n/generated/params.ts
@@ -550,8 +550,11 @@ export interface MessageParams {
"oauthConsent.scopes.notInRole": { label: string | number };
"oauthConsent.scopes.sites": { count: number };
"oauthConsent.title": { client: string | number };
+ "oauthConsent.titleFrom": { host: string | number; client: string | number };
"oauthConsent.who.fromHost": { host: string | number };
+ "oauthConsent.who.lookalike": { client: string | number };
"oauthConsent.who.sendsBack": { host: string | number };
+ "oauthConsent.who.thisComputerApp": { scheme: string | number };
"overview.commentsWaiting": { count: number };
"pageStatus.leftOut": { count: number };
"pageStatus.publishTitle": { title: string | number };
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts
index 936fa00b..35236550 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: d6d17d53696a6da9
+// hash: c7bc7dea9efce92e
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "ربط {client}",
+ titleFrom: "{host}: ربط {client}",
description: "يطلب وكيل العمل في هذا الـStudio نيابةً عنك. تحقّق مما يُسمح له به قبل الموافقة.",
invalid: {
title: "لا يمكن استخدام هذا الطلب",
@@ -16,8 +17,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "من يطلب",
fromHost: "يُعرَّف عبر {host}",
registered: "مسجّل في هذا الـStudio",
+ selfRegistered: "مسجّل ذاتيًا، غير مُتحقَّق منه",
+ lookalike:
+ "يشبه الاسم {client}، لكن أي أحد يمكنه تسجيل عميل بأي اسم. وافق فقط إذا بدأت هذا الاتصال من {client} بنفسك وكان العنوان الذي يعيدك إليه، المعروض أعلاه، هو الذي يستخدمه.",
sendsBack: "يعيدك إلى {host}",
thisComputer: "يعيدك إلى تطبيق على هذا الحاسوب",
+ thisComputerApp: "يعيدك إلى تطبيق على هذا الكمبيوتر ({scheme}:)",
loopbackWarning:
"كل عنوان يمكن لهذا الوكيل أن يعيدك إليه موجود على هذا الحاسوب. وافق فقط إذا بدأت الاتصال من تطبيق هنا.",
stepUp: "هذا الوكيل متصل بالفعل ويطلب المزيد. ما يُسمح له به اليوم محدَّد.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.de.ts b/apps/studio/src/i18n/lazy/oauthConsent.de.ts
index f92f280d..18d9a3de 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.de.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.de.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): de `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 07b0fd828ea3973f
+// hash: d3fc2f9b7d88dce0
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "{client} verbinden",
+ titleFrom: "{host}: {client} verbinden",
description:
"Ein Agent möchte in diesem Studio für dich arbeiten. Prüfe, was er darf, bevor du zustimmst.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Wer fragt",
fromHost: "Identifiziert durch {host}",
registered: "In diesem Studio registriert",
+ selfRegistered: "Selbst registriert, nicht geprüft",
+ lookalike:
+ "Der Name sieht nach {client} aus, aber jeder kann einen Client unter beliebigem Namen registrieren. Stimme nur zu, wenn du diese Verbindung selbst aus {client} gestartet hast und die Adresse, zu der sie dich zurückschickt (oben), die ist, die es verwendet.",
sendsBack: "Leitet dich zurück zu {host}",
thisComputer: "Leitet dich zurück zu einer App auf diesem Computer",
+ thisComputerApp: "Schickt dich zu einer App auf diesem Computer zurück ({scheme}:)",
loopbackWarning:
"Jede Adresse, zu der dieser Agent dich zurückleiten kann, liegt auf diesem Computer. Stimme nur zu, wenn du die Verbindung aus einer App hier gestartet hast.",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.en.ts b/apps/studio/src/i18n/lazy/oauthConsent.en.ts
index 824f54f2..00b60455 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.en.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.en.ts
@@ -6,6 +6,7 @@
export const messages = {
oauthConsent: {
title: "Connect {client}",
+ titleFrom: "{host}: connect {client}",
description:
"An agent asks to work in this Studio for you. Check what it may do before you approve.",
invalid: {
@@ -17,8 +18,12 @@ export const messages = {
title: "Who is asking",
fromHost: "Identified by {host}",
registered: "Registered with this Studio",
+ selfRegistered: "Self-registered, not verified",
+ lookalike:
+ "The name looks like {client}, but anyone can register a client under any name. Approve only if you started this connection from {client} yourself and the address it sends you back to, shown above, is the one it uses.",
sendsBack: "Sends you back to {host}",
thisComputer: "Sends you back to an app on this computer",
+ thisComputerApp: "Sends you back to an app on this computer ({scheme}:)",
loopbackWarning:
"Every address this agent can send you back to is on this computer. Approve only if you started the connection from an app here.",
stepUp: "This agent is already connected and asks for more. What it may do today is ticked.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.es.ts b/apps/studio/src/i18n/lazy/oauthConsent.es.ts
index e66f7343..26b85a03 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.es.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.es.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): es `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 6e539e2b036bb8c6
+// hash: c0a9bf4916410522
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Conectar {client}",
+ titleFrom: "{host}: conectar {client}",
description:
"Un agente pide trabajar en este Studio por ti. Revisa lo que puede hacer antes de aprobar.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Quién lo pide",
fromHost: "Identificado por {host}",
registered: "Registrado en este Studio",
+ selfRegistered: "Autorregistrado, sin verificar",
+ lookalike:
+ "El nombre parece {client}, pero cualquiera puede registrar un cliente con cualquier nombre. Aprueba solo si iniciaste tú esta conexión desde {client} y la dirección a la que te devuelve, indicada arriba, es la que usa.",
sendsBack: "Te devuelve a {host}",
thisComputer: "Te devuelve a una app de este equipo",
+ thisComputerApp: "Te devuelve a una app de este equipo ({scheme}:)",
loopbackWarning:
"Todas las direcciones a las que este agente puede devolverte están en este equipo. Aprueba solo si iniciaste la conexión desde una app de aquí.",
stepUp: "Este agente ya está conectado y pide más. Lo que puede hacer hoy está marcado.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts
index db08d4f8..b89202b8 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): fa `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 9378e7d2a7816050
+// hash: 14b933da3ad6923c
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "اتصال {client}",
+ titleFrom: "{host}: اتصال {client}",
description:
"یک عامل میخواهد در این Studio از طرف شما کار کند. پیش از تأیید، ببینید چه کارهایی مجاز است.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "چه کسی درخواست میدهد",
fromHost: "شناساییشده با {host}",
registered: "ثبتشده در این Studio",
+ selfRegistered: "خودثبتشده، تأییدنشده",
+ lookalike:
+ "این نام شبیه {client} است، اما هر کسی میتواند کلاینتی با هر نامی ثبت کند. فقط وقتی تأیید کن که خودت این اتصال را از {client} شروع کردهای و نشانیای که تو را به آن برمیگرداند (بالا) همان نشانیای است که استفاده میکند.",
sendsBack: "شما را به {host} برمیگرداند",
thisComputer: "شما را به برنامهای در این رایانه برمیگرداند",
+ thisComputerApp: "تو را به برنامهای روی این رایانه برمیگرداند ({scheme}:)",
loopbackWarning:
"همهٔ نشانیهایی که این عامل میتواند شما را به آنها برگرداند روی این رایانه است. فقط اگر اتصال را از برنامهای در همینجا شروع کردهاید تأیید کنید.",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts
index fa02c1fb..3735b966 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): fr `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 63aa7946c1e148ca
+// hash: 89c891ee68bf3306
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Connecter {client}",
+ titleFrom: "{host} : connecter {client}",
description:
"Un agent demande à travailler dans ce Studio pour vous. Vérifiez ce qu'il peut faire avant d'approuver.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Qui le demande",
fromHost: "Identifié par {host}",
registered: "Enregistré dans ce Studio",
+ selfRegistered: "Auto-inscrit, non vérifié",
+ lookalike:
+ "Le nom ressemble à {client}, mais n’importe qui peut inscrire un client sous n’importe quel nom. N’approuvez que si vous avez lancé vous-même cette connexion depuis {client} et que l’adresse vers laquelle elle vous renvoie, indiquée ci-dessus, est celle qu’il utilise.",
sendsBack: "Vous renvoie vers {host}",
thisComputer: "Vous renvoie vers une application de cet ordinateur",
+ thisComputerApp: "Vous renvoie vers une application de cet ordinateur ({scheme}:)",
loopbackWarning:
"Toutes les adresses vers lesquelles cet agent peut vous renvoyer sont sur cet ordinateur. N'approuvez que si vous avez lancé la connexion depuis une application ici.",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.he.ts b/apps/studio/src/i18n/lazy/oauthConsent.he.ts
index 004d3d06..37bbe1d2 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.he.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.he.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): he `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 37c0b05241b08508
+// hash: 3f94b9b19b92115e
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "חיבור {client}",
+ titleFrom: "{host}: חיבור {client}",
description: "סוכן מבקש לעבוד ב-Studio הזה בשמך. כדאי לבדוק מה מותר לו לפני האישור.",
invalid: {
title: "אי אפשר להשתמש בבקשה הזו",
@@ -16,8 +17,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "מי מבקש",
fromHost: "מזוהה על ידי {host}",
registered: "רשום ב-Studio הזה",
+ selfRegistered: "נרשם בעצמו, לא מאומת",
+ lookalike:
+ "השם נראה כמו {client}, אבל כל אחד יכול לרשום לקוח בכל שם. אשר רק אם אתה התחלת את החיבור הזה מ-{client} והכתובת שאליה הוא מחזיר אותך, המוצגת למעלה, היא זו שהוא משתמש בה.",
sendsBack: "יחזיר אותך אל {host}",
thisComputer: "יחזיר אותך לאפליקציה במחשב הזה",
+ thisComputerApp: "מחזיר אותך לאפליקציה במחשב הזה ({scheme}:)",
loopbackWarning:
"כל כתובת שהסוכן הזה יכול להחזיר אותך אליה נמצאת במחשב הזה. יש לאשר רק אם התחלת את החיבור מאפליקציה כאן.",
stepUp: "הסוכן הזה כבר מחובר ומבקש יותר. מה שמותר לו היום מסומן.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts
index 5bdf098e..6f143391 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): hi `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: b558b0bef4ac3ed0
+// hash: ca9d9114af0b5c08
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "{client} कनेक्ट करें",
+ titleFrom: "{host}: {client} कनेक्ट करें",
description:
"एक एजेंट इस Studio में आपकी ओर से काम करना चाहता है। मंज़ूरी देने से पहले देखें कि वह क्या कर सकता है।",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "कौन पूछ रहा है",
fromHost: "{host} से पहचाना गया",
registered: "इस Studio में रजिस्टर्ड",
+ selfRegistered: "स्वयं पंजीकृत, सत्यापित नहीं",
+ lookalike:
+ "यह नाम {client} जैसा दिखता है, लेकिन कोई भी किसी भी नाम से क्लाइंट पंजीकृत कर सकता है। केवल तभी स्वीकृत करें जब आपने यह कनेक्शन खुद {client} से शुरू किया हो और ऊपर दिखाया गया वापसी पता वही हो जिसका वह उपयोग करता है।",
sendsBack: "आपको {host} पर वापस भेजता है",
thisComputer: "आपको इस कंप्यूटर के किसी ऐप पर वापस भेजता है",
+ thisComputerApp: "आपको इस कंप्यूटर के एक ऐप पर वापस भेजता है ({scheme}:)",
loopbackWarning:
"यह एजेंट आपको जिन भी पतों पर वापस भेज सकता है, वे सब इसी कंप्यूटर पर हैं। मंज़ूरी तभी दें जब आपने कनेक्शन यहीं के किसी ऐप से शुरू किया हो।",
stepUp: "यह एजेंट पहले से कनेक्टेड है और और ज़्यादा मांग रहा है। आज वह जो कर सकता है, वह चुना हुआ है।",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.id.ts b/apps/studio/src/i18n/lazy/oauthConsent.id.ts
index a8166525..56ab642f 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.id.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.id.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): id `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 8b80d49448b0fcea
+// hash: c54a2392a96c5282
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Hubungkan {client}",
+ titleFrom: "{host}: hubungkan {client}",
description:
"Sebuah agen meminta untuk bekerja di Studio ini atas nama Anda. Periksa apa yang boleh dilakukannya sebelum menyetujui.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Siapa yang meminta",
fromHost: "Diidentifikasi oleh {host}",
registered: "Terdaftar di Studio ini",
+ selfRegistered: "Terdaftar sendiri, belum diverifikasi",
+ lookalike:
+ "Namanya mirip {client}, tetapi siapa pun bisa mendaftarkan klien dengan nama apa pun. Setujui hanya jika kamu sendiri memulai koneksi ini dari {client} dan alamat tujuan kembali yang ditampilkan di atas adalah yang dipakainya.",
sendsBack: "Mengembalikan Anda ke {host}",
thisComputer: "Mengembalikan Anda ke aplikasi di komputer ini",
+ thisComputerApp: "Mengembalikanmu ke aplikasi di komputer ini ({scheme}:)",
loopbackWarning:
"Setiap alamat tujuan agen ini mengembalikan Anda ada di komputer ini. Setujui hanya jika Anda memulai koneksi dari aplikasi di sini.",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.it.ts b/apps/studio/src/i18n/lazy/oauthConsent.it.ts
index b2cc5ed4..615f3eb3 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.it.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.it.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): it `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 0b67d5b2bc3557b1
+// hash: b8b6c8b1978e6cd1
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Collega {client}",
+ titleFrom: "{host}: collega {client}",
description:
"Un agente chiede di lavorare in questo Studio per te. Controlla cosa può fare prima di approvare.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Chi lo chiede",
fromHost: "Identificato da {host}",
registered: "Registrato in questo Studio",
+ selfRegistered: "Registrato da sé, non verificato",
+ lookalike:
+ "Il nome sembra {client}, ma chiunque può registrare un client con qualsiasi nome. Approva solo se hai avviato tu questa connessione da {client} e l’indirizzo a cui ti riporta, indicato sopra, è quello che usa.",
sendsBack: "Ti rimanda a {host}",
thisComputer: "Ti rimanda a un'app su questo computer",
+ thisComputerApp: "Ti riporta a un’app su questo computer ({scheme}:)",
loopbackWarning:
"Ogni indirizzo a cui questo agente può rimandarti è su questo computer. Approva solo se hai avviato la connessione da un'app qui.",
stepUp: "Questo agente è già collegato e chiede di più. Ciò che può fare oggi è spuntato.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts
index cbdbe6f4..91cc1554 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): ja `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: d97237187f90ddf0
+// hash: 4d2e18542f31f2de
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "{client} を接続",
+ titleFrom: "{host}: {client} を接続",
description:
"エージェントがあなたの代わりにこの Studio で作業することを求めています。承認する前に、できることを確認してください。",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "依頼元",
fromHost: "{host} で識別",
registered: "この Studio に登録済み",
+ selfRegistered: "自己登録、未確認",
+ lookalike:
+ "名前は {client} に見えますが、誰でも任意の名前でクライアントを登録できます。この接続を自分で {client} から開始し、上に表示された戻り先のアドレスがそのクライアントの使うものである場合にのみ承認してください。",
sendsBack: "{host} に戻ります",
thisComputer: "このコンピューター上のアプリに戻ります",
+ thisComputerApp: "このコンピューターのアプリに戻ります ({scheme}:)",
loopbackWarning:
"このエージェントが戻す先のアドレスはすべてこのコンピューター上にあります。ここにあるアプリから接続を始めた場合にのみ承認してください。",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts
index 1273a411..2cf86260 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): ko `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: c65704fd372347a7
+// hash: 46d4ba78d2e9409c
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "{client} 연결",
+ titleFrom: "{host}: {client} 연결",
description:
"에이전트가 이 Studio에서 나를 대신해 일하겠다고 요청합니다. 승인하기 전에 할 수 있는 일을 확인하세요.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "요청한 곳",
fromHost: "{host}(으)로 식별됨",
registered: "이 Studio에 등록됨",
+ selfRegistered: "자체 등록됨, 확인되지 않음",
+ lookalike:
+ "이름이 {client}처럼 보이지만 누구나 어떤 이름으로든 클라이언트를 등록할 수 있습니다. 직접 {client}에서 이 연결을 시작했고 위에 표시된 돌아갈 주소가 그 클라이언트가 쓰는 주소일 때만 승인하세요.",
sendsBack: "{host}(으)로 돌아갑니다",
thisComputer: "이 컴퓨터의 앱으로 돌아갑니다",
+ thisComputerApp: "이 컴퓨터의 앱으로 돌아갑니다 ({scheme}:)",
loopbackWarning:
"이 에이전트가 돌려보낼 수 있는 주소는 모두 이 컴퓨터에 있습니다. 이 컴퓨터의 앱에서 연결을 시작한 경우에만 승인하세요.",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts
index 334b3fc1..9975a91e 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): nl `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 0cc1edc4ecc6e454
+// hash: 7ff408a54a6c971a
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "{client} verbinden",
+ titleFrom: "{host}: {client} koppelen",
description:
"Een agent vraagt om voor je in deze Studio te werken. Controleer wat hij mag voordat je goedkeurt.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Wie het vraagt",
fromHost: "Geïdentificeerd door {host}",
registered: "Geregistreerd bij deze Studio",
+ selfRegistered: "Zelf geregistreerd, niet geverifieerd",
+ lookalike:
+ "De naam lijkt op {client}, maar iedereen kan een client onder elke naam registreren. Keur alleen goed als je deze verbinding zelf vanuit {client} hebt gestart en het adres waarnaar ze je terugstuurt, hierboven, het adres is dat het gebruikt.",
sendsBack: "Stuurt je terug naar {host}",
thisComputer: "Stuurt je terug naar een app op deze computer",
+ thisComputerApp: "Stuurt je terug naar een app op deze computer ({scheme}:)",
loopbackWarning:
"Elk adres waarnaar deze agent je kan terugsturen, staat op deze computer. Keur alleen goed als je de verbinding vanuit een app hier hebt gestart.",
stepUp: "Deze agent is al verbonden en vraagt om meer. Wat hij nu mag, is aangevinkt.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts
index acc22e3a..66818ed7 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): pl `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: b4c66e63431ba80f
+// hash: aa6ae745d3f932f5
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Połącz {client}",
+ titleFrom: "{host}: połącz {client}",
description:
"Agent prosi o pracę w tym Studio w twoim imieniu. Sprawdź, co może robić, zanim zatwierdzisz.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Kto prosi",
fromHost: "Identyfikowany przez {host}",
registered: "Zarejestrowany w tym Studio",
+ selfRegistered: "Zarejestrowany samodzielnie, niezweryfikowany",
+ lookalike:
+ "Nazwa wygląda jak {client}, ale każdy może zarejestrować klienta pod dowolną nazwą. Zatwierdź tylko, jeśli to ty rozpocząłeś to połączenie z {client}, a adres powrotu pokazany powyżej jest tym, którego używa.",
sendsBack: "Odeśle cię do {host}",
thisComputer: "Odeśle cię do aplikacji na tym komputerze",
+ thisComputerApp: "Odsyła cię do aplikacji na tym komputerze ({scheme}:)",
loopbackWarning:
"Każdy adres, do którego ten agent może cię odesłać, jest na tym komputerze. Zatwierdź tylko wtedy, gdy połączenie rozpoczęła aplikacja na tym komputerze.",
stepUp:
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts
index 7d85e344..583c7eb7 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): pt-BR `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 2356f90339549047
+// hash: ad075ac96f19d9a4
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Conectar {client}",
+ titleFrom: "{host}: conectar {client}",
description:
"Um agente pede para trabalhar neste Studio por você. Confira o que ele pode fazer antes de aprovar.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Quem está pedindo",
fromHost: "Identificado por {host}",
registered: "Registrado neste Studio",
+ selfRegistered: "Autorregistrado, não verificado",
+ lookalike:
+ "O nome parece {client}, mas qualquer pessoa pode registrar um cliente com qualquer nome. Aprove só se você mesmo iniciou esta conexão pelo {client} e o endereço para onde ele leva você, mostrado acima, é o que ele usa.",
sendsBack: "Leva você de volta para {host}",
thisComputer: "Leva você de volta para um app neste computador",
+ thisComputerApp: "Leva você de volta a um app deste computador ({scheme}:)",
loopbackWarning:
"Todos os endereços para os quais este agente pode levar você de volta estão neste computador. Aprove só se você iniciou a conexão por um app daqui.",
stepUp: "Este agente já está conectado e pede mais. O que ele pode fazer hoje está marcado.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts
index c9130bba..271decdb 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): ru `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 48e760099b8a5100
+// hash: df5de542275cbd08
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Подключить {client}",
+ titleFrom: "{host}: подключить {client}",
description:
"Агент просит работать в этом Studio от вашего имени. Проверьте, что ему можно, прежде чем разрешить.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Кто просит",
fromHost: "Идентифицирован через {host}",
registered: "Зарегистрирован в этом Studio",
+ selfRegistered: "Зарегистрирован сам, не проверен",
+ lookalike:
+ "Название похоже на {client}, но кто угодно может зарегистрировать клиента под любым именем. Одобряйте, только если вы сами начали это подключение из {client} и адрес возврата, указанный выше, — тот, который он использует.",
sendsBack: "Вернёт вас на {host}",
thisComputer: "Вернёт вас в приложение на этом компьютере",
+ thisComputerApp: "Возвращает вас в приложение на этом компьютере ({scheme}:)",
loopbackWarning:
"Все адреса, на которые этот агент может вас вернуть, находятся на этом компьютере. Разрешайте, только если подключение начато из приложения здесь.",
stepUp: "Этот агент уже подключён и просит больше. То, что ему можно сейчас, отмечено.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts
index 9e21eb2a..a04b11d5 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): tr `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: ed9f39fdef85a60c
+// hash: 9cd67ab61daa4c7b
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "{client} bağla",
+ titleFrom: "{host}: {client} bağla",
description:
"Bir agent bu Studio'da senin adına çalışmak istiyor. Onaylamadan önce neler yapabileceğine bak.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Kim istiyor",
fromHost: "{host} ile tanımlanıyor",
registered: "Bu Studio'ya kayıtlı",
+ selfRegistered: "Kendini kaydetmiş, doğrulanmamış",
+ lookalike:
+ "Ad {client} gibi görünüyor ama herkes herhangi bir adla istemci kaydedebilir. Yalnızca bu bağlantıyı {client} üzerinden kendin başlattıysan ve yukarıda gösterilen dönüş adresi onun kullandığı adresse onayla.",
sendsBack: "Seni {host} adresine geri gönderir",
thisComputer: "Seni bu bilgisayardaki bir uygulamaya geri gönderir",
+ thisComputerApp: "Seni bu bilgisayardaki bir uygulamaya geri gönderir ({scheme}:)",
loopbackWarning:
"Bu agent'ın seni geri gönderebileceği her adres bu bilgisayarda. Yalnızca bağlantıyı buradaki bir uygulamadan başlattıysan onayla.",
stepUp: "Bu agent zaten bağlı ve daha fazlasını istiyor. Bugün yapabildikleri işaretli.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts
index f6151423..b3b13b80 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): uk `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 7666c9230f67d63a
+// hash: 0a57d72b191bd1f8
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "Під'єднати {client}",
+ titleFrom: "{host}: підключити {client}",
description:
"Агент просить працювати в цьому Studio від вашого імені. Перевірте, що йому можна, перш ніж дозволити.",
invalid: {
@@ -17,8 +18,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "Хто просить",
fromHost: "Ідентифікований через {host}",
registered: "Зареєстрований у цьому Studio",
+ selfRegistered: "Зареєстрований сам, не перевірений",
+ lookalike:
+ "Назва схожа на {client}, але будь-хто може зареєструвати клієнта під будь-якою назвою. Схвалюйте, лише якщо ви самі почали це підключення з {client} і адреса повернення, показана вище, — та, яку він використовує.",
sendsBack: "Поверне вас на {host}",
thisComputer: "Поверне вас у застосунок на цьому комп'ютері",
+ thisComputerApp: "Повертає вас до застосунку на цьому комп’ютері ({scheme}:)",
loopbackWarning:
"Усі адреси, на які цей агент може вас повернути, на цьому комп'ютері. Дозволяйте, лише якщо під'єднання почато із застосунку тут.",
stepUp: "Цей агент уже під'єднаний і просить більше. Те, що йому можна зараз, позначено.",
diff --git a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts
index 98cad255..a6d2a43b 100644
--- a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts
+++ b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts
@@ -1,11 +1,12 @@
// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `oauthConsent.*` messages.
// Do not edit: change the translation file and import it; only English is edited here.
-// hash: 1ac1987970d2db80
+// hash: c6ec3e4e73790371
import type { NamespaceMessages } from "../namespaces.ts";
export const messages: NamespaceMessages<"oauthConsent"> = {
oauthConsent: {
title: "连接 {client}",
+ titleFrom: "{host}:连接 {client}",
description: "有个代理请求代表你在这个 Studio 中工作。批准前请先查看它可以做什么。",
invalid: {
title: "无法使用此请求",
@@ -15,8 +16,12 @@ export const messages: NamespaceMessages<"oauthConsent"> = {
title: "谁在请求",
fromHost: "由 {host} 标识",
registered: "已在此 Studio 注册",
+ selfRegistered: "自行注册,未经验证",
+ lookalike:
+ "这个名称看起来像 {client},但任何人都可以用任意名称注册客户端。只有当你亲自从 {client} 发起此连接,并且上方显示的返回地址正是它使用的地址时才批准。",
sendsBack: "会把你送回 {host}",
thisComputer: "会把你送回这台电脑上的应用",
+ thisComputerApp: "将你送回这台电脑上的应用({scheme}:)",
loopbackWarning:
"这个代理能把你送回的所有地址都在这台电脑上。只有当你是从这里的应用发起连接时才批准。",
stepUp: "这个代理已经连接,现在请求更多权限。它目前能做的已勾选。",
diff --git a/apps/studio/src/lib/api/agent-grants.ts b/apps/studio/src/lib/api/agent-grants.ts
index 76ad4a05..972bb991 100644
--- a/apps/studio/src/lib/api/agent-grants.ts
+++ b/apps/studio/src/lib/api/agent-grants.ts
@@ -14,8 +14,18 @@ export interface ConsentScope {
/** What the consent screen shows (`GET /api/agent-grants/consent`). */
export interface ConsentInfo {
- client: { id: string; name: string; host?: string };
- redirect: { host: string; loopback: boolean };
+ client: {
+ id: string;
+ /** Self-asserted, made safe to show by the server (plain text, at most 100 characters). */
+ name: string;
+ /** `document`: a metadata-document client, identified by `host`; `self-registered`: DCR. */
+ kind: "document" | "self-registered" | "server";
+ host?: string;
+ /** The well-known client the name looks like, when this is not that client. */
+ lookalike?: string;
+ };
+ /** `app`: a native app's private-use scheme (no web host). */
+ redirect: { host: string; loopback: boolean; app?: string };
loopbackOnly: boolean;
scopes: ConsentScope[];
offlineAccess: boolean;
diff --git a/apps/studio/src/routes/OAuthConsentPage.tsx b/apps/studio/src/routes/OAuthConsentPage.tsx
index 1eb5ad2e..bd2573c1 100644
--- a/apps/studio/src/routes/OAuthConsentPage.tsx
+++ b/apps/studio/src/routes/OAuthConsentPage.tsx
@@ -43,11 +43,16 @@ export function OAuthConsentPage() {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
});
- const name = info.data?.client.name ?? "";
+ const client = info.data?.client;
+ const name = client?.name ?? "";
+ // A metadata-document client's host is its identity: it stays in view whatever the name says.
+ const title = client?.host
+ ? c("oauthConsent.titleFrom", { client: name, host: client.host })
+ : c("oauthConsent.title", { client: name });
return (
}
- title={c("oauthConsent.title", { client: name })}
+ title={title}
description={c("oauthConsent.description")}
loading={info.isPending && request.length > 0}
width="narrow"
@@ -115,33 +120,7 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo })
return (
{decide.isError &&
}
-
} title={c("oauthConsent.who.title")}>
-
-
-
- {info.client.name}
-
- {info.client.host
- ? c("oauthConsent.who.fromHost", { host: info.client.host })
- : c("oauthConsent.who.registered")}
-
-
- {info.redirect.loopback
- ? c("oauthConsent.who.thisComputer")
- : c("oauthConsent.who.sendsBack", { host: info.redirect.host })}
-
-
-
- {info.loopbackOnly && (
-
}
- description={c("oauthConsent.who.loopbackWarning")}
- />
- )}
- {info.existing &&
}
-
-
+
}
title={c("oauthConsent.scopes.title")}
@@ -232,3 +211,53 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo })
);
}
+
+/**
+ * Who is asking (§4 item 1): a document client's host first, the self-asserted name, how the
+ * server knows the client, where the browser goes back to, and the warnings.
+ */
+function WhoIsAsking({ info }: { info: ConsentInfo }) {
+ const c = useT("oauthConsent");
+ return (
+ } title={c("oauthConsent.who.title")}>
+
+
+
+ {info.client.host && (
+ {c("oauthConsent.who.fromHost", { host: info.client.host })}
+ )}
+ {info.client.name}
+ {!info.client.host && (
+
+ {info.client.kind === "self-registered"
+ ? c("oauthConsent.who.selfRegistered")
+ : c("oauthConsent.who.registered")}
+
+ )}
+
+ {info.redirect.app
+ ? c("oauthConsent.who.thisComputerApp", { scheme: info.redirect.app })
+ : info.redirect.loopback
+ ? c("oauthConsent.who.thisComputer")
+ : c("oauthConsent.who.sendsBack", { host: info.redirect.host })}
+
+
+
+ {info.client.lookalike && (
+
+ )}
+ {info.loopbackOnly && (
+
}
+ description={c("oauthConsent.who.loopbackWarning")}
+ />
+ )}
+ {info.existing &&
}
+
+
+ );
+}
diff --git a/apps/studio/test/oauth-consent.test.tsx b/apps/studio/test/oauth-consent.test.tsx
index e1dbab6a..c47f5ee0 100644
--- a/apps/studio/test/oauth-consent.test.tsx
+++ b/apps/studio/test/oauth-consent.test.tsx
@@ -12,7 +12,7 @@ import { asRole, bodyOf, on, serve, wrap } from "./roles-fixtures.tsx";
const SIGNED = "client_id=c1&scope=site%3Aread+content%3Awrite+site%3Apublish&sig=abc";
const INFO: ConsentInfo = {
- client: { id: "c1", name: "Claude Code", host: "claude.ai" },
+ client: { id: "c1", name: "Claude Code", kind: "document", host: "claude.ai" },
redirect: { host: "127.0.0.1:33418", loopback: true },
loopbackOnly: true,
scopes: [
@@ -57,7 +57,8 @@ describe("OAuth consent screen", () => {
}),
);
renderPage();
- expect(await screen.findByText("Connect Claude Code")).toBeTruthy();
+ // The document's host is in the title and leads the identity, whatever the name says.
+ expect(await screen.findByText("claude.ai: connect Claude Code")).toBeTruthy();
expect(screen.getByText("Identified by claude.ai")).toBeTruthy();
expect(screen.getByText(/Every address this agent can send you back to/)).toBeTruthy();
// The signed request goes to the server untouched.
@@ -78,6 +79,48 @@ describe("OAuth consent screen", () => {
});
});
+ it("labels a self-registered client as not verified and warns about a well-known name", async () => {
+ serve(
+ ...asRole("owner"),
+ on("GET", "/api/agent-grants/consent", {
+ status: 200,
+ body: {
+ ...INFO,
+ client: { id: "dcr1", name: "Claude", kind: "self-registered", lookalike: "Claude" },
+ redirect: { host: "claude-ai.callback.example", loopback: false },
+ loopbackOnly: false,
+ },
+ }),
+ );
+ renderPage();
+ expect(await screen.findByText("Connect Claude")).toBeTruthy();
+ expect(screen.getByText("Self-registered, not verified")).toBeTruthy();
+ expect(screen.queryByText("Registered with this Studio")).toBeNull();
+ expect(screen.getByText(/The name looks like Claude, but anyone can register/)).toBeTruthy();
+ expect(screen.getByText("Sends you back to claude-ai.callback.example")).toBeTruthy();
+ });
+
+ it("shows a private-use redirect as an app on this computer, never as a web host", async () => {
+ serve(
+ ...asRole("owner"),
+ on("GET", "/api/agent-grants/consent", {
+ status: 200,
+ body: {
+ ...INFO,
+ client: { id: "dcr2", name: "Cursor", kind: "self-registered" },
+ redirect: { host: "", loopback: true, app: "cursor" },
+ loopbackOnly: false,
+ },
+ }),
+ );
+ renderPage();
+ expect(
+ await screen.findByText("Sends you back to an app on this computer (cursor:)"),
+ ).toBeTruthy();
+ // Cursor registers itself under its own name: no look-alike warning for it.
+ expect(screen.queryByText(/The name looks like/)).toBeNull();
+ });
+
it("asks for explicit consent before an Auto connection", async () => {
const fetch = serve(
...asRole("owner"),
diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example
index 79adf905..291ae1ce 100644
--- a/deploy/docker/.env.example
+++ b/deploy/docker/.env.example
@@ -47,6 +47,17 @@ CLOUDHAL_AUTOMATIONS=on
# Extra origins the API accepts browser requests from, comma-separated (normally empty).
CLOUDHAL_TRUSTED_ORIGINS=
+# Remote MCP clients (Claude, ChatGPT, Cursor, VS Code) connecting by URL over OAuth; needs
+# CLOUDHAL_BASE_URL. `off` turns OAuth off, or only Client ID Metadata Documents (CIMD), or only
+# dynamic client registration (DCR; Cursor needs it). The host list (comma-separated, `*.host`
+# allowed) limits the hosts a client's metadata document or redirect may use; empty: any.
+CLOUDHAL_MCP_OAUTH=on
+CLOUDHAL_MCP_CIMD=on
+CLOUDHAL_MCP_DCR=on
+CLOUDHAL_MCP_CLIENT_HOSTS=
+# Extra private-use redirect schemes of native apps, comma-separated (cursor and vscode are known).
+CLOUDHAL_MCP_REDIRECT_SCHEMES=
+
# Secrets, each passed to the container only when set here or in the shell.
# Better Auth's secret is created on the volume at first start (/data/.cloudhal/auth-secret) and
# kept there; set this only to supply your own (32 or more random characters). Never leave it empty.
diff --git a/deploy/docker/compose.yml b/deploy/docker/compose.yml
index 2d0b6238..5dfca8a9 100644
--- a/deploy/docker/compose.yml
+++ b/deploy/docker/compose.yml
@@ -24,6 +24,13 @@ services:
- CLOUDHAL_SITE_URL=${CLOUDHAL_SITE_URL:-}
- CLOUDHAL_AUTOMATIONS=${CLOUDHAL_AUTOMATIONS:-on}
- CLOUDHAL_TRUSTED_ORIGINS=${CLOUDHAL_TRUSTED_ORIGINS:-}
+ # Remote MCP clients (RFC-0044; docs/guides/mcp-oauth.md): `off` turns OAuth, metadata
+ # documents or dynamic registration off; the host list limits where clients come from.
+ - CLOUDHAL_MCP_OAUTH=${CLOUDHAL_MCP_OAUTH:-on}
+ - CLOUDHAL_MCP_CIMD=${CLOUDHAL_MCP_CIMD:-on}
+ - CLOUDHAL_MCP_DCR=${CLOUDHAL_MCP_DCR:-on}
+ - CLOUDHAL_MCP_CLIENT_HOSTS=${CLOUDHAL_MCP_CLIENT_HOSTS:-}
+ - CLOUDHAL_MCP_REDIRECT_SCHEMES=${CLOUDHAL_MCP_REDIRECT_SCHEMES:-}
# Secrets. A name without a value is passed through from .env or the shell only when it is
# set there, so an unset BETTER_AUTH_SECRET leaves the container with the secret it created
# on the volume (/data/.cloudhal/auth-secret). Never set it to an empty value.
diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md
index f32230bd..07164865 100644
--- a/docs/adr/0045-mcp-authorization-server.md
+++ b/docs/adr/0045-mcp-authorization-server.md
@@ -13,7 +13,16 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve
3. **One grant row per member and client** (`agent_grants`, migration `0031_oauth`): the scopes after narrowing at consent, the mode, an elevation, `last_used_at` (written at most every 5 min) and the revocation. Every token call reads it; no row, a revoked row, a row of a later grant than the token's `cloudhal_grant` (revoked, then connected again) or no membership is a 401 with the `invalid_token` challenge. Ending a connection (Studio's revoke, or removing the member, where it shares the membership's batch) revokes the grant, deletes the provider's stored consent and pending authorization codes and revokes its refresh tokens in one SQL batch. The principal is `narrow(narrow(token.scope, grant.scopes), roleScopes(role))` with the mode clamped like a key's, `grantId`, `keyScopes` and `person` — the same `Principal` shape a key produces, so policy, audit, approvals, modes, elevations and `forSite` are unchanged.
4. **Consent is Cloudhal's.** The provider's `consentPage` is Studio's `/oauth/consent`; the screen posts to `/api/agent-grants/consent`, which verifies the signed request, narrows the requested scopes by the member's **installation** role (the cap #173's step-up relies on), caps the mode, calls the provider's consent endpoint with the scopes kept (a subset of the request, as the provider requires), writing the grant, `agent_owner` and the `connect_agent` audit row in one batch before it calls the provider. Before the provider's authorize step, Cloudhal deletes the provider's stored consent for the member and client when no live grant exists, or when the member's current role would give a requested scope the grant does not hold (a promotion followed by a step-up), so the provider's consent memory (Decision 8) never skips Studio's screen when the grant would change. Agent code that gives a key something of its own (an `auto` release approval, an elevation, an automation run) asks `agentCredential()` and never treats a connection as the person behind it. The provider's own `/oauth2/consent` refuses HTTP callers (a per-instance server key) and is fenced in `createApi` with every other provider endpoint that is not authorize, token or revoke (client and consent CRUD, introspection, userinfo, registration, the `jwt()` plugin's `/token`).
5. **No authorization server without a stable origin** (`mcpOauthFor`): the admin Worker uses the request's origin (as Better Auth's `baseURL` already does); `cloudhal studio` needs `--base-url` / `CLOUDHAL_BASE_URL` and logs why OAuth is off otherwise; plain HTTP is accepted on loopback only; `CLOUDHAL_MCP_OAUTH=off` turns it off on both. The playground stays out (Decision 10).
-6. **Registration is Phase 3.** Phase 2 has no CIMD and no DCR; `OauthServer.createClient` (server-side only) creates a pre-registered public client for tests, the provider's `clientPrivileges` admitting only the server's own calls.
+6. **Registration (Phase 3a, amended 2026-11-09).** Phase 2 had none and a server-side test client (`OauthServer.createClient`); Phase 3a replaces it with the two paths of RFC-0044 §3, and `createClient` is gone (the suites register through DCR or CIMD; `clientPrivileges` now admits nobody). See "Phase 3a" below.
+
+## Phase 3a: client registration (2026-11-09)
+1. **CIMD** is `@better-auth/cimd` 1.7.18's discovery with the MCP 2026-07-28 profile, wrapped by Cloudhal (`cimd-discovery.ts`) and fetched through `cimdFetch` over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile; its own cache is off and its fetch windows and state capacity (10,000) are set out of reach, so the stored row is the only cache and Cloudhal's budgets the only budgets, on Workers (instance per request) and Node (instance per process) alike. A stored client validated within 60 min (its row's `updatedAt`) is used without a fetch. Every fetch spends the caller's budget (20 per address per minute, past which that caller gets 429); a document not stored yet also spends 30 per origin and 120 in all per minute, and needs room under the cap of 5,000 (the oldest grantless client not fetched for 10 min is evicted, else 503; the well-known clients' document hosts and the host list are exempt from the cap and never evicted, judged on the URL's host name in code, never by `LIKE` over the URL, whose `%` spans `/`). A failed refetch is recorded on the row (its validation time cleared) and refuses the client until a fetch succeeds; a stored row is served as it is only when the plugin itself declines to fetch, and only within 24 h of its last good fetch. Unused document clients are pruned after three hours, up to four batches of 50 an hour; an index on `("clientDiscoveryId", "updatedAt")` (migration `0032`) serves the count, the eviction and the prune. `CLOUDHAL_MCP_CIMD=off` turns it off and also ends existing document clients' connections at their next refresh (they no longer resolve), the conservative choice.
+2. **DCR** is Cloudhal's route `POST /api/auth/oauth2/register` (`dcr.ts`), mounted before Better Auth's catch-all: rate limits in `rate_limits` (10 per address per hour, 300 per day, every attempt counted), a 5 KB body, a cap of 5,000 clients (503), public clients only (an absent method becomes `none`; no secret is ever minted), `client_name` 1–100 characters, `grant_types` reduced to `authorization_code`/`refresh_token`, and every other field dropped before the provider's `registerOAuthClient` runs server-side with empty headers. The provider's own endpoint answers 404 to HTTP callers (the `before` hook and the fence, which opens exactly `REGISTER_PATH` only while DCR is on). Clients with no live connection and no connection activity for 30 days are deleted by the hourly `oauth.clients.prune` system job on both schedulers (document clients after a day), with every provider row of theirs in one batch per kind. `CLOUDHAL_MCP_DCR=off` removes the route and `registration_endpoint`, and `/oauth2/authorize` refuses the clients DCR stored earlier; connections they already have keep working until revoked or pruned.
+3. **Redirect URIs** (`redirect-policy.ts`): https on a non-loopback host; http on exactly `localhost`/`127.0.0.1`/`[::1]` with any port (the provider's RFC 8252 matching); or a native app's private-use scheme (`scheme:/…`, matched exactly) that is reverse-domain or a known app's (`cursor`, `vscode`, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), never `javascript`/`data`/`file`/`vbscript`/`about`/`blob` and friends or an `http` look-alike; at most 10, one refused entry refusing the registration. The rules and the host list run at registration (DCR) and at every authorization (`validateRedirectUri`).
+4. **`CLOUDHAL_MCP_CLIENT_HOSTS`** (opt-in, Decision 9): the hosts a metadata document may come from (stored clients included) and every client may redirect to (`localhost` admits loopback; private-use redirects then need a known scheme). A value that does not parse turns registration off.
+5. **AS metadata** advertises `client_id_metadata_document_supported` with CIMD on, `registration_endpoint` with DCR on, and `token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]` (the provider's shared-secret methods are dropped: no client here holds a secret).
+6. **`set_embargo` from a connection** files the gate-only release as a `schedule_release` request (`on-approval`, bound to `rl1:`) instead of saving the gate without effect; it is notified (`schedule-approval-due`) while its request is pending and unexpired, and a person approves it in Studio with their own approval. A grant-bound `auto` approval would need a contract change (the `auto` approval names a key) and is not taken.
+7. **Consent identity** (`client-identity.ts`): a client name is shown plain (control, bidi, zero-width and format characters removed, at most two combining marks per character, spaces collapsed) and at most 100 characters; a document client leads with its host, first in the title; a DCR client is "self-registered, not verified"; a name that looks like a well-known document client's (Claude, Claude Code, ChatGPT, Codex, VS Code, compared after NFKC and a small confusable map) is flagged unless the client is that client's document on its real host. A UTS #39 confusable skeleton is a follow-up. A private-use redirect is shown as an app on this computer.
## Alternatives considered
| Alternative | Pros | Cons | Why not |
@@ -24,6 +33,6 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve
| Issuer per request without a configured origin on Node | Works on any address | Tokens and `iss` change with the address a client used | An issuer must be stable (§10) |
## Consequences
-- Positive: Claude, ChatGPT, Cursor and other remote MCP clients can connect by URL once Phase 3 adds registration; a token is never more than a key of the same member; revoking a connection or removing a member takes effect on the next call.
+- Positive: Claude, ChatGPT, Cursor and other remote MCP clients can connect by URL (Phase 3a: CIMD, and DCR for clients without a document); a token is never more than a key of the same member; revoking a connection or removing a member takes effect on the next call.
- Negative: one extra indexed read per Better Auth instance (the plugin seeds the `oauthResource` row at init; per request on Workers); a `BETTER_AUTH_SECRET` rotation invalidates the signing key and every token.
- Risks: `@better-auth/mcp` is young (1.7.x); the family is pinned exactly and `better-auth-pins.test.ts` keeps it in step. Consent memory (Decision 8) is the provider's: a subset of granted scopes skips the screen and writes no audit row in Phase 2.
diff --git a/docs/guides/cloudflare-hardening.md b/docs/guides/cloudflare-hardening.md
index 9f1dd9b8..3b1ba7ab 100644
--- a/docs/guides/cloudflare-hardening.md
+++ b/docs/guides/cloudflare-hardening.md
@@ -72,3 +72,49 @@ The WAF works only on a zone, so it applies when you serve the site or Studio on
- Whether it deploys on Workers Free has **not been verified** yet, so it stays opt-in.
- To try it, set `"rateLimit": true` under `analytics` or `comments` and deploy. If wrangler refuses the binding, remove the key: the app-level checks keep working without it.
- The binding fails open: when it cannot answer, the request goes through.
+
+## Remote MCP clients (OAuth)
+
+Claude, ChatGPT, Cursor, VS Code and the MCP Inspector connect to `https:///mcp` by URL: the admin Worker is their OAuth 2.1 authorization server ([RFC-0044](../rfcs/0044-mcp-oauth-authorization.md), [ADR-0045](../adr/0045-mcp-authorization-server.md)). A member approves each connection on Studio's consent screen, and a connection never holds more than an agent key of that member would. Agent keys keep working next to it.
+
+### The switches
+
+| Variable | Default | What it does |
+| --- | --- | --- |
+| `CLOUDHAL_MCP_OAUTH` | on | `off` turns the authorization server off: `/mcp` takes agent keys and Studio sessions only. |
+| `CLOUDHAL_MCP_CIMD` | on | `off`: clients can no longer identify themselves with a Client ID Metadata Document (an HTTPS URL as their `client_id`, as Claude Code, Codex and VS Code do). Unlike DCR-off, this also ends their existing connections: a stored document client resolves to nothing, so its next token refresh fails and the agent must be reconnected once CIMD is back on. |
+| `CLOUDHAL_MCP_DCR` | on | `off`: no dynamic client registration (`/api/auth/oauth2/register`), and the metadata stops advertising it. Cursor has no metadata document and needs it. |
+| `CLOUDHAL_MCP_CLIENT_HOSTS` | empty | Comma-separated hosts clients may come from and send members back to, for example `claude.ai,claude.com,chatgpt.com,vscode.dev,cursor.com,www.cursor.com,localhost`. Empty: any host. |
+| `CLOUDHAL_MCP_REDIRECT_SCHEMES` | empty | Extra private-use redirect schemes of native apps, comma-separated. `cursor` and `vscode` are known already. |
+
+With CIMD and DCR both off, no client can start a new connection: an unknown `client_id` is refused at `/oauth2/authorize`, and so is every client either path stored earlier.
+
+On the admin Worker, set them as secrets, which `cloudhal deploy` keeps (it rewrites the Worker's plain vars on every deploy):
+
+```bash
+npx wrangler secret put CLOUDHAL_MCP_DCR --name -admin
+```
+
+On Node (`cloudhal studio`, the Docker image) they are environment variables; `compose.yml` passes all five through from `.env`.
+
+### What a client can and cannot do
+
+- **Metadata documents (CIMD).** The `client_id` must be an HTTPS URL with a path, and the document there must name exactly that URL as its `client_id`. The server fetches it with the same SSRF-safe fetcher media imports use: HTTPS on port 443, DNS names only (no IP addresses), every resolved address public (on Node the connection is pinned to the checked address), no redirects followed, at most 5 KB, 5 seconds. It never fetches from the admin Worker's own host or a site's domain.
+ - **Caching.** A stored client is used from its row for an hour after each successful fetch, on every runtime, so members' sign-ins and token refreshes cost no fetch. After that it is fetched again and replaced by the document: a changed callback or name takes effect within the hour. If that fetch fails (the document is gone or no longer valid), the failure is recorded on the row and the client is refused, refresh included, until a fetch succeeds.
+ - **Budgets**, counted across isolates and processes (the fetch library's own windows are set out of reach): 20 fetches a minute per caller address, for any client, past which that caller gets `429`; and, for documents not stored yet, 30 a minute per client origin and 120 a minute in all. A stored client never spends the shared budgets, so anonymous calls cannot lock members' clients out. A stored row is never served in place of a fetch the caller's budget refused. It is served as it is only when the fetch library itself declines to fetch, and only up to 24 hours after its last successful fetch.
+ - **Storage.** At most 5,000 document clients are stored. A new one past that evicts the oldest client with no live connection that was not fetched in the last 10 minutes, or gets `503`. Clients from hosts on `CLOUDHAL_MCP_CLIENT_HOSTS` and the well-known clients' document hosts (`claude.ai`, `claude.com`, `chatgpt.com`, `openai.com`, `vscode.dev`, `github.com`) are exempt from the cap and never evicted. A client with no connection and no fetch for three hours is deleted by the hourly job, and its next authorization stores it again.
+- **The consent screen.** A metadata-document client is named by its document's host, first in the page title ("claude.ai: connect Claude Code") and in the identity card. Its self-chosen name is shown plain (control, bidirectional, zero-width and format characters removed, at most two combining marks per character) and at most 100 characters long. A dynamically registered client is labelled "Self-registered, not verified". A warning appears when a name looks like a well-known document client's (Claude, Claude Code, ChatGPT, Codex, VS Code) and the client is not that client's document on its real host. Names are compared after Unicode NFKC and a small map of Cyrillic, Greek and digit look-alikes. A private-use redirect reads "an app on this computer (`cursor:`)", never as a web host.
+- **Dynamic registration (DCR).** Anonymous, so it is limited: 10 registrations per client address per hour and 300 per installation per day (invalid attempts count too), at most 5,000 registered clients (`503` beyond), a body of at most 5 KB. Only public clients register: no client secret is ever issued. A client's name (1–100 characters), up to 10 redirect URIs, and the grant and response types are kept; logos, URIs, contacts and keys are dropped, never stored or shown. A client nobody connected, used or disconnected in 30 days is deleted by the scheduler's hourly job; a client with a live connection is never pruned.
+- **Redirect URIs.** `https://` on a public host; `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, where the port may change between runs (RFC 8252 §7.3); or a native app's private-use scheme (RFC 8252 §7.1), written `scheme:/…`, when the scheme is reverse-domain (`com.example.app:/callback`) or a known app's (`cursor://anysphere.cursor-mcp/oauth/callback`, `vscode://…`, plus `CLOUDHAL_MCP_REDIRECT_SCHEMES`). Everything but the loopback port must match the registered URI exactly. Schemes a browser or the system acts on (`javascript`, `data`, `file`, `vbscript`, `about`, `blob`, …) and look-alikes of `http` are always refused, and one refused URI refuses the whole registration.
+- **The host list.** With `CLOUDHAL_MCP_CLIENT_HOSTS` set, a metadata document must be served from a listed host, and every client, however it registered, may send members back only to listed hosts. Loopback redirects then need `localhost` on the list, and private-use redirects a known app scheme (reverse-domain schemes are refused). The list applies to new registrations and to every authorization. Removing a redirect host cuts off its clients at their next sign-in, and their existing connections keep working until revoked. Removing a document host ends that host's document clients at once, refreshes included, because they no longer resolve. A list that does not parse turns client registration off, and the log says why.
+- **Turning DCR off.** When every client your members use has a metadata document (Claude, Claude Code, Codex and VS Code do), `CLOUDHAL_MCP_DCR=off` removes the anonymous registration endpoint. Clients it registered earlier can no longer start an authorization (for any member), but the connections they already have keep working, refreshes included, until a member disconnects them in Studio or they are pruned 30 days after their last use. Revoke them there to end them at once.
+
+### Rotating `BETTER_AUTH_SECRET`
+
+The secret also encrypts the key that signs access tokens. A new secret signs every member out, and the stored signing key can no longer be decrypted, so the server cannot issue tokens until that key is deleted and a new one is created on the next sign-in. After rotating it:
+
+```bash
+npx wrangler d1 execute -db --remote --command 'DELETE FROM jwks'
+```
+
+On Node, run `DELETE FROM jwks` on the project's `data.db`. Access tokens signed with the old key stop verifying at once, so every connected client is asked to sign in again on its next call (one holding a refresh token may get a new token without the member). Connections stay listed under the members who made them, and revoking one still ends it at once.
diff --git a/docs/guides/self-host.md b/docs/guides/self-host.md
index c625f57f..8dcc9bd2 100644
--- a/docs/guides/self-host.md
+++ b/docs/guides/self-host.md
@@ -60,6 +60,8 @@ location / {
}
```
+> **Warning:** behind a reverse proxy, always set `CLOUDHAL_BEHIND_PROXY=1` (the `--behind-proxy` flag of `cloudhal studio`). Without it, every request seems to come from the proxy's own address, so every client shares one rate-limit bucket. Remote MCP clients are hit hardest: 20 junk authorization requests a minute from anyone then block the token refreshes of every connected agent whose client document is due for its hourly refetch ([hardening](cloudflare-hardening.md#remote-mcp-clients-oauth)).
+
Serve `/data/dist` from the volume for the site host. It is the stable path: a real folder with the 0.6.3 image, and from 0.6.4 on a symlink to the newest build, which the server must follow (nginx and Caddy do by default). A build replaces the symlink atomically, so the server never sees it missing — except for a moment on the first build after upgrading from 0.6.3, when the real folder moves aside and the symlink takes its place. A mount point of your own at `/data/dist` cannot be moved: `site-build` refuses it, so serve the volume's `/data/dist` instead.
## Environment
@@ -79,6 +81,9 @@ All of these are read by `cloudhal studio` in the container, except those marked
| `CLOUDHAL_SITE_NAME` | `My site` | The name of that first site. |
| `CLOUDHAL_AUTOMATIONS` | `on` | `off` stops scheduled automations; releases and gates go on ([schedules](schedules.md)). |
| `CLOUDHAL_TRUSTED_ORIGINS` | empty | Extra origins the API accepts browser requests from, comma-separated. |
+| `CLOUDHAL_MCP_OAUTH`, `CLOUDHAL_MCP_CIMD`, `CLOUDHAL_MCP_DCR` | `on` | `off` turns off OAuth for remote MCP clients, or only one way clients register: metadata documents (CIMD) or dynamic registration (DCR). OAuth needs `CLOUDHAL_BASE_URL`. See [hardening](cloudflare-hardening.md#remote-mcp-clients-oauth). |
+| `CLOUDHAL_MCP_CLIENT_HOSTS` | empty | Hosts OAuth clients may come from and send members back to, comma-separated (`claude.ai,*.example.com`, `localhost` for loopback redirects). Empty: any host. |
+| `CLOUDHAL_MCP_REDIRECT_SCHEMES` | empty | Extra private-use redirect schemes of native apps, comma-separated (`cursor` and `vscode` are known already). |
| `BETTER_AUTH_SECRET` | created on the volume | Better Auth's secret. Unset, the container creates one at first start and keeps it at `/data/.cloudhal/auth-secret`; set it only to supply your own, and never empty. |
| `CLOUDHAL_AI_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | empty | Workers AI over REST for Build chat (both or neither). The token is kept in the container's memory, never written to the volume. |
diff --git a/packages/api/package.json b/packages/api/package.json
index 13ccbdfe..20b25509 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -38,6 +38,7 @@
"better-auth": "catalog:",
"@better-auth/api-key": "catalog:",
"@better-auth/mcp": "catalog:",
+ "@better-auth/cimd": "catalog:",
"@better-auth/oauth-provider": "catalog:",
"@better-auth/core": "catalog:",
"@better-auth/utils": "catalog:",
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 98e79cc6..d5ee5e81 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -13,7 +13,7 @@ import { type BuildHub, createBuildHub } from "./build.ts";
import type { ApiDeps, Env } from "./context.ts";
import { errorHandler } from "./errors.ts";
import { mountMcp } from "./mcp-mount.ts";
-import { fencedOauthPath, mountOauthServer } from "./oauth-server.ts";
+import { fencedOauthPath, mountClientRegistration, mountOauthServer } from "./oauth-server.ts";
import { PLAYGROUND_PRINCIPAL, playgroundGuard } from "./playground.ts";
import { activity, deploymentActivity } from "./routes/activity.ts";
import { mountAdmin } from "./routes/admin-mount.ts";
@@ -163,7 +163,7 @@ export function createApi(deps: ApiDeps) {
setContext(c, deps, shared);
// The fenced Better Auth endpoints never reach credential resolution.
// Raw and decoded: whichever form Better Auth or Hono would dispatch on is fenced.
- if (fencedAuthPath(new URL(c.req.url).pathname) || fencedAuthPath(c.req.path))
+ if (fencedAuthPath(new URL(c.req.url).pathname, deps) || fencedAuthPath(c.req.path, deps))
return c.json({ error: "not_found", message: "not found" }, 404);
const principal = await resolvePrincipal(c, deps);
c.set("principal", principal);
@@ -185,10 +185,7 @@ export function createApi(deps: ApiDeps) {
const problems = mountAdmin(app, deps);
app.get("/api/health", (c) => c.json(problems.length ? { ok: true, problems } : { ok: true }));
- if (deps.authHandler) {
- const handler = deps.authHandler;
- app.on(["GET", "POST"], "/api/auth/*", (c) => handler(c.req.raw));
- }
+ mountAuthHandler(app, deps);
app.get("/api/me", meHandler(membership));
@@ -269,8 +266,16 @@ export function createApi(deps: ApiDeps) {
}
/** Better Auth paths HTTP callers never reach (raw and decoded forms are both checked). */
-function fencedAuthPath(path: string): boolean {
- return FENCED_AUTH_PATHS.test(path) || fencedOauthPath(path);
+function fencedAuthPath(path: string, deps: ApiDeps): boolean {
+ const open = { register: Boolean(deps.oauthServer?.register) };
+ return FENCED_AUTH_PATHS.test(path) || fencedOauthPath(path, open);
+}
+
+/** Better Auth's routes; RFC-0044 §3: DCR is Cloudhal's route, ahead of the catch-all. */
+function mountAuthHandler(app: Hono, deps: ApiDeps): void {
+ if (deps.oauthServer?.register) mountClientRegistration(app, deps.oauthServer.register);
+ const handler = deps.authHandler;
+ if (handler) app.on(["GET", "POST"], "/api/auth/*", (c) => handler(c.req.raw));
}
/**
diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts
index 07778268..4ceca3e6 100644
--- a/packages/api/src/auth.ts
+++ b/packages/api/src/auth.ts
@@ -1,8 +1,8 @@
import type { Auth } from "better-auth";
import { createAgentGrantService } from "./auth/agent-grants.ts";
import { createAgentKeyService } from "./auth/agent-keys.ts";
+import { oauthClientRegistration } from "./auth/dcr-server.ts";
import { authDeps } from "./auth/deps.ts";
-import { createOauthClient } from "./auth/oauth-clients.ts";
import { createPrincipalResolver } from "./auth/principal.ts";
import { createStaffService } from "./auth/staff.ts";
import { type AuthOptions, type CloudhalAuth, signUpMode } from "./auth/types.ts";
@@ -15,6 +15,13 @@ export {
type ConsentScope,
consentScopes,
} from "./auth/agent-grants.ts";
+export {
+ CIMD_MAX_BYTES,
+ CIMD_TIMEOUT_MS,
+ type CimdFetchOptions,
+ cimdFetch,
+} from "./auth/cimd-fetch.ts";
+export { DCR_MAX_BODY_BYTES, RegistrationError } from "./auth/dcr.ts";
export { agentIdForGrant } from "./auth/grants.ts";
export { actorIdForUser, agentIdForKey, agentKeyStateFromSql } from "./auth/key-rows.ts";
export {
@@ -23,6 +30,22 @@ export {
OAUTH_SCOPES,
oauthEndpoints,
} from "./auth/oauth.ts";
+export {
+ pruneCimdClients,
+ pruneDcrClients,
+ pruneUnusedClients,
+} from "./auth/oauth-client-rows.ts";
+export {
+ CIMD_LIMITS,
+ type CimdLimits,
+ DCR_LIMITS,
+ type DcrLimits,
+ type OauthRegistration,
+ type OauthRegistrationEnv,
+ oauthRegistrationFrom,
+ parseClientHosts,
+ parseRedirectSchemes,
+} from "./auth/oauth-registration.ts";
export {
ADDABLE_KEY_SCOPES,
AGENT_KEY_RATE_LIMIT,
@@ -75,11 +98,27 @@ export function createAuth(options: AuthOptions): CloudhalAuth {
getOAuthServerConfig(): Promise>;
}
).getOAuthServerConfig()) ?? {};
- return metadata;
+ return narrowAuthMethods(metadata);
},
- createClient: (input) => createOauthClient(d, input),
+ ...oauthClientRegistration(d, options.mcpOauth),
},
}
: {}),
};
}
+
+/**
+ * RFC-0044 §2: the token endpoint takes public clients and `private_key_jwt` only. The provider
+ * also lists the shared-secret methods, but no client here ever holds a secret (CIMD and DCR
+ * clients are public or key-bound), so they are not advertised.
+ */
+function narrowAuthMethods(metadata: Record): Record {
+ const methods = metadata.token_endpoint_auth_methods_supported;
+ if (!Array.isArray(methods)) return metadata;
+ return {
+ ...metadata,
+ token_endpoint_auth_methods_supported: methods.filter(
+ (m) => m === "none" || m === "private_key_jwt",
+ ),
+ };
+}
diff --git a/packages/api/src/auth/agent-consent.ts b/packages/api/src/auth/agent-consent.ts
index e7374bbc..ab419d21 100644
--- a/packages/api/src/auth/agent-consent.ts
+++ b/packages/api/src/auth/agent-consent.ts
@@ -21,6 +21,13 @@ import {
roleScopes,
type Scope,
} from "@cloudhal/agent-runtime";
+import {
+ type ClientKind,
+ clientKind,
+ displayName,
+ documentHost,
+ lookalikeOf,
+} from "./client-identity.ts";
import type { AuthDeps } from "./deps.ts";
import { type AgentGrant, agentIdForGrant } from "./grants.ts";
import { SERVER_KEY_HEADER } from "./instance.ts";
@@ -36,12 +43,24 @@ export interface ConsentScope {
export interface ConsentInfo {
readonly client: {
readonly id: string;
+ /** The self-asserted name, made safe to show (`displayName`: plain, at most 100). */
readonly name: string;
+ /** How the server knows the client (`client-identity.ts`). */
+ readonly kind: ClientKind;
/** The `client_id` host of a metadata-document client (`claude.ai`), else absent. */
readonly host?: string | undefined;
+ /** The well-known client the name looks like, when this is not that client (`Claude`). */
+ readonly lookalike?: string | undefined;
+ };
+ /**
+ * Where the browser is sent back: its host, and whether it is this computer (loopback); for a
+ * native app's private-use scheme, `app` (the scheme) and no host.
+ */
+ readonly redirect: {
+ readonly host: string;
+ readonly loopback: boolean;
+ readonly app?: string | undefined;
};
- /** Where the browser is sent back: its host, and whether it is this computer (loopback). */
- readonly redirect: { readonly host: string; readonly loopback: boolean };
/** Every registered redirect URI is loopback (the spec's extra warning). */
readonly loopbackOnly: boolean;
readonly scopes: readonly ConsentScope[];
@@ -58,15 +77,21 @@ export interface ConsentInfo {
interface ClientRow {
clientId: string;
name?: string | null;
+ clientDiscoveryId?: string | null;
+ userId?: string | null;
+ referenceId?: string | null;
redirectUris?: string[] | string | null;
disabled?: boolean | null;
}
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
-function hostOf(uri: string): { host: string; loopback: boolean } | undefined {
+function hostOf(uri: string): { host: string; loopback: boolean; app?: string } | undefined {
try {
const url = new URL(uri);
+ // A private-use scheme opens an app on this computer: its "host" is the app's to read.
+ if (url.protocol !== "http:" && url.protocol !== "https:")
+ return { host: "", loopback: true, app: url.protocol.slice(0, -1) };
return { host: url.host, loopback: LOOPBACK.has(url.hostname) };
} catch {
return undefined;
@@ -124,15 +149,18 @@ export async function consentInfo(
const registered = listOf(client.redirectUris);
const existing = await d.grants.live(who.userId, client.clientId);
const cap = modeCap(who.member.role);
- const documentHost = client.clientId.startsWith("https://")
- ? hostOf(client.clientId)?.host
- : undefined;
+ const host = documentHost(client);
+ const kind = clientKind(client);
+ const name = displayName(client.name, client.clientId);
+ const lookalike = lookalikeOf(name, client);
const sites = who.member.sites ? Object.keys(who.member.sites) : undefined;
return {
client: {
id: client.clientId,
- name: client.name?.trim() || client.clientId,
- ...(documentHost ? { host: documentHost } : {}),
+ name,
+ kind,
+ ...(host ? { host } : {}),
+ ...(lookalike ? { lookalike } : {}),
},
redirect,
loopbackOnly: registered.length > 0 && registered.every((u) => hostOf(u)?.loopback),
@@ -235,7 +263,7 @@ export async function giveConsent(
{
userId: who.userId,
clientId: client.clientId,
- clientName: (client.name?.trim() || client.clientId).slice(0, 100),
+ clientName: displayName(client.name, client.clientId),
scopes: effective,
mode: input.mode,
},
diff --git a/packages/api/src/auth/cimd-discovery.ts b/packages/api/src/auth/cimd-discovery.ts
new file mode 100644
index 00000000..f987f26f
--- /dev/null
+++ b/packages/api/src/auth/cimd-discovery.ts
@@ -0,0 +1,194 @@
+/**
+ * RFC-0044 §3: Client ID Metadata Documents, with Cloudhal's bounds around `@better-auth/cimd`'s
+ * discovery. On its own the plugin would fetch a stored client's document whenever its cache
+ * missed (on Workers, every `getClient`: authorize, consent, every token refresh), keep its own
+ * per-process fetch windows (on Node, for the life of the process), and store a client row for
+ * every new URL. Around the plugin's `resolve`:
+ *
+ * 1. **Host list.** With `CLOUDHAL_MCP_CLIENT_HOSTS` set, a document host off the list is refused,
+ * stored or not (a stored one resolves to nothing, so it cannot authorize or refresh).
+ * 2. **Stored and fresh: no fetch.** A stored client whose row was validated within
+ * `freshMinutes` (the row's `updatedAt`, which the plugin sets on every fetch) is used as it
+ * is, on every runtime. This is the only cache: the plugin's own is off, and its own fetch
+ * windows are set out of reach, so Cloudhal's budgets are the only ones.
+ * 3. **The caller's budget first**, for every fetch: past it, that caller gets 429 (stored client
+ * or not), so nobody can make the server serve an outdated row by spending their own budget.
+ * 4. **A document not stored yet** also spends its origin's and the installation's budget, and
+ * needs room: at `maxClients` the oldest grantless client not fetched for `evictMinutes` is
+ * evicted, else 503. Hosts on the host list and the well-known clients' document hosts are
+ * exempt from the cap and never evicted.
+ * 5. **Refetch.** A stored client older than the window is fetched again and replaced by the
+ * document. If the fetch fails, the failure is recorded on the row (its validation time is
+ * cleared) and the client is refused until a fetch succeeds. Only when the plugin itself
+ * declines to fetch (its rate limit, which Cloudhal sets out of reach) is a stored row served
+ * as it is, and only while it is younger than `maxStaleHours`.
+ *
+ * The plugin validates (`client_id` = URL, the MCP profile, size, media type) and stores or
+ * refreshes the row.
+ */
+import { type CimdOptions, createCimdClientDiscovery } from "@better-auth/cimd";
+import type { GenericEndpointContext } from "@better-auth/core";
+import {
+ type ClientDiscovery,
+ type ClientMetadataResourceFetch,
+ extendOAuthProvider,
+} from "@better-auth/oauth-provider";
+import type { BetterAuthPlugin } from "better-auth";
+import { APIError } from "better-auth/api";
+import type { CimdLimits } from "./oauth-registration.ts";
+import { hostMatches } from "./redirect-policy.ts";
+
+/** Document hosts of the well-known clients (RFC-0044 §11): exempt from the cap and eviction. */
+export const WELL_KNOWN_DOCUMENT_HOSTS: readonly string[] = [
+ "claude.ai",
+ "claude.com",
+ "chatgpt.com",
+ "openai.com",
+ "vscode.dev",
+ "github.com",
+];
+
+export interface CimdGuard {
+ readonly limits: CimdLimits;
+ readonly clientHosts?: readonly string[] | undefined;
+ /** Stored document clients (`countCimdClients`). */
+ readonly count: () => Promise;
+ /**
+ * Deletes the oldest document client with no live connection and no use since `before`,
+ * whose host is not in `exempt`; how many went (0 or 1).
+ */
+ readonly evict: (input: { before: Date; isExempt: (host: string) => boolean }) => Promise;
+ /** Records a failed refetch on the row (its validation time cleared). */
+ readonly markFailed: (clientId: string) => Promise;
+ /** One hit of a fixed-window counter (`StaffStore.hit`); false once `max` is passed. */
+ readonly hit: (key: string, windowMs: number, max: number) => Promise;
+ /** The caller's address from the runtime's trusted header. */
+ readonly address: (headers: Headers | undefined) => string;
+ readonly now: () => Date;
+}
+
+type StoredClient = Parameters[2];
+
+const unavailable = (status: "TOO_MANY_REQUESTS" | "SERVICE_UNAVAILABLE", description: string) =>
+ new APIError(status, { error: "temporarily_unavailable", error_description: description });
+
+function updatedAtOf(client: NonNullable): number {
+ const value = (client as { updatedAt?: Date | string | number | null }).updatedAt;
+ const ms = value === null || value === undefined ? Number.NaN : new Date(value).getTime();
+ return Number.isNaN(ms) ? 0 : ms;
+}
+
+/** Hosts exempt from the cap and from eviction: the host list's and the well-known clients'. */
+export function exemptHosts(guard: Pick): string[] {
+ return [...WELL_KNOWN_DOCUMENT_HOSTS, ...(guard.clientHosts ?? [])];
+}
+
+export function isExempt(host: string, exempt: readonly string[]): boolean {
+ return exempt.some(
+ (p) => hostMatches(host, p) || (!p.startsWith("*.") && hostMatches(host, `*.${p}`)),
+ );
+}
+
+/** The plugin's own "not now" (its fetch windows): no fetch happened. */
+function pluginDeclined(e: unknown): boolean {
+ return (
+ e instanceof APIError &&
+ (e.body as { error?: unknown } | undefined)?.error === "temporarily_unavailable"
+ );
+}
+
+/** Room for a document not stored yet (`maxClients`), evicting an unused one if needed. */
+async function admit(guard: CimdGuard, host: string): Promise {
+ const exempt = exemptHosts(guard);
+ if (isExempt(host, exempt) || (await guard.count()) < guard.limits.maxClients) return;
+ const before = new Date(guard.now().getTime() - guard.limits.evictMinutes * 60_000);
+ if ((await guard.evict({ before, isExempt: (h) => isExempt(h, exempt) })) > 0) return;
+ throw unavailable(
+ "SERVICE_UNAVAILABLE",
+ "this server stores no more clients right now; try again later",
+ );
+}
+
+/** Exported for the suite's unit cases; the plugin calls it through `guardedCimd`. */
+export async function guardedResolve(
+ inner: ClientDiscovery,
+ guard: CimdGuard,
+ input: { ctx: GenericEndpointContext; clientId: string; existing: StoredClient },
+): Promise {
+ const { ctx, clientId, existing } = input;
+ if (!inner.matches(clientId)) return null;
+ const url = new URL(clientId);
+ const { limits } = guard;
+ const hosts = guard.clientHosts;
+ if (hosts && hosts.length > 0 && !hosts.some((p) => hostMatches(url.hostname, p))) {
+ if (existing) return null;
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description: "client_id URL is not on this server's list of client hosts",
+ });
+ }
+ const age = existing ? guard.now().getTime() - updatedAtOf(existing) : Number.POSITIVE_INFINITY;
+ if (existing && age < limits.freshMinutes * 60_000) return existing;
+ const tooMany = () =>
+ unavailable("TOO_MANY_REQUESTS", "too many client metadata fetches; try again in a minute");
+ const address = guard.address(ctx.headers);
+ if (!(await guard.hit(`cloudhal:cimd:addr:${address}`, 60_000, limits.perAddressPerMinute)))
+ throw tooMany();
+ if (!existing) {
+ await admit(guard, url.hostname);
+ if (
+ !(await guard.hit(`cloudhal:cimd:origin:${url.origin}`, 60_000, limits.perOriginPerMinute)) ||
+ !(await guard.hit("cloudhal:cimd:all", 60_000, limits.perMinute))
+ )
+ throw tooMany();
+ }
+ try {
+ return await inner.resolve(ctx, clientId, existing);
+ } catch (e) {
+ if (existing && pluginDeclined(e) && age < limits.maxStaleHours * 3_600_000) return existing;
+ if (existing && !pluginDeclined(e)) await guard.markFailed(clientId);
+ throw e;
+ }
+}
+
+/** Plugin windows Cloudhal's budgets always reach first (the plugin requires positive integers). */
+const OUT_OF_REACH = 1_000_000;
+
+/**
+ * The `cimd()` plugin with Cloudhal's guard (same id, MCP 2026-07-28 profile, the runtime's
+ * transport).
+ */
+export function guardedCimd(
+ fetch: ClientMetadataResourceFetch,
+ guard: CimdGuard,
+): BetterAuthPlugin {
+ const options: CimdOptions = {
+ fetchClientMetadataResource: fetch,
+ metadataProfile: "mcp-2026-07-28",
+ // The stored row is the cache (step 2): the plugin's own would outlive a changed document.
+ metadataRevalidationInterval: 0,
+ // Also the size of the plugin's per-origin and per-client fetch state, which refuses at
+ // capacity: out of reach too (entries hold at most a 5 KB document and its validators).
+ maxCacheEntries: 10_000,
+ // Cloudhal's budgets in `rate_limits` are the only ones (steps 3 and 4): on Node the
+ // plugin's windows would last the life of the process and could be drained anonymously.
+ metadataFetchPolicy: {
+ minimumFetchInterval: 0,
+ maximumConcurrentFetches: OUT_OF_REACH,
+ maximumConcurrentFetchesPerOrigin: OUT_OF_REACH,
+ maximumFetchesPerMinute: OUT_OF_REACH,
+ maximumFetchesPerOriginPerMinute: OUT_OF_REACH,
+ },
+ };
+ const inner = createCimdClientDiscovery(options);
+ const discovery: ClientDiscovery = {
+ ...inner,
+ resolve: (ctx, clientId, existing) => guardedResolve(inner, guard, { ctx, clientId, existing }),
+ };
+ return {
+ id: "cimd",
+ init(ctx) {
+ extendOAuthProvider(ctx, { clientDiscovery: discovery });
+ },
+ } as BetterAuthPlugin;
+}
diff --git a/packages/api/src/auth/cimd-fetch.ts b/packages/api/src/auth/cimd-fetch.ts
new file mode 100644
index 00000000..16c33f93
--- /dev/null
+++ b/packages/api/src/auth/cimd-fetch.ts
@@ -0,0 +1,104 @@
+/**
+ * RFC-0044 §3: how the authorization server fetches a client's metadata document (and, for a
+ * `private_key_jwt` client, its `jwks_uri`). The `cimd()` plugin needs a transport that resolves
+ * the host once, refuses special-use addresses and refuses redirects; Cloudhal already has one,
+ * the SSRF-safe fetcher of media imports (`safeFetch`, RFC-0004 §5.7), so this adapter puts it
+ * behind the plugin's `fetch`-shaped interface:
+ *
+ * - Node (`cloudhal studio`): `nodeTransport()` from `@cloudhal/core-ts/media-node` resolves every
+ * A/AAAA record, refuses the name if any address is not public, and pins the TLS connection to
+ * the vetted address.
+ * - Workers: `workersTransport()` from `@cloudhal/core-ts/media` (DoH checks; Workers cannot pin,
+ * and their `fetch` has no route to private networks), with the deployment's own hosts in
+ * `blockedHosts`.
+ *
+ * Either way: https on port 443 only, DNS names only (no IP literals), no internal names, no
+ * redirects followed, at most `CIMD_MAX_BYTES`, `CIMD_TIMEOUT_MS` in all, GET only, no request
+ * headers from the plugin forwarded except `Accept` (so no conditional revalidation: every refetch
+ * is a full 200 the plugin validates again). The plugin itself checks the JSON media type, the
+ * size again, `client_id` = URL, the MCP profile and the cache headers this adapter passes back.
+ *
+ * Worker-safe: no Node imports (the Node transport is injected by the runtime).
+ */
+import type { ClientMetadataResourceFetch } from "@better-auth/oauth-provider";
+import { type FetchTransport, safeFetch } from "@cloudhal/core-ts/media";
+
+/** RFC-0044 §3: "5 KB limit, 5 s timeout" (the plugin's own limits are the same). */
+export const CIMD_MAX_BYTES = 5 * 1024;
+export const CIMD_TIMEOUT_MS = 5_000;
+
+/** Response headers the plugin reads (media type and HTTP caching); nothing else is passed on. */
+const PASSED_HEADERS = [
+ "content-type",
+ "cache-control",
+ "vary",
+ "expires",
+ "date",
+ "age",
+ "etag",
+ "last-modified",
+] as const;
+
+export interface CimdFetchOptions {
+ /** The SSRF-safe transport: `nodeTransport()` on Node, `workersTransport()` on Workers. */
+ readonly transport: FetchTransport;
+ /**
+ * This deployment's own hosts, never fetched (its Studio, every site's domains); a function is
+ * asked once per fetch, so the list follows domain changes without a read on other requests.
+ */
+ readonly blockedHosts?: readonly string[] | (() => Promise) | undefined;
+ /** Whole fetch (default `CIMD_TIMEOUT_MS`); tests shorten it. */
+ readonly timeoutMs?: number | undefined;
+ /** Test seam: which resolved addresses count as public (default: `isPublicAddress`). */
+ readonly isAllowedAddress?: ((address: string) => boolean) | undefined;
+}
+
+function urlOf(input: Parameters[0]): string {
+ if (typeof input === "string") return input;
+ if (input instanceof URL) return input.href;
+ return input.url;
+}
+
+/** Rejects as soon as `signal` aborts (the plugin's own 5 s timer), else follows `p`. */
+function untilAborted(p: Promise, signal: AbortSignal | null | undefined): Promise {
+ if (!signal) return p;
+ if (signal.aborted) return Promise.reject(new Error("aborted"));
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(new Error("aborted"));
+ signal.addEventListener("abort", onAbort, { once: true });
+ p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
+ });
+}
+
+/** The plugin's `fetchClientMetadataResource` over `safeFetch`. */
+export function cimdFetch(options: CimdFetchOptions): ClientMetadataResourceFetch {
+ const timeoutMs = options.timeoutMs ?? CIMD_TIMEOUT_MS;
+ return async (input, init) => {
+ const method = (
+ init?.method ?? (input instanceof Request ? input.method : "GET")
+ ).toUpperCase();
+ if (method !== "GET") throw new TypeError("client metadata is fetched with GET only");
+ const blocked = options.blockedHosts;
+ const blockedHosts = typeof blocked === "function" ? await blocked() : blocked;
+ const result = await untilAborted(
+ safeFetch(urlOf(input), {
+ transport: options.transport,
+ maxBytes: CIMD_MAX_BYTES,
+ // The plugin checks the JSON media type itself (`application/json` or `+json`).
+ accept: [],
+ maxRedirects: 0,
+ headersTimeoutMs: timeoutMs,
+ totalTimeoutMs: timeoutMs,
+ ...(blockedHosts ? { blockedHosts } : {}),
+ ...(options.isAllowedAddress ? { isAllowedAddress: options.isAllowedAddress } : {}),
+ }),
+ init?.signal,
+ );
+ const headers = new Headers();
+ for (const name of PASSED_HEADERS) {
+ const value = result.headers.get(name);
+ if (value !== null) headers.set(name, value);
+ }
+ return new Response(new Uint8Array(result.bytes), { status: 200, headers });
+ };
+}
diff --git a/packages/api/src/auth/cimd-guard.ts b/packages/api/src/auth/cimd-guard.ts
new file mode 100644
index 00000000..86b8152d
--- /dev/null
+++ b/packages/api/src/auth/cimd-guard.ts
@@ -0,0 +1,28 @@
+/**
+ * The CIMD guard's wiring (`cimd-discovery.ts`): Cloudhal's `rate_limits` budgets, the cap and
+ * its eviction, failure marking on the row, and the caller's trusted address.
+ */
+import type { StaffStore } from "../staff.ts";
+import type { CimdGuard } from "./cimd-discovery.ts";
+import { countCimdClients, evictCimdClient, markCimdFetchFailed } from "./oauth-client-rows.ts";
+import { cimdLimits } from "./oauth-registration.ts";
+import type { AuthOptions } from "./types.ts";
+
+export function cimdGuardFor(
+ options: AuthOptions,
+ staff: StaffStore,
+ runtime: { readonly now: () => Date; readonly ipHeader: string | undefined },
+): CimdGuard {
+ const registration = options.mcpOauth?.registration;
+ return {
+ limits: cimdLimits(registration),
+ clientHosts: registration?.clientHosts,
+ count: () => countCimdClients(options.sql),
+ evict: (input) => evictCimdClient(options.sql, input),
+ markFailed: (clientId) => markCimdFetchFailed(options.sql, clientId),
+ hit: (key, windowMs, max) => staff.hit(key, windowMs, max),
+ // The runtime's trusted address (#165); without one, one shared bucket as elsewhere.
+ address: (headers) => headers?.get(runtime.ipHeader ?? "cf-connecting-ip") ?? "unknown",
+ now: runtime.now,
+ };
+}
diff --git a/packages/api/src/auth/client-identity.ts b/packages/api/src/auth/client-identity.ts
new file mode 100644
index 00000000..27bb3131
--- /dev/null
+++ b/packages/api/src/auth/client-identity.ts
@@ -0,0 +1,288 @@
+/**
+ * RFC-0044 §4 and §6 ("Client impersonation"): what the consent screen and "Connected agents" may
+ * say about a client. A client's name is self-asserted (a metadata document's or a registration's
+ * `client_name`), so it is shown only as plain, short text, and next to the identity the server
+ * can vouch for:
+ *
+ * - `document`: a Client ID Metadata Document client; the document URL's host is its identity.
+ * - `self-registered`: dynamic registration; nothing is verified.
+ * - `server`: a client the server itself stores (none are made today).
+ *
+ * A name that looks like one of the well-known document clients' (`lookalike`: Claude, Claude
+ * Code, ChatGPT, Codex, VS Code) is flagged unless the client is that client's document on its
+ * real host. Names are compared after NFKD without combining marks and a small confusable map
+ * (Cyrillic, Greek, Armenian and Cherokee look-alikes of Latin letters, Latin small capitals,
+ * `0`/`O`, `1`/`l`/`I`); a full UTS #39 skeleton is a follow-up (#144).
+ *
+ * Linear only (AGENTS.md rule 13): one pass over the code points; the only pattern tests one
+ * character at a time.
+ */
+
+/** The longest name shown (as DCR's own limit). */
+export const CLIENT_NAME_MAX = 100;
+
+export type ClientKind = "document" | "self-registered" | "server";
+
+interface ClientRowIdentity {
+ readonly clientId: string;
+ readonly name?: string | null;
+ readonly clientDiscoveryId?: string | null;
+ readonly userId?: string | null;
+ readonly referenceId?: string | null;
+}
+
+/**
+ * The well-known clients that identify themselves with a metadata document (RFC-0044 §11), and
+ * the hosts their documents live on. Cursor is not one: it always registers itself, so its own
+ * name is no sign of impersonation.
+ */
+const WELL_KNOWN: readonly { readonly name: string; readonly hosts: readonly string[] }[] = [
+ { name: "Claude Code", hosts: ["claude.ai", "claude.com"] },
+ { name: "Claude", hosts: ["claude.ai", "claude.com"] },
+ { name: "ChatGPT", hosts: ["chatgpt.com", "openai.com"] },
+ { name: "Codex", hosts: ["chatgpt.com", "openai.com"] },
+ { name: "VS Code", hosts: ["vscode.dev", "github.com"] },
+ { name: "Visual Studio Code", hosts: ["vscode.dev", "github.com"] },
+];
+
+/** At most this many combining marks are kept on one base character. */
+const MAX_MARKS = 2;
+const MARK = /^\p{M}$/u;
+
+/** Code points never shown: controls, bidi controls and marks, zero-width and other format characters. */
+function hidden(cp: number): boolean {
+ if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) return true; // C0, DEL, C1
+ if (cp === 0xad || cp === 0x061c || cp === 0x180e) return true; // soft hyphen, ALM, MVS
+ if (cp >= 0x200b && cp <= 0x200f) return true; // zero-width, LRM, RLM
+ if (cp >= 0x202a && cp <= 0x202e) return true; // bidi embeddings and overrides
+ if (cp >= 0x2060 && cp <= 0x206f) return true; // word joiner, invisible operators, isolates
+ if (cp === 0xfeff || (cp >= 0xfff9 && cp <= 0xfffb)) return true; // BOM, annotations
+ if (cp >= 0xe0000 && cp <= 0xe007f) return true; // tag characters
+ return false;
+}
+
+/** Space characters, collapsed to one ASCII space (U+2003 and friends included). */
+function space(cp: number): boolean {
+ return (
+ cp === 0x20 ||
+ cp === 0xa0 ||
+ cp === 0x1680 ||
+ (cp >= 0x2000 && cp <= 0x200a) ||
+ cp === 0x2028 ||
+ cp === 0x2029 ||
+ cp === 0x202f ||
+ cp === 0x205f ||
+ cp === 0x3000
+ );
+}
+
+/**
+ * A client name as it may be shown: hidden characters removed, runs of spaces collapsed, trimmed,
+ * at most `CLIENT_NAME_MAX` code points; `fallback` when nothing is left.
+ */
+export function displayName(raw: string | null | undefined, fallback: string): string {
+ let out = "";
+ let length = 0;
+ let pendingSpace = false;
+ let marks = 0;
+ for (const ch of raw ?? "") {
+ const cp = ch.codePointAt(0) ?? 0;
+ if (hidden(cp)) continue;
+ if (space(cp)) {
+ pendingSpace = out.length > 0;
+ marks = 0;
+ continue;
+ }
+ if (MARK.test(ch)) {
+ // Stacked marks render tall over the lines around the name: a few per character only.
+ if (out.length === 0 || pendingSpace || marks >= MAX_MARKS) continue;
+ marks += 1;
+ } else marks = 0;
+ if (pendingSpace) {
+ if (length + 1 >= CLIENT_NAME_MAX) break;
+ out += " ";
+ length += 1;
+ pendingSpace = false;
+ }
+ if (length >= CLIENT_NAME_MAX) break;
+ out += ch;
+ length += 1;
+ }
+ return out || fallback.slice(0, CLIENT_NAME_MAX);
+}
+
+export function clientKind(row: ClientRowIdentity): ClientKind {
+ if (row.clientDiscoveryId === "cimd") return "document";
+ if (!row.clientDiscoveryId && !row.userId && !row.referenceId) return "self-registered";
+ return "server";
+}
+
+/** Look-alikes of Latin letters and digits, after NFKC (which already folds fullwidth forms). */
+const CONFUSABLE: Readonly> = {
+ // Cyrillic
+ а: "a",
+ в: "b",
+ с: "c",
+ е: "e",
+ һ: "h",
+ і: "i",
+ ј: "j",
+ к: "k",
+ ӏ: "l",
+ м: "m",
+ н: "h",
+ о: "o",
+ р: "p",
+ ԛ: "q",
+ ѕ: "s",
+ т: "t",
+ и: "u",
+ ѵ: "v",
+ ԝ: "w",
+ х: "x",
+ у: "y",
+ А: "a",
+ В: "b",
+ С: "c",
+ Е: "e",
+ Н: "h",
+ І: "i",
+ Ј: "j",
+ К: "k",
+ М: "m",
+ О: "o",
+ Р: "p",
+ Ѕ: "s",
+ Т: "t",
+ Х: "x",
+ У: "y",
+ Ү: "y",
+ // Greek
+ α: "a",
+ β: "b",
+ ε: "e",
+ η: "n",
+ ι: "i",
+ κ: "k",
+ ν: "v",
+ ο: "o",
+ ρ: "p",
+ τ: "t",
+ υ: "u",
+ χ: "x",
+ γ: "y",
+ Α: "a",
+ Β: "b",
+ Ε: "e",
+ Ζ: "z",
+ Η: "h",
+ Ι: "i",
+ Κ: "k",
+ Μ: "m",
+ Ν: "n",
+ Ο: "o",
+ Ρ: "p",
+ Τ: "t",
+ Υ: "y",
+ Χ: "x",
+ // Armenian
+ ս: "u",
+ օ: "o",
+ ո: "n",
+ հ: "h",
+ ց: "g",
+ զ: "q",
+ // Latin small capitals and other look-alikes outside a-z
+ ᴀ: "a",
+ ʙ: "b",
+ ᴄ: "c",
+ ᴅ: "d",
+ ᴇ: "e",
+ ɢ: "g",
+ ʜ: "h",
+ ɪ: "i",
+ ᴊ: "j",
+ ᴋ: "k",
+ ʟ: "l",
+ ᴍ: "m",
+ ɴ: "n",
+ ᴏ: "o",
+ ᴘ: "p",
+ ʀ: "r",
+ ꜱ: "s",
+ ᴛ: "t",
+ ᴜ: "u",
+ ᴠ: "v",
+ ᴡ: "w",
+ ʏ: "y",
+ ᴢ: "z",
+ ɑ: "a",
+ ı: "i",
+ ȷ: "j",
+ // Cherokee
+ Ꭺ: "a",
+ Ᏼ: "b",
+ Ꮯ: "c",
+ Ꭰ: "d",
+ Ꭼ: "e",
+ Ꮐ: "g",
+ Ꮋ: "h",
+ Ꭻ: "j",
+ Ꮶ: "k",
+ Ꮮ: "l",
+ Ꮇ: "m",
+ Ꮲ: "p",
+ Ꮪ: "s",
+ Ꭲ: "t",
+ Ꮩ: "v",
+ Ꮃ: "w",
+ Ꮓ: "z",
+ // Digits and letters that pass for one another
+ "0": "o",
+ "1": "l",
+ I: "l",
+ "|": "l",
+};
+
+/**
+ * NFKD with its combining marks dropped (`Cláude`, `Clȧude` → `Claude`; fullwidth forms fold
+ * too), confusables mapped, then lower-case letters and digits only (`V S-Code` = `vscode`).
+ */
+function folded(text: string): string {
+ let out = "";
+ for (const raw of text.normalize("NFKD")) {
+ if (MARK.test(raw)) continue;
+ const ch = (CONFUSABLE[raw] ?? raw).toLowerCase();
+ if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) out += ch;
+ }
+ return out;
+}
+
+const onHost = (host: string, hosts: readonly string[]) =>
+ hosts.some((h) => host === h || host.endsWith(`.${h}`));
+
+/**
+ * The well-known document client a name looks like (`Сlaude · from claude.ai` → `Claude`), unless
+ * the client is a metadata document on that client's own host; undefined for a server client.
+ */
+export function lookalikeOf(name: string, row: ClientRowIdentity): string | undefined {
+ const kind = clientKind(row);
+ if (kind === "server") return undefined;
+ const f = folded(name);
+ const known = WELL_KNOWN.find((k) => f.includes(folded(k.name)));
+ if (!known) return undefined;
+ const host = kind === "document" ? (documentHost(row) ?? "").toLowerCase() : "";
+ return kind === "document" && onHost(host.split(":")[0] ?? "", known.hosts)
+ ? undefined
+ : known.name;
+}
+
+/** The document host of a `document` client (`claude.ai`), else undefined. */
+export function documentHost(row: ClientRowIdentity): string | undefined {
+ if (clientKind(row) !== "document") return undefined;
+ try {
+ return new URL(row.clientId).host;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/api/src/auth/dcr-server.ts b/packages/api/src/auth/dcr-server.ts
new file mode 100644
index 00000000..bbbf87de
--- /dev/null
+++ b/packages/api/src/auth/dcr-server.ts
@@ -0,0 +1,67 @@
+/**
+ * RFC-0044 §3: `OauthServer.register` when DCR is on: Cloudhal's checks (`dcr.ts`), then the
+ * provider's `registerOAuthClient` called server-side with empty headers (no session, no initial
+ * access token: an anonymous public client). A provider refusal becomes a `RegistrationError`
+ * with the provider's RFC 7591 error body.
+ */
+import { APIError } from "better-auth/api";
+import { RegistrationError, registerDcrClient } from "./dcr.ts";
+import type { AuthDeps } from "./deps.ts";
+import type { McpOauthServerOptions } from "./oauth.ts";
+import { dcrLimits } from "./oauth-registration.ts";
+import type { OauthServer } from "./types.ts";
+
+type RegisterApi = {
+ registerOAuthClient(input: {
+ body: Record;
+ headers: Headers;
+ }): Promise>;
+};
+
+/** The `register` member of `OauthServer`, or nothing with DCR off. */
+export function oauthClientRegistration(
+ d: AuthDeps,
+ options: McpOauthServerOptions,
+): Pick {
+ const registration = options.registration;
+ if (registration?.dcr !== true) return {};
+ const api = d.instance.api as unknown as RegisterApi;
+ const register = async (body: Record) => {
+ try {
+ return await api.registerOAuthClient({ body, headers: new Headers() });
+ } catch (e) {
+ if (e instanceof APIError) {
+ const b = (e.body ?? {}) as {
+ error?: unknown;
+ error_description?: unknown;
+ message?: unknown;
+ };
+ const error = typeof b.error === "string" ? b.error : "invalid_client_metadata";
+ const description =
+ typeof b.error_description === "string"
+ ? b.error_description
+ : typeof b.message === "string"
+ ? b.message
+ : "the client metadata was refused";
+ throw new RegistrationError(400, error, description);
+ }
+ throw e;
+ }
+ };
+ return {
+ register: (input) =>
+ registerDcrClient(
+ {
+ sql: d.sql,
+ staff: d.staffStore,
+ limits: dcrLimits(registration),
+ policy: {
+ clientHosts: registration.clientHosts,
+ schemes: registration.redirectSchemes,
+ },
+ register,
+ },
+ input,
+ ),
+ };
+}
diff --git a/packages/api/src/auth/dcr.ts b/packages/api/src/auth/dcr.ts
new file mode 100644
index 00000000..874029ce
--- /dev/null
+++ b/packages/api/src/auth/dcr.ts
@@ -0,0 +1,140 @@
+/**
+ * RFC-0044 §3: dynamic client registration (RFC 7591), the limited fallback for clients without a
+ * Client ID Metadata Document (Cursor). The endpoint is anonymous, so Cloudhal checks everything
+ * before the provider sees the request, then registers the client server-side with a clean body
+ * (no cookie, no `Authorization`, so never as a member and never with an initial access token):
+ *
+ * 1. rate limits in `rate_limits` (#173): 10 per client address per hour and 300 per installation
+ * per day, counted for every attempt, valid or not;
+ * 2. at most 5,000 registered clients (503 beyond it), pruned after 30 unused days;
+ * 3. the metadata: `client_name` 1–100 characters; `redirect_uris` 1–10, each https, loopback
+ * http or a native app's private-use scheme (`redirect-policy.ts`; on
+ * `CLOUDHAL_MCP_CLIENT_HOSTS` when set), one bad entry refusing the whole request; public clients only
+ * (`token_endpoint_auth_method` `none`, also when absent: no secret is ever minted);
+ * `grant_types` ⊆ {`authorization_code`, `refresh_token`} with `authorization_code`;
+ * `response_types` = [`code`]. Only these fields, `application_type` and `scope` reach the
+ * provider: logos, URIs, contacts, JWKS and software statements are dropped, never stored.
+ *
+ * Errors are RFC 7591 §3.2.2 bodies (`invalid_client_metadata`, `invalid_redirect_uri`).
+ */
+
+import type { SqlDriver } from "@cloudhal/storage";
+import type { StaffStore } from "../staff.ts";
+import { countDcrClients } from "./oauth-client-rows.ts";
+import type { DcrLimits } from "./oauth-registration.ts";
+import { type RedirectPolicy, redirectUrisProblem } from "./redirect-policy.ts";
+
+/** RFC-0044 §3: "Body ≤ 5 KB". */
+export const DCR_MAX_BODY_BYTES = 5 * 1024;
+const NAME_MAX = 100;
+const GRANTS: ReadonlySet = new Set(["authorization_code", "refresh_token"]);
+
+/** A refusal with its HTTP status and RFC 7591 / RFC 6749 error body. */
+export class RegistrationError extends Error {
+ override readonly name = "RegistrationError";
+ readonly status: 400 | 413 | 429 | 503;
+ readonly error: string;
+ constructor(status: RegistrationError["status"], error: string, description: string) {
+ super(description);
+ this.status = status;
+ this.error = error;
+ }
+ body(): { error: string; error_description: string } {
+ return { error: this.error, error_description: this.message };
+ }
+}
+
+const invalid = (description: string) =>
+ new RegistrationError(400, "invalid_client_metadata", description);
+
+/** The body the provider registers: the checked fields only. */
+export function registrationBody(raw: unknown, policy: RedirectPolicy): Record {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
+ throw invalid("the body must be a JSON object");
+ const m = raw as Record;
+ const name = typeof m.client_name === "string" ? m.client_name.trim() : "";
+ if (name.length === 0 || name.length > NAME_MAX)
+ throw invalid(`client_name is required (1–${NAME_MAX} characters)`);
+ const redirect = redirectUrisProblem(m.redirect_uris, policy);
+ if (redirect) throw new RegistrationError(400, "invalid_redirect_uri", redirect);
+ const auth = m.token_endpoint_auth_method ?? "none";
+ if (auth !== "none")
+ throw invalid("only public clients may register here (token_endpoint_auth_method none)");
+ const grants = m.grant_types === undefined ? ["authorization_code"] : m.grant_types;
+ if (!Array.isArray(grants) || !grants.every((g) => typeof g === "string"))
+ throw invalid("grant_types must be a list of strings");
+ // Clients send one grant union to every server: keep what this server issues.
+ const kept = [...new Set(grants.filter((g) => GRANTS.has(g)))];
+ if (!kept.includes("authorization_code"))
+ throw invalid("grant_types must include authorization_code");
+ if (
+ m.response_types !== undefined &&
+ !(
+ Array.isArray(m.response_types) &&
+ m.response_types.length === 1 &&
+ m.response_types[0] === "code"
+ )
+ )
+ throw invalid('response_types must be ["code"]');
+ if (
+ m.application_type !== undefined &&
+ m.application_type !== "web" &&
+ m.application_type !== "native"
+ )
+ throw invalid("application_type must be web or native");
+ if (m.scope !== undefined && (typeof m.scope !== "string" || m.scope.length > 1000))
+ throw invalid("scope must be a space-separated string");
+ return {
+ client_name: name,
+ redirect_uris: [...(m.redirect_uris as string[])],
+ token_endpoint_auth_method: "none",
+ grant_types: kept,
+ response_types: ["code"],
+ ...(m.application_type !== undefined ? { application_type: m.application_type } : {}),
+ ...(typeof m.scope === "string" && m.scope.trim() ? { scope: m.scope.trim() } : {}),
+ };
+}
+
+export interface DcrDeps {
+ readonly sql: SqlDriver;
+ readonly staff: StaffStore;
+ readonly limits: DcrLimits;
+ /** Redirect rules: the host list and the extra private-use schemes. */
+ readonly policy: RedirectPolicy;
+ /** The provider's server-side `registerOAuthClient` (no HTTP, no session). */
+ readonly register: (body: Record) => Promise>;
+}
+
+/** Registers one public client for an anonymous caller at `address`; RFC 7591's response body. */
+export async function registerDcrClient(
+ d: DcrDeps,
+ input: { readonly text: string; readonly address: string },
+): Promise> {
+ const { limits } = d;
+ if (
+ !(await d.staff.hit(`cloudhal:dcr:addr:${input.address}`, 3_600_000, limits.perAddressPerHour))
+ )
+ throw new RegistrationError(429, "rate_limited", "too many registrations; try again later");
+ if (!(await d.staff.hit("cloudhal:dcr:day", 86_400_000, limits.perDay)))
+ throw new RegistrationError(
+ 429,
+ "rate_limited",
+ "this server takes no more registrations today; try again tomorrow",
+ );
+ if (new TextEncoder().encode(input.text).byteLength > DCR_MAX_BODY_BYTES)
+ throw new RegistrationError(413, "invalid_client_metadata", "the metadata is larger than 5 KB");
+ let raw: unknown;
+ try {
+ raw = JSON.parse(input.text);
+ } catch {
+ throw invalid("the body must be JSON");
+ }
+ const body = registrationBody(raw, d.policy);
+ if ((await countDcrClients(d.sql)) >= limits.maxClients)
+ throw new RegistrationError(
+ 503,
+ "temporarily_unavailable",
+ "this server has too many registered clients; try again later",
+ );
+ return d.register(body);
+}
diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts
index 8ebf91f3..87cd0f50 100644
--- a/packages/api/src/auth/instance.ts
+++ b/packages/api/src/auth/instance.ts
@@ -5,6 +5,7 @@ import { APIError, createAuthMiddleware } from "better-auth/api";
import { admin, organization } from "better-auth/plugins";
import { createAccessControl } from "better-auth/plugins/access";
import type { StaffStore } from "../staff.ts";
+import { cimdGuardFor } from "./cimd-guard.ts";
import type { GrantStore } from "./grants.ts";
import { betterAuthLogger, oauthEndpoints, oauthPlugins } from "./oauth.ts";
import { oauthBeforeHook } from "./oauth-hooks.ts";
@@ -72,9 +73,8 @@ export function createInstance(
// RFC-0044 §2: the authorization server, when the runtime has a stable public origin.
const oauth = options.mcpOauth
? oauthPlugins(oauthEndpoints(options.baseURL).issuer, options.mcpOauth, {
- fromServer: (headers) =>
- constantTimeEqual(headers.get(SERVER_KEY_HEADER) ?? "", internal.serverKey),
grantOf: async (userId, clientId) => (await internal.grants.live(userId, clientId))?.id,
+ cimdGuard: cimdGuardFor(options, staff, { now: clock, ipHeader }),
})
: [];
const instance = betterAuth({
@@ -110,9 +110,18 @@ export function createInstance(
throw new APIError("FORBIDDEN", { message: "consent is given on Studio's screen" });
return;
}
+ // RFC-0044 §3: clients register through Cloudhal's checked route, which calls the
+ // provider server-side (no request); the provider's endpoint itself is never reached.
+ if (ctx.path === "/oauth2/register" && ctx.request)
+ throw new APIError("NOT_FOUND", { message: "not found" });
// RFC-0044: stale consent memory, DPoP and the refresh family's absolute lifetime.
if (options.mcpOauth && OAUTH_HOOK_PATHS.has(ctx.path))
- return oauthBeforeHook(ctx as never, { grants: internal.grants, staff, now: clock });
+ return oauthBeforeHook(ctx as never, {
+ grants: internal.grants,
+ staff,
+ now: clock,
+ dcr: options.mcpOauth.registration?.dcr === true,
+ });
if (!ctx.path.startsWith("/sign-up") || mode !== "first-user") return;
const existing = await ctx.context.adapter.count({ model: "user" });
if (existing > 0)
@@ -198,10 +207,7 @@ export function createInstance(
/** Per-instance secrets of the server's own internal calls (never sent to a client). */
export interface InternalKeys {
- /**
- * Marks calls the server makes to the provider itself: the consent `AgentGrantService.consent`
- * forwards, and server-side client registration (`OauthServer.createClient`).
- */
+ /** Marks the server's own call to the provider: the consent `AgentGrantService.consent` forwards. */
readonly serverKey: string;
/** The `agent_grants` rows (RFC-0044 §5): the token claim and the authorize checks read them. */
readonly grants: GrantStore;
diff --git a/packages/api/src/auth/oauth-client-rows.ts b/packages/api/src/auth/oauth-client-rows.ts
new file mode 100644
index 00000000..ab3f3a57
--- /dev/null
+++ b/packages/api/src/auth/oauth-client-rows.ts
@@ -0,0 +1,180 @@
+/**
+ * RFC-0044 §3: the OAuth client rows anonymous callers can make the server store, and their
+ * bounds. Dynamic registration (`dcr.ts`) and metadata documents (`cimd-discovery.ts`) each have a
+ * cap, and the hourly `oauth.clients.prune` job (`oauth-prune-job.ts`) deletes the unused ones with
+ * every provider row of theirs in one atomic batch per kind.
+ */
+import type { SqlDriver } from "@cloudhal/storage";
+import { CIMD_LIMITS, DCR_LIMITS } from "./oauth-registration.ts";
+
+/**
+ * A dynamically registered client: no discovery source (CIMD sets `cimd`) and no owner (the
+ * provider records the member or reference only for registrations made with a session or an
+ * initial access token, which this server never accepts).
+ */
+export const dcrClient = (t = "") =>
+ `${t}"clientDiscoveryId" IS NULL AND ${t}"userId" IS NULL AND ${t}"referenceId" IS NULL`;
+
+/** How many dynamically registered clients exist (the cap, §3). */
+export async function countDcrClients(sql: SqlDriver): Promise {
+ const row = await sql.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM "oauthClient" WHERE ${dcrClient()}`,
+ );
+ return Number(row?.n ?? 0);
+}
+
+/** The most clients one pruning pass deletes (D1 prefers short statements). */
+export const PRUNE_BATCH = 50;
+
+/** The clients a metadata document made (`cimd()` stores them under its discovery id). */
+export const cimdClient = (t = "") => `${t}"clientDiscoveryId" = 'cimd'`;
+
+/** How many document clients are stored (the cap). */
+export async function countCimdClients(sql: SqlDriver): Promise {
+ const row = await sql.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM "oauthClient" WHERE ${cimdClient()}`,
+ );
+ return Number(row?.n ?? 0);
+}
+
+interface PruneKind {
+ readonly where: (alias: string) => string;
+ readonly since: "createdAt" | "updatedAt";
+ readonly cutoff: string;
+ readonly limit: number;
+ /** Only these clients (eviction's pick), each still checked against the predicate. */
+ readonly ids?: readonly string[] | undefined;
+}
+
+/**
+ * Deletes unused clients of one kind, at most `limit`: no live connection, no connection made,
+ * used or ended since `cutoff`, and the client's own row older than it (`since`: when it was
+ * registered, or when its document was last fetched). A client with a live connection is never
+ * deleted, however idle: ending it is its member's decision ("Connected agents"). Every row the
+ * provider keeps for the client goes in the same atomic batch, each statement selecting the same
+ * clients (the predicate reads only `oauthClient` and `agent_grants`, which the batch changes
+ * last). Returns how many were deleted.
+ */
+async function pruneKind(sql: SqlDriver, kind: PruneKind): Promise {
+ const ids = kind.ids ?? [];
+ const only = ids.length ? ` AND c."clientId" IN (${ids.map(() => "?").join(", ")})` : "";
+ const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE ${kind.where("c.")} AND c."${kind.since}" < ?${only} AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."${kind.since}", c."clientId" LIMIT ${Math.max(1, Math.floor(kind.limit))}`;
+ const { cutoff } = kind;
+ const params = [cutoff, ...ids, cutoff, cutoff, cutoff];
+ const code = (path: string) =>
+ `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`;
+ const results = await sql.batch([
+ { sql: `DELETE FROM "oauthAccessToken" WHERE "clientId" IN (${prunable})`, params },
+ { sql: `DELETE FROM "oauthRefreshToken" WHERE "clientId" IN (${prunable})`, params },
+ { sql: `DELETE FROM "oauthConsent" WHERE "clientId" IN (${prunable})`, params },
+ { sql: `DELETE FROM "oauthClientResource" WHERE "clientId" IN (${prunable})`, params },
+ {
+ sql: `DELETE FROM "verification" WHERE "value" LIKE '%authorization_code%' AND ${code("$.type")} = 'authorization_code' AND ${code("$.query.client_id")} IN (${prunable})`,
+ params,
+ },
+ { sql: `DELETE FROM "oauthClient" WHERE "clientId" IN (${prunable})`, params },
+ ]);
+ return results.at(-1)?.changes ?? 0;
+}
+
+const hoursBefore = (now: Date, hours: number) =>
+ new Date(now.getTime() - hours * 3_600_000).toISOString();
+
+/** Dynamically registered clients unused for `unusedDays` since they registered (§3: 30). */
+export function pruneDcrClients(
+ sql: SqlDriver,
+ options: { readonly now: Date; readonly unusedDays?: number },
+): Promise {
+ const days = options.unusedDays ?? DCR_LIMITS.unusedDays;
+ return pruneKind(sql, {
+ where: dcrClient,
+ since: "createdAt",
+ cutoff: hoursBefore(options.now, days * 24),
+ limit: PRUNE_BATCH,
+ });
+}
+
+/** Batches one pruning run deletes document clients in, at most (a burst drains in hours). */
+export const CIMD_PRUNE_BATCHES = 4;
+
+/**
+ * Document clients nobody connected through and nobody fetched for `unusedHours` (default 3),
+ * in up to `CIMD_PRUNE_BATCHES` batches while each comes back full: the next authorization
+ * fetches the document and stores the client again.
+ */
+export async function pruneCimdClients(
+ sql: SqlDriver,
+ options: { readonly now: Date; readonly unusedHours?: number },
+): Promise {
+ const cutoff = hoursBefore(options.now, options.unusedHours ?? CIMD_LIMITS.unusedHours);
+ let total = 0;
+ for (let i = 0; i < CIMD_PRUNE_BATCHES; i++) {
+ const n = await pruneKind(sql, {
+ where: cimdClient,
+ since: "updatedAt",
+ cutoff,
+ limit: PRUNE_BATCH,
+ });
+ total += n;
+ if (n < PRUNE_BATCH) break;
+ }
+ return total;
+}
+
+/** How many of the oldest unused document clients eviction looks at for a non-exempt one. */
+export const EVICT_CANDIDATES = 50;
+
+/**
+ * Makes room for one document client (the cap): deletes the oldest one with no live connection
+ * and no use since `before` whose **host** is not exempt. The host is read from the URL, never
+ * matched with `LIKE` over the whole `client_id` (`%` also spans `/`, so a path could pass for
+ * an exempt host). The candidates are read first; the pick is deleted in one atomic batch that
+ * checks the predicate again. How many went (0 or 1).
+ */
+export async function evictCimdClient(
+ sql: SqlDriver,
+ input: { readonly before: Date; readonly isExempt: (host: string) => boolean },
+): Promise {
+ const cutoff = input.before.toISOString();
+ const candidates = await sql.all<{ clientId: string }>(
+ `SELECT c."clientId" FROM "oauthClient" c WHERE ${cimdClient("c.")} AND c."updatedAt" < ? AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."updatedAt", c."clientId" LIMIT ${EVICT_CANDIDATES}`,
+ [cutoff, cutoff, cutoff, cutoff],
+ );
+ const victim = candidates.find((r) => !input.isExempt(hostnameOf(r.clientId)));
+ if (!victim) return 0;
+ return pruneKind(sql, {
+ where: cimdClient,
+ since: "updatedAt",
+ cutoff,
+ limit: 1,
+ ids: [victim.clientId],
+ });
+}
+
+/** A client id's host name, lower case; "" when it is not a URL (never exempt). */
+function hostnameOf(clientId: string): string {
+ try {
+ return new URL(clientId).hostname.toLowerCase();
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Records a failed refetch of a document client on its row: the validation time is cleared, so
+ * the row is never served as fresh or stale again until a fetch succeeds.
+ */
+export async function markCimdFetchFailed(sql: SqlDriver, clientId: string): Promise {
+ await sql.run(
+ `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" = ? AND ${cimdClient()}`,
+ [new Date(0).toISOString(), clientId],
+ );
+}
+
+/** Both kinds, as the hourly job runs them; how many of each were deleted. */
+export async function pruneUnusedClients(
+ sql: SqlDriver,
+ options: { readonly now: Date },
+): Promise<{ dcr: number; cimd: number }> {
+ return { dcr: await pruneDcrClients(sql, options), cimd: await pruneCimdClients(sql, options) };
+}
diff --git a/packages/api/src/auth/oauth-clients.ts b/packages/api/src/auth/oauth-clients.ts
deleted file mode 100644
index 255c9310..00000000
--- a/packages/api/src/auth/oauth-clients.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * RFC-0044 §3 (pre-registered clients): a public client created by the server itself. Phase 2
- * has no registration endpoint (CIMD and DCR are Phase 3), so this is how tests, and later
- * `cloudhal mcp clients create`, get a `client_id`. The provider's `adminCreateOAuthClient` runs
- * with the member's session and the server key, the only caller `clientPrivileges` admits.
- */
-import type { AuthDeps } from "./deps.ts";
-import { SERVER_KEY_HEADER } from "./instance.ts";
-import { OAUTH_SCOPES } from "./oauth.ts";
-import type { OauthClientInput } from "./types.ts";
-
-function isLoopback(uri: string): boolean {
- try {
- return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(uri).hostname);
- } catch {
- return false;
- }
-}
-
-export async function createOauthClient(
- d: AuthDeps,
- input: OauthClientInput,
-): Promise<{ clientId: string }> {
- if (!d.endpoints) throw new Error("the authorization server is off");
- const headers = new Headers(input.headers);
- headers.set(SERVER_KEY_HEADER, d.internal.serverKey);
- const api = d.instance.api as unknown as {
- adminCreateOAuthClient(input: {
- headers: Headers;
- body: Record;
- }): Promise<{ client_id: string }>;
- };
- const created = await api.adminCreateOAuthClient({
- headers,
- body: {
- client_name: input.name,
- redirect_uris: [...input.redirectUris],
- token_endpoint_auth_method: "none",
- grant_types: ["authorization_code", "refresh_token"],
- response_types: ["code"],
- // RFC 8252: a loopback callback is a native app's; `web` needs HTTPS redirect URIs.
- application_type: input.redirectUris.every(isLoopback) ? "native" : "web",
- scope: OAUTH_SCOPES.join(" "),
- resources: [d.endpoints.resource],
- },
- });
- return { clientId: created.client_id };
-}
diff --git a/packages/api/src/auth/oauth-hooks.ts b/packages/api/src/auth/oauth-hooks.ts
index c8b1a033..5bacb7fd 100644
--- a/packages/api/src/auth/oauth-hooks.ts
+++ b/packages/api/src/auth/oauth-hooks.ts
@@ -8,6 +8,8 @@
* the member sees Studio's consent screen again.
* The request's parameters are read from the query, or from the form body of a POST, as the
* provider reads them.
+ * - `/oauth2/authorize` with `CLOUDHAL_MCP_DCR=off`: a client dynamic registration made earlier
+ * is refused (no new authorization; connections it already has keep working until revoked).
* - `/oauth2/token`: no DPoP in v1 (ADR-0010), so a `DPoP` proof is refused (400) instead of
* minting a sender-constrained token `/mcp` would not check, or falling back to Bearer. A refresh
* token is refused when its family began before the live grant (a revoke, then a reconnect: the
@@ -28,6 +30,8 @@ export interface OauthHookDeps {
readonly grants: GrantStore;
readonly staff: StaffStore;
readonly now: () => Date;
+ /** Dynamic registration is on (`CLOUDHAL_MCP_DCR`); off, its clients cannot authorize. */
+ readonly dcr: boolean;
}
type Where = { field: string; value: unknown }[];
@@ -55,7 +59,10 @@ interface HookContext {
/** Runs the checks for `ctx.path`; throws the OAuth error the provider would. */
export async function oauthBeforeHook(ctx: HookContext, d: OauthHookDeps): Promise {
- if (ctx.path === "/oauth2/authorize") return forgetStaleConsent(ctx, d);
+ if (ctx.path === "/oauth2/authorize") {
+ await refuseDcrClientWhenOff(ctx, d);
+ return forgetStaleConsent(ctx, d);
+ }
if (ctx.path === "/oauth2/token") return checkTokenRequest(ctx, d);
}
@@ -75,13 +82,43 @@ export async function consentIsStale(
});
}
-async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise {
- // Exactly as the provider reads them (`getAuthorizationRequestParameters`): the form body only
- // for an HTTP `POST /oauth2/authorize`; when it resumes authorize after a sign-in or a consent
- // (a POST whose body is that request's), the parameters are in the query.
+/**
+ * The authorization request's parameters exactly as the provider reads them
+ * (`getAuthorizationRequestParameters`): the form body only for an HTTP `POST /oauth2/authorize`;
+ * when it resumes authorize after a sign-in or a consent (a POST whose body is that request's),
+ * the query.
+ */
+function authorizeParams(ctx: HookContext): Record {
const settings = ctx.authorizeSettings ?? { isAuthorize: true };
const fromBody = ctx.method === "POST" && settings.isAuthorize === true;
- const params = (fromBody ? ctx.body : ctx.query) ?? {};
+ return (fromBody ? ctx.body : ctx.query) ?? {};
+}
+
+interface ClientOwnerRow {
+ clientDiscoveryId?: string | null;
+ userId?: string | null;
+ referenceId?: string | null;
+}
+
+/** With DCR off, a client it registered (no discovery source, no owner) starts nothing new. */
+async function refuseDcrClientWhenOff(ctx: HookContext, d: OauthHookDeps): Promise {
+ if (d.dcr) return;
+ const clientId = authorizeParams(ctx).client_id;
+ if (typeof clientId !== "string" || !clientId) return;
+ const row = await ctx.context.adapter.findOne({
+ model: "oauthClient",
+ where: [{ field: "clientId", value: clientId }],
+ });
+ if (row && !row.clientDiscoveryId && !row.userId && !row.referenceId)
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client",
+ error_description:
+ "dynamic client registration is off on this server, so this client cannot connect",
+ });
+}
+
+async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise {
+ const params = authorizeParams(ctx);
const clientId = params.client_id;
if (typeof clientId !== "string" || !clientId) return;
const session = await getSessionFromCtx(ctx as never).catch(() => null);
diff --git a/packages/api/src/auth/oauth-registration.ts b/packages/api/src/auth/oauth-registration.ts
new file mode 100644
index 00000000..f2b88243
--- /dev/null
+++ b/packages/api/src/auth/oauth-registration.ts
@@ -0,0 +1,173 @@
+/**
+ * RFC-0044 §3 (Phase 3): how OAuth clients that were not created by the server get a
+ * `client_id`, and the operator's switches for it (§10: Worker vars, or the environment on Node).
+ *
+ * - **CIMD** (`CLOUDHAL_MCP_CIMD`, on unless `off`): a client names an HTTPS URL as its
+ * `client_id`; the server fetches the document there through the runtime's SSRF-safe transport
+ * (`cimd-fetch.ts`), at most hourly per client (`cimd-discovery.ts`), with a cap and pruning.
+ * - **DCR** (`CLOUDHAL_MCP_DCR`, on unless `off`): RFC 7591 `POST /api/auth/oauth2/register`,
+ * public clients only, with rate limits, a cap and pruning (`dcr.ts`, `oauth-client-rows.ts`).
+ * Off, the clients it registered earlier cannot start a new authorization either.
+ * - `CLOUDHAL_MCP_CLIENT_HOSTS` limits the hosts documents come from and every redirect goes to;
+ * `CLOUDHAL_MCP_REDIRECT_SCHEMES` adds private-use redirect schemes (`redirect-policy.ts`).
+ *
+ * With both off, no client registered by either path can start an authorization.
+ */
+import type { ClientMetadataResourceFetch } from "@better-auth/oauth-provider";
+import { schemeProblem } from "./redirect-policy.ts";
+
+export interface DcrLimits {
+ /** Registrations per client address per hour (§3: 10). */
+ readonly perAddressPerHour: number;
+ /** Registrations per installation per day (§3: 300). */
+ readonly perDay: number;
+ /** Dynamically registered clients that may exist at once (§3: 5,000; beyond it 503). */
+ readonly maxClients: number;
+ /** A client nobody used for this many days is deleted by the hourly job (§3: 30). */
+ readonly unusedDays: number;
+}
+
+export const DCR_LIMITS: DcrLimits = {
+ perAddressPerHour: 10,
+ perDay: 300,
+ maxClients: 5_000,
+ unusedDays: 30,
+};
+
+export interface CimdLimits {
+ /** Metadata fetches per caller address per minute, whoever the client is (past it: 429). */
+ readonly perAddressPerMinute: number;
+ /** Fetches of documents not stored yet, per client origin and in all, per minute. */
+ readonly perOriginPerMinute: number;
+ readonly perMinute: number;
+ /** Document clients stored at once; a new one beyond it evicts an unused one, or gets 503. */
+ readonly maxClients: number;
+ /** A client fetched within this many minutes is never evicted to make room. */
+ readonly evictMinutes: number;
+ /** A stored document younger than this is used without fetching it again (minutes). */
+ readonly freshMinutes: number;
+ /** The oldest a stored row may be served when the fetch is declined (never after a failure). */
+ readonly maxStaleHours: number;
+ /** A document client with no connection and no fetch for this many hours is deleted. */
+ readonly unusedHours: number;
+}
+
+/**
+ * Outbound metadata fetches and stored document clients, counted across every isolate in
+ * Cloudhal's `rate_limits`. A stored client is fetched at most once per `freshMinutes`; only
+ * documents not stored yet spend the shared per-origin budget, so anonymous callers cannot
+ * starve the clients members already use. The next authorization recreates a pruned or evicted
+ * client from its document, so unused rows go after hours.
+ */
+export const CIMD_LIMITS: CimdLimits = {
+ perAddressPerMinute: 20,
+ perOriginPerMinute: 30,
+ perMinute: 120,
+ maxClients: 5_000,
+ evictMinutes: 10,
+ freshMinutes: 60,
+ maxStaleHours: 24,
+ unusedHours: 3,
+};
+
+export interface OauthRegistration {
+ /** CIMD's transport (`cimdFetch(...)`); absent: CIMD is off. */
+ readonly cimd?: ClientMetadataResourceFetch | undefined;
+ /** Dynamic client registration; absent or false: `/register` is not served or advertised. */
+ readonly dcr?: boolean | undefined;
+ /**
+ * `CLOUDHAL_MCP_CLIENT_HOSTS`: hosts a CIMD document may be served from, and hosts a DCR
+ * client may redirect to (`localhost` admits loopback redirects). Empty: any host.
+ */
+ readonly clientHosts?: readonly string[] | undefined;
+ /**
+ * `CLOUDHAL_MCP_REDIRECT_SCHEMES`: private-use redirect schemes allowed besides the built-in
+ * ones (`cursor`, `vscode`) and reverse-domain schemes (`redirect-policy.ts`).
+ */
+ readonly redirectSchemes?: readonly string[] | undefined;
+ /** Overrides of `DCR_LIMITS` (tests). */
+ readonly limits?: Partial | undefined;
+ /** Overrides of `CIMD_LIMITS` (tests). */
+ readonly cimdLimits?: Partial | undefined;
+}
+
+/** The variables both runtimes read (RFC-0044 §10). */
+export interface OauthRegistrationEnv {
+ readonly CLOUDHAL_MCP_CIMD?: string | undefined;
+ readonly CLOUDHAL_MCP_DCR?: string | undefined;
+ readonly CLOUDHAL_MCP_CLIENT_HOSTS?: string | undefined;
+ readonly CLOUDHAL_MCP_REDIRECT_SCHEMES?: string | undefined;
+}
+
+const off = (value: string | undefined) => value?.trim().toLowerCase() === "off";
+
+/** A DNS label: 1–63 of `a-z`, `0-9` and `-`, not starting or ending with `-`. */
+function isLabel(label: string): boolean {
+ if (label.length === 0 || label.length > 63) return false;
+ if (label.startsWith("-") || label.endsWith("-")) return false;
+ for (const c of label)
+ if (!((c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c === "-")) return false;
+ return true;
+}
+
+/** A host pattern an operator may list: a DNS name, or `*.` and one. */
+function isHostPattern(pattern: string): boolean {
+ const name = pattern.startsWith("*.") ? pattern.slice(2) : pattern;
+ return name.length > 0 && name.length <= 253 && name.split(".").every(isLabel);
+}
+
+/** `CLOUDHAL_MCP_CLIENT_HOSTS` as a list; throws on an entry that is not a host name. */
+export function parseClientHosts(value: string | undefined): string[] {
+ const hosts = (value ?? "")
+ .split(",")
+ .map((h) => h.trim().toLowerCase())
+ .filter(Boolean);
+ for (const h of hosts)
+ if (!isHostPattern(h))
+ throw new Error(`CLOUDHAL_MCP_CLIENT_HOSTS: "${h}" is not a host name (or *.host)`);
+ return hosts;
+}
+
+/** `CLOUDHAL_MCP_REDIRECT_SCHEMES` as a list; throws on an entry `isPrivateUseScheme` refuses. */
+export function parseRedirectSchemes(value: string | undefined): string[] {
+ const schemes = (value ?? "")
+ .split(",")
+ .map((h) => h.trim().toLowerCase())
+ .filter(Boolean);
+ for (const scheme of schemes)
+ if (schemeProblem(scheme))
+ throw new Error(`CLOUDHAL_MCP_REDIRECT_SCHEMES: "${scheme}" cannot be a redirect scheme`);
+ return schemes;
+}
+
+/**
+ * The registration a runtime runs, from its variables and its CIMD transport. A list of client
+ * hosts that does not parse turns registration off (fail closed) and says why in `problem`.
+ */
+export function oauthRegistrationFrom(
+ env: OauthRegistrationEnv,
+ cimd: ClientMetadataResourceFetch,
+): OauthRegistration & { readonly problem?: string } {
+ let clientHosts: string[];
+ let redirectSchemes: string[];
+ try {
+ clientHosts = parseClientHosts(env.CLOUDHAL_MCP_CLIENT_HOSTS);
+ redirectSchemes = parseRedirectSchemes(env.CLOUDHAL_MCP_REDIRECT_SCHEMES);
+ } catch (e) {
+ return { dcr: false, problem: e instanceof Error ? e.message : String(e) };
+ }
+ return {
+ ...(off(env.CLOUDHAL_MCP_CIMD) ? {} : { cimd }),
+ dcr: !off(env.CLOUDHAL_MCP_DCR),
+ ...(clientHosts.length > 0 ? { clientHosts } : {}),
+ ...(redirectSchemes.length > 0 ? { redirectSchemes } : {}),
+ };
+}
+
+export function dcrLimits(registration: OauthRegistration | undefined): DcrLimits {
+ return { ...DCR_LIMITS, ...registration?.limits };
+}
+
+export function cimdLimits(registration: OauthRegistration | undefined): CimdLimits {
+ return { ...CIMD_LIMITS, ...registration?.cimdLimits };
+}
diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts
index 698687ff..ff764bb1 100644
--- a/packages/api/src/auth/oauth.ts
+++ b/packages/api/src/auth/oauth.ts
@@ -10,8 +10,12 @@
* - Tokens: RFC 9068 JWT access tokens (`typ: at+jwt`) for 1 h, `aud` = the `/mcp` resource, `iss`
* = the issuer; refresh tokens for 30 d, rotated on every use with `mcp()`'s 30 s reuse window
* (Decision 2).
- * - Registration: none in Phase 2 (CIMD and DCR are Phase 3); clients are created server-side
- * (`adminCreateOAuthClient`), and the session-backed client CRUD endpoints are refused.
+ * - Registration (Phase 3, `oauth-registration.ts`): Client ID Metadata Documents through
+ * `cimd()` and the runtime's SSRF-safe transport, and dynamic registration, which Cloudhal
+ * checks and limits first (`dcr.ts`) and then hands to the provider server-side. Redirect URIs
+ * are matched exactly (loopback without the port, RFC 8252 §7.3) and must be https or loopback
+ * http (`redirect-policy.ts`). No member creates or edits clients: the session-backed client
+ * CRUD endpoints are refused here and fenced in `createApi`.
*
* Worker-safe: the plugins and `jose` use WebCrypto and no `eval`.
*/
@@ -19,6 +23,9 @@ import { mcp } from "@better-auth/mcp";
import { KEY_SCOPES } from "@cloudhal/agent-runtime";
import type { BetterAuthPlugin } from "better-auth";
import { jwt } from "better-auth/plugins";
+import { type CimdGuard, guardedCimd } from "./cimd-discovery.ts";
+import type { OauthRegistration } from "./oauth-registration.ts";
+import { redirectProblem } from "./redirect-policy.ts";
/** `offline_access` asks for a refresh token (the plugin issues one only then). */
export const OFFLINE_ACCESS = "offline_access";
@@ -49,6 +56,8 @@ export interface McpOauthServerOptions {
* only (the plugin refuses anything else).
*/
readonly resource: string;
+ /** RFC-0044 §3 (Phase 3): CIMD and DCR. Absent: neither; only clients already stored connect. */
+ readonly registration?: OauthRegistration | undefined;
}
/**
@@ -60,6 +69,7 @@ export interface McpOauthServerOptions {
export function mcpOauthFor(
baseURL: string | undefined,
setting?: string | undefined,
+ registration?: OauthRegistration | undefined,
): { readonly mcpOauth: McpOauthServerOptions } | { readonly off: string } {
if (setting?.trim().toLowerCase() === "off") return { off: "CLOUDHAL_MCP_OAUTH=off" };
if (!baseURL)
@@ -70,7 +80,8 @@ export function mcpOauthFor(
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (url.protocol !== "https:" && !loopback)
return { off: `the base URL ${url.origin} is plain HTTP outside loopback; use https` };
- return { mcpOauth: { resource: oauthEndpoints(url.origin).resource } };
+ const resource = oauthEndpoints(url.origin).resource;
+ return { mcpOauth: { resource, ...(registration ? { registration } : {}) } };
}
/** Issuer and resource of a deployment, both derived from its public base URL (§10). */
@@ -88,7 +99,10 @@ export function oauthPlugins(
options: McpOauthServerOptions,
hooks: OauthServerHooks,
): BetterAuthPlugin[] {
- const { fromServer, grantOf } = hooks;
+ const { grantOf, cimdGuard } = hooks;
+ const registration = options.registration;
+ const policy = { clientHosts: registration?.clientHosts, schemes: registration?.redirectSchemes };
+ const dcr = registration?.dcr === true;
return [
jwt({
jwks: { keyPairConfig: { alg: ACCESS_TOKEN_ALG, crv: "Ed25519" } },
@@ -109,13 +123,20 @@ export function oauthPlugins(
refreshTokenExpiresIn: REFRESH_TOKEN_TTL_S,
refreshTokenReuseInterval: REFRESH_REUSE_WINDOW_S,
codeExpiresIn: AUTHORIZATION_CODE_TTL_S,
- // Registration is Phase 3: no DCR, and no member may create or edit clients through the
- // session-backed endpoints (they are also fenced in `createApi`); only the server itself
- // (`OauthServer.createClient`) creates a pre-registered client.
- allowDynamicClientRegistration: false,
- allowUnauthenticatedClientRegistration: false,
- clientPrivileges: ({ headers, action }) => action === "create" && fromServer(headers),
- resourcePrivileges: ({ headers, action }) => action === "link" && fromServer(headers),
+ // §3: DCR is anonymous and public-only. The HTTP endpoint is Cloudhal's (`dcr.ts` checks
+ // and limits the request, then calls the provider server-side without a session); the
+ // provider's own route stays fenced. A loopback redirect makes the client `native`.
+ allowDynamicClientRegistration: dcr,
+ allowUnauthenticatedClientRegistration: dcr,
+ clientRegistrationDefaultApplicationType: "infer",
+ // No member creates, edits or links clients through the session-backed endpoints.
+ clientPrivileges: () => false,
+ resourcePrivileges: () => false,
+ // §6 "Open redirect": the registered URI must match (loopback without the port, a
+ // private-use scheme exactly) and pass the redirect rules and the host list now, whichever
+ // way and whenever the client registered.
+ validateRedirectUri: (uri, _registered, matched) =>
+ matched && redirectProblem(uri, policy) === undefined,
// Tokens are stored under a hash Cloudhal can compute too, so the refresh path can find a
// token's family and enforce the absolute lifetime (`oauth-hooks.ts`).
storeTokens: { hash: (token) => hashToken(token) },
@@ -133,15 +154,19 @@ export function oauthPlugins(
},
],
}),
+ // §3: CIMD with the runtime's transport and the MCP 2026-07-28 profile, inside Cloudhal's
+ // guard: stored clients served from their row within the freshness window, a cap on stored
+ // clients, and fetch budgets that never block a stored client (`cimd-discovery.ts`).
+ ...(registration?.cimd ? [guardedCimd(registration.cimd, cimdGuard)] : []),
];
}
/** What `oauthPlugins` asks of Cloudhal. */
export interface OauthServerHooks {
- /** True for the server's own calls (`SERVER_KEY_HEADER`): only they may create clients. */
- readonly fromServer: (headers: Headers) => boolean;
/** The id of the member's live grant for a client, if any. */
readonly grantOf: (userId: string, clientId: string) => Promise;
+ /** CIMD's bounds: the host list, the cap and the fetch budgets (`cimd-discovery.ts`). */
+ readonly cimdGuard: CimdGuard;
}
/** The access-token claim that names the grant (RFC-0044 §5; a private claim name, RFC 7519 §4.3). */
diff --git a/packages/api/src/auth/redirect-policy.ts b/packages/api/src/auth/redirect-policy.ts
new file mode 100644
index 00000000..6758d397
--- /dev/null
+++ b/packages/api/src/auth/redirect-policy.ts
@@ -0,0 +1,165 @@
+/**
+ * RFC-0044 §3 and §6 ("Open redirect"): which redirect URIs an OAuth client may use, for both
+ * registration paths (a Client ID Metadata Document and dynamic registration).
+ *
+ * - `https://` on a host that is not loopback, or `http://` on exactly `localhost`, `127.0.0.1` or
+ * `[::1]` (RFC 8252 §7.3; the port is free there: the provider matches loopback redirects
+ * without the port, §8.3).
+ * - A native app's private-use scheme (RFC 8252 §7.1), matched exactly: `scheme:/…` where the
+ * scheme is reverse-domain (has a dot, `com.example.app:/cb`) or on the short list of known
+ * apps (`cursor`, `vscode`) plus `CLOUDHAL_MCP_REDIRECT_SCHEMES`. Never a scheme a browser or
+ * the system runs or reads (`javascript`, `data`, `file`, `blob`, …) or one that looks like
+ * http. Every client here is public or native, which RFC 8252 §8.4 requires of such schemes.
+ * - Nothing else: no plain HTTP elsewhere, no fragment, no userinfo, at most 2,048 characters.
+ * - With `CLOUDHAL_MCP_CLIENT_HOSTS` set (`clientHosts`), every redirect host must be on it
+ * (`claude.ai` or `*.example.com`): a loopback redirect then needs `localhost` on the list, and a
+ * private-use redirect a scheme of the known list (reverse-domain schemes are refused).
+ * - The rules run at registration (DCR) and at every authorization (`validateRedirectUri`), so a
+ * host taken off the list, or a document's refused URI, is never used.
+ *
+ * Linear checks only (AGENTS.md rule 13): the URL parser, `indexOf` and comparisons.
+ */
+
+/** RFC-0044 §3: the most redirect URIs one client may register. */
+export const MAX_REDIRECT_URIS = 10;
+const MAX_URI_LENGTH = 2048;
+const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+/** The host part of an `http://` URI as written (the parser would normalise `127.1`). */
+function rawHttpHost(uri: string): string | undefined {
+ if (uri.slice(0, 7).toLowerCase() !== "http://") return undefined;
+ const rest = uri.slice(7);
+ let end = rest.length;
+ for (const c of ["/", "?", "#"]) {
+ const at = rest.indexOf(c);
+ if (at >= 0 && at < end) end = at;
+ }
+ const authority = rest.slice(0, end);
+ if (authority.startsWith("[")) {
+ const close = authority.indexOf("]");
+ return close < 0 ? undefined : authority.slice(0, close + 1).toLowerCase();
+ }
+ const colon = authority.indexOf(":");
+ return (colon < 0 ? authority : authority.slice(0, colon)).toLowerCase();
+}
+
+/** True for a host pattern match: `example.com` exactly, `*.example.com` any subdomain. */
+export function hostMatches(host: string, pattern: string): boolean {
+ const h = host.toLowerCase();
+ const p = pattern.toLowerCase();
+ if (p.startsWith("*.")) {
+ const suffix = p.slice(1);
+ return h.length > suffix.length && h.endsWith(suffix);
+ }
+ return h === p;
+}
+
+/** Private-use schemes of well-known native MCP clients (Cursor, VS Code). */
+export const KNOWN_REDIRECT_SCHEMES: readonly string[] = ["cursor", "vscode"];
+
+/** Schemes a browser or the system acts on itself: never a redirect target. */
+const DANGEROUS_SCHEMES: ReadonlySet = new Set([
+ "javascript",
+ "vbscript",
+ "data",
+ "file",
+ "about",
+ "blob",
+ "filesystem",
+ "view-source",
+ "jar",
+ "intent",
+ "chrome",
+ "mailto",
+ "tel",
+ "sms",
+ "ftp",
+]);
+
+/** Why `scheme` (lower case, no `:`) can never be a private-use redirect scheme, if it cannot. */
+export function schemeProblem(scheme: string): string | undefined {
+ if (scheme.length === 0 || scheme.length > 64) return "a redirect scheme is 1–64 characters";
+ const first = scheme.charCodeAt(0);
+ if (first < 97 || first > 122) return "a redirect scheme starts with a letter";
+ for (const c of scheme)
+ if (!((c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c === "+" || c === "-" || c === "."))
+ return "a redirect scheme has letters, digits, + - . only";
+ if (DANGEROUS_SCHEMES.has(scheme)) return `${scheme}: is never a redirect target`;
+ // `http`, `https` and lookalikes (`https-app`, `httpx`), and WebSocket schemes.
+ if (scheme.startsWith("http") || scheme === "ws" || scheme === "wss")
+ return `${scheme}: looks like a web scheme`;
+ return undefined;
+}
+
+export interface RedirectPolicy {
+ /** `CLOUDHAL_MCP_CLIENT_HOSTS`; empty or absent: any host. */
+ readonly clientHosts?: readonly string[] | undefined;
+ /** `CLOUDHAL_MCP_REDIRECT_SCHEMES`, added to `KNOWN_REDIRECT_SCHEMES`. */
+ readonly schemes?: readonly string[] | undefined;
+}
+
+/** The problem with a private-use redirect (`scheme` lower case), or `undefined`. */
+function privateUseProblem(
+ uri: string,
+ scheme: string,
+ policy: RedirectPolicy,
+): string | undefined {
+ const problem = schemeProblem(scheme);
+ if (problem) return problem;
+ if (uri.slice(scheme.length, scheme.length + 2) !== ":/")
+ return `a private-use redirect URI is ${scheme}:/… (RFC 8252 §7.1)`;
+ const listed = [...KNOWN_REDIRECT_SCHEMES, ...(policy.schemes ?? [])].includes(scheme);
+ const restricted = (policy.clientHosts?.length ?? 0) > 0;
+ if (listed) return undefined;
+ if (restricted)
+ return `${scheme}: is not a known app scheme, and CLOUDHAL_MCP_CLIENT_HOSTS is set (add it to CLOUDHAL_MCP_REDIRECT_SCHEMES)`;
+ if (!scheme.includes("."))
+ return `${scheme}: is not reverse-domain (com.example.app) or a known app scheme (CLOUDHAL_MCP_REDIRECT_SCHEMES)`;
+ return undefined;
+}
+
+/** Why a redirect URI is refused, or `undefined` when it may be used. */
+export function redirectProblem(uri: string, policy: RedirectPolicy = {}): string | undefined {
+ if (uri.length > MAX_URI_LENGTH) return "a redirect URI is longer than 2,048 characters";
+ let url: URL;
+ try {
+ url = new URL(uri);
+ } catch {
+ return `${uri.slice(0, 100)} is not an absolute URI`;
+ }
+ if (uri.includes("#")) return "a redirect URI must not have a fragment";
+ if (url.username || url.password) return "a redirect URI must not carry credentials";
+ const scheme = url.protocol.slice(0, -1).toLowerCase();
+ if (scheme !== "http" && scheme !== "https") return privateUseProblem(uri, scheme, policy);
+ const loopback = scheme === "http" && LOOPBACK_HOSTS.has(rawHttpHost(uri) ?? "");
+ if (scheme === "http" && !loopback)
+ return `http redirect URIs are allowed on localhost, 127.0.0.1 and [::1] only: ${url.host}`;
+ if (scheme === "https" && LOOPBACK_HOSTS.has(url.hostname))
+ return "a loopback redirect URI uses http (RFC 8252 §7.3)";
+ const hosts = policy.clientHosts;
+ if (hosts && hosts.length > 0) {
+ const host = loopback ? "localhost" : url.hostname;
+ if (!hosts.some((p) => hostMatches(host, p)))
+ return `${host} is not on this server's list of client hosts (CLOUDHAL_MCP_CLIENT_HOSTS)`;
+ }
+ return undefined;
+}
+
+/**
+ * Why a client's redirect URIs are refused as a whole (1–10, each allowed), or `undefined`. One
+ * bad entry refuses the registration: entries are never dropped silently.
+ */
+export function redirectUrisProblem(
+ uris: unknown,
+ policy: RedirectPolicy = {},
+): string | undefined {
+ if (!Array.isArray(uris) || uris.length === 0) return "redirect_uris is required";
+ if (uris.length > MAX_REDIRECT_URIS)
+ return `at most ${MAX_REDIRECT_URIS} redirect URIs may be registered`;
+ for (const uri of uris) {
+ if (typeof uri !== "string") return "redirect_uris must be strings";
+ const problem = redirectProblem(uri, policy);
+ if (problem) return problem;
+ }
+ return undefined;
+}
diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts
index 9c4871c5..421bc2a1 100644
--- a/packages/api/src/auth/types.ts
+++ b/packages/api/src/auth/types.ts
@@ -156,16 +156,15 @@ export interface OauthServer {
/** The RFC 8414 authorization server metadata document. */
readonly metadata: () => Promise>;
/**
- * Server-side only (no HTTP route): a pre-registered public client, created as the signed-in
- * member `headers` belong to. Phase 2 uses it in tests; Phase 3 adds CIMD, DCR and a CLI.
+ * RFC-0044 §3, with DCR on: registers a public client for an anonymous caller (the body as sent,
+ * the caller's address for the rate limit); throws `RegistrationError`. Absent with DCR off.
*/
- readonly createClient: (input: OauthClientInput) => Promise<{ clientId: string }>;
-}
-
-export interface OauthClientInput {
- readonly headers: Headers;
- readonly name: string;
- readonly redirectUris: readonly string[];
+ readonly register?:
+ | ((input: {
+ readonly text: string;
+ readonly address: string;
+ }) => Promise>)
+ | undefined;
}
export interface AgentKeySummary {
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index fa2aae27..51787671 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -42,6 +42,7 @@ export {
export type { ActorResolver, ApiDeps } from "./context.ts";
export type { ApiErrorBody } from "./errors.ts";
export { MailError, type MailMessage, type MailSender } from "./mail.ts";
+export { oauthClientsPruneJob } from "./oauth-prune-job.ts";
export {
isBlockedInPlayground,
PLAYGROUND_ALLOW,
diff --git a/packages/api/src/oauth-prune-job.ts b/packages/api/src/oauth-prune-job.ts
new file mode 100644
index 00000000..818a83d4
--- /dev/null
+++ b/packages/api/src/oauth-prune-job.ts
@@ -0,0 +1,42 @@
+/**
+ * RFC-0044 §3 "Lifetime": the scheduler's hourly system job that deletes unused OAuth clients:
+ * dynamically registered ones nobody used for 30 days (`pruneDcrClients`, one batch of 50), and
+ * document clients nobody connected through or fetched for three hours (`pruneCimdClients`, up
+ * to four batches of 50 while they come back full; the next authorization stores them again).
+ * On the admin Worker's tick and on Node's (`cloudhal studio`, `dev`, `mcp`), whether DCR and
+ * CIMD are on or off.
+ */
+import { hourlyAt, type SystemJobDefinition } from "@cloudhal/schedules";
+import type { SqlDriver } from "@cloudhal/storage";
+import { CIMD_PRUNE_BATCHES, pruneCimdClients, pruneDcrClients } from "./auth/oauth-client-rows.ts";
+
+export function oauthClientsPruneJob(
+ sql: SqlDriver,
+ log: (line: string) => void,
+ /** The deployment's limits (`DcrLimits.unusedDays`, `CimdLimits.unusedHours`); default theirs. */
+ limits: { readonly dcrUnusedDays?: number; readonly cimdUnusedHours?: number } = {},
+): SystemJobDefinition {
+ return {
+ name: "oauth.clients.prune",
+ // Batches of six statements: one for DCR, up to CIMD_PRUNE_BATCHES for documents.
+ cost: 6 * (1 + CIMD_PRUNE_BATCHES),
+ // The hourly jobs' :17 watermark: due jobs run by time, then name, so this one runs after
+ // the analytics rollup and the comments job and waits for the next minute when a free
+ // plan's tick is full.
+ next: hourlyAt(17),
+ async run({ now }) {
+ const dcr = await pruneDcrClients(sql, {
+ now,
+ ...(limits.dcrUnusedDays !== undefined ? { unusedDays: limits.dcrUnusedDays } : {}),
+ });
+ const cimd = await pruneCimdClients(sql, {
+ now,
+ ...(limits.cimdUnusedHours !== undefined ? { unusedHours: limits.cimdUnusedHours } : {}),
+ });
+ if (dcr + cimd > 0)
+ log(
+ `oauth clients: ${dcr} unused registration(s), ${cimd} unused document client(s) deleted`,
+ );
+ },
+ };
+}
diff --git a/packages/api/src/oauth-server.ts b/packages/api/src/oauth-server.ts
index de0eef61..e042c2ea 100644
--- a/packages/api/src/oauth-server.ts
+++ b/packages/api/src/oauth-server.ts
@@ -7,26 +7,30 @@
* outside `/api` and cacheable like the protected resource metadata;
* - §4: Studio's consent API and `/api/agent-grants` (list and revoke; mode changes and
* elevations are Phase 3's "Connected agents");
+ * - §3 (Phase 3): Cloudhal's `POST /api/auth/oauth2/register` when DCR is on
+ * (`mountClientRegistration`, before Better Auth's catch-all);
* - the fence: provider endpoints that are not part of the code flow answer 404 over HTTP.
*/
import { isAgentMode } from "@cloudhal/agent-runtime";
import { trimEndChar } from "@cloudhal/core-ts/names";
import { type Context, Hono } from "hono";
import { personActor } from "./auth/agent-grants.ts";
+import { DCR_MAX_BODY_BYTES, RegistrationError } from "./auth/dcr.ts";
import type { OauthServer } from "./auth/types.ts";
import type { Env } from "./context.ts";
import { badRequest, unauthorized } from "./errors.ts";
import { param } from "./params.ts";
+import { clientIp } from "./tokens.ts";
/** RFC 8414 §3: the well-known path; the path-inserted form appends the issuer's path. */
export const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
/**
* The provider endpoints a client or Studio reaches over HTTP. Everything else under
- * `/api/auth/oauth2/` (client and consent CRUD, introspection, userinfo, registration until
- * Phase 3) and the `jwt()` plugin's session-token endpoint `/api/auth/token` answer 404, like the
- * organization, admin and api-key endpoints (RFC-0005 I3). Consent goes through
- * `/api/agent-grants/consent`, never the provider's own endpoint.
+ * `/api/auth/oauth2/` (client and consent CRUD, introspection, userinfo) and the `jwt()` plugin's
+ * session-token endpoint `/api/auth/token` answer 404, like the organization, admin and api-key
+ * endpoints (RFC-0005 I3). Consent goes through `/api/agent-grants/consent`, never the provider's
+ * own endpoint; registration through Cloudhal's route, open only with DCR on.
*/
const OPEN_OAUTH_PATHS = new Set([
"/api/auth/oauth2/authorize",
@@ -34,8 +38,17 @@ const OPEN_OAUTH_PATHS = new Set([
"/api/auth/oauth2/revoke",
]);
-/** True for a Better Auth path of the authorization server that HTTP callers may not reach. */
-export function fencedOauthPath(pathname: string): boolean {
+/** RFC-0044 §3: where clients register (RFC 7591); Cloudhal's route, not the provider's. */
+export const REGISTER_PATH = "/api/auth/oauth2/register";
+
+/**
+ * True for a Better Auth path of the authorization server that HTTP callers may not reach.
+ * `register`: DCR is on, so `REGISTER_PATH` is open (Cloudhal's route answers it).
+ */
+export function fencedOauthPath(pathname: string, open: { register?: boolean } = {}): boolean {
+ // Exactly this path: a variant (a trailing slash) would miss Cloudhal's route and reach the
+ // provider's own endpoint through Better Auth's catch-all.
+ if (pathname === REGISTER_PATH) return !open.register;
const path = trimEndChar(pathname, "/");
if (path === "/api/auth/token") return true;
// The provider also answers its metadata under the issuer (`/api/auth/.well-known/…`), with
@@ -56,6 +69,67 @@ export function mountAuthorizationServerMetadata(app: Hono, server: OauthSe
app.get(AUTHORIZATION_SERVER_PATH, handler);
}
+const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" } as const;
+
+/** The body as text, at most `max + 1` bytes read (so an oversized body is refused unread). */
+async function cappedText(req: Request, max: number): Promise {
+ const reader = req.body?.getReader();
+ if (!reader) return "";
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ chunks.push(value);
+ size += value.byteLength;
+ if (size > max) {
+ await reader.cancel().catch(() => {});
+ break;
+ }
+ }
+ const bytes = new Uint8Array(size);
+ let at = 0;
+ for (const c of chunks) {
+ bytes.set(c, at);
+ at += c.byteLength;
+ }
+ return new TextDecoder().decode(bytes);
+}
+
+/**
+ * RFC-0044 §3: `POST /api/auth/oauth2/register` (RFC 7591, `ROUTE_SCOPES` public as all of
+ * `/api/auth/*`), mounted before Better Auth's catch-all. JSON only; 201 with the client, or the
+ * RFC 7591 error body; never cached.
+ */
+export function mountClientRegistration(
+ app: Hono,
+ register: NonNullable,
+): void {
+ app.post(REGISTER_PATH, async (c) => {
+ const type = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
+ if (type !== "application/json")
+ return c.json(
+ {
+ error: "invalid_client_metadata",
+ error_description: "send the metadata as application/json",
+ },
+ 400,
+ NO_STORE,
+ );
+ const declared = Number(c.req.header("content-length") ?? 0);
+ const text =
+ declared > DCR_MAX_BODY_BYTES
+ ? "x".repeat(DCR_MAX_BODY_BYTES + 1)
+ : await cappedText(c.req.raw, DCR_MAX_BODY_BYTES);
+ try {
+ return c.json(await register({ text, address: clientIp(c) }), 201, NO_STORE);
+ } catch (e) {
+ if (e instanceof RegistrationError) return c.json(e.body(), e.status, NO_STORE);
+ throw e;
+ }
+ });
+}
+
/** RFC-0044 Phase 2: the metadata documents and `/api/agent-grants`. */
export function mountOauthServer(app: Hono, server: OauthServer): void {
mountAuthorizationServerMetadata(app, server);
diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts
index 4a4999cb..599586c1 100644
--- a/packages/api/src/routes/notifications.ts
+++ b/packages/api/src/routes/notifications.ts
@@ -12,7 +12,7 @@
* (the daily cap was reached), `comments-purge-waiting` (a takedown has waited over 2 minutes for
* the live page) and `comments-approval` (an agent's comment request this person can decide).
* - schedules (RFC-0008 §14): `schedule-approval-due` (a release awaiting approval runs within 60
- * minutes; for people who may decide it), `schedule-upcoming` (within 15 minutes) and
+ * minutes, or runs on approval and waits for it; for people who may decide it), `schedule-upcoming` (within 15 minutes) and
* `schedule-published` (the last 24 hours) for its creator and approver; `schedule-conflict`,
* `schedule-missed`, `schedule-expired` and `schedule-failed` (the last 7 days) for them and for
* Editors; `scheduler-stalled` for Administrators and anyone with a release due within 24 hours;
@@ -327,6 +327,23 @@ async function commentNotifications(c: Context, now: Date): Promise,
+ r: { siteId: string; id: string; when?: { kind: string } | undefined },
+ times: { due: number; t: number },
+): Promise {
+ const { due, t } = times;
+ if (r.when?.kind !== "on-approval") return due - t <= 60 * MINUTE && due >= t;
+ const rec = await c.get("schedules")?.store.getReleaseRecord(r.siteId, r.id);
+ const request = rec?.requestId ? await c.get("approvals")?.get(rec.requestId) : undefined;
+ return request?.status === "pending" && Date.parse(request.expiresAt) > t;
+}
+
/** The schedule kinds (RFC-0008 §14), computed from state; nothing is stored. */
async function scheduleNotifications(c: Context, now: Date): Promise {
const deps = c.get("schedules");
@@ -366,7 +383,7 @@ async function scheduleNotifications(c: Context, now: Date): Promise= t) {
+ if (await approvalDue(c, r, { due, t })) {
const decides =
!roles ||
(r.createdBy.id !== principal.actor.id &&
diff --git a/packages/api/src/testing-mcp-oauth-bounds.ts b/packages/api/src/testing-mcp-oauth-bounds.ts
new file mode 100644
index 00000000..1ec886fd
--- /dev/null
+++ b/packages/api/src/testing-mcp-oauth-bounds.ts
@@ -0,0 +1,578 @@
+/**
+ * Shared suite for the review of RFC-0044 Phase 3a (PR #178): the bounds around client
+ * registration, on better-sqlite3 and Miniflare D1. Next to `testing-mcp-oauth-registration.ts`.
+ *
+ * 1. Document clients are capped and pruned like DCR's.
+ * 2. Anonymous calls cannot spend the fetch budgets members' clients need: a stored, fresh client
+ * is never fetched; a stored one is never blocked by a budget (stale-if-exhausted); only new
+ * documents spend the per-origin budget. A second deployment over the same database stands in
+ * for a Worker, which rebuilds the plugin (and its cache) on every request.
+ * 3. Cursor registers with its real body, a private-use scheme included; dangerous schemes never.
+ * 4. The client host list applies at every authorization, to clients registered before it.
+ * 5. With DCR off, clients it registered earlier start no new authorization; connections they
+ * already have keep working.
+ * 6. The consent screen's identity: names made plain and short, the kind, look-alike names.
+ */
+
+import type { ClientDiscovery } from "@better-auth/oauth-provider";
+import { APIError } from "better-auth/api";
+import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
+import { clearAccessTokenKeyCache } from "./auth/access-token.ts";
+import { type CimdGuard, guardedResolve } from "./auth/cimd-discovery.ts";
+import { pruneCimdClients } from "./auth/oauth-client-rows.ts";
+import { CIMD_LIMITS, type OauthRegistration } from "./auth/oauth-registration.ts";
+import { oauthClientsPruneJob } from "./oauth-prune-job.ts";
+import {
+ DOC,
+ document,
+ FETCH_FAILED,
+ type Served,
+ stubTransport,
+ tryAuthorize,
+ withCimd,
+} from "./testing-mcp-oauth-registration.ts";
+import {
+ authorize,
+ connect,
+ consentRequest,
+ deploy,
+ ISSUER,
+ type Json,
+ mcpClient,
+ REDIRECT,
+ registerClient,
+ type Server,
+ setupServer,
+ token,
+} from "./testing-oauth-harness.ts";
+import type { RolesBackend } from "./testing-roles.ts";
+
+const READ = "site:read content:read";
+/** Claude Code's metadata document (RFC-0044 §3). */
+const CLAUDE_CODE = "https://claude.ai/oauth/claude-code-client-metadata";
+const HOUR = 3_600_000;
+const DAY = 24 * HOUR;
+/** Cursor 3.14.27's registration as observed (forum.cursor.com/t/167608). */
+const CURSOR_BODY = {
+ client_name: "Cursor",
+ redirect_uris: [
+ "cursor://anysphere.cursor-mcp/oauth/callback",
+ "https://www.cursor.com/agents/mcp/oauth/callback",
+ "http://localhost:8787/callback",
+ ],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+};
+
+/** The same database and owner, served by another deployment (a Worker's next request). */
+async function redeploy(s: Server, options: { registration: OauthRegistration }): Promise {
+ return { ...s, ...(await deploy(s.backend, options)) };
+}
+
+/** An anonymous `/authorize` for `clientId` from `address`; the response status and text. */
+async function anonymousAuthorize(s: Server, clientId: string, address: string) {
+ const params = new URLSearchParams({
+ response_type: "code",
+ client_id: clientId,
+ redirect_uri: REDIRECT,
+ scope: READ,
+ code_challenge: "x".repeat(43),
+ code_challenge_method: "S256",
+ });
+ const res = await s.app.request(`${ISSUER}/oauth2/authorize?${params}`, {
+ headers: { "cf-connecting-ip": address },
+ });
+ return { status: res.status, text: await res.text() };
+}
+
+/** Authorize went on: the consent screen, or (consent remembered, Decision 8) a code. */
+const proceeds = (r: Awaited>) =>
+ r.consentScreen || Boolean(r.location?.searchParams.get("code"));
+
+const ageRow = (s: Server, clientId: string, ms: number) =>
+ s.sql.run(`UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" = ?`, [
+ new Date(Date.now() - ms).toISOString(),
+ clientId,
+ ]);
+
+export function describeMcpOauthBounds(label: string, backend: RolesBackend): void {
+ describe(`MCP OAuth registration bounds: ${label}`, { timeout: 180_000 }, () => {
+ afterAll(async () => {
+ await backend.dispose?.();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ clearAccessTokenKeyCache();
+ });
+
+ it("caps stored document clients and prunes unused ones (the next authorize recreates them)", async () => {
+ const other = "https://agents.example.com/other.json";
+ const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) }));
+ const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 1 } }));
+ const { access } = await connect(
+ { ...s, clientId: DOC },
+ { cookie: s.owner.cookie, scope: READ },
+ );
+ const full = await tryAuthorize(s, other);
+ expect(full.status).toBe(503);
+ expect(full.reason).toContain("stores no more clients");
+ // A live connection keeps its client however idle; an unused one goes after a day.
+ await registerClient(s); // a DCR client is not a document client: untouched here
+ const stored = async () =>
+ (
+ await s.sql.all<{ clientId: string }>(
+ `SELECT "clientId" FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd'`,
+ )
+ ).map((r) => r.clientId);
+ expect(await pruneCimdClients(s.sql, { now: new Date(Date.now() + 2 * DAY) })).toBe(0);
+ expect(await stored()).toEqual([DOC]);
+ const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie }))
+ .body as unknown as Json[];
+ await s.call("DELETE", `/api/agent-grants/${grants.find((g) => g.clientId === DOC)?.id}`, {
+ cookie: s.owner.cookie,
+ headers: { origin: s.origin },
+ });
+ expect(await pruneCimdClients(s.sql, { now: new Date(Date.now() + 2 * DAY) })).toBe(1);
+ expect(await stored()).toEqual([]);
+ void access;
+ // Room again: another document can connect.
+ expect((await tryAuthorize(s, other)).consentScreen).toBe(true);
+ });
+
+ it("keeps stored clients connecting and refreshing while anonymous calls drain the budgets", async () => {
+ const transport = stubTransport((url) =>
+ url.toString() === DOC ? { body: document() } : { status: 404 },
+ );
+ const registration = withCimd(transport, {
+ cimdLimits: { perAddressPerMinute: 4, perOriginPerMinute: 3, perMinute: 5 },
+ });
+ const s = await setupServer(backend, registration);
+ const claude = { ...s, clientId: DOC };
+ const first = await connect(claude, {
+ cookie: s.owner.cookie,
+ scope: `${READ} offline_access`,
+ });
+ const fetches = () => transport.requests.filter((u) => u === DOC).length;
+ expect(fetches()).toBe(1);
+ // Anonymous callers from many addresses exhaust the per-origin and the total budget.
+ const answers: number[] = [];
+ for (let i = 0; i < 8; i++)
+ answers.push(
+ (await anonymousAuthorize(s, `https://f${i}.example.com/c.json`, `203.0.113.${i}`))
+ .status,
+ );
+ expect(answers.at(-1)).toBe(429);
+ // A new request on another deployment (a Worker rebuilds the plugin): the stored, fresh
+ // client is used from its row, with no fetch, for authorize and for a refresh.
+ const w = await redeploy(s, registration);
+ expect(proceeds(await tryAuthorize(w, DOC))).toBe(true);
+ const refreshed = await token(
+ { ...w, clientId: DOC },
+ { grant_type: "refresh_token", refresh_token: first.refresh ?? "" },
+ );
+ expect(refreshed.status, JSON.stringify(refreshed.body)).toBe(200);
+ expect(fetches()).toBe(1);
+ // Older than the freshness window: it is fetched again, and only the caller's budget counts.
+ await ageRow(s, DOC, 2 * HOUR);
+ expect(proceeds(await tryAuthorize(w, DOC))).toBe(true);
+ expect(fetches()).toBe(2);
+ // The caller's own budget spent too: that caller is told to wait (429), never served an
+ // outdated row it could make the server use by spending its own budget.
+ await ageRow(s, DOC, 2 * HOUR);
+ for (let i = 0; i < 4; i++)
+ await anonymousAuthorize(w, `https://g${i}.example.com/c.json`, "unknown");
+ const waiting = await token(
+ { ...w, clientId: DOC },
+ { grant_type: "refresh_token", refresh_token: refreshed.body.refresh_token as string },
+ );
+ expect(waiting.status).toBe(429);
+ expect(fetches()).toBe(2);
+ // A new document from that caller is refused too.
+ const blocked = await tryAuthorize(w, "https://agents.example.com/new.json");
+ expect(blocked.status).toBe(429);
+ });
+
+ it("keeps a stored client refreshing on the same process after junk URLs on its origin", async () => {
+ // Node keeps one auth instance for the life of the process: no redeploy here.
+ const transport = stubTransport((url) =>
+ url.toString() === CLAUDE_CODE
+ ? { body: document({ client_id: CLAUDE_CODE }) }
+ : { status: 404 },
+ );
+ const s = await setupServer(backend, withCimd(transport));
+ const claude = { ...s, clientId: CLAUDE_CODE };
+ const first = await connect(claude, {
+ cookie: s.owner.cookie,
+ scope: `${READ} offline_access`,
+ });
+ // Two hours later (every cache the old code kept has expired), about 30 a minute of new
+ // URLs on claude.ai, from enough addresses to pass each one's budget.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(Date.now() + 2 * HOUR);
+ for (let i = 0; i < 30; i++)
+ await anonymousAuthorize(s, `https://claude.ai/x${i}`, `198.51.100.${i % 10}`);
+ const renewed = await token(claude, {
+ grant_type: "refresh_token",
+ refresh_token: first.refresh ?? "",
+ });
+ expect(renewed.status, JSON.stringify(renewed.body)).toBe(200);
+ expect(transport.requests.filter((u) => u === CLAUDE_CODE).length).toBe(2);
+ expect(proceeds(await tryAuthorize(s, CLAUDE_CODE))).toBe(true);
+ });
+
+ it("follows a changed document after the freshness window and drops a removed one", async () => {
+ let served: Served = { body: document() };
+ const transport = stubTransport(() => served);
+ const s = await setupServer(backend, withCimd(transport));
+ const claude = { ...s, clientId: DOC };
+ const first = await connect(claude, {
+ cookie: s.owner.cookie,
+ scope: `${READ} offline_access`,
+ });
+ // The document moves its callback: once the stored row is older than an hour, only the new
+ // one is accepted.
+ const moved = "http://127.0.0.1/moved";
+ served = { body: document({ redirect_uris: [moved] }) };
+ await ageRow(s, DOC, 2 * HOUR);
+ expect((await tryAuthorize(s, DOC, REDIRECT)).reason).toBe("invalid redirect uri");
+ expect(proceeds(await tryAuthorize(s, DOC, "http://127.0.0.1:5000/moved"))).toBe(true);
+ // The document disappears: after the window the client is refused, at refresh too, and
+ // stays refused (the failure is recorded on the row), whatever budget the caller spends.
+ served = { status: 404 };
+ await ageRow(s, DOC, 2 * HOUR);
+ const gone = await token(claude, {
+ grant_type: "refresh_token",
+ refresh_token: first.refresh ?? "",
+ });
+ expect(gone.status).not.toBe(200);
+ const row = await s.sql.get<{ updatedAt: string }>(
+ `SELECT "updatedAt" FROM "oauthClient" WHERE "clientId" = ?`,
+ [DOC],
+ );
+ expect(Date.parse(row?.updatedAt ?? "")).toBe(0);
+ for (let i = 0; i < 25; i++)
+ await anonymousAuthorize(s, `https://h${i}.example.com/c.json`, "unknown");
+ const again = await token(claude, {
+ grant_type: "refresh_token",
+ refresh_token: first.refresh ?? "",
+ });
+ expect(again.status).not.toBe(200);
+ expect((await tryAuthorize(s, DOC, "http://127.0.0.1:5000/moved")).consentScreen).toBe(false);
+ });
+
+ it("serves a stored row the plugin declines to refetch only within 24 hours, never after a failure", async () => {
+ const failed: string[] = [];
+ const guard: CimdGuard = {
+ limits: CIMD_LIMITS,
+ count: async () => 0,
+ evict: async () => 0,
+ markFailed: async (id) => {
+ failed.push(id);
+ },
+ hit: async () => true,
+ address: () => "203.0.113.9",
+ now: () => new Date(),
+ };
+ const declined = new APIError("TOO_MANY_REQUESTS", { error: "temporarily_unavailable" });
+ const inner = (error: unknown) =>
+ ({
+ id: "cimd",
+ matches: () => true,
+ resolve: async () => {
+ throw error;
+ },
+ }) as unknown as ClientDiscovery;
+ const stored = (hours: number) =>
+ ({ clientId: DOC, updatedAt: new Date(Date.now() - hours * HOUR) }) as never;
+ const ctx = { headers: new Headers() } as never;
+ const run = (error: unknown, hours: number) =>
+ guardedResolve(inner(error), guard, { ctx, clientId: DOC, existing: stored(hours) });
+ expect(await run(declined, 2)).toMatchObject({ clientId: DOC });
+ await expect(run(declined, 25)).rejects.toBe(declined);
+ const fetchFailed = new APIError("BAD_REQUEST", { error: "invalid_client" });
+ await expect(run(fetchFailed, 2)).rejects.toBe(fetchFailed);
+ expect(failed).toEqual([DOC]);
+ });
+
+ it("evicts by the client's real host: a path that names a well-known host is still junk", async () => {
+ const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) }));
+ const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 3 } }));
+ // `%` in SQL LIKE spans `/`: these would pass for claude.ai with a pattern over the URL.
+ for (let i = 0; i < 3; i++)
+ await anonymousAuthorize(
+ s,
+ `https://junk${i}.example.com/x.claude.ai/c.json`,
+ `192.0.2.${i}`,
+ );
+ await s.sql.run(
+ `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" LIKE 'https://junk%'`,
+ [new Date(Date.now() - HOUR).toISOString()],
+ );
+ // A third-party client (not well-known) still gets its first connection: one junk row goes.
+ expect(
+ (await tryAuthorize(s, "https://agents.example.com/inspector.json")).consentScreen,
+ ).toBe(true);
+ const left = await s.sql.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM "oauthClient" WHERE "clientId" LIKE 'https://junk%'`,
+ );
+ expect(Number(left?.n)).toBe(2);
+ });
+
+ it("never evicts a client with a live connection or a well-known host's, however old", async () => {
+ const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) }));
+ const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 2 } }));
+ await connect({ ...s, clientId: DOC }, { cookie: s.owner.cookie, scope: READ }); // live grant
+ await anonymousAuthorize(s, CLAUDE_CODE, "192.0.2.50"); // stored, no grant, exempt host
+ await s.sql.run(
+ `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientDiscoveryId" = 'cimd'`,
+ [new Date(Date.now() - DAY).toISOString()],
+ );
+ const refused = await anonymousAuthorize(s, "https://junk.example.com/c.json", "192.0.2.51");
+ expect(refused.status).toBe(503);
+ const kept = await s.sql.all<{ clientId: string }>(
+ `SELECT "clientId" FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd' ORDER BY "clientId"`,
+ );
+ expect(kept.map((r) => r.clientId)).toEqual([DOC, CLAUDE_CODE].sort());
+ });
+
+ it("prunes more than one batch of unused document clients in a run, with the operator's hours", async () => {
+ const s = await setupServer(backend);
+ const old = new Date(Date.now() - 2 * HOUR).toISOString();
+ for (let i = 0; i < 120; i++)
+ await s.sql.run(
+ `INSERT INTO "oauthClient" ("id", "clientId", "clientDiscoveryId", "redirectUris", "createdAt", "updatedAt") VALUES (?, ?, 'cimd', '[]', ?, ?)`,
+ [`junk${i}`, `https://junk${i}.example.com/c.json`, old, old],
+ );
+ const count = async () =>
+ Number(
+ (
+ await s.sql.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd'`,
+ )
+ )?.n,
+ );
+ const lines: string[] = [];
+ const run = (limits: { cimdUnusedHours?: number }) =>
+ oauthClientsPruneJob(s.sql, (l) => lines.push(l), limits).run({
+ now: new Date(),
+ trigger: "cron",
+ });
+ await run({}); // two hours old: under the default three
+ expect(await count()).toBe(120);
+ await run({ cimdUnusedHours: 1 });
+ expect(await count()).toBe(0);
+ expect(lines).toEqual([
+ "oauth clients: 0 unused registration(s), 120 unused document client(s) deleted",
+ ]);
+ });
+
+ it("makes room for a well-known client's first connection when the cap is full of junk", async () => {
+ const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) }));
+ const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 3 } }));
+ for (let i = 0; i < 3; i++)
+ await anonymousAuthorize(s, `https://junk${i}.example.com/c.json`, `192.0.2.${i}`);
+ // Full, and every junk row was fetched just now: a fourth unknown document waits.
+ const fourth = await anonymousAuthorize(s, "https://junk3.example.com/c.json", "192.0.2.3");
+ expect(fourth.status).toBe(503);
+ // Claude Code's first connection here is exempt from the cap.
+ const first = await connect(
+ { ...s, clientId: CLAUDE_CODE },
+ { cookie: s.owner.cookie, scope: READ },
+ );
+ expect(first.access).toBeTruthy();
+ // Junk older than the eviction window makes way for a new document, one at a time.
+ await s.sql.run(
+ `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" LIKE 'https://junk%'`,
+ [new Date(Date.now() - HOUR).toISOString()],
+ );
+ expect((await tryAuthorize(s, "https://agents.example.com/new.json")).consentScreen).toBe(
+ true,
+ );
+ const left = await s.sql.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM "oauthClient" WHERE "clientId" LIKE 'https://junk%'`,
+ );
+ expect(Number(left?.n)).toBe(2);
+ });
+
+ it("registers Cursor with its real body, and refuses dangerous or unknown schemes", async () => {
+ const s = await setupServer(backend, {
+ registration: { dcr: true, limits: { perAddressPerHour: 100 } },
+ });
+ const cursor = await s.call("POST", "/api/auth/oauth2/register", { body: CURSOR_BODY });
+ expect(cursor.status, JSON.stringify(cursor.body)).toBe(201);
+ expect(cursor.body.redirect_uris).toEqual(CURSOR_BODY.redirect_uris);
+ const id = cursor.body.client_id as string;
+ // Private-use redirects match exactly: no port or path relaxation.
+ expect(
+ (await tryAuthorize(s, id, CURSOR_BODY.redirect_uris[0] as string)).consentScreen,
+ ).toBe(true);
+ expect(
+ (await tryAuthorize(s, id, "cursor://anysphere.cursor-mcp/oauth/other")).consentScreen,
+ ).toBe(false);
+ expect((await tryAuthorize(s, id, "http://localhost:9999/callback")).consentScreen).toBe(
+ true,
+ );
+ const register = (uri: string) =>
+ s.call("POST", "/api/auth/oauth2/register", {
+ body: { ...CURSOR_BODY, redirect_uris: [uri] },
+ });
+ for (const uri of [
+ "javascript://x/%0aalert(1)",
+ "data:/text/html,x",
+ "file:///etc/passwd",
+ "vbscript://x/y",
+ "about:/blank",
+ "blob:/x",
+ "httpx://evil.example/cb",
+ "https-app://cb/x",
+ "cursor:callback",
+ "myapp://app/callback",
+ ]) {
+ const r = await register(uri);
+ expect(r.status, uri).toBe(400);
+ expect(r.body.error, uri).toBe("invalid_redirect_uri");
+ }
+ expect((await register("com.example.app:/oauth/callback")).status).toBe(201);
+ expect(
+ (await register("vscode://vscode.github-authentication/did-authenticate")).status,
+ ).toBe(201);
+ // An operator's own scheme, and the host list's narrower rule.
+ const t = await setupServer(backend, {
+ registration: { dcr: true, redirectSchemes: ["myapp"], clientHosts: ["localhost"] },
+ });
+ const to = (uri: string) =>
+ t.call("POST", "/api/auth/oauth2/register", {
+ body: { ...CURSOR_BODY, redirect_uris: [uri] },
+ });
+ expect((await to("myapp://app/callback")).status).toBe(201);
+ expect((await to("cursor://anysphere.cursor-mcp/oauth/callback")).status).toBe(201);
+ expect((await to("com.example.app:/oauth/callback")).status).toBe(400);
+ // A web client never gets a private-use redirect, whatever the provider would infer.
+ for (const uri of ["cursor://anysphere.cursor-mcp/oauth/callback", "com.example.app:/cb"]) {
+ const web = await s.call("POST", "/api/auth/oauth2/register", {
+ body: { ...CURSOR_BODY, application_type: "web", redirect_uris: [uri] },
+ });
+ expect(web.status, uri).toBe(400);
+ expect(web.body.error, uri).toBe("invalid_redirect_uri");
+ }
+ });
+
+ it("applies the client host list at every authorization, to clients registered before it", async () => {
+ const s = await setupServer(backend);
+ const evil = "https://cursor-login.evil.example/cb";
+ const client = await registerClient(s, { client_name: "Cursor", redirect_uris: [evil] });
+ expect((await tryAuthorize(s, client, evil)).consentScreen).toBe(true);
+ const listed = await redeploy(s, {
+ registration: { dcr: true, clientHosts: ["cursor.com", "localhost"] },
+ });
+ const r = await tryAuthorize(listed, client, evil);
+ expect(r.consentScreen).toBe(false);
+ expect(r.reason).toBe("invalid redirect uri");
+ // A loopback redirect still passes with `localhost` listed.
+ expect((await tryAuthorize(listed, s.clientId)).consentScreen).toBe(true);
+ });
+
+ it("refuses new authorizations of DCR clients once DCR is off; their connections keep working", async () => {
+ const s = await setupServer(backend);
+ const { access, refresh } = await connect(s, {
+ cookie: s.owner.cookie,
+ scope: `${READ} offline_access`,
+ });
+ const off = await redeploy(s, { registration: { dcr: false } });
+ const r = await tryAuthorize(off, s.clientId);
+ expect(r.consentScreen).toBe(false);
+ expect(r.reason).toContain("dynamic client registration is off");
+ await (await mcpClient(off.app, access)).listTools();
+ const renewed = await token(off, {
+ grant_type: "refresh_token",
+ refresh_token: refresh ?? "",
+ });
+ expect(renewed.status, JSON.stringify(renewed.body)).toBe(200);
+ });
+
+ it("shows a client's name plain and short, its kind, and flags look-alikes of document clients", async () => {
+ const name = `Claude\u202e edoC\u200b ${"\u2003".repeat(400)}from claude.ai · sends you back to claude.ai ${"x".repeat(300)}`;
+ const transport = stubTransport((url) => ({
+ body: document({
+ client_id: url.toString(),
+ client_name: url.toString() === CLAUDE_CODE ? "Claude Code" : name,
+ }),
+ }));
+ const s = await setupServer(backend, {
+ registration: {
+ ...withCimd(transport).registration,
+ limits: { perAddressPerHour: 100 },
+ },
+ });
+ const consent = async (clientId: string, redirectUri = REDIRECT) => {
+ const start = await authorize(s, {
+ cookie: s.owner.cookie,
+ scope: READ,
+ clientId,
+ redirectUri,
+ });
+ const request = consentRequest(start.location as URL);
+ return (
+ await s.call("GET", `/api/agent-grants/consent?request=${encodeURIComponent(request)}`, {
+ cookie: s.owner.cookie,
+ })
+ ).body;
+ };
+ const info = async (clientId: string) => (await consent(clientId)).client as Json;
+ const doc = await info(DOC);
+ expect(doc).toMatchObject({ kind: "document", host: "agents.example.com" });
+ expect([...(doc.name as string)].length).toBeLessThanOrEqual(100);
+ expect(doc.name).not.toMatch(/[\u202e\u200b\u2003]/);
+ expect(doc.name).toMatch(/^Claude edoC from claude\.ai/);
+ // A document named like Claude on another host is flagged; Claude Code's own is not.
+ expect(doc.lookalike).toBe("Claude");
+ expect(await info(CLAUDE_CODE)).toMatchObject({
+ kind: "document",
+ host: "claude.ai",
+ name: "Claude Code",
+ });
+ expect((await info(CLAUDE_CODE)).lookalike).toBeUndefined();
+ // Self-registered names: confusables are folded before comparing; Cursor's own name is not
+ // a look-alike (Cursor always registers itself).
+ for (const fake of [
+ "Claude · Identified by claude.ai",
+ "\u0421laude",
+ "\uff23\uff4c\uff41\uff55\uff44\uff45",
+ "C1aude",
+ "CIaude",
+ "Chat\u0047\u0420T",
+ "VS \u0421ode",
+ "Cl\u00e1ude",
+ "Cla\u0307ude",
+ "\u1d04\u029f\u1d00\u1d1c\u1d05\u1d07",
+ "\u13dflaude",
+ "Cl\u0430\u057dde",
+ ]) {
+ const id = await registerClient(s, { client_name: fake });
+ expect((await info(id)).lookalike, fake).toBeDefined();
+ }
+ const cursor = await registerClient(s, { client_name: "Cursor" });
+ expect(await info(cursor)).toMatchObject({ kind: "self-registered", name: "Cursor" });
+ expect((await info(cursor)).lookalike).toBeUndefined();
+ expect((await info(s.clientId)).lookalike).toBeUndefined(); // "Test Agent"
+ // Stacked combining marks: a few per character at most.
+ const marks = await registerClient(s, { client_name: `Z${"\u0301".repeat(30)}oe` });
+ expect([...((await info(marks)).name as string)].length).toBe(5);
+ // A private-use redirect is an app on this computer, never a web host.
+ const app = "cursor://claude.ai/oauth/callback";
+ const native = await registerClient(s, { client_name: "Cursor", redirect_uris: [app] });
+ expect((await consent(native, app)).redirect).toEqual({
+ host: "",
+ loopback: true,
+ app: "cursor",
+ });
+ // The connection keeps the shown name.
+ await connect({ ...s, clientId: DOC }, { cookie: s.owner.cookie, scope: READ });
+ const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie }))
+ .body as unknown as Json[];
+ expect(grants.find((g) => g.clientId === DOC)?.clientName).toBe(doc.name);
+ void FETCH_FAILED;
+ });
+ });
+}
diff --git a/packages/api/src/testing-mcp-oauth-registration.ts b/packages/api/src/testing-mcp-oauth-registration.ts
new file mode 100644
index 00000000..6207b0bc
--- /dev/null
+++ b/packages/api/src/testing-mcp-oauth-registration.ts
@@ -0,0 +1,454 @@
+/**
+ * Shared suite for RFC-0044 Phase 3a (§3, §6, §8): how clients get a `client_id`, on
+ * better-sqlite3 and Miniflare D1.
+ *
+ * - CIMD with a stubbed transport (`cimdFetch` over `safeFetch`, the media import's SSRF rules): a
+ * good document connects all the way to `/mcp`; a wrong `client_id`, a private address, an
+ * oversized document, a redirect, a timeout and a host off the allow-list are refused; the
+ * metadata advertises `client_id_metadata_document_supported`.
+ * - DCR: the full flow to `/mcp`; the metadata rules (public only, redirect URIs, size); the rate
+ * limits per address and per day; the cap; pruning; the off switch; the allow-list.
+ * - Loopback redirects match without the port (RFC 8252 §7.3), nothing else does.
+ * - With CIMD and DCR off, an unknown client is refused.
+ */
+import type { FetchTransport } from "@cloudhal/core-ts/media";
+import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
+import { clearAccessTokenKeyCache } from "./auth/access-token.ts";
+import { cimdFetch } from "./auth/cimd-fetch.ts";
+import { pruneDcrClients } from "./auth/oauth-client-rows.ts";
+import type { OauthRegistration } from "./auth/oauth-registration.ts";
+import { redirectProblem } from "./auth/redirect-policy.ts";
+import { oauthClientsPruneJob } from "./oauth-prune-job.ts";
+import {
+ authorize,
+ connect,
+ ISSUER,
+ type Json,
+ mcpClient,
+ REDIRECT,
+ registerClient,
+ type Server,
+ setupServer,
+} from "./testing-oauth-harness.ts";
+import type { RolesBackend } from "./testing-roles.ts";
+
+const READ = "site:read content:read";
+const DAY = 86_400_000;
+/** A client metadata document URL (RFC-0044 §3: HTTPS with a path). */
+export const DOC = "https://agents.example.com/oauth/client.json";
+/** A public address the stub resolver answers (TEST-NET-3 is not public: use a real range). */
+const PUBLIC = "93.184.215.14";
+
+export interface Served {
+ readonly status?: number;
+ readonly headers?: Record;
+ readonly body?: string;
+ /** Never answers (the timeout case). */
+ readonly hang?: boolean;
+}
+
+/** A transport that serves documents from the test and records every request. */
+export function stubTransport(
+ serve: (url: URL) => Served,
+ addresses: readonly string[] = [PUBLIC],
+): FetchTransport & { readonly requests: string[] } {
+ const requests: string[] = [];
+ return {
+ name: "stub",
+ pins: true,
+ requests,
+ resolve: async () => [...addresses],
+ request: (url, init) => {
+ requests.push(url.toString());
+ const answer = serve(url);
+ if (answer.hang)
+ return new Promise((_, reject) =>
+ init.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }),
+ );
+ return Promise.resolve({
+ status: answer.status ?? 200,
+ headers: new Headers({ "content-type": "application/json", ...answer.headers }),
+ body: new Response(answer.body ?? "").body,
+ });
+ },
+ };
+}
+
+export function document(overrides: Json = {}): string {
+ return JSON.stringify({
+ client_id: DOC,
+ client_name: "Claude Code",
+ redirect_uris: ["http://127.0.0.1/callback", "http://localhost/callback"],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ ...overrides,
+ });
+}
+
+/** CIMD (and DCR, unless turned off) over `transport`, with a short timeout. */
+export function withCimd(transport: FetchTransport, extra: Partial = {}) {
+ return {
+ registration: {
+ cimd: cimdFetch({ transport, blockedHosts: ["localhost"], timeoutMs: 200 }),
+ dcr: true,
+ ...extra,
+ } satisfies OauthRegistration,
+ };
+}
+
+/** `authorize` as the member's browser for another client: the consent screen, or the refusal. */
+export async function tryAuthorize(s: Server, clientId: string, redirectUri = REDIRECT) {
+ const r = await authorize(s, { cookie: s.owner.cookie, scope: READ, clientId, redirectUri });
+ const consentScreen = r.location?.pathname === "/oauth/consent";
+ const text = consentScreen ? "" : await r.res.text();
+ // A refusal is a JSON error, or the authorization server's error page (never the redirect URI).
+ const reason = r.location?.searchParams.get("error_description") ?? text;
+ return { ...r, consentScreen, status: r.res.status, text, reason };
+}
+
+export const FETCH_FAILED = /Failed to fetch metadata document/;
+
+export function describeMcpOauthRegistration(label: string, backend: RolesBackend): void {
+ describe(`MCP OAuth client registration: ${label}`, { timeout: 180_000 }, () => {
+ afterAll(async () => {
+ await backend.dispose?.();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ clearAccessTokenKeyCache();
+ });
+
+ describe("Client ID Metadata Documents", () => {
+ it("connects a CIMD client all the way to /mcp, and advertises CIMD", async () => {
+ const transport = stubTransport(() => ({
+ body: document(),
+ headers: { "cache-control": "max-age=600" },
+ }));
+ const s = await setupServer(backend, withCimd(transport));
+ const meta = (await s.call("GET", "/.well-known/oauth-authorization-server/api/auth")).body;
+ expect(meta.client_id_metadata_document_supported).toBe(true);
+ expect(meta.token_endpoint_auth_methods_supported).toContain("none");
+ const client = { ...s, clientId: DOC };
+ // RFC 8252 §7.3: the document registers no port; the client picks one at run time.
+ const { access } = await connect(client, { cookie: s.owner.cookie, scope: READ });
+ await (await mcpClient(s.app, access)).listTools();
+ expect(transport.requests[0]).toBe(DOC);
+ // The connection is the member's, named as the document names the client.
+ const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie }))
+ .body as unknown as Json[];
+ expect(grants.find((g) => g.clientId === DOC)).toMatchObject({ clientName: "Claude Code" });
+ });
+
+ it("refuses a document whose client_id is not its URL", async () => {
+ const transport = stubTransport(() => ({
+ body: document({ client_id: "https://agents.example.com/other.json" }),
+ }));
+ const s = await setupServer(backend, withCimd(transport));
+ const r = await tryAuthorize(s, DOC);
+ expect(r.consentScreen).toBe(false);
+ expect(r.reason).toContain("does not match the metadata document URL");
+ });
+
+ it("never fetches from a private or loopback address, or an IP literal", async () => {
+ const privateDns = stubTransport(() => ({ body: document() }), [PUBLIC, "10.0.0.7"]);
+ const s = await setupServer(backend, withCimd(privateDns));
+ expect((await tryAuthorize(s, DOC)).reason).toMatch(FETCH_FAILED);
+ expect(privateDns.requests).toEqual([]);
+ const loopback = stubTransport(() => ({ body: document() }), ["127.0.0.1"]);
+ const t = await setupServer(backend, withCimd(loopback));
+ expect((await tryAuthorize(t, DOC)).consentScreen).toBe(false);
+ expect(loopback.requests).toEqual([]);
+ const literal = "https://93.184.215.14/client.json";
+ const any = stubTransport(() => ({ body: document({ client_id: literal }) }));
+ const u = await setupServer(backend, withCimd(any));
+ expect((await tryAuthorize(u, literal)).consentScreen).toBe(false);
+ expect(any.requests).toEqual([]);
+ });
+
+ it("never fetches from the deployment's own hosts or its sites' domains", async () => {
+ const transport = stubTransport(() => ({ body: document() }));
+ const s = await setupServer(backend, {
+ registration: {
+ cimd: cimdFetch({ transport, blockedHosts: async () => ["agents.example.com"] }),
+ dcr: true,
+ },
+ });
+ expect((await tryAuthorize(s, DOC)).reason).toMatch(FETCH_FAILED);
+ expect(transport.requests).toEqual([]);
+ });
+
+ it("refuses an oversized document, a redirect and a server that does not answer", async () => {
+ const big = stubTransport(() => ({
+ body: document({ client_name: "x".repeat(6 * 1024) }),
+ }));
+ const s = await setupServer(backend, withCimd(big));
+ expect((await tryAuthorize(s, DOC)).reason).toMatch(FETCH_FAILED);
+ const moved = stubTransport(() => ({
+ status: 302,
+ headers: { location: "https://agents.example.com/elsewhere.json" },
+ }));
+ const t = await setupServer(backend, withCimd(moved));
+ expect((await tryAuthorize(t, DOC)).reason).toMatch(FETCH_FAILED);
+ expect(moved.requests).toEqual([DOC]);
+ const slow = stubTransport(() => ({ hang: true }));
+ const u = await setupServer(backend, withCimd(slow));
+ const started = Date.now();
+ expect((await tryAuthorize(u, DOC)).reason).toMatch(FETCH_FAILED);
+ expect(Date.now() - started).toBeLessThan(5_000);
+ });
+
+ it("fetches only from allowed hosts when CLOUDHAL_MCP_CLIENT_HOSTS is set", async () => {
+ const transport = stubTransport(() => ({ body: document() }));
+ const s = await setupServer(
+ backend,
+ withCimd(transport, { clientHosts: ["claude.ai", "localhost"] }),
+ );
+ expect((await tryAuthorize(s, DOC)).consentScreen).toBe(false);
+ expect(transport.requests).toEqual([]);
+ const t = await setupServer(
+ backend,
+ withCimd(transport, { clientHosts: ["*.example.com", "localhost"] }),
+ );
+ expect((await tryAuthorize(t, DOC)).consentScreen).toBe(true);
+ });
+
+ it("never uses a document's redirect URI that the redirect rules refuse", async () => {
+ const transport = stubTransport(() => ({
+ body: document({
+ redirect_uris: ["http://127.0.0.1/callback", "myapp://app/callback"],
+ }),
+ }));
+ const s = await setupServer(backend, withCimd(transport));
+ expect((await tryAuthorize(s, DOC)).consentScreen).toBe(true);
+ expect((await tryAuthorize(s, DOC, "myapp://app/callback")).consentScreen).toBe(false);
+ });
+ });
+
+ describe("dynamic client registration", () => {
+ const register = (s: Server, body: Json, headers: Record = {}) =>
+ s.call("POST", "/api/auth/oauth2/register", {
+ body: {
+ client_name: "Cursor",
+ redirect_uris: ["http://localhost:8787/callback"],
+ ...body,
+ },
+ headers,
+ });
+
+ it("registers a public client that connects to /mcp; the metadata names the endpoint", async () => {
+ const s = await setupServer(backend);
+ const meta = (await s.call("GET", "/.well-known/oauth-authorization-server")).body;
+ expect(meta.registration_endpoint).toBe(`${ISSUER}/oauth2/register`);
+ const res = await register(s, { redirect_uris: [REDIRECT] });
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ expect(res.res.headers.get("cache-control")).toBe("no-store");
+ // Absent, the method is `none`: no secret is ever minted.
+ expect(res.body.token_endpoint_auth_method).toBe("none");
+ expect(res.body.client_secret).toBeUndefined();
+ const client = { ...s, clientId: res.body.client_id as string };
+ const { access } = await connect(client, { cookie: s.owner.cookie, scope: READ });
+ await (await mcpClient(s.app, access)).listTools();
+ });
+
+ it("takes public clients with https, loopback or known app redirect URIs only", async () => {
+ const s = await setupServer(backend, {
+ registration: { dcr: true, limits: { perAddressPerHour: 100 } },
+ });
+ const refused: [Json, string][] = [
+ [{ token_endpoint_auth_method: "client_secret_basic" }, "invalid_client_metadata"],
+ [{ redirect_uris: ["http://cursor.example.com/callback"] }, "invalid_redirect_uri"],
+ [{ redirect_uris: ["myapp://app/callback"] }, "invalid_redirect_uri"],
+ [{ redirect_uris: [REDIRECT, "javascript://x/%0aalert(1)"] }, "invalid_redirect_uri"],
+ [{ redirect_uris: ["https://localhost/callback"] }, "invalid_redirect_uri"],
+ [{ redirect_uris: ["https://app.example.com/cb#x"] }, "invalid_redirect_uri"],
+ [{ redirect_uris: [] }, "invalid_redirect_uri"],
+ [
+ { redirect_uris: Array.from({ length: 11 }, (_, i) => `https://a.example.com/${i}`) },
+ "invalid_redirect_uri",
+ ],
+ [{ client_name: "" }, "invalid_client_metadata"],
+ [{ client_name: "x".repeat(101) }, "invalid_client_metadata"],
+ [{ grant_types: ["client_credentials"] }, "invalid_client_metadata"],
+ [{ response_types: ["token"] }, "invalid_client_metadata"],
+ [{ scope: "*" }, "invalid_scope"],
+ ];
+ for (const [body, error] of refused) {
+ const r = await register(s, body);
+ expect(r.status, JSON.stringify(body)).toBe(400);
+ expect(r.body.error, JSON.stringify(body)).toBe(error);
+ }
+ const big = await register(s, { software_statement: "x".repeat(6 * 1024) });
+ expect(big.status).toBe(413);
+ // Self-asserted URIs and keys are dropped, never stored or shown (RFC 7591 §5).
+ const ok = await register(s, {
+ logo_uri: "https://evil.example.com/logo.png",
+ jwks_uri: "https://evil.example.com/jwks",
+ grant_types: ["authorization_code", "refresh_token", "client_credentials"],
+ });
+ expect(ok.status, JSON.stringify(ok.body)).toBe(201);
+ expect(ok.body.logo_uri).toBeUndefined();
+ expect(ok.body.jwks_uri).toBeUndefined();
+ expect(ok.body.grant_types).toEqual(["authorization_code", "refresh_token"]);
+ });
+
+ it("never reaches the provider's own endpoint over HTTP, even past createApi", async () => {
+ const s = await setupServer(backend);
+ const direct = await s.auth.handler(
+ new Request(`${ISSUER}/oauth2/register`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ client_name: "x", redirect_uris: [REDIRECT] }),
+ }),
+ );
+ expect(direct.status).toBe(404);
+ });
+
+ it("limits registrations per address per hour and per installation per day", async () => {
+ const s = await setupServer(backend, {
+ registration: { dcr: true, limits: { perAddressPerHour: 2, perDay: 4 } },
+ });
+ const from = (ip: string) => ({ "cf-connecting-ip": ip });
+ expect((await register(s, {}, from("203.0.113.1"))).status).toBe(201);
+ // Invalid attempts count too.
+ expect((await register(s, { client_name: "" }, from("203.0.113.1"))).status).toBe(400);
+ const third = await register(s, {}, from("203.0.113.1"));
+ expect(third.status).toBe(429);
+ expect(third.body.error).toBe("rate_limited");
+ expect((await register(s, {}, from("203.0.113.2"))).status).toBe(201);
+ // setupServer's own registration was the first of the day: this is the fifth.
+ expect((await register(s, {}, from("203.0.113.3"))).status).toBe(429);
+ });
+
+ it("refuses registrations beyond the cap with 503", async () => {
+ const s = await setupServer(backend, {
+ registration: { dcr: true, limits: { maxClients: 2 } },
+ });
+ expect((await register(s, {})).status).toBe(201);
+ const full = await register(s, {});
+ expect(full.status).toBe(503);
+ expect(full.body.error).toBe("temporarily_unavailable");
+ });
+
+ it("prunes clients nobody used for 30 days, never one with a live connection", async () => {
+ const s = await setupServer(backend);
+ // s.clientId: connected and live. `idle`: never connected. `ended`: connected, revoked.
+ await connect(s, { cookie: s.owner.cookie, scope: READ });
+ const idle = await registerClient(s);
+ const ended = { ...s, clientId: await registerClient(s) };
+ await connect(ended, { cookie: s.owner.cookie, scope: `${READ} offline_access` });
+ const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie }))
+ .body as unknown as Json[];
+ const endedGrant = grants.find((g) => g.clientId === ended.clientId);
+ await s.call("DELETE", `/api/agent-grants/${endedGrant?.id}`, {
+ cookie: s.owner.cookie,
+ headers: { origin: s.origin },
+ });
+ const clients = async () =>
+ (await s.sql.all<{ clientId: string }>(`SELECT "clientId" FROM "oauthClient"`)).map(
+ (r) => r.clientId,
+ );
+ // Within 30 days nothing goes.
+ expect(await pruneDcrClients(s.sql, { now: new Date(Date.now() + 29 * DAY) })).toBe(0);
+ // The scheduler's hourly job does it (Workers and Node), and says so.
+ const lines: string[] = [];
+ await oauthClientsPruneJob(s.sql, (l) => lines.push(l)).run({
+ now: new Date(Date.now() + 31 * DAY),
+ trigger: "cron",
+ });
+ expect(lines).toEqual([
+ "oauth clients: 2 unused registration(s), 0 unused document client(s) deleted",
+ ]);
+ expect(await clients()).toEqual([s.clientId]);
+ const leftovers = await s.sql.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM "oauthRefreshToken" WHERE "clientId" = ?`,
+ [ended.clientId],
+ );
+ expect(Number(leftovers?.n)).toBe(0);
+ // A pruned client is unknown: it registers again.
+ expect((await tryAuthorize(s, idle)).consentScreen).toBe(false);
+ });
+
+ it("is off with CLOUDHAL_MCP_DCR=off; with CIMD off too, unknown clients are refused", async () => {
+ const s = await setupServer(backend, { registration: { dcr: false } });
+ const meta = (await s.call("GET", "/.well-known/oauth-authorization-server")).body;
+ expect(meta.registration_endpoint).toBeUndefined();
+ expect(meta.client_id_metadata_document_supported).toBeUndefined();
+ expect((await register(s, {})).status).toBe(404);
+ expect((await tryAuthorize(s, "made-up-client")).consentScreen).toBe(false);
+ expect((await tryAuthorize(s, DOC)).consentScreen).toBe(false);
+ });
+
+ it("takes only redirect hosts on CLOUDHAL_MCP_CLIENT_HOSTS when it is set", async () => {
+ // `localhost` on the list admits loopback redirects (setupServer registers one).
+ const t = await setupServer(backend, {
+ registration: { dcr: true, clientHosts: ["cursor.com", "localhost"] },
+ });
+ const to = (uri: string) => register(t, { redirect_uris: [uri] });
+ expect((await to("https://www.cursor.com/agents/mcp/oauth/callback")).status).toBe(400);
+ expect((await to("https://claude.ai/api/mcp/auth_callback")).status).toBe(400);
+ expect((await to("https://cursor.com/agents/mcp/oauth/callback")).status).toBe(201);
+ expect((await to(REDIRECT)).status).toBe(201);
+ });
+ });
+
+ describe("loopback redirect matching (RFC 8252 §7.3)", () => {
+ it("matches loopback redirects on any port, and nothing else loosely", async () => {
+ const s = await setupServer(backend);
+ const client = await registerClient(s, { redirect_uris: ["http://127.0.0.1/callback"] });
+ for (const port of ["33418", "8080", ""]) {
+ const uri = `http://127.0.0.1${port ? `:${port}` : ""}/callback`;
+ expect((await tryAuthorize(s, client, uri)).consentScreen, uri).toBe(true);
+ }
+ for (const uri of [
+ "http://localhost:33418/callback",
+ "http://127.0.0.1:33418/callback/other",
+ "http://127.0.0.1:33418/Callback",
+ "http://127.0.0.1:33418/callback?x=1",
+ "https://127.0.0.1:33418/callback",
+ ])
+ expect((await tryAuthorize(s, client, uri)).consentScreen, uri).toBe(false);
+ const web = await registerClient(s, { redirect_uris: ["https://app.example.com/cb"] });
+ expect((await tryAuthorize(s, web, "https://app.example.com/cb")).consentScreen).toBe(true);
+ expect((await tryAuthorize(s, web, "https://app.example.com:8443/cb")).consentScreen).toBe(
+ false,
+ );
+ });
+
+ it("checks redirect URIs with linear rules", () => {
+ expect(redirectProblem("http://localhost:1/cb")).toBeUndefined();
+ expect(redirectProblem("http://[::1]:1/cb")).toBeUndefined();
+ expect(redirectProblem("https://claude.ai/api/mcp/auth_callback")).toBeUndefined();
+ expect(redirectProblem("http://127.1/cb")).toBeDefined();
+ expect(redirectProblem("http://localhost.evil.com/cb")).toBeDefined();
+ expect(redirectProblem("http://user@localhost/cb")).toBeDefined();
+ expect(redirectProblem(`https://a.com/${"a".repeat(100_000)}`)).toBeDefined();
+ expect(redirectProblem("http://".padEnd(100_000, "["))).toBeDefined();
+ expect(
+ redirectProblem("https://x.cursor.com/cb", { clientHosts: ["*.cursor.com"] }),
+ ).toBeUndefined();
+ expect(
+ redirectProblem("https://cursor.com/cb", { clientHosts: ["*.cursor.com"] }),
+ ).toBeDefined();
+ expect(
+ redirectProblem("http://localhost:9/cb", { clientHosts: ["claude.ai"] }),
+ ).toBeDefined();
+ expect(
+ redirectProblem("http://localhost:9/cb", { clientHosts: ["localhost"] }),
+ ).toBeUndefined();
+ });
+ });
+
+ it("never takes a client-sent X-Forwarded-For as the address (#165)", async () => {
+ const s = await setupServer(backend, {
+ registration: { dcr: true, limits: { perAddressPerHour: 1 } },
+ });
+ // setupServer's registration used the shared bucket of requests without a trusted address.
+ const r = await s.call("POST", "/api/auth/oauth2/register", {
+ body: { client_name: "x", redirect_uris: [REDIRECT] },
+ headers: { "x-forwarded-for": "198.51.100.9" },
+ });
+ expect(r.status).toBe(429);
+ });
+ });
+}
diff --git a/packages/api/src/testing-mcp-oauth-server.ts b/packages/api/src/testing-mcp-oauth-server.ts
index 99a55c87..87aead5d 100644
--- a/packages/api/src/testing-mcp-oauth-server.ts
+++ b/packages/api/src/testing-mcp-oauth-server.ts
@@ -3,8 +3,8 @@
* API and the token path on `/mcp`, on better-sqlite3 and Miniflare D1. Discovery leads to the
* AS metadata; the code flow with PKCE (S256 only); the token on `/mcp`; refresh rotation with
* its reuse window; revocation; `aud` and `iss` bound to the deployment; step-up then a new
- * consent; scopes narrowed by the member's installation role. The client is pre-registered
- * server-side (`OauthServer.createClient`): registration (CIMD, DCR) is Phase 3.
+ * consent; scopes narrowed by the member's installation role. The client registers through DCR
+ * (`registerClient`); registration itself is `testing-mcp-oauth-registration.ts`'s.
*/
import { narrow, roleScopes, scopesForMode } from "@cloudhal/agent-runtime";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
@@ -67,8 +67,11 @@ export function describeMcpOauthServer(label: string, backend: RolesBackend): vo
expect(meta.authorization_response_iss_parameter_supported).toBe(true);
expect(meta.grant_types_supported).toEqual(["authorization_code", "refresh_token"]);
expect(meta.scopes_supported).toEqual(OAUTH_SCOPES);
- // Phase 2: no registration endpoint yet (CIMD and DCR are Phase 3).
- expect(meta.registration_endpoint).toBeUndefined();
+ // Phase 3: DCR is on in this deployment (the harness's default), CIMD is not.
+ expect(meta.registration_endpoint).toBe(`${ISSUER}/oauth2/register`);
+ expect(meta.client_id_metadata_document_supported).toBeUndefined();
+ // §2: public clients and private_key_jwt only; no client holds a shared secret.
+ expect(meta.token_endpoint_auth_methods_supported).toEqual(["none", "private_key_jwt"]);
// Decision 6: the root alias returns the same document.
const alias = await s.app.request(`${ORIGIN}/.well-known/oauth-authorization-server`);
expect(await alias.json()).toEqual(meta);
@@ -85,7 +88,11 @@ export function describeMcpOauthServer(label: string, backend: RolesBackend): vo
{ cookie: s.owner.cookie },
);
expect(info.status, JSON.stringify(info.body)).toBe(200);
- expect(info.body.client).toEqual({ id: s.clientId, name: "Test Agent" });
+ expect(info.body.client).toEqual({
+ id: s.clientId,
+ name: "Test Agent",
+ kind: "self-registered",
+ });
expect(info.body.redirect).toEqual({ host: "127.0.0.1:33418", loopback: true });
expect(info.body.loopbackOnly).toBe(true);
expect(info.body.scopes).toEqual([
@@ -190,7 +197,9 @@ export function describeMcpOauthServer(label: string, backend: RolesBackend): vo
for (const path of [
"/api/auth/oauth2/consent",
"/api/auth/oauth2/create-client",
- "/api/auth/oauth2/register",
+ // Only Cloudhal's checked route registers: no variant reaches the provider's own.
+ "/api/auth/oauth2/register/",
+ "/api/auth/oauth2/%72egister",
"/api/auth/oauth2/get-consents",
"/api/auth/oauth2/introspect",
"/api/auth/token",
diff --git a/packages/api/src/testing-oauth-harness.ts b/packages/api/src/testing-oauth-harness.ts
index 30d5e0ef..f4f5b62d 100644
--- a/packages/api/src/testing-oauth-harness.ts
+++ b/packages/api/src/testing-oauth-harness.ts
@@ -1,15 +1,16 @@
/**
- * Test harness for RFC-0044 Phase 2: a deployment that runs the authorization server, a
- * pre-registered public client (Phase 2 has no registration endpoint; `OauthServer.createClient`
- * is server-side only), and the browser and client halves of the code flow with PKCE, driven
- * with `app.request` and a signed-in session cookie. Used by `testing-mcp-oauth-server.ts`.
+ * Test harness for RFC-0044: a deployment that runs the authorization server, a public client
+ * registered the way a client without a metadata document registers (DCR, `POST
+ * /api/auth/oauth2/register`; CIMD clients are `testing-mcp-oauth-registration.ts`'s), and the
+ * browser and client halves of the code flow with PKCE, driven with `app.request` and a signed-in
+ * session cookie. Used by `testing-mcp-oauth-server.ts` and the registration suite.
*/
import { AuditLog } from "@cloudhal/agent-runtime";
import { seedStarterSite } from "@cloudhal/core-ts";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { expect } from "vitest";
import { createApi } from "./app.ts";
-import { createAuth } from "./auth.ts";
+import { createAuth, type OauthRegistration } from "./auth.ts";
import type { RolesBackend } from "./testing-roles.ts";
export type Json = Record;
@@ -29,6 +30,8 @@ export interface DeploymentOptions {
readonly resource?: string;
/** The trusted client-address header (`AuthOptions.clientAddressHeader`; turns rate limits on). */
readonly clientAddressHeader?: string;
+ /** CIMD and DCR (default: DCR on, CIMD off). */
+ readonly registration?: OauthRegistration;
}
export type Deployment = Awaited>;
@@ -95,7 +98,10 @@ export async function deploy(
baseURL,
secret: SECRET,
signUp: "first-user",
- mcpOauth: { resource: options.resource ?? `${new URL(baseURL).origin}/mcp` },
+ mcpOauth: {
+ resource: options.resource ?? `${new URL(baseURL).origin}/mcp`,
+ registration: options.registration ?? { dcr: true },
+ },
...(options.clientAddressHeader ? { clientAddressHeader: options.clientAddressHeader } : {}),
});
const server = auth.oauth;
@@ -153,11 +159,10 @@ export async function setupServer(backend: RolesBackend, options: DeploymentOpti
});
expect(signUp.status, JSON.stringify(signUp.body)).toBe(200);
const owner = { cookie: cookieOf(signUp.res), userId: (signUp.body.user as Json).id as string };
- const { clientId } = await d.server.createClient({
- headers: new Headers({ cookie: owner.cookie }),
- name: "Test Agent",
- redirectUris: [REDIRECT],
- });
+ const clientId =
+ options.registration?.dcr === false
+ ? ""
+ : await registerClient(d, { client_name: "Test Agent" });
/** A member who joined through an invite (RFC-0005 §7), signed in. */
/** `email` again: a removed member invited back keeps their account (RFC-0005 §7.4). */
const join = async (role: string, extra: Json = {}, again?: string) => {
@@ -183,10 +188,28 @@ export async function setupServer(backend: RolesBackend, options: DeploymentOpti
export type Server = Awaited>;
+/** RFC 7591 registration as an MCP client sends it; the new `client_id`. */
+export async function registerClient(d: Deployment, metadata: Json = {}): Promise {
+ const res = await d.call("POST", "/api/auth/oauth2/register", {
+ body: {
+ client_name: "Test Agent",
+ redirect_uris: [REDIRECT],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ ...metadata,
+ },
+ });
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ return res.body.client_id as string;
+}
+
export interface AuthorizeOptions {
readonly cookie: string;
readonly scope: string;
readonly clientId?: string;
+ /** Default `REDIRECT`. */
+ readonly redirectUri?: string;
readonly challengeMethod?: string | null;
readonly resource?: string;
/** `POST`: the parameters as a form body (the provider accepts both). */
@@ -199,7 +222,7 @@ export async function authorize(s: Server, o: AuthorizeOptions) {
const params = new URLSearchParams({
response_type: "code",
client_id: o.clientId ?? s.clientId,
- redirect_uri: REDIRECT,
+ redirect_uri: o.redirectUri ?? REDIRECT,
scope: o.scope,
state: "st4te",
resource: o.resource ?? RESOURCE,
@@ -255,9 +278,16 @@ export async function token(s: Server, form: Record) {
/** The whole flow: authorize, consent with every requested scope, exchange the code. */
export async function connect(
s: Server,
- o: { cookie: string; scope: string; mode?: string; keep?: readonly string[] },
+ o: {
+ cookie: string;
+ scope: string;
+ mode?: string;
+ keep?: readonly string[];
+ redirectUri?: string;
+ },
) {
- const start = await authorize(s, { cookie: o.cookie, scope: o.scope });
+ const redirectUri = o.redirectUri ?? REDIRECT;
+ const start = await authorize(s, { cookie: o.cookie, scope: o.scope, redirectUri });
expect(start.location?.pathname, start.location?.href).toBe("/oauth/consent");
const request = consentRequest(start.location as URL);
const approved = await consent(s, o.cookie, request, {
@@ -272,7 +302,7 @@ export async function connect(
const tokens = await token(s, {
grant_type: "authorization_code",
code,
- redirect_uri: REDIRECT,
+ redirect_uri: redirectUri,
code_verifier: start.verifier,
});
expect(tokens.status, JSON.stringify(tokens.body)).toBe(200);
diff --git a/packages/api/src/testing-schedules.ts b/packages/api/src/testing-schedules.ts
index aeb15c59..131d356e 100644
--- a/packages/api/src/testing-schedules.ts
+++ b/packages/api/src/testing-schedules.ts
@@ -24,6 +24,7 @@ import {
requestRelease,
type SchedulesDeps,
} from "@cloudhal/mcp-server/schedules";
+import { defineTools } from "@cloudhal/mcp-server/tools";
import { SqlScheduleStore } from "@cloudhal/schedules/sql";
import { MemoryBlobStore, type SqlDriver } from "@cloudhal/storage";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
@@ -525,6 +526,67 @@ export function describeSchedulesApi(label: string, factory: SchedulesApiFactory
});
});
+ it("an OAuth connection's set_embargo waits for a person; approving puts it live (RFC-0044)", async () => {
+ const live = (await store.listContentEntries(site.id))[0] as ContentEntry;
+ const setEmbargo = defineTools().find((t) => t.name === "set_embargo");
+ if (!setEmbargo) throw new Error("set_embargo");
+ // A token principal: an agent with a grant and no key, the member behind it an Editor.
+ const editor = person("editor");
+ const principal: Principal = {
+ actor: agent,
+ scopes: ["schedules:write", "content:write", "content:read", "site:read"],
+ mode: "edits",
+ grantId: "grant_desk00000001",
+ ...(editor.person ? { person: editor.person } : {}),
+ };
+ const result = (await setEmbargo.handler(
+ { siteId: site.id, nodeId: live.id, from: plus(5 * HOUR), expectedRev: live.rev },
+ { store, principal, schedules: deps, approvals, now: () => clock.now },
+ )) as { status: string; approvalId: string; release: Release };
+ expect(result.status).toBe("approval-pending");
+ expect(result.release.state).toBe("awaiting_approval");
+ // the people who may decide it are told (§14), the agent's own member included, for as
+ // long as the request waits (not only in the minute it was filed)
+ clock.now = new Date(NOW.getTime() + 3 * HOUR);
+ const kinds = ((await call("/api/notifications", "editor")).body.items as Json[]).map(
+ (n) => n.kind,
+ );
+ expect(kinds).toContain("schedule-approval-due");
+ const approved = await post(`${S()}/approvals/${result.approvalId}/approve`, {}, "owner");
+ expect(approved.status, approved.text).toBe(200);
+ expect(await schedules.getRelease(site.id, result.release.id)).toMatchObject({
+ state: "scheduled",
+ when: { kind: "on-approval" },
+ approval: { kind: "person", approvalId: result.approvalId },
+ });
+ });
+
+ it("stops telling deciders about an OAuth connection's embargo once its request expired", async () => {
+ const live = (await store.listContentEntries(site.id))[0] as ContentEntry;
+ const setEmbargo = defineTools().find((t) => t.name === "set_embargo");
+ if (!setEmbargo) throw new Error("set_embargo");
+ const editor = person("editor");
+ const principal: Principal = {
+ actor: agent,
+ scopes: ["schedules:write", "content:write", "content:read", "site:read"],
+ mode: "edits",
+ grantId: "grant_desk00000002",
+ ...(editor.person ? { person: editor.person } : {}),
+ };
+ const result = (await setEmbargo.handler(
+ { siteId: site.id, nodeId: live.id, from: plus(30 * HOUR), expectedRev: live.rev },
+ { store, principal, schedules: deps, approvals, now: () => clock.now },
+ )) as { status: string; approvalId: string };
+ expect(result.status).toBe("approval-pending");
+ const kinds = async () =>
+ ((await call("/api/notifications", "editor")).body.items as Json[]).map((n) => n.kind);
+ clock.now = new Date(NOW.getTime() + 23 * HOUR);
+ expect(await kinds()).toContain("schedule-approval-due");
+ // The request lives 24 hours; after that nobody can approve it, so nobody is told to.
+ clock.now = new Date(NOW.getTime() + 25 * HOUR);
+ expect(await kinds()).not.toContain("schedule-approval-due");
+ });
+
it("automations: built-ins and agent tasks with their key; pause, resume, delete; preview", async () => {
const builtin = await post(`${S()}/automations`, {
name: "Links",
diff --git a/packages/api/test/better-auth-pins.test.ts b/packages/api/test/better-auth-pins.test.ts
index f2378bab..b2fcaab8 100644
--- a/packages/api/test/better-auth-pins.test.ts
+++ b/packages/api/test/better-auth-pins.test.ts
@@ -51,6 +51,7 @@ describe("better-auth family pins", () => {
const versions = catalog();
for (const name of [
"@better-auth/api-key",
+ "@better-auth/cimd",
"@better-auth/kysely-adapter",
"@better-auth/mcp",
"@better-auth/oauth-provider",
diff --git a/packages/api/test/mcp-oauth-registration.test.ts b/packages/api/test/mcp-oauth-registration.test.ts
new file mode 100644
index 00000000..ab75ad29
--- /dev/null
+++ b/packages/api/test/mcp-oauth-registration.test.ts
@@ -0,0 +1,42 @@
+import { SiteGraphStore } from "@cloudhal/core-ts";
+import { MemoryBlobStore } from "@cloudhal/storage";
+import { D1Driver } from "@cloudhal/storage/d1";
+import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite";
+import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing";
+import Database from "better-sqlite3";
+import { describeMcpOauthBounds } from "../src/testing-mcp-oauth-bounds.ts";
+import { describeMcpOauthRegistration } from "../src/testing-mcp-oauth-registration.ts";
+
+const sqlite = {
+ async create() {
+ const database = new Database(":memory:");
+ const sql = new BetterSqlite3Driver(database);
+ const blobs = new MemoryBlobStore();
+ const store = new SiteGraphStore({ sql, blobs });
+ await store.init();
+ return { store, sql, blobs, database };
+ },
+};
+describeMcpOauthRegistration("better-sqlite3", sqlite);
+describeMcpOauthBounds("better-sqlite3", sqlite);
+
+let d1: Awaited> | undefined;
+const reset = createTableReset();
+const miniflare = {
+ async create() {
+ d1 ??= await createMiniflareD1();
+ const sql = new D1Driver(d1.db);
+ const blobs = new MemoryBlobStore();
+ const store = new SiteGraphStore({ sql, blobs });
+ await reset(sql, () => store.init());
+ return { store, sql, blobs, database: d1.db };
+ },
+};
+// One Miniflare for both suites (the table reset keeps its schema); closed after the second.
+describeMcpOauthRegistration("Cloudflare D1 (Miniflare)", miniflare);
+describeMcpOauthBounds("Cloudflare D1 (Miniflare)", {
+ ...miniflare,
+ async dispose() {
+ await d1?.dispose();
+ },
+});
diff --git a/packages/cli/src/commands/scheduler-local.ts b/packages/cli/src/commands/scheduler-local.ts
index f797449a..64b2a6d7 100644
--- a/packages/cli/src/commands/scheduler-local.ts
+++ b/packages/cli/src/commands/scheduler-local.ts
@@ -14,7 +14,7 @@
* (§11.6): automations stop (no occurrences, no windows, automation keys refused); releases and
* gates go on.
*/
-import { releaseApprovalCheck } from "@cloudhal/api";
+import { oauthClientsPruneJob, releaseApprovalCheck } from "@cloudhal/api";
import { SqlCommentStore } from "@cloudhal/comments/sql";
import type { SiteGraphStore } from "@cloudhal/core-ts";
import {
@@ -108,6 +108,7 @@ export function localSystemJobs(
o.log(`schedules retention: ${r.runs} run(s), ${r.releases} release(s) deleted`);
},
},
+ oauthClientsPruneJob(sql, o.log),
];
if (o.comments)
jobs.push({
diff --git a/packages/cli/src/commands/studio.ts b/packages/cli/src/commands/studio.ts
index dc07a94f..e76777d7 100644
--- a/packages/cli/src/commands/studio.ts
+++ b/packages/cli/src/commands/studio.ts
@@ -2,8 +2,14 @@ import { access } from "node:fs/promises";
import path from "node:path";
import { ApprovalStore, AuditLog, loadSkills } from "@cloudhal/agent-runtime";
import { AiStore, type CloudhalApi, createApi, restTransport } from "@cloudhal/api";
-import { mcpOauthFor, type OauthServer } from "@cloudhal/api/auth";
+import {
+ cimdFetch,
+ mcpOauthFor,
+ type OauthServer,
+ oauthRegistrationFrom,
+} from "@cloudhal/api/auth";
import { CLIENT_ADDRESS_HEADER, serveNode } from "@cloudhal/api/node";
+import type { SiteGraphStore } from "@cloudhal/core-ts";
import { openLocalStore } from "@cloudhal/core-ts/local";
import { nodeTransport } from "@cloudhal/core-ts/media-node";
import { SqlScheduleStore } from "@cloudhal/schedules/sql";
@@ -170,7 +176,7 @@ export async function studio(options: StudioOptions): Promise {
};
let firstOwner: SetupToken | undefined;
// RFC-0044 §10: the issuer is the public base URL; without one, OAuth stays off.
- const oauth = mcpOauthFor(server.baseUrl, env.CLOUDHAL_MCP_OAUTH);
+ const oauth = studioOauth(server.baseUrl, env, { store: local.store, log: options.log });
if (setup) {
const { createAuthFor } = await import("@cloudhal/api/auth/node");
const auth = await createAuthFor({
@@ -224,6 +230,26 @@ export async function studio(options: StudioOptions): Promise {
};
}
+/**
+ * The authorization server's options (RFC-0044 §10). §3: client metadata documents come through
+ * the media import's pinning Node transport, never from this Studio's own host or a site's
+ * domain; `CLOUDHAL_MCP_CIMD`, `_DCR` and `_CLIENT_HOSTS` switch registration.
+ */
+function studioOauth(
+ baseUrl: string | undefined,
+ env: Readonly>,
+ o: { store: Pick; log: ((line: string) => void) | undefined },
+): ReturnType {
+ const ownHosts = async () => [
+ ...(baseUrl ? [new URL(baseUrl).hostname] : []),
+ ...(await o.store.listSites()).flatMap((s) => s.domains.map((d) => d.hostname)),
+ ];
+ const cimd = cimdFetch({ transport: nodeTransport(), blockedHosts: ownHosts });
+ const registration = oauthRegistrationFrom(env, cimd);
+ if (registration.problem) o.log?.(`OAuth client registration is off: ${registration.problem}`);
+ return mcpOauthFor(baseUrl, env.CLOUDHAL_MCP_OAUTH, registration);
+}
+
/** Logs why remote MCP clients cannot connect by URL (RFC-0044 §10); nothing when they can. */
function logOauthOff(
log: ((line: string) => void) | undefined,
diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts
index 902b8c43..a28f8cb3 100644
--- a/packages/cloudflare/src/admin-worker.ts
+++ b/packages/cloudflare/src/admin-worker.ts
@@ -47,11 +47,18 @@ import { AnalyticsService } from "@cloudhal/analytics/service";
import { SqlQueryBudget } from "@cloudhal/analytics/sql";
import { DATASET_RE, WaeReader } from "@cloudhal/analytics/wae";
import { type AiBinding, AiStore, bindingTransport, createApi } from "@cloudhal/api";
-import { type CloudhalAuth, createAuth, mcpOauthFor } from "@cloudhal/api/auth";
+import {
+ type CloudhalAuth,
+ cimdFetch,
+ createAuth,
+ mcpOauthFor,
+ oauthRegistrationFrom,
+} from "@cloudhal/api/auth";
import { PURGE_LIMITS } from "@cloudhal/comments";
import { SqlCommentStore } from "@cloudhal/comments/sql";
import { type AiSearchEnv, SiteGraphStore, searchProviderFromEnv } from "@cloudhal/core-ts";
import { type BlockType, createBlockRegistry, type PropsSchema } from "@cloudhal/core-ts/blocks";
+import { workersTransport } from "@cloudhal/core-ts/media";
import { SqlScheduleStore } from "@cloudhal/schedules/sql";
import { migrate, migrations, type SqlDriver } from "@cloudhal/storage";
import { D1Driver } from "@cloudhal/storage/d1";
@@ -106,20 +113,41 @@ export interface AdminEnv extends CommentsEnv, SchedulerEnv, MailEnv, BuildRooms
CLOUDHAL_RENDER?: string;
/** `off` turns the OAuth authorization server for remote MCP clients off (RFC-0044 §10). */
CLOUDHAL_MCP_OAUTH?: string;
+ /** `off`: no Client ID Metadata Documents (RFC-0044 §3). */
+ CLOUDHAL_MCP_CIMD?: string;
+ /** `off`: no dynamic client registration (RFC-0044 §3; Cursor needs it). */
+ CLOUDHAL_MCP_DCR?: string;
+ /** Comma-separated hosts OAuth clients may come from (`claude.ai,*.example.com`); unset: any. */
+ CLOUDHAL_MCP_CLIENT_HOSTS?: string;
+ /** Extra private-use redirect schemes of native clients (besides `cursor`, `vscode`). */
+ CLOUDHAL_MCP_REDIRECT_SCHEMES?: string;
}
/**
* Better Auth for one request: request-scoped state (background tasks, plugin promises) and no
* I/O at construction. RFC-0044 §10: the OAuth issuer is the origin the request came to
- * (`CLOUDHAL_MCP_OAUTH=off` turns the authorization server off).
+ * (`CLOUDHAL_MCP_OAUTH=off` turns the authorization server off). §3: client metadata documents
+ * are fetched with the media import's Workers transport, never from this Worker's own host or a
+ * site's domain (read only when a document is fetched).
*/
function adminAuth(
env: AdminEnv,
sql: D1Driver,
url: URL,
- run: { readonly waitUntil: (p: Promise) => void; readonly log: (line: string) => void },
+ run: {
+ readonly waitUntil: (p: Promise) => void;
+ readonly log: (line: string) => void;
+ readonly store: Pick;
+ },
): CloudhalAuth {
- const oauth = mcpOauthFor(url.origin, env.CLOUDHAL_MCP_OAUTH);
+ const ownHosts = async () => [
+ url.hostname,
+ ...(await run.store.listSites()).flatMap((site) => site.domains.map((d) => d.hostname)),
+ ];
+ const cimd = cimdFetch({ transport: workersTransport(), blockedHosts: ownHosts });
+ const registration = oauthRegistrationFrom(env, cimd);
+ if (registration.problem) run.log(`OAuth client registration is off: ${registration.problem}`);
+ const oauth = mcpOauthFor(url.origin, env.CLOUDHAL_MCP_OAUTH, registration);
return createAuth({
database: env.DB,
sql,
@@ -456,7 +484,7 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) {
});
if (due) ctx.waitUntil(due);
- const auth = adminAuth(env, sql, url, { waitUntil: (p) => ctx.waitUntil(p), log });
+ const auth = adminAuth(env, sql, url, { waitUntil: (p) => ctx.waitUntil(p), log, store });
const audit = new AuditLog(sql);
const approvals = new ApprovalStore(sql);
const mail = adminMail(env);
diff --git a/packages/cloudflare/src/scheduler.ts b/packages/cloudflare/src/scheduler.ts
index e8834be5..c1b40ab0 100644
--- a/packages/cloudflare/src/scheduler.ts
+++ b/packages/cloudflare/src/scheduler.ts
@@ -24,7 +24,7 @@
* `CLOUDHAL_ANALYTICS_CRON=off` / `CLOUDHAL_COMMENTS_CRON=off`; both read as `alarm` until the next
* `cloudhal deploy` removes them.
*/
-import { releaseApprovalCheck } from "@cloudhal/api";
+import { oauthClientsPruneJob, releaseApprovalCheck } from "@cloudhal/api";
import { PURGE_LIMITS } from "@cloudhal/comments";
import { SqlCommentStore } from "@cloudhal/comments/sql";
import {
@@ -272,6 +272,7 @@ export function adminSystemJobs(
},
},
listValuesJob(sql, { paid: env.CLOUDHAL_TICK_BUDGET === "paid", log }),
+ oauthClientsPruneJob(sql, log),
];
if (o.commentsOn)
jobs.push({
diff --git a/packages/core-ts/src/media/fetcher.ts b/packages/core-ts/src/media/fetcher.ts
index 1f554bd1..051cf75b 100644
--- a/packages/core-ts/src/media/fetcher.ts
+++ b/packages/core-ts/src/media/fetcher.ts
@@ -79,6 +79,8 @@ export interface SafeFetchResult {
readonly address?: string;
/** Bytes received (also when streamed to `onChunk`). */
readonly size: number;
+ /** The last hop's response headers (RFC-0044 §3: a CIMD document's cache headers). */
+ readonly headers: Headers;
}
// ───────────────────────────── addresses ─────────────────────────────
@@ -451,6 +453,7 @@ export async function safeFetch(
return {
bytes,
size,
+ headers: res.headers,
contentType,
url: url.toString(),
chain,
diff --git a/packages/mcp-server/src/gate-approval.ts b/packages/mcp-server/src/gate-approval.ts
index ba32179a..6f32c6bd 100644
--- a/packages/mcp-server/src/gate-approval.ts
+++ b/packages/mcp-server/src/gate-approval.ts
@@ -1,8 +1,8 @@
/**
* The approval a gate change (RFC-0008 §6.4) carries when no request is needed: the person's, or
* an agent key's `auto` approval for a reduction. An OAuth connection (RFC-0044) is an agent too,
- * never the person behind it, and has no `auto` approval of its own yet, so its gate change waits
- * for a person.
+ * never the person behind it, and has no `auto` approval of its own (the release contract's
+ * `auto` approval names a key), so its gate change is filed for a person to approve.
*/
import { agentCredential } from "@cloudhal/agent-runtime";
import type { Release } from "@cloudhal/core-contracts";
@@ -10,10 +10,14 @@ import { canonicalInstant } from "@cloudhal/schedules";
import { type ToolContext, ToolError } from "./common.ts";
import { personApproval } from "./schedules.ts";
-/** What `set_embargo` answers an OAuth connection on live content. */
+/** What `set_embargo` answers an OAuth connection on live content without out-of-band approvals. */
export const NO_GRANT_GATES =
"The embargo is saved, but an OAuth connection cannot put it live on its own: the content stays visible until a person publishes it (or an agent key sets the embargo).";
+/** What `set_embargo` answers an OAuth connection on live content: a person approves the gate. */
+export const GRANT_GATE_PENDING =
+ "The embargo is saved. A person must approve putting it live in Cloudhal Studio (site → Activity → Approvals); until then the content stays visible. Do not call set_embargo again for it.";
+
/** The agent key of a call that passed `autoReleaseVerdict` (which refuses anything else). */
export function keyOf(ctx: ToolContext): string {
const credential = agentCredential(ctx.principal);
diff --git a/packages/mcp-server/src/schedule-gates.ts b/packages/mcp-server/src/schedule-gates.ts
new file mode 100644
index 00000000..2d55dfc8
--- /dev/null
+++ b/packages/mcp-server/src/schedule-gates.ts
@@ -0,0 +1,78 @@
+/**
+ * The gate helpers of the schedule tools (RFC-0008 §7.6, §10.8): the article or page a gate tool
+ * names, the own-work check, the gate write (CAS on `rev`, then a snapshot) and whether a working
+ * copy differs from the live version only in its gates. Split from `schedule-tools.ts` (size
+ * ratchet).
+ */
+import type { ContentEntry, Page, Site } from "@cloudhal/core-contracts";
+import { canonicalJson } from "@cloudhal/core-ts";
+import { ACTOR_OWNERSHIP, callerPerson, type ToolContext, ToolError } from "./common.ts";
+import { liveRefs } from "./schedules.ts";
+
+export type GatedNode = (ContentEntry | Page) & { kind: "contentEntry" | "page" };
+
+export async function gatedNode(
+ ctx: ToolContext,
+ siteId: string,
+ nodeId: string,
+): Promise {
+ const entry = await ctx.store.getContentEntry(nodeId);
+ if (entry && entry.siteId === siteId) return { ...entry, kind: "contentEntry" };
+ const page = await ctx.store.getPage(nodeId);
+ if (page && page.siteId === siteId) return { ...page, kind: "page" };
+ throw new ToolError("not_found", `no article or page ${nodeId} on this site`);
+}
+
+export async function assertOwnNode(ctx: ToolContext, node: GatedNode): Promise {
+ if (ctx.limit?.target !== "release") return;
+ const owned =
+ node.kind === "contentEntry" &&
+ (await (ctx.ownership ?? ACTOR_OWNERSHIP).personOf((node as ContentEntry).author)) ===
+ callerPerson(ctx);
+ if (!owned) throw new ToolError("denied", "your role schedules only your own articles");
+}
+
+/** Writes new gates on the working copy (CAS on `expectedRev`) and snapshots, like any edit. */
+export async function writeGates(
+ ctx: ToolContext,
+ node: GatedNode,
+ availability: ContentEntry["availability"],
+ expectedRev: number,
+): Promise {
+ if (node.rev !== expectedRev)
+ throw new ToolError(
+ "conflict",
+ `${node.id} is at rev ${node.rev}, you sent ${expectedRev}; read it again`,
+ );
+ const { kind: _k, availability: _a, ...rest } = node;
+ const next = { ...rest, ...(availability ? { availability } : {}) };
+ const saved =
+ node.kind === "contentEntry"
+ ? await ctx.store.updateContentEntry(next as ContentEntry)
+ : await ctx.store.updatePage(next as Page);
+ await ctx.store.createSnapshot(node.siteId, {
+ actor: ctx.principal.actor,
+ reason: "agentMutation",
+ label: `gates by ${ctx.principal.actor.name ?? ctx.principal.actor.id}`,
+ });
+ return saved;
+}
+
+/** True when the working copy differs from the live version only in its gates (a gate-only change). */
+export async function gateOnly(
+ ctx: ToolContext,
+ site: Site,
+ node: ContentEntry | Page,
+): Promise {
+ const ref = (await liveRefs(ctx.store, site)).find((r) => r.id === node.id);
+ if (!ref) return false;
+ const live = (await ctx.store.readSnapshotNode(ref).catch(() => undefined)) as
+ | (ContentEntry | Page)
+ | undefined;
+ if (!live) return false;
+ const strip = (n: ContentEntry | Page) => {
+ const { availability: _a, rev: _r, updatedAt: _u, ...rest } = n;
+ return canonicalJson(rest);
+ };
+ return strip(live) === strip(node);
+}
diff --git a/packages/mcp-server/src/schedule-tools.ts b/packages/mcp-server/src/schedule-tools.ts
index 792256b5..f7b9579a 100644
--- a/packages/mcp-server/src/schedule-tools.ts
+++ b/packages/mcp-server/src/schedule-tools.ts
@@ -33,7 +33,7 @@ import type {
ScheduleRun,
Site,
} from "@cloudhal/core-contracts";
-import { canonicalJson, gateChanges } from "@cloudhal/core-ts";
+import { gateChanges } from "@cloudhal/core-ts";
import {
budgetLeft,
canonicalInstant,
@@ -62,7 +62,8 @@ import {
type ToolDefinition,
ToolError,
} from "./common.ts";
-import { directApproval, keyOf, NO_GRANT_GATES } from "./gate-approval.ts";
+import { directApproval, GRANT_GATE_PENDING, keyOf, NO_GRANT_GATES } from "./gate-approval.ts";
+import { assertOwnNode, gatedNode, gateOnly, writeGates } from "./schedule-gates.ts";
import {
approvalProblem,
autoReleaseVerdict,
@@ -436,66 +437,6 @@ async function releaseFor(
// ───────────────────────────── gates (§7.6, §10.8) ─────────────────────────────
-type GatedNode = (ContentEntry | Page) & { kind: "contentEntry" | "page" };
-
-async function gatedNode(ctx: ToolContext, siteId: string, nodeId: string): Promise {
- const entry = await ctx.store.getContentEntry(nodeId);
- if (entry && entry.siteId === siteId) return { ...entry, kind: "contentEntry" };
- const page = await ctx.store.getPage(nodeId);
- if (page && page.siteId === siteId) return { ...page, kind: "page" };
- throw new ToolError("not_found", `no article or page ${nodeId} on this site`);
-}
-
-async function assertOwnNode(ctx: ToolContext, node: GatedNode): Promise {
- if (ctx.limit?.target !== "release") return;
- const owned =
- node.kind === "contentEntry" &&
- (await (ctx.ownership ?? ACTOR_OWNERSHIP).personOf((node as ContentEntry).author)) ===
- callerPerson(ctx);
- if (!owned) throw new ToolError("denied", "your role schedules only your own articles");
-}
-
-/** Writes new gates on the working copy (CAS on `expectedRev`) and snapshots, like any edit. */
-async function writeGates(
- ctx: ToolContext,
- node: GatedNode,
- availability: ContentEntry["availability"],
- expectedRev: number,
-): Promise {
- if (node.rev !== expectedRev)
- throw new ToolError(
- "conflict",
- `${node.id} is at rev ${node.rev}, you sent ${expectedRev}; read it again`,
- );
- const { kind: _k, availability: _a, ...rest } = node;
- const next = { ...rest, ...(availability ? { availability } : {}) };
- const saved =
- node.kind === "contentEntry"
- ? await ctx.store.updateContentEntry(next as ContentEntry)
- : await ctx.store.updatePage(next as Page);
- await ctx.store.createSnapshot(node.siteId, {
- actor: ctx.principal.actor,
- reason: "agentMutation",
- label: `gates by ${ctx.principal.actor.name ?? ctx.principal.actor.id}`,
- });
- return saved;
-}
-
-/** True when the working copy differs from the live version only in its gates (a gate-only change). */
-async function gateOnly(ctx: ToolContext, site: Site, node: ContentEntry | Page): Promise {
- const ref = (await liveRefs(ctx.store, site)).find((r) => r.id === node.id);
- if (!ref) return false;
- const live = (await ctx.store.readSnapshotNode(ref).catch(() => undefined)) as
- | (ContentEntry | Page)
- | undefined;
- if (!live) return false;
- const strip = (n: ContentEntry | Page) => {
- const { availability: _a, rev: _r, updatedAt: _u, ...rest } = n;
- return canonicalJson(rest);
- };
- return strip(live) === strip(node);
-}
-
/** One node, one `on-approval` release carrying its gate change, scheduled with `approval`. */
async function gateRelease(
ctx: ToolContext,
@@ -522,6 +463,47 @@ async function gateRelease(
return scheduleWith(ctx, rec, { kind: "on-approval" }, approval);
}
+/** What `set_embargo` answers (`approval-pending`: an OAuth connection's gate, RFC-0044). */
+const EMBARGO_STATUS = ["set", "scheduled", "approval-pending", "with-next-publish"] as const;
+const EMBARGO_RESULT = obj(
+ {
+ node: ANY,
+ release: ANY,
+ status: { type: "string", enum: [...EMBARGO_STATUS] },
+ approvalId: { type: "string" },
+ message: { type: "string" },
+ },
+ ["node", "status"],
+);
+
+/**
+ * An OAuth connection's gate-only change (RFC-0044): it has no approval of its own, so the pinned
+ * release `gateRelease` would schedule is filed as a `schedule_release` request to run on
+ * approval, bound to its `rl1:` hash like every release request (Studio's Approvals, and a
+ * `schedule-approval-due` notification); approving it puts the embargo live. Without out-of-band
+ * approvals the gate waits for the next publish.
+ */
+async function gateRequest(ctx: ToolContext, site: Site, nodeId: string) {
+ if (!ctx.approvals) return { status: "with-next-publish" as const, message: NO_GRANT_GATES };
+ const pinned = await releaseService(deps(ctx), ctx.store, ctx.now).create({
+ siteId: site.id,
+ set: [nodeId],
+ createdBy: ctx.principal.actor,
+ metadata: { [RELEASE_META.source]: "set_embargo", [RELEASE_META.reduce]: "1" },
+ });
+ const rec = await recordOf(ctx, site.id, pinned.release.id);
+ const when = { kind: "on-approval" } as const;
+ const filed = await fileRequest(
+ ctx,
+ rec,
+ when,
+ "schedule_release",
+ "Put the embargo live",
+ "tool",
+ );
+ return { ...filed, message: GRANT_GATE_PENDING };
+}
+
// ───────────────────────────── runs (§11.2) ─────────────────────────────
/** The run envelope `begin_run` answers with (§11.2). */
@@ -1012,15 +994,7 @@ export function scheduleTools(): ToolDefinition[] {
{ siteId: id, nodeId: id, from: TIMESTAMP, expectedRev: { type: "integer", minimum: 0 } },
["siteId", "nodeId", "from", "expectedRev"],
),
- outputSchema: obj(
- {
- node: ANY,
- release: ANY,
- status: { type: "string", enum: ["set", "scheduled", "with-next-publish"] },
- message: { type: "string" },
- },
- ["node", "status"],
- ),
+ outputSchema: EMBARGO_RESULT,
annotations: WRITE,
scopes: ["schedules:write", "content:write"],
own: "release",
@@ -1060,7 +1034,7 @@ export function scheduleTools(): ToolDefinition[] {
"This content has other unpublished edits, so the embargo cannot go live on its own: it is still visible until it is published. Ask a person to publish (or schedule) it.",
};
const approval = directApproval(ctx, at);
- if (!approval) return { node: saved, status: "with-next-publish", message: NO_GRANT_GATES };
+ if (!approval) return { node: saved, ...(await gateRequest(ctx, site, node.id)) };
const release = await gateRelease(ctx, site, node.id, "set_embargo", approval, true);
return { node: saved, release, status: "scheduled" };
},
diff --git a/packages/mcp-server/test/schedules.test.ts b/packages/mcp-server/test/schedules.test.ts
index e75401a4..7e86a4ae 100644
--- a/packages/mcp-server/test/schedules.test.ts
+++ b/packages/mcp-server/test/schedules.test.ts
@@ -489,7 +489,7 @@ describe("gates over MCP (RFC-0008 §7.6, §10.8)", () => {
expect(earlier.text).toMatch(/lift_embargo/);
});
- it("set_embargo through an OAuth connection never records the person's approval (RFC-0044 inv. 5)", async () => {
+ it("set_embargo through an OAuth connection files the gate for a person, never approves it (RFC-0044 inv. 5)", async () => {
const live = (await s.store.getContentEntry(s.entry.id)) as ContentEntry;
// A token principal: an agent with a grant, no key, and the member behind it.
const { keyId: _key, ...noKey } = agentKey("edits");
@@ -505,8 +505,28 @@ describe("gates over MCP (RFC-0008 §7.6, §10.8)", () => {
expectedRev: live.rev,
});
expect(hide.isError, hide.text).toBe(false);
- expect(hide.data?.status).toBe("with-next-publish");
- expect(hide.data?.release).toBeUndefined();
+ // RFC-0044 Phase 3: the gate-only release waits for a person, who approves it in Studio.
+ expect(hide.data).toMatchObject({
+ status: "approval-pending",
+ node: { availability: { from: plus(7 * HOUR) } },
+ });
+ const release = hide.data?.release as Release;
+ expect(release).toMatchObject({
+ state: "awaiting_approval",
+ when: { kind: "on-approval" },
+ metadata: { "cloudhal.source": "set_embargo", "cloudhal.reduce": "1" },
+ });
+ expect(release.approval ?? null).toBeNull();
+ const request = await s.approvals.get(hide.data?.approvalId as string);
+ expect(request).toMatchObject({
+ tool: "schedule_release",
+ status: "pending",
+ cause: "tool",
+ releaseId: release.id,
+ actor: { kind: "agent" },
+ input: { siteId: s.site.id, releaseId: release.id, when: { kind: "on-approval" } },
+ });
+ expect(request?.stateHash).toMatch(/^rl1:/);
const releases = await s.schedules.listReleases(s.site.id);
expect(
releases.some((r) => r.approval?.kind === "person" && r.approval.by.id === "user_sched00001"),
diff --git a/packages/storage/migrations/0032_oauth_client_kind_index.ts b/packages/storage/migrations/0032_oauth_client_kind_index.ts
new file mode 100644
index 00000000..6e772b2b
--- /dev/null
+++ b/packages/storage/migrations/0032_oauth_client_kind_index.ts
@@ -0,0 +1,15 @@
+import type { Migration } from "../src/migrate.ts";
+
+/**
+ * RFC-0044 §3 (review of #178): an index on the kind of an OAuth client and when its document was
+ * last validated. Anonymous `/oauth2/authorize` calls count the stored metadata-document clients
+ * (`clientDiscoveryId = 'cimd'`, the cap), evict the oldest unused one, and the hourly job prunes
+ * them by `updatedAt`; without the index each of these reads the whole `oauthClient` table. A new
+ * migration, because 0031 is already on `main`. Plain SQL; additive.
+ */
+export const migration0032OauthClientKindIndex: Migration = {
+ name: "0032_oauth_client_kind_index",
+ sql: `
+CREATE INDEX "oauthClient_clientDiscoveryId_updatedAt_idx" ON "oauthClient" ("clientDiscoveryId", "updatedAt");
+`,
+};
diff --git a/packages/storage/migrations/index.ts b/packages/storage/migrations/index.ts
index 20aab2eb..40860008 100644
--- a/packages/storage/migrations/index.ts
+++ b/packages/storage/migrations/index.ts
@@ -30,6 +30,7 @@ import { migration0028SiteMembers } from "./0028_site_members.ts";
import { migration0029EntryListValues } from "./0029_entry_list_values.ts";
import { migration0030RateLimits } from "./0030_rate_limits.ts";
import { migration0031Oauth } from "./0031_oauth.ts";
+import { migration0032OauthClientKindIndex } from "./0032_oauth_client_kind_index.ts";
/** All Cloudhal storage migrations, in order. */
export const migrations: readonly Migration[] = [
@@ -64,4 +65,5 @@ export const migrations: readonly Migration[] = [
migration0029EntryListValues,
migration0030RateLimits,
migration0031Oauth,
+ migration0032OauthClientKindIndex,
];
diff --git a/packages/storage/test/migrate.test.ts b/packages/storage/test/migrate.test.ts
index b77bc81f..dc6191cf 100644
--- a/packages/storage/test/migrate.test.ts
+++ b/packages/storage/test/migrate.test.ts
@@ -49,6 +49,7 @@ describe("migrate", () => {
"0029_entry_list_values",
"0030_rate_limits",
"0031_oauth",
+ "0032_oauth_client_kind_index",
]);
expect((await migrate(driver, migrations)).applied).toEqual([]);
expect(await appliedMigrations(driver)).toEqual([
@@ -83,6 +84,7 @@ describe("migrate", () => {
"0029_entry_list_values",
"0030_rate_limits",
"0031_oauth",
+ "0032_oauth_client_kind_index",
]);
const tables = await driver.all<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
@@ -206,6 +208,7 @@ describe("migrate", () => {
"0029_entry_list_values",
"0030_rate_limits",
"0031_oauth",
+ "0032_oauth_client_kind_index",
]);
expect(await appliedMigrations(driver)).toEqual([
"0001_init",
@@ -239,6 +242,7 @@ describe("migrate", () => {
"0029_entry_list_values",
"0030_rate_limits",
"0031_oauth",
+ "0032_oauth_client_kind_index",
]);
await h.dispose();
});
@@ -279,6 +283,7 @@ describe("migrate", () => {
"0029_entry_list_values",
"0030_rate_limits",
"0031_oauth",
+ "0032_oauth_client_kind_index",
]);
const ok = await driver.get("SELECT name FROM sqlite_master WHERE name = 'ok'");
expect(ok).toBeUndefined();
@@ -387,3 +392,17 @@ describe("0031_oauth: the authorization server's tables (RFC-0044 §7)", () => {
await (h ? h.dispose() : driver.close());
});
});
+
+describe("0032_oauth_client_kind_index (RFC-0044 §3, review of #178)", () => {
+ it("indexes document clients by kind and validation time, so the cap count reads the index", async () => {
+ const driver = await BetterSqlite3Driver.open(":memory:");
+ await migrate(driver, migrations);
+ const plan = await driver.all<{ detail: string }>(
+ `EXPLAIN QUERY PLAN SELECT COUNT(*) FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd'`,
+ );
+ expect(plan.map((p) => p.detail).join(" ")).toContain(
+ "oauthClient_clientDiscoveryId_updatedAt_idx",
+ );
+ await driver.close();
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1357a242..72344176 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -173,6 +173,9 @@ catalogs:
'@better-auth/api-key':
specifier: 1.7.18
version: 1.7.18
+ '@better-auth/cimd':
+ specifier: 1.7.18
+ version: 1.7.18
'@better-auth/core':
specifier: 1.7.18
version: 1.7.18
@@ -592,6 +595,9 @@ importers:
'@better-auth/api-key':
specifier: 'catalog:'
version: 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))
+ '@better-auth/cimd':
+ specifier: 'catalog:'
+ version: 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/oauth-provider@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)))(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))
'@better-auth/core':
specifier: 'catalog:'
version: 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)
@@ -1423,6 +1429,14 @@ packages:
better-auth: ^1.7.18
better-call: 1.4.5
+ '@better-auth/cimd@1.7.18':
+ resolution: {integrity: sha512-Oln0ZIQwkzqCGjMJpP0RSBjYvrS4APozUpPfUVIuIdhTAepAprp4VWu1WlLCs0gFHhsJlq3QjlFfv80W/lJ/IA==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.18
+ '@better-auth/oauth-provider': ^1.7.18
+ better-auth: ^1.7.18
+ better-call: 1.4.5
+
'@better-auth/core@1.7.18':
resolution: {integrity: sha512-GPue1yR24fd+RkCiOpVtdBwtCqkgGHRMGxUL6A11LzUv0JsHA2hnIFEdoxorr9TWO2kJLvYOuUu0vjV9Wxd4AA==}
peerDependencies:
@@ -5275,6 +5289,13 @@ snapshots:
better-call: 1.4.5(zod@4.6.7)
zod: 4.6.7
+ '@better-auth/cimd@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/oauth-provider@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)))(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))':
+ dependencies:
+ '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)
+ '@better-auth/oauth-provider': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))
+ better-auth: 1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
+ better-call: 1.4.5(zod@4.6.7)
+
'@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)':
dependencies:
'@better-auth/utils': 0.5.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 2c1636e9..93034eeb 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -56,6 +56,7 @@ catalog:
"@better-auth/core": 1.7.18
"@better-auth/oauth-provider": 1.7.18
"@better-auth/mcp": 1.7.18
+ "@better-auth/cimd": 1.7.18
jose: 6.2.12
"@better-auth/utils": 0.5.1
better-call: 1.4.5
diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json
index 44a8d92f..0957c308 100644
--- a/scripts/size-baseline.json
+++ b/scripts/size-baseline.json
@@ -25,7 +25,7 @@
"packages/core-ts/src/surface-interop.ts": 1233,
"packages/core-ts/src/taxonomy.ts": 1718,
"packages/mcp-server/src/menu-tools.ts": 1058,
- "packages/mcp-server/src/schedule-tools.ts": 1579,
+ "packages/mcp-server/src/schedule-tools.ts": 1553,
"packages/mcp-server/src/schedules.ts": 833,
"packages/mcp-server/src/taxonomy-tools.ts": 1433,
"packages/schedules/src/recurrence.ts": 939,
@@ -304,7 +304,7 @@
"params": 5
},
"packages/api/src/app.ts::createApi": {
- "lines": 160
+ "lines": 157
},
"packages/api/src/build-turn.ts::runBuildTurn": {
"lines": 260
@@ -505,7 +505,7 @@
"params": 5
},
"packages/mcp-server/src/schedule-tools.ts::scheduleTools": {
- "lines": 1012
+ "lines": 1004
},
"packages/mcp-server/src/schedules.ts::autoReleaseVerdict": {
"params": 6