// Cloud: the DCR root-domain collision, end to end over the product API. // // The bug this guards (issue #1120): two DIFFERENT integrations can share one // registrable root domain but differ by host, e.g. a "Cloudflare" MCP // integration whose OAuth lives on mcp.cloudflare.com vs the plain "Cloudflare" // REST integration on api.cloudflare.com. When the MCP integration is connected // it Dynamic-Client-Registers an oauth_client automatically. That auto-minted // client must NEVER leak into the REST integration's Add-connection app picker // (it is plumbing, not an app the user picked), and connecting the MCP // integration a second time must REUSE the same client rather than mint a // "name 2" duplicate. // // Everything here is over the wire: the typed product client drives the real // cloud API, and a real authorization server (with an RFC 7591 registration // endpoint) runs inside the scenario on 127.0.0.1. To reproduce the collision // the scenario stores mcp.cloudflare.com / api.cloudflare.com as the clients' // endpoint URLs (the classifier keys off those strings, never the network) // while pointing the actual registration round-trip at the local server. // // This e2e proves the SERVER-SIDE half over the wire: (b) DCR mints a client // against the MCP host, (d) reconnecting reuses it with no duplicate row, and // the REAL `listClients` projection marks that client as // `dynamic_client_registration` while preserving its mcp.cloudflare.com // endpoints. Those two facts (origin kind + persisted host) are the entire // input the picker classifier keys off. The picker's own verdict for the REST // integration (that the DCR client is offered in no tier) is asserted against // the exact production classifier in the React package's integration test // (packages/react/src/plugins/dcr-root-domain-isolation.test.ts); it lives // there because that pure function ships in @executor-js/react, and the e2e // package deliberately does not depend on the React layer. See the report. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const); const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; // A same-provider, different-host pair: same registrable root (cloudflare.com), // distinct hosts. These are the endpoint URLs the clients are PERSISTED with, // which is all the classifier looks at. const MCP_ISSUER = "https://mcp.cloudflare.com"; const MCP_AUTHORIZE = "https://mcp.cloudflare.com/authorize"; const MCP_TOKEN = "https://mcp.cloudflare.com/token"; const REST_AUTHORIZE = "https://api.cloudflare.com/client/v4/oauth/authorize"; const REST_TOKEN = "https://api.cloudflare.com/client/v4/oauth/token"; scenario( "OAuth DCR · an MCP integration's auto-registered client never leaks into a same-root REST integration's app picker, and reconnecting reuses it", {}, Effect.scoped( Effect.gen(function* () { const target = yield* Target; const { client: makeClient } = yield* Api; // A real authorization server with an RFC 7591 registration endpoint. Its // /register mints a client_id; the mcp.cloudflare.com URLs above are what // we persist so the classifier sees the real-world collision shape. const oauth = yield* serveOAuthTestServer(); const identity = yield* target.newIdentity(); const client = yield* makeClient(api, identity); // Two integrations on the SAME provider root, different hosts: an MCP-style // one (mcp.cloudflare.com) and a plain REST one (api.cloudflare.com). const mcpIntegration = IntegrationSlug.make(unique("cloudflare_mcp")); const restIntegration = IntegrationSlug.make(unique("cloudflare_api")); // The REST integration declares its OAuth endpoints up front (api.cloudflare.com). yield* client.openapi.addSpec({ payload: { spec: { kind: "blob", value: JSON.stringify({ openapi: "3.0.3", info: { title: "Cloudflare REST API", version: "1.0.0" }, paths: { "/zones": { get: { operationId: "listZones", tags: ["default"], responses: { "200": { description: "zones" } }, }, }, }, }), }, slug: restIntegration, baseUrl: "https://api.cloudflare.com/client/v4", authenticationTemplate: [ { slug: "oauth", kind: "oauth2", authorizationUrl: REST_AUTHORIZE, tokenUrl: REST_TOKEN, scopes: ["read"], }, ], }, }); // (b) Connect the MCP-style integration via Dynamic Client Registration. // The registration round-trip hits the real local server; the persisted // client carries the mcp.cloudflare.com endpoints and its issuer. const registerMcp = () => client.oauth.registerDynamic({ payload: { owner: "org", // Passed but overridden by the server's deterministic dcr- slug // when an issuer is present; kept as a fallback for the no-issuer path. slug: OAuthClientSlug.make(unique("cloudflare-mcp")), issuer: MCP_ISSUER, registrationEndpoint: oauth.registrationEndpoint, authorizationUrl: MCP_AUTHORIZE, tokenUrl: MCP_TOKEN, scopes: ["read"], clientName: "Cloudflare MCP", originIntegration: mcpIntegration, }, }); const first = yield* registerMcp(); expect( String(first.client), "the auto-registered client gets a deterministic issuer-host slug", ).toBe("dcr-mcp-cloudflare-com"); // (d) Reconnect the MCP integration: the SAME authorization server (issuer) // must reuse the existing client, not mint a duplicate "…-2". const second = yield* registerMcp(); expect( String(second.client), "reconnecting the MCP integration reuses the same DCR client (no duplicate row)", ).toBe(String(first.client)); // The REAL API projection of every client the owner can see. This is the // exact shape the picker classifier consumes. const clients = yield* client.oauth.listClients(); const dcrRows = clients.filter( (entry) => entry.origin.kind === "dynamic_client_registration", ); expect( dcrRows.map((entry) => String(entry.slug)), "exactly one DCR client exists for the shared authorization server (no duplicate)", ).toEqual(["dcr-mcp-cloudflare-com"]); const dcrRow = dcrRows[0]!; // The picker excludes a client iff its origin is DCR — assert the API // projects that origin so the classifier will hide it. expect( dcrRow.origin.kind, "the auto-registered client is projected as a DCR client (the flag the picker hides on)", ).toBe("dynamic_client_registration"); // The persisted endpoints are the MCP host, NOT the local registration // server: this is the same-root/different-host collision the classifier // must not silently promote into the REST integration's picker. expect( dcrRow.tokenUrl, "the DCR client keeps the MCP host it was registered for, not the registration endpoint", ).toBe(MCP_TOKEN); // Sanity: the REST integration itself has NO manual app, so its picker // would fall back to near/other tiers if the DCR client were ever // classified as a real app — the React test proves it never is. expect( clients.some((entry) => entry.origin.kind === "manual"), "no manual OAuth app was registered in this scenario", ).toBe(false); }), ), );