Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions alchemy.access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
});
14 changes: 14 additions & 0 deletions docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down