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
4 changes: 4 additions & 0 deletions apps/cloud/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,11 +520,15 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'

import type { startInstance } from './start.ts'

declare module '@tanstack/react-start' {
interface Register {
ssr: true

router: Awaited<ReturnType<typeof getRouter>>

config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
1 change: 1 addition & 0 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tanstack/react-router": "catalog:",
"better-auth": "^1.6.11",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"jose": "^5.9.6",
Expand Down
67 changes: 60 additions & 7 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { Effect } from "effect";
import { Effect, Layer } from "effect";
import { HttpEffect, HttpRouter } from "effect/unstable/http";

import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
import {
dbProviderLayer,
ExecutorApp,
textFailureStrategy,
accountProviderMiddlewareLayer,
BetterAuth,
betterAuthIdentityLayer,
betterAuthAccountProvider,
makeBetterAuthAdminApiLayer,
makeBetterAuthSystemApiLayer,
type BetterAuthHandle,
} from "@executor-js/api/server";

import { loadConfig, type CloudflareEnv } from "./config";
import { makeCloudflarePlugins } from "./plugins";
import { createD1ExecutorDb } from "./db/d1";
import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access";
import { buildD1BetterAuth } from "./auth/builtin-auth";
import {
CloudflareCodeExecutorProvider,
makeCloudflareHostConfig,
Expand Down Expand Up @@ -44,9 +56,26 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
// Open and idempotently bring up the D1 schema once. This is the long-lived
// handle the per-request scoped executor reads through the DbProvider seam.
const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
const identityLayer = cloudflareAccessIdentityLayer(config);
const mcpAgentHandler = makeCloudflareMcpAgentHandler(config);
const approvalHandler = makeCloudflareApprovalHandler(config, env);

const isBuiltin = config.authMode === "builtin";
let betterAuth: BetterAuthHandle | null = null;
let identityLayer;
let accountMiddleware;

if (isBuiltin) {
betterAuth = await buildD1BetterAuth(env.DB, config);
const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth);
identityLayer = betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer));
accountMiddleware = accountProviderMiddlewareLayer(
betterAuthAccountProvider.pipe(Layer.provide(betterAuthLayer)),
);
} else {
identityLayer = cloudflareAccessIdentityLayer(config);
accountMiddleware = cloudflareAccountMiddleware(config);
}

const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, betterAuth, identityLayer);
const approvalHandler = makeCloudflareApprovalHandler(config, env, betterAuth);

const { appLayer, toWebHandler } = ExecutorApp.make({
plugins,
Expand All @@ -62,14 +91,38 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
// The account API (`/api/account/*`) backs the shared multiplayer shell's
// auth context; `me` reflects the Access principal. Members/keys are
// Access-managed, so the rest of the surface is stubbed.
account: cloudflareAccountMiddleware(config),
account: accountMiddleware,
},
extensions: {
routes: [
// Browser approval of paused MCP executions: the console resume page
// reads paused detail (GET) and records the decision (POST .../resume),
// Access-gated, routed to the owning session's Durable Object.
// Access/BetterAuth-gated, routed to the owning session's Durable Object.
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(approvalHandler)),
...(isBuiltin && betterAuth
? [
HttpRouter.add(
"GET",
"/api/auth/cli-login",
HttpEffect.fromWebHandler(
async () =>
new Response(
JSON.stringify({
provider: "better-auth",
deviceAuthorizationEndpoint: `${config.webBaseUrl}/api/auth/device/code`,
tokenEndpoint: `${config.webBaseUrl}/api/auth/device/token`,
clientId: "executor-cli",
requestFormat: "json",
}),
{ headers: { "content-type": "application/json" } },
),
),
),
HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(betterAuth.handler)),
makeBetterAuthAdminApiLayer({ betterAuth, mountPrefix: "/api" }),
makeBetterAuthSystemApiLayer({ betterAuth, mountPrefix: "/api" }),
]
: []),
],
},
config: { mountPrefix: "/api", failure: textFailureStrategy },
Expand Down
87 changes: 87 additions & 0 deletions apps/host-cloudflare/src/auth/builtin-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { betterAuth } from "better-auth";

import {
makeBetterAuthSharedOptions,
seedOrgAndAdmin,
ensureInviteCodeTable,
findRedeemableCode,
consumeInviteCode,
type BetterAuthInstance,
type BetterAuthDbClient,
type SignupGate,
type BetterAuthHandle,
} from "@executor-js/api/server";

import type { CloudflareConfig } from "../config";

export const d1ClientAdapter = (db: D1Database): BetterAuthDbClient => ({
execute: async (sql, args) => {
const stmt = db.prepare(sql).bind(...(args ?? []));
const result = await stmt.all();
return {
rows: result.results ?? [],
rowsAffected: result.meta?.changes ?? 0,
};
},
});

export const buildD1BetterAuth = async (
db: D1Database,
config: CloudflareConfig,
): Promise<BetterAuthHandle> => {
const dbClient = d1ClientAdapter(db);

let auth: BetterAuthInstance | null = null;
const orgRef = { id: "" };
const gate: SignupGate = {
get organizationId() {
return orgRef.id;
},
getAuth: () => auth,
findRedeemableCode: (code) => findRedeemableCode(dbClient, code),
consumeInviteCode: (code, by) => consumeInviteCode(dbClient, code, by),
};

const sharedOptions = makeBetterAuthSharedOptions(
() => orgRef.id,
{
authSecret: config.betterAuthSecret!,
webBaseUrl: config.webBaseUrl!,
},
gate,
);

const authOptions = {
...sharedOptions,
database: db,
};

const authInstance = betterAuth(authOptions);
auth = authInstance as any;
await (await authInstance.$context).runMigrations();
await ensureInviteCodeTable(dbClient);

const seedConfig = {
orgSlug: config.organizationSlug,
organizationName: config.organizationName,
bootstrapAdminEmail: config.bootstrapAdminEmail,
bootstrapAdminPassword: config.bootstrapAdminPassword,
bootstrapAdminName: config.bootstrapAdminName,
};

const { organizationId, organizationName } = await seedOrgAndAdmin(
authInstance as any,
dbClient,
seedConfig,
);
orgRef.id = organizationId;

return {
auth: authInstance as any,
organizationId,
organizationName,
organizationSlug: config.organizationSlug,
handler: authInstance.handler,
dbClient,
};
};
1 change: 1 addition & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CloudflareConfig } from "../config";
import { principalFromAccessClaims } from "./cloudflare-access";

const config: CloudflareConfig = {
authMode: "access",
accessTeamDomain: "team.cloudflareaccess.com",
accessAud: "aud-tag",
accessNameClaim: "name",
Expand Down
47 changes: 40 additions & 7 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,21 @@ export interface CloudflareEnv {
* behind Access, or the instance is wide open.
*/
readonly ENABLE_DEV_AUTH?: string;
/** Better Auth variables */
readonly AUTH_MODE?: string;
readonly BETTER_AUTH_SECRET?: string;
readonly AUTH_SECRET?: string;
readonly EXECUTOR_BOOTSTRAP_ADMIN_EMAIL?: string;
readonly EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD?: string;
readonly EXECUTOR_BOOTSTRAP_ADMIN_NAME?: string;
}

export interface CloudflareConfig {
readonly authMode: "access" | "builtin";
readonly betterAuthSecret?: string;
readonly bootstrapAdminEmail?: string;
readonly bootstrapAdminPassword?: string;
readonly bootstrapAdminName?: string;
readonly accessTeamDomain: string;
readonly accessAud: string;
readonly accessNameClaim: string;
Expand Down Expand Up @@ -137,14 +149,30 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => {
"EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1",
);
}
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 rawAuthMode = (env.AUTH_MODE ?? "access").toLowerCase();
const authMode: "access" | "builtin" = rawAuthMode === "builtin" ? "builtin" : "access";

const betterAuthSecret = (env.BETTER_AUTH_SECRET ?? env.AUTH_SECRET)?.trim();
if (authMode === "builtin" && (!betterAuthSecret || betterAuthSecret.length < 32)) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Better Auth requires a secure secret to boot
throw new Error(
"BETTER_AUTH_SECRET (or AUTH_SECRET) must be set and be at least 32 characters long when AUTH_MODE=builtin",
);
}

const enableDevAuth = authMode === "access" && env.ENABLE_DEV_AUTH === "true";
const accessTeamDomain =
authMode === "access" ? normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN) : "";
const accessAud = authMode === "access" ? (env.ACCESS_AUD ?? "").trim() : "";

if (authMode === "access") {
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 @@ -156,6 +184,11 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => {
);
}
return {
authMode,
betterAuthSecret,
bootstrapAdminEmail: env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
bootstrapAdminPassword: env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,
bootstrapAdminName: env.EXECUTOR_BOOTSTRAP_ADMIN_NAME,
accessTeamDomain,
accessAud,
accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name",
Expand Down
36 changes: 31 additions & 5 deletions apps/host-cloudflare/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Predicate } from "effect";
import { Effect, Predicate, Layer } from "effect";

import {
McpAuthProvider,
Expand All @@ -16,6 +16,12 @@ import {
} from "@executor-js/cloudflare/mcp/do-headers";
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
import {
BetterAuth,
betterAuthMcpAuth,
type BetterAuthHandle,
type IdentityProvider,
} from "@executor-js/api/server";

import type { CloudflareConfig, CloudflareEnv } from "../config";
import { cloudflareAccessMcpAuth } from "./auth";
Expand Down Expand Up @@ -62,12 +68,26 @@ const renderAuthError = (
return jsonRpcResponse(503, -32001, outcome.message);
};

const authenticate = (request: Request, config: CloudflareConfig) =>
const authenticate = (
request: Request,
config: CloudflareConfig,
betterAuth: BetterAuthHandle | null,
identityLayer: Layer.Layer<IdentityProvider> | null,
) =>
Effect.gen(function* () {
const auth = yield* McpAuthProvider;
const outcome = yield* auth.authenticate(request);
return { auth, outcome };
}).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
}).pipe(
Effect.provide(
config.authMode === "builtin" && betterAuth && identityLayer
? betterAuthMcpAuth.pipe(
Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
Layer.provide(identityLayer),
)
: cloudflareAccessMcpAuth(config),
),
);

const propsForPrincipal = (
request: Request,
Expand All @@ -92,7 +112,11 @@ const propsForPrincipal = (
};
});

export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
export const makeCloudflareMcpAgentHandler = (
config: CloudflareConfig,
betterAuth: BetterAuthHandle | null,
identityLayer: Layer.Layer<IdentityProvider> | null,
) => {
const serve = McpSessionDO.serve("/mcp", {
binding: "MCP_SESSION",
transport: "streamable-http",
Expand All @@ -102,7 +126,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
if (request.method === "OPTIONS") return corsPreflightResponse();
const sessionId = request.headers.get("mcp-session-id");

const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
const { auth, outcome } = await Effect.runPromise(
authenticate(request, config, betterAuth, identityLayer),
);
if (!Predicate.isTagged(outcome, "Authenticated")) {
if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
await Effect.runPromise(
Expand Down
Loading
Loading