Skip to the content

Capsid menu

app/lib/mcp/door.ts

322 lines and 20 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of door.ts
FunctionLinesComplexityCoveredCRAPMutation
mcpOrigin44 to 472100%OkCRAP 2No data: mutation testing has not run on this repo yet
isMcpHost50 to 521100%OkCRAP 1No data: mutation testing has not run on this repo yet
text61 to 662100%OkCRAP 2No data: mutation testing has not run on this repo yet
redirect68 to 731100%OkCRAP 1No data: mutation testing has not run on this repo yet
redirectError76 to 832100%OkCRAP 2No data: mutation testing has not run on this repo yet
liveness90 to 942100%OkCRAP 2No data: mutation testing has not run on this repo yet
escape96 to 96167%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:96>96 to 961100%OkCRAP 1No data: mutation testing has not run on this repo yet
clientLabel99 to 107350%OkCRAP 4.1No data: mutation testing has not run on this repo yet
consentPage114 to 1505100%OkCRAP 5No data: mutation testing has not run on this repo yet
authorizeGet152 to 163391%OkCRAP 3No data: mutation testing has not run on this repo yet
authorizePost165 to 184593%WatchCRAP 5No data: mutation testing has not run on this repo yet
callback186 to 219696%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:207>207 to 207150%OkCRAP 1.1No data: mutation testing has not run on this repo yet
fetch223 to 2451580%WatchCRAP 16.8No data: mutation testing has not run on this repo yet
fetch254 to 27310100%WatchCRAP 10No data: mutation testing has not run on this repo yet
agentHandler283 to 2943100%OkCRAP 3No data: mutation testing has not run on this repo yet
providerFor302 to 3332100%OkCRAP 2No data: mutation testing has not run on this repo yet
clientRegistrationCallback328 to 3281100%OkCRAP 1No data: mutation testing has not run on this repo yet
aiDoor336 to 3487100%WatchCRAP 7No data: mutation testing has not run on this repo yet

Imports

It imports app/lib/agent-keys.server.ts, app/lib/deployed.ts, app/lib/mcp/registration.ts, app/lib/mcp/scopes.ts, app/lib/mcp/server.ts, app/lib/people.server.ts. Imported by workers/app.ts.

Source

// The AI door: everything on carrel-mcp.dustinedwards.info (design decision 7, corrected 2026-09-27).
//
// That hostname is exempt from Carrel's Worker-level Access (a hostname Bypass app, setup in the PR),
// because an MCP client must reach the OAuth endpoints before anyone has signed in. So nothing here
// trusts Access's own header. Instead this door is an OAuth 2.1 authorization server of its own
// (@cloudflare/workers-oauth-provider): CIMD for Claude, Claude Code and ChatGPT, a tightened
// registration for Grok and Gemini (registration.ts), and the person signs in upstream through
// Cloudflare Access for SaaS (site-runtime's access-login). The verified email must belong to an
// active person in Carrel, checked when the grant is made and again on every request.
//
// Every request through this door is an AI session, told apart by the hostname it arrived on, never
// by email. The pages are never served here, and this door is never served on the browser's host.
//
// Routes on this host, outermost first:
//   /mcp                          a named agent key (agent-keys.server.ts) goes straight to agentHandler;
//                                 anything else: the provider checks the bearer token, then apiHandler
//                                 finds the person and hands the request to server.ts
//   /authorize  /callback         the consent page and the Access sign-in, below
//   /register                     tightened registration, rate limited here, then the provider
//   /token, /.well-known/*        served by the provider
//   /health                       liveness, and the deployed commit for the deploy's live gate
//   anything else                 404

import OAuthProvider, { AuthorizationError, CimdFetchError, type AuthRequest, type ClientInfo, type OAuthHelpers } from "@cloudflare/workers-oauth-provider";

import { agentClient, agentPrincipal } from "~/lib/agent-keys.server";
import { deployedSha } from "~/lib/deployed";
import { findAgentViewer, findViewer } from "~/lib/people.server";

import { accessSaas, authorizationUrl, finishSignIn, newSignIn } from "@dustinedwards/site-runtime/access-login";
import { allowedRedirects, checkRegistration, REGISTER_PATH, registrationLimited } from "./registration";
import { AGENT_SCOPES, effectiveScopes, grantScopes, SCOPE_WRITE, SCOPES_SUPPORTED } from "./scopes";
import { carrelOrigin, handleMcp, MCP_ROUTE } from "./server";

/** What a grant stores, encrypted, and hands to every request made with its tokens. */
type DoorProps = { personId: number; email: string; client: string; clientId: string };

type DoorEnv = Env & { OAUTH_PROVIDER: OAuthHelpers };

/**
 * The AI door's origin. Read loosely, like CARREL_ORIGIN, because a wrangler.jsonc from before this
 * change has no CARREL_MCP_ORIGIN and its generated types would not either.
 */
export function mcpOrigin(env: Env): string {
  const configured = (env as { CARREL_MCP_ORIGIN?: string }).CARREL_MCP_ORIGIN?.trim();
  return (configured || "https://carrel-mcp.dustinedwards.info").replace(/\/+$/, "");
}

/** Whether a request arrived on the AI door's hostname. The only thing that decides the door. */
export function isMcpHost(request: Request, env: Env): boolean {
  return new URL(request.url).host.toLowerCase() === new URL(mcpOrigin(env)).host.toLowerCase();
}

// Caching and indexing only. The security headers are workers/security-headers.ts's, applied by
// workers/app.ts to every response on this host.
const PRIVATE_HEADERS: Record<string, string> = {
  "Cache-Control": "no-store",
  "X-Robots-Tag": "noindex, nofollow",
};

function text(body: string, status: number, headers?: Headers): Response {
  const out = new Headers(headers);
  out.set("Content-Type", "text/plain; charset=utf-8");
  for (const [name, value] of Object.entries(PRIVATE_HEADERS)) out.set(name, value);
  return new Response(body, { status, headers: out });
}

function redirect(location: string, headers?: Headers): Response {
  const out = new Headers(headers);
  out.set("Location", location);
  out.set("Cache-Control", "no-store");
  return new Response(null, { status: 302, headers: out });
}

/** The OAuth error answer to the client, once its redirect URI is known good. */
function redirectError(request: AuthRequest, description: string, headers?: Headers): Response {
  const url = new URL(request.redirectUri);
  url.searchParams.set("error", "access_denied");
  url.searchParams.set("error_description", description);
  url.searchParams.set("state", request.state);
  if (request.issuer) url.searchParams.set("iss", request.issuer);
  return redirect(url.href, headers);
}

/**
 * Liveness on the AI door, the one Carrel host outside Access, so CI can read it before anyone signs in.
 * The live gate polls it until `sha` is the commit it deployed. It says nothing else: no backup age, no
 * table, no error text (the backup health stays on the browser host's /health, behind Access).
 */
function liveness(env: Env): Response {
  const out = new Headers({ "Content-Type": "application/json; charset=utf-8" });
  for (const [name, value] of Object.entries(PRIVATE_HEADERS)) out.set(name, value);
  return new Response(JSON.stringify({ ok: true, sha: deployedSha(env) }), { status: 200, headers: out });
}

const escape = (value: string) => value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

/** The name a client is credited by: its CIMD client_name, or else the host that publishes it. */
function clientLabel(client: ClientInfo | null, clientId: string): string {
  const name = client?.clientName?.trim();
  if (name) return name.slice(0, 100);
  try {
    return new URL(clientId).hostname;
  } catch {
    return "an unnamed AI client";
  }
}

/**
 * The consent page, shown for every authorization of every client: nothing remembers an approval.
 * Consent has to be an act, so it is a real form. Everything from the client is escaped: a CIMD
 * document's name is chosen by whoever serves it, and a registered client's by whoever registered it.
 */
function consentPage(client: ClientInfo | null, request: AuthRequest, handle: string): string {
  const name = escape(clientLabel(client, request.clientId));
  const redirectHost = new URL(request.redirectUri).hostname;
  const local = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(redirectHost);
  const publisher = request.clientId.startsWith("https://")
    ? `Published by <strong>${escape(new URL(request.clientId).hostname)}</strong>.`
    : "This app registered itself with Carrel, so its name is its own claim, not checked.";
  const writes = grantScopes(request.scope).includes(SCOPE_WRITE);
  const asks = writes
    ? "It asks to read, save AI drafts beside yours, and flag problems. You can allow reading only."
    : "It asks to read only. It will not be able to save drafts or flag problems.";
  return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Allow ${name} to use Carrel?</title>
<style>
  :root { color-scheme: light dark; }
  body { font: 17px/1.6 Georgia, "Iowan Old Style", serif; max-width: 34rem; margin: 4rem auto; padding: 0 1.25rem; }
  h1 { font-size: 1.4rem; line-height: 1.3; }
  .fine { font-size: .9rem; opacity: .8; }
  button { font: inherit; padding: .55rem 1.1rem; border-radius: .35rem; cursor: pointer; margin-right: .5rem; }
</style>
</head><body>
<h1>Allow ${name} to use Carrel as you?</h1>
<p>${publisher} Its access will be sent to <strong>${escape(redirectHost)}</strong>.</p>
${local ? "<p><strong>This sends access to an app on your computer.</strong> Continue only if you just started signing in from it.</p>" : ""}
<p>${asks} Either way it can do only what your role in Carrel allows. Publishing is for Dustin's own sessions only, and never while a flag is open.</p>
<p class="fine">Next you sign in with Cloudflare Access. Only people Carrel already knows are let in.</p>
<form method="post">
  <input type="hidden" name="handle" value="${escape(handle)}">
  <button name="decision" value="approve">Continue to sign in</button>
  ${writes ? '<button name="decision" value="read-only">Allow reading only</button>' : ""}
  <button name="decision" value="deny">Deny</button>
</form>
</body></html>`;
}

async function authorizeGet(request: Request, env: DoorEnv): Promise<Response> {
  if (!accessSaas(env)) return text("Carrel's AI door is not configured: ACCESS_SAAS_CLIENT_ID or ACCESS_SAAS_CLIENT_SECRET is unset. Not configured means not open.", 503);
  const oauth = env.OAUTH_PROVIDER;
  const authRequest = await oauth.parseAuthRequest(request);
  const client = await oauth.lookupClient(authRequest.clientId);
  if (!client) return text("Unknown OAuth client.", 400);
  const consent = await oauth.beginConsent(authRequest);
  consent.headers.set("Content-Type", "text/html; charset=utf-8");
  // no-transform keeps Cloudflare from injecting its Web Analytics beacon into this page.
  consent.headers.set("Cache-Control", "no-store, no-transform");
  return new Response(consentPage(client, authRequest, consent.handle), { headers: consent.headers });
}

async function authorizePost(request: Request, env: DoorEnv): Promise<Response> {
  const saas = accessSaas(env);
  if (!saas) return text("Carrel's AI door is not configured. Not configured means not open.", 503);
  const oauth = env.OAUTH_PROVIDER;
  const form = await request.formData();
  const handle = String(form.get("handle") ?? "");
  const decision = form.get("decision");
  if (decision !== "approve" && decision !== "read-only") {
    const denied = await oauth.denyConsent(request, handle);
    return new Response(null, { status: 302, headers: denied.headers });
  }
  const consented = await oauth.approveConsent(request, handle);
  // Least scope: read, and write only when the client asked for it and the person did not narrow
  // the grant to reading. The callback grants exactly this.
  const approved = { ...consented, request: { ...consented.request, scope: grantScopes(consented.request.scope, { readOnly: decision === "read-only" }) } };
  // Only now, after consent, does the sign-in start. The verifier and nonce stay server-side.
  const signIn = newSignIn();
  const { state, headers } = await oauth.beginUpstream(approved.request, { data: signIn, headers: approved.headers });
  return redirect(await authorizationUrl(saas, { redirectUri: `${mcpOrigin(env)}/callback`, state, ...signIn }), headers);
}

async function callback(request: Request, env: DoorEnv): Promise<Response> {
  const saas = accessSaas(env);
  if (!saas) return text("Carrel's AI door is not configured. Not configured means not open.", 503);
  const oauth = env.OAUTH_PROVIDER;
  const { request: original, data, headers } = await oauth.finishUpstream<{ verifier: string; nonce: string }>(request);
  const params = new URL(request.url).searchParams;
  const code = params.get("code");
  if (params.get("error") || !code) return redirectError(original, "Sign-in with Cloudflare Access did not complete.", headers);

  const signedIn = await finishSignIn(saas, { code, redirectUri: `${mcpOrigin(env)}/callback`, verifier: data.verifier, nonce: data.nonce });
  if (!signedIn.ok) {
    console.warn(JSON.stringify({ door: "sign-in-refused", reason: signedIn.reason }));
    return redirectError(original, "Carrel could not verify the sign-in.", headers);
  }
  const viewer = await findViewer(env.DB, signedIn.email);
  if (!viewer) {
    // Access let them in, but Carrel does not know them: no row, no access (design section 3).
    console.warn(JSON.stringify({ door: "refused", reason: "unknown-person" }));
    return redirectError(original, "This person is not in Carrel.", headers);
  }

  const client = clientLabel(await oauth.lookupClient(original.clientId).catch(() => null), original.clientId);
  const props: DoorProps = { personId: viewer.id, email: viewer.email, client, clientId: original.clientId };
  const { redirectTo } = await oauth.completeAuthorization({
    request: original,
    userId: String(viewer.id),
    metadata: { client },
    scope: original.scope,
    props,
  });
  headers.set("Location", redirectTo);
  headers.set("Cache-Control", "no-store");
  return new Response(null, { status: 302, headers });
}

/** Everything on this host the provider does not own. */
const loginHandler = {
  async fetch(request: Request, env: DoorEnv): Promise<Response> {
    const { pathname } = new URL(request.url);
    try {
      if (pathname === "/health") return liveness(env);
      if (pathname === "/authorize" && request.method === "GET") return await authorizeGet(request, env);
      if (pathname === "/authorize" && request.method === "POST") return await authorizePost(request, env);
      if (pathname === "/callback" && request.method === "GET") return await callback(request, env);
      return text("Not found.", 404);
    } catch (error) {
      // Per the provider's consent-page guide: redirect only to a validated client, render the rest.
      if (error instanceof AuthorizationError && error.redirectUri) {
        const url = new URL(error.redirectUri);
        url.searchParams.set("error", error.code);
        url.searchParams.set("error_description", error.description);
        if (error.state) url.searchParams.set("state", error.state);
        if (error.issuer) url.searchParams.set("iss", error.issuer);
        return redirect(url.href);
      }
      if (error instanceof AuthorizationError) return text(`${error.description} Start the sign-in again from your MCP client.`, 400);
      if (error instanceof CimdFetchError) return text("This app could not be verified: its client metadata document could not be fetched.", 400);
      throw error;
    }
  },
};

/**
 * The authenticated MCP surface. The provider has checked the token by the time this runs. The
 * person is looked up again on every request: a grant lives for weeks, and a person disabled in
 * Carrel must lose the door at once, not when the grant expires.
 */
export const apiHandler = {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const { props, auth } = ctx as ExecutionContext & { props?: Partial<DoorProps>; auth?: { audience?: string; scope?: string[] } };
    // The token must have been issued for this server (RFC 8707), checked here as well as by the
    // provider, so a provider set up for a second resource could never hand Carrel a token for it.
    // Nothing passes the token on: the site and the novels repository take Carrel's own keys.
    if (auth?.audience !== `${mcpOrigin(env)}${MCP_ROUTE}`) {
      console.warn(JSON.stringify({ door: "refused", reason: "audience", audience: auth?.audience ?? null }));
      return text("Forbidden. This token was not issued for Carrel.", 403);
    }
    const viewer = props?.email ? await findViewer(env.DB, props.email) : null;
    if (!viewer || viewer.id !== props?.personId) {
      console.warn(JSON.stringify({ door: "refused", reason: viewer ? "person-changed" : "unknown-person" }));
      return text("Forbidden. This grant does not belong to an active person in Carrel.", 403);
    }
    const session = { viewer, client: props.client || "an unnamed AI client", scopes: effectiveScopes(auth.scope ?? []) };
    const response = await handleMcp(request, env, ctx, session, { carrelOrigin: carrelOrigin(env) });
    const out = new Response(response.body, response);
    for (const [name, value] of Object.entries(PRIVATE_HEADERS)) out.headers.set(name, value);
    return out;
  },
};

/**
 * The named-agent surface. A key (agent-keys.server.ts) names an agent, and the agent is the person
 * row "agent:<name>" in Carrel's People table, found again on every request: disabling that row
 * revokes the agent at once, and its role on each project is whatever People says, never the key's.
 * A key with no active row is refused here and never falls through to anything. Writes are credited
 * to "agent:<name>", the same text as the person, never to Dustin.
 */
async function agentHandler(request: Request, env: Env, ctx: ExecutionContext, name: string): Promise<Response> {
  const viewer = await findAgentViewer(env.DB, name);
  if (!viewer) {
    console.warn(JSON.stringify({ door: "refused", reason: "agent-not-in-carrel", agent: name }));
    return text("Forbidden. This agent key does not belong to an active agent in Carrel.", 403);
  }
  console.log(JSON.stringify({ door: "agent-key-accepted", agent: name }));
  const response = await handleMcp(request, env, ctx, { viewer, client: agentClient(name), scopes: AGENT_SCOPES }, { carrelOrigin: carrelOrigin(env) });
  const out = new Response(response.body, response);
  for (const [header, value] of Object.entries(PRIVATE_HEADERS)) out.headers.set(header, value);
  return out;
}

const providers = new Map<string, OAuthProvider<Env>>();

/**
 * One provider per origin and registration allowlist: the resource URI and the registration policy
 * are both fixed at construction, so a changed allowlist gets a new provider, never a stale one.
 */
function providerFor(env: Env): OAuthProvider<Env> {
  const origin = mcpOrigin(env);
  const allowed = allowedRedirects(env);
  const key = `${origin} ${[...allowed].join(" ")}`;
  let provider = providers.get(key);
  if (!provider) {
    provider = new OAuthProvider<Env>({
      apiRoute: MCP_ROUTE,
      apiHandler,
      defaultHandler: loginHandler as unknown as ExportedHandler<Env>,
      authorizeEndpoint: "/authorize",
      tokenEndpoint: "/token",
      scopesSupported: SCOPES_SUPPORTED,
      resourceMetadata: {
        resource: `${origin}${MCP_ROUTE}`,
        authorization_servers: [origin],
        scopes_supported: SCOPES_SUPPORTED,
        resource_name: "Carrel",
      },
      // CIMD first. claude.ai, Claude Code and ChatGPT send their client_id as a metadata URL and
      // never register (measured in dustinedwards-mcp, 2026-07-30). Needs global_fetch_strictly_public
      // in wrangler.jsonc.
      clientIdMetadataDocumentEnabled: true,
      // Tightened registration beside it, for Grok and Gemini (rule 13, registration.ts): exact
      // callbacks only, a code and refresh grant only, rate limited in aiDoor before it gets here.
      clientRegistrationEndpoint: REGISTER_PATH,
      clientRegistrationCallback: ({ clientMetadata }) => checkRegistration(allowed, clientMetadata),
    });
    providers.set(key, provider);
  }
  return provider;
}

/** Serves one request that arrived on the AI door's hostname. */
export async function aiDoor(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
  const { pathname } = new URL(request.url);
  if (pathname === MCP_ROUTE || pathname.startsWith(`${MCP_ROUTE}/`)) {
    // Tried, never required: no match (no secret, a bad header, an OAuth token) is the OAuth path, unchanged.
    const agent = await agentPrincipal(env, request.headers.get("Authorization"));
    if (agent) return agentHandler(request, env, ctx, agent);
  }
  if (pathname === REGISTER_PATH && request.method === "POST") {
    const limited = await registrationLimited(request, env);
    if (limited) return limited;
  }
  return providerFor(env).fetch(request, env, ctx);
}