diff --git a/alchemy.access.ts b/alchemy.access.ts index eff7805df..6e1ef36c4 100644 --- a/alchemy.access.ts +++ b/alchemy.access.ts @@ -9,7 +9,9 @@ // workflow's Access verify step). import * as Cloudflare from "alchemy/Cloudflare"; +import * as ZeroTrust from "@distilled.cloud/cloudflare/zero-trust"; import * as Config from "effect/Config"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; const WORKER_PREFIX = "open-seo"; @@ -57,6 +59,31 @@ export const requireAllowedEmails = (remedy: string) => return emails; }); +/** + * Whether the live application currently has Managed OAuth turned on. + * + * Read *before* the Access.Application resource reconciles, because that + * reconcile is what clears it: alchemy exposes no `oauthConfiguration` prop, + * and its update sends a PUT-style body assembled only from declared props, so + * Cloudflare drops the setting every deploy. + * + * Best-effort by construction — a failure here must never fail a deploy, and + * false only costs the operator the warning they get today (none). + */ +const readManagedOauthEnabled = (accountId: string, domain: string) => + ZeroTrust.listAccessApplicationsForAccount({ accountId }).pipe( + Effect.map((response) => + (response.result ?? []).some( + (app) => + "domain" in app && + app.domain === domain && + "oauthConfiguration" in app && + app.oauthConfiguration?.enabled === true, + ), + ), + Effect.catch(() => Effect.succeed(false)), + ); + /** The gate itself: an email allow-policy on a self-hosted Access application. */ export const emailAccessGate = (options: { policyId: string; @@ -67,15 +94,32 @@ export const emailAccessGate = (options: { emails: string[]; }) => Effect.gen(function* () { + const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; const allow = yield* Cloudflare.Access.Policy(options.policyId, { name: options.policyName, decision: "allow", include: options.emails.map((email) => ({ email: { email } })), }); - return yield* Cloudflare.Access.Application(options.applicationId, { - type: "self_hosted", - name: options.applicationName, - domain: options.domain, - policies: [allow.policyId], - }); + const hadManagedOauth = yield* readManagedOauthEnabled( + accountId, + options.domain, + ); + const application = yield* Cloudflare.Access.Application( + options.applicationId, + { + type: "self_hosted", + name: options.applicationName, + domain: options.domain, + policies: [allow.policyId], + }, + ); + // Say so at deploy time. The symptom otherwise surfaces much later, in an + // MCP client, as `Unexpected content type: text/html` (Access serving its + // login page) with nothing connecting it back to a deploy. + if (hadManagedOauth) { + yield* Console.log( + `Managed OAuth on the Access application for ${options.domain} was cleared by this deploy — re-enable it in Zero Trust (Access controls -> Applications -> Edit -> Additional settings -> OAuth) or MCP clients cannot authenticate.`, + ); + } + return application; }); diff --git a/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md b/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md index 8510b6893..8152040c0 100644 --- a/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md +++ b/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md @@ -20,6 +20,20 @@ Managed OAuth is required for MCP clients and is not enabled by default. and log in but expose no tools. 7. Save. +> **Re-enable this after every deploy.** `pnpm deploy:selfhost` reconciles the +> Access application and Cloudflare clears `Managed OAuth` in the process, so an +> otherwise routine version update silently breaks MCP authentication. The +> deploy prints a reminder when it detects that it cleared the setting. The +> symptom, if you miss it, is an MCP client failing with +> `Unexpected content type: text/html` — that "HTML" is the Access login page. +> To check the current state without opening the dashboard: +> +> ```bash +> curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ +> "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \ +> | jq '.result[] | {domain, oauth: .oauth_configuration.enabled}' +> ``` + MCP clients should connect to: ```text