Skip to content
Merged
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
22 changes: 13 additions & 9 deletions apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ bunx wrangler login
bun run deploy:setup # apps/host-cloudflare — provisions D1 + secret + deploys
```

`deploy:setup` (scripts/deploy.sh) is idempotent: it creates/reuses the
`executor` D1 database, writes its id into `wrangler.jsonc`, generates +
uploads `EXECUTOR_SECRET_KEY`, and deploys. It then prints the one manual step.
`deploy:setup` (scripts/deploy.sh) is idempotent. It creates or reuses the
`executor` D1 database, writes its id into `wrangler.jsonc`, generates and
uploads `EXECUTOR_SECRET_KEY`, then deploys. It then prints the one manual step.

### The one manual step — Cloudflare Access

The Worker returns 401 until it's behind a Cloudflare Access application. In the
Zero Trust dashboard:
After the first deploy, API and MCP requests return 503 and name the missing
Access variables until configuration is complete. In the Zero Trust dashboard:

1. **Access → Applications → Add an application → Self-hosted**
2. Application domain: `executor-cloudflare.<your-subdomain>.workers.dev`
Expand All @@ -52,13 +52,17 @@ Zero Trust dashboard:
```bash
bunx wrangler deploy \
--var ACCESS_AUD:<aud> \
--var ACCESS_TEAM_DOMAIN:<your-team>.cloudflareaccess.com
--var ACCESS_TEAM_DOMAIN:<your-team>.cloudflareaccess.com \
--var ADMIN_EMAILS:<admin@example.com>
```
(or set them in `wrangler.jsonc` and redeploy)

Now visiting the Worker prompts an Access login; the Worker validates the issued
JWT on every request. MCP clients present an Access JWT or
`Cf-Access-Client-Id`/`-Secret` service-token headers.
JWT on every request. Unauthenticated requests return 401. MCP clients present
an Access JWT or `Cf-Access-Client-Id`/`-Secret` service-token headers.

The Access values are live Worker variables, not values in `wrangler.jsonc`.
Wrangler's `keep_vars` option preserves them during later code deploys. Run the
command above again whenever you need to change them.

## Local development

Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"jsonc-parser": "^3.3.1",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
Expand Down
18 changes: 11 additions & 7 deletions apps/host-cloudflare/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# wrangler.jsonc
# 3. generates + uploads EXECUTOR_SECRET_KEY (the at-rest secret key) if unset
# 4. deploys the Worker
# 5. prints the single manual step: the Cloudflare Access application
# 5. prints the single manual step: configure the Cloudflare Access application
#
# Idempotent — safe to re-run. Run from anywhere:
# bash apps/host-cloudflare/scripts/deploy.sh
Expand Down Expand Up @@ -71,18 +71,22 @@ cat <<'NEXT'

==> One manual step left: turn on Cloudflare Access (the auth layer)

The Worker is deployed but every request returns 401 until you put it behind
a Cloudflare Access application. In the Zero Trust dashboard:
The Worker is deployed but is not ready to serve requests until you configure
a Cloudflare Access application. API and MCP requests return 503 and name the
missing variables until configuration is complete. In the Zero Trust
dashboard:

1. Access -> Applications -> Add an application -> Self-hosted
2. Application domain: executor-cloudflare.<your-subdomain>.workers.dev
3. Add an Access policy (e.g. "Emails ending in @yourcompany.com")
4. After saving, copy the Application Audience (AUD) tag, then set:
bunx wrangler deploy --var ACCESS_AUD:<aud> \
--var ACCESS_TEAM_DOMAIN:<your-team>.cloudflareaccess.com
(or edit the vars in wrangler.jsonc and redeploy)
bunx wrangler deploy --var ACCESS_AUD:<aud> \
--var ACCESS_TEAM_DOMAIN:<your-team>.cloudflareaccess.com \
--var ADMIN_EMAILS:<admin@example.com>

That's it — visiting the Worker URL now prompts a Cloudflare Access login,
Wrangler preserves these live variables during later code deploys.

That's it. Visiting the Worker URL now prompts a Cloudflare Access login,
and the Worker validates the issued JWT on every request.

NEXT
5 changes: 4 additions & 1 deletion apps/host-cloudflare/src/auth/cloudflare-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export const principalFromAccessClaims = (
export const makeAccessVerifier = (config: CloudflareConfig) => {
const issuer = `https://${config.accessTeamDomain}`;
// Cached, lazily-fetched team signing keys; jose handles rotation + caching.
const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));
const jwks = config.enableDevAuth
? null
: createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));

// Dev/single-user escape hatch: bypass Access entirely, every request is a
// fixed admin. Only when explicitly enabled (and the instance is otherwise
Expand All @@ -79,6 +81,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {
const verify = (request: Request): Effect.Effect<Principal | null> =>
Effect.gen(function* () {
if (config.enableDevAuth) return devPrincipal;
if (!jwks) return null;
const token = request.headers.get("Cf-Access-Jwt-Assertion");
if (!token) return null;

Expand Down
74 changes: 74 additions & 0 deletions apps/host-cloudflare/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readFileSync } from "node:fs";

import { describe, expect, it } from "@effect/vitest";
import { parse } from "jsonc-parser";

import { loadConfig } from "./config";

type ConfigEnv = Parameters<typeof loadConfig>[0];

const makeEnv = (overrides: Partial<ConfigEnv> = {}): ConfigEnv => ({
EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
VITE_PUBLIC_SITE_URL: "https://executor.example.com",
...overrides,
});

describe("loadConfig", () => {
it("rejects missing Cloudflare Access configuration outside local development", () => {
expect(() => loadConfig(makeEnv())).toThrowError(
"Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.",
);
});

it("rejects the repository's former team-domain placeholder", () => {
expect(() =>
loadConfig(
makeEnv({
ACCESS_TEAM_DOMAIN: "your-team.cloudflareaccess.com",
ACCESS_AUD: "aud-tag",
}),
),
).toThrowError(
"Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN before serving requests.",
);
});

it("allows local development to bypass Cloudflare Access", () => {
expect(loadConfig(makeEnv({ ENABLE_DEV_AUTH: "true" }))).toMatchObject({
accessTeamDomain: "",
accessAud: "",
enableDevAuth: true,
});
});

it("normalises configured Access values without requiring an administrator", () => {
expect(
loadConfig(
makeEnv({
ACCESS_TEAM_DOMAIN: "https://Team.cloudflareaccess.com/",
ACCESS_AUD: " aud-tag ",
}),
),
).toMatchObject({
accessTeamDomain: "Team.cloudflareaccess.com",
accessAud: "aud-tag",
adminEmails: [],
enableDevAuth: false,
});
});
});

describe("Cloudflare deployment configuration", () => {
it("preserves operator-managed Access variables across deploys", () => {
const config = parse(readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8")) as {
readonly keep_vars?: boolean;
readonly vars?: Readonly<Record<string, unknown>>;
};

expect(config.keep_vars).toBe(true);
expect(config.vars).not.toHaveProperty("ACCESS_TEAM_DOMAIN");
expect(config.vars).not.toHaveProperty("ACCESS_AUD");
expect(config.vars).not.toHaveProperty("ADMIN_EMAILS");
expect(config.vars).toHaveProperty("ENABLE_DEV_AUTH", "false");
});
});
49 changes: 44 additions & 5 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ export interface CloudflareEnv {
readonly MCP_SESSION: DurableObjectNamespace;
readonly MCP_EXECUTION_OWNER?: DurableObjectNamespace;
/** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */
readonly ACCESS_TEAM_DOMAIN: string;
readonly ACCESS_TEAM_DOMAIN?: string;
/** The Access application's AUD tag (the JWT audience to verify). */
readonly ACCESS_AUD: string;
readonly ACCESS_AUD?: string;
/** Claim holding the display name (default `name`). */
readonly ACCESS_NAME_CLAIM?: string;
/** Claim holding the user's groups (default `groups`). */
Expand Down Expand Up @@ -72,12 +72,44 @@ export interface CloudflareConfig {
readonly enableDevAuth: boolean;
}

type CloudflareConfigEnv = Omit<
CloudflareEnv,
"DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER"
>;

type CloudflareAccessEnv = Pick<
CloudflareConfigEnv,
"ACCESS_TEAM_DOMAIN" | "ACCESS_AUD" | "ENABLE_DEV_AUTH"
>;

const splitLower = (value: string | undefined): readonly string[] =>
(value ?? "")
.split(",")
.map((part) => part.trim().toLowerCase())
.filter((part) => part.length > 0);

const normalizeAccessTeamDomain = (value: string | undefined): string =>
(value ?? "")
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "");

export const missingCloudflareAccessVars = (env: CloudflareAccessEnv): readonly string[] => {
if (env.ENABLE_DEV_AUTH === "true") return [];
const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN);
const accessAud = (env.ACCESS_AUD ?? "").trim();
return [
...(accessTeamDomain.length === 0 ||
accessTeamDomain.toLowerCase() === "your-team.cloudflareaccess.com"
? ["ACCESS_TEAM_DOMAIN"]
: []),
...(accessAud.length === 0 ? ["ACCESS_AUD"] : []),
];
};

export const cloudflareAccessConfigErrorMessage = (missingVars: readonly string[]): string =>
`Cloudflare Access is not configured. Set ${missingVars.join(" and ")} before serving requests.`;

// The org slug doubles as a URL segment (`/<slug>/policies`), so an
// operator-set value must fit the shared grammar and avoid reserved root
// segments — a colliding slug would shadow real routes (notably /api, /mcp,
Expand All @@ -93,7 +125,7 @@ const resolveOrgSlug = (value: string | undefined): string => {
return value;
};

export const loadConfig = (env: CloudflareEnv): CloudflareConfig => {
export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => {
const secretKey = env.EXECUTOR_SECRET_KEY?.trim();
if (!secretKey || secretKey.length < 16) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key
Expand All @@ -102,6 +134,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => {
);
}
const enableDevAuth = env.ENABLE_DEV_AUTH === "true";
const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN);
const accessAud = (env.ACCESS_AUD ?? "").trim();
const missingAccessVars = missingCloudflareAccessVars(env);
if (missingAccessVars.length > 0) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: production must fail closed without a valid Access verifier
throw new Error(cloudflareAccessConfigErrorMessage(missingAccessVars));
}
const webBaseUrl = resolvePublicOrigin({ explicit: env.VITE_PUBLIC_SITE_URL, env: {} });
if (!webBaseUrl && !enableDevAuth && !warnedNoCloudflareOrigin) {
warnedNoCloudflareOrigin = true;
Expand All @@ -113,8 +152,8 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => {
);
}
return {
accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
accessAud: env.ACCESS_AUD,
accessTeamDomain,
accessAud,
accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name",
accessGroupsClaim: env.ACCESS_GROUPS_CLAIM ?? "groups",
adminEmails: splitLower(env.ADMIN_EMAILS),
Expand Down
58 changes: 48 additions & 10 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsof
const dir = fileURLToPath(new URL(".", import.meta.url));
const runId = randomUUID().slice(0, 8);

const ensureStaticAssets = () => {
// CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA
// assets dir wrangler.jsonc points `assets.directory` at) is absent and
// `unstable_dev`'s assets validation aborts boot. These tests drive the
// API/MCP surface (all `run_worker_first` paths), not the SPA.
const distIndex = resolve(dir, "../dist/index.html");
if (!existsSync(distIndex)) {
mkdirSync(resolve(dir, "../dist"), { recursive: true });
writeFileSync(distIndex, "<!doctype html><title>executor</title>");
}
};

// Inline spec (no network); registers one tool, exercising the D1 write path.
const SPEC = JSON.stringify({
openapi: "3.0.0",
Expand Down Expand Up @@ -77,21 +89,13 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
let worker: Unstable_DevWorker;

beforeAll(async () => {
// CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA
// assets dir wrangler.jsonc points `assets.directory` at) is absent and
// `unstable_dev`'s assets validation aborts boot. This e2e drives the
// API/MCP surface (all `run_worker_first` paths), not the SPA, so a minimal
// placeholder index.html satisfies the validation without a real build.
const distIndex = resolve(dir, "../dist/index.html");
if (!existsSync(distIndex)) {
mkdirSync(resolve(dir, "../dist"), { recursive: true });
writeFileSync(distIndex, "<!doctype html><title>executor</title>");
}
ensureStaticAssets();

worker = await unstable_dev(resolve(dir, "worker.ts"), {
config: resolve(dir, "../wrangler.jsonc"),
ip: "127.0.0.1",
local: true,
persist: false,
experimental: { disableExperimentalWarning: true },
vars: {
EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
Expand Down Expand Up @@ -474,3 +478,37 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
expect(resumed.result?.structuredContent?.status).toBe("completed");
}, 60_000);
});

describe("cloudflare host configuration errors", () => {
let worker: Unstable_DevWorker;

beforeAll(async () => {
ensureStaticAssets();
worker = await unstable_dev(resolve(dir, "worker.ts"), {
config: resolve(dir, "../wrangler.jsonc"),
ip: "127.0.0.1",
local: true,
persist: false,
experimental: { disableExperimentalWarning: true },
vars: {
EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
},
});
}, 120_000);

afterAll(async () => {
await worker?.stop();
});

it("returns an actionable response when Cloudflare Access is not configured", async () => {
for (const path of ["/api/account/me", "/mcp"]) {
const response = await worker.fetch(path);

expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
await expect(response.text()).resolves.toBe(
"Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.\n",
);
}
});
});
20 changes: 19 additions & 1 deletion apps/host-cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { makeCloudflareApp } from "./app";
import type { CloudflareEnv } from "./config";
import {
cloudflareAccessConfigErrorMessage,
missingCloudflareAccessVars,
type CloudflareEnv,
} from "./config";

// The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported
// at the Worker entry module scope for the runtime to find them.
Expand Down Expand Up @@ -27,8 +31,22 @@ const resolveHandler = (env: CloudflareEnv) => {
return handlerPromise;
};

const accessConfigErrorResponse = (missingVars: readonly string[]): Response =>
new Response(`${cloudflareAccessConfigErrorMessage(missingVars)}\n`, {
status: 503,
headers: {
"cache-control": "no-store",
"content-type": "text/plain; charset=utf-8",
},
});

export default {
fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
const missingAccessVars = missingCloudflareAccessVars(env);
if (missingAccessVars.length > 0) {
return accessConfigErrorResponse(missingAccessVars);
}

const serve = await resolveHandler(env);
if (new URL(request.url).pathname === "/mcp") {
return serve.mcp(request, env, ctx);
Expand Down
Loading