From 4657325ae1c91331aecd46b189ddd2121aa68687 Mon Sep 17 00:00:00 2001 From: Edu Yubero Date: Tue, 8 Sep 2026 15:54:54 +0200 Subject: [PATCH] fix(selfhost): warn when a deploy clears Managed OAuth on the Access app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-host MCP authentication depends on Managed OAuth being enabled on the Cloudflare Access application, which the operations guide has the operator turn on by hand. Deploying again silently undoes it: alchemy's Access.Application exposes no oauthConfiguration prop, and its reconcile sends a PUT-style body assembled only from declared props, so Cloudflare drops oauth_configuration. The failure then surfaces much later and somewhere else entirely — an MCP client reporting `Unexpected content type: text/html`, which is Access serving its login page — with nothing tying it back to a routine version update days earlier. Read the live setting before the resource reconciles and, when the deploy cleared it, say so on the deploy output. Also document the behaviour and how to check the current state next to the enable steps. This reports the problem rather than fixing it: re-applying the setting means a full-replace PUT on the application, which would carry the allow-policy with it, and the declarative fix belongs upstream in alchemy (the underlying @distilled.cloud client already accepts oauthConfiguration). The read is best-effort and can never fail a deploy. Refs #304 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EZja8vK2mx6gf2BfZMAXDj --- alchemy.access.ts | 56 +++++++++++++++++++--- docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md | 14 ++++++ 2 files changed, 64 insertions(+), 6 deletions(-) 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