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
20 changes: 20 additions & 0 deletions apps/host-cloudflare-alchemy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Optional for non-interactive/CI deployments. For local use, prefer the
# Alchemy profile created by `bun run auth` and leave these unset.
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_API_TOKEN=

# Existing Cloudflare Access application configuration.
ACCESS_TEAM_DOMAIN=your-team.cloudflareaccess.com
ACCESS_AUD=
ADMIN_EMAILS=admin@example.com

# Generate once with `openssl rand -hex 32`, then preserve it for the lifetime
# of this D1 database. Changing it makes stored encrypted credentials unreadable.
EXECUTOR_SECRET_KEY=

# Optional Worker configuration. Omit entries that are not needed.
# SELF_HOSTED_ORG_ID=default
# SELF_HOSTED_ORG_NAME=Default
# SELF_HOSTED_ORG_SLUG=default
# ALLOW_LOCAL_NETWORK=false
# VITE_PUBLIC_SITE_URL=https://executor-cloudflare.example.workers.dev
1 change: 1 addition & 0 deletions apps/host-cloudflare-alchemy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @executor-js/host-cloudflare-alchemy
121 changes: 121 additions & 0 deletions apps/host-cloudflare-alchemy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Executor Cloudflare infrastructure with Alchemy

This package is an opt-in Alchemy v2 deployment for
`@executor-js/host-cloudflare`. The Alchemy stack is an Effect program, while
it deliberately deploys the existing async Worker entry and Durable Object
classes unchanged.

## Why a separate workspace?

Executor currently uses Effect `4.0.0-beta.59`. Alchemy `2.0.0-beta.66`
requires Effect `>=4.0.0-beta.100`, so deployment tooling uses its own
compatible Effect dependency and does not force an application-wide upgrade.

## Fresh deployment

For local deployments, authenticate through Alchemy's browser-based profile and
bootstrap its account-wide remote state store:

```bash
bun run --cwd apps/host-cloudflare-alchemy auth
bun run --cwd apps/host-cloudflare-alchemy auth:status
bun run --cwd apps/host-cloudflare-alchemy state:bootstrap
```

`auth` runs `alchemy login --configure`, so it can select the disposable
Cloudflare account without storing an API token in this repository. The
bootstrap command creates or updates Alchemy's `alchemy-state-store` Worker; it
does not deploy Executor. For non-interactive CI, use
`bun run --cwd apps/host-cloudflare-alchemy token:create` to mint a scoped token,
then provide `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` through the CI
secret store.

Copy the environment template and provide the installation-specific Worker
configuration. Cloudflare credentials may stay commented when using an Alchemy
profile:

```bash
cp apps/host-cloudflare-alchemy/.env.example apps/host-cloudflare-alchemy/.env
$EDITOR apps/host-cloudflare-alchemy/.env

bun run --cwd apps/host-cloudflare plan:alchemy
bun run --cwd apps/host-cloudflare deploy:alchemy
```

Never commit `.env`; it contains the Worker encryption key.

### Isolated validation in an existing account

Use the `validation` commands when a separate test account is impractical:

```bash
bun run --cwd apps/host-cloudflare plan:alchemy:validation
bun run --cwd apps/host-cloudflare deploy:alchemy:validation
```

This stage does not adopt resources and uses the isolated physical names
`executor-cloudflare-validation`, `executor-validation`, and
`executor-blobs-validation`. It still shares the account-wide Alchemy state
store and Cloudflare usage limits. Remove only that stage with
`bun run --cwd apps/host-cloudflare-alchemy destroy:validation` after checking
the destroy plan.

The deploy command builds the SPA and verifies the stable MCP Apps shell asset
before running the `prod` Alchemy stack. The stack creates:

- Worker `executor-cloudflare`
- D1 database `executor`
- R2 bucket `executor-blobs`
- SQLite-backed `McpSessionDO` and `McpExecutionOwnerDirectoryDO` namespaces
- static assets and the `ASSETS` binding

Alchemy's Worker provider defaults newly hosted Durable Object classes to
SQLite, emits `ASSETS` whenever static assets are configured, and matches
foreign Durable Object classes by binding name during the first adopting
deploy. The existing binding and class names are therefore intentionally
unchanged.

Create the Cloudflare Access application before deploying so its audience can
be supplied as `ACCESS_AUD`. Managed OAuth for interactive MCP clients still
uses the setup described by `apps/host-cloudflare/scripts/preview.ts`; Alchemy's
current Access Application resource does not expose `oauth_configuration`.

## Adopting an existing Wrangler deployment

Do not run adoption against production without first recovering the exact
plaintext `EXECUTOR_SECRET_KEY` used by the existing Worker. Rotating that key
makes encrypted credentials already stored in D1 unreadable.

After rehearsing against a disposable Wrangler-created deployment, inspect the
adoption-aware dry run, then use the adoption command:

```bash
bun run --cwd apps/host-cloudflare plan:alchemy:adopt
bun run --cwd apps/host-cloudflare deploy:alchemy:adopt
```

The second command changes ownership and deploys the stack. Do not approve it
unless the dry run preserves every stateful resource and binding.

For reference, the underlying adoption command is:

```bash
alchemy deploy --stage prod --adopt
```

Alchemy currently does not expose Cloudflare's `keep_bindings` upload option.
Every live Worker binding must therefore be represented in `alchemy.run.ts`
before adoption. Copy any existing `SELF_HOSTED_ORG_ID`,
`SELF_HOSTED_ORG_NAME`, `SELF_HOSTED_ORG_SLUG`, `ALLOW_LOCAL_NETWORK`, and
`VITE_PUBLIC_SITE_URL` values into `.env`; omitted organization ID/name values
default to `default`/`Default`, while the other three remain absent. Keep the
Wrangler deployment path available until a plan and post-deploy smoke test
confirm that D1, R2, both Durable Object classes, static assets, Access
variables, and the existing secret are preserved.

## Current scope

Wrangler remains the default deploy command, local development server, and type
generator while this path is validated. This package does not automate the
Cloudflare Access application because Alchemy cannot yet create it with the
Managed OAuth configuration required by Executor's MCP endpoint.
195 changes: 195 additions & 0 deletions apps/host-cloudflare-alchemy/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import type { PlatformError } from "effect/PlatformError";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";

const HOST_PACKAGE_URL = import.meta.resolve("@executor-js/host-cloudflare/package.json");
const HOST_ASSETS_DIR = fileURLToPath(new URL("./dist", HOST_PACKAGE_URL));
const HOST_WORKER_ENTRY = fileURLToPath(import.meta.resolve("@executor-js/host-cloudflare/worker"));

const WORKER_FIRST_ROUTES: string[] = [
"/api/*",
"/mcp",
"/mcp/*",
"/.well-known/*",
"/v1",
"/v1/*",
];

const namesForStage = (stage: string) => {
const suffix = stage === "prod" ? "" : `-${stage}`;
return {
worker: `executor-cloudflare${suffix}`,
database: `executor${suffix}`,
bucket: `executor-blobs${suffix}`,
};
};

const optionalString = (name: string) =>
Config.string(name).pipe(Config.option, Config.map(Option.getOrUndefined));

const compareCodeUnits = (left: string, right: string) =>
left < right ? -1 : left > right ? 1 : 0;

class AssetHashError extends Data.TaggedError("AssetHashError")<{
readonly cause: unknown;
}> {}

const hashAssetsDirectory = (root: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const files: string[] = [];
const walk = (directory: string): Effect.Effect<void, PlatformError> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(directory);
entries.sort(compareCodeUnits);
for (const entry of entries) {
const entryPath = path.join(directory, entry);
const linkTarget = yield* fs.readLink(entryPath).pipe(Effect.option);
if (Option.isSome(linkTarget)) {
files.push(entryPath);
continue;
}

const info = yield* fs.stat(entryPath);
if (info.type === "Directory") {
yield* walk(entryPath);
} else {
files.push(entryPath);
}
}
});

yield* walk(root);

const assets: Array<{ relativePath: string; contents: Uint8Array }> = [];
for (const file of files) {
assets.push({
relativePath: path.relative(root, file).split(path.sep).join("/"),
contents: yield* fs.readFile(file),
});
}

return yield* Effect.try({
try: () => {
const hash = createHash("sha256");
for (const { relativePath, contents } of assets) {
hash.update(relativePath);
hash.update("\0");
hash.update(String(contents.byteLength));
hash.update("\0");
hash.update(contents);
}
return hash.digest("hex");
},
catch: (cause) => new AssetHashError({ cause }),
});
});

export default Alchemy.Stack(
"ExecutorCloudflare",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const stage = yield* Alchemy.Stage;
const names = namesForStage(stage);

// Installation-specific values are explicit inputs rather than live vars.
// In particular, EXECUTOR_SECRET_KEY must never be regenerated for an
// existing D1 database: changing it would make stored credentials unreadable.
const accessTeamDomain = yield* Config.string("ACCESS_TEAM_DOMAIN");
const accessAud = yield* Config.string("ACCESS_AUD");
const adminEmails = yield* Config.string("ADMIN_EMAILS");
const organizationId = yield* Config.string("SELF_HOSTED_ORG_ID").pipe(
Config.withDefault("default"),
);
const organizationName = yield* Config.string("SELF_HOSTED_ORG_NAME").pipe(
Config.withDefault("Default"),
);
const organizationSlug = yield* optionalString("SELF_HOSTED_ORG_SLUG");
const allowLocalNetwork = yield* optionalString("ALLOW_LOCAL_NETWORK");
const publicSiteUrl = yield* optionalString("VITE_PUBLIC_SITE_URL");
const executorSecretKey = yield* Config.redacted("EXECUTOR_SECRET_KEY");
const assetsHash = yield* hashAssetsDirectory(HOST_ASSETS_DIR).pipe(
Effect.mapError(
(cause) =>
new Config.ConfigError(
new ConfigProvider.SourceError({
message: `Unable to hash built assets in ${HOST_ASSETS_DIR}`,
cause,
}),
),
),
);

const database = yield* Cloudflare.D1.Database("DB", {
name: names.database,
});
const blobs = yield* Cloudflare.R2.Bucket("BLOBS", {
name: names.bucket,
});

const worker = yield* Cloudflare.Worker("Worker", {
name: names.worker,
main: HOST_WORKER_ENTRY,
compatibility: {
date: "2025-04-01",
flags: ["nodejs_compat"],
},
observability: { enabled: true },
// Alchemy emits the required `ASSETS` fetcher binding whenever an assets
// config is present; the binding name is fixed by the Worker provider.
// Supplying a deterministic content hash also lets unchanged plans
// converge instead of conservatively treating the directory as dirty.
assets: {
directory: HOST_ASSETS_DIR,
hash: assetsHash,
notFoundHandling: "single-page-application",
runWorkerFirst: WORKER_FIRST_ROUTES,
},
env: {
DB: database,
BLOBS: blobs,
// New local classes become SQLite-backed DOs. On first `--adopt`,
// Alchemy matches foreign classes by binding name and reuses them when
// these class names match the existing Wrangler deployment.
MCP_SESSION: Cloudflare.DurableObject("MCP_SESSION", {
className: "McpSessionDO",
}),
MCP_EXECUTION_OWNER: Cloudflare.DurableObject("MCP_EXECUTION_OWNER", {
className: "McpExecutionOwnerDirectoryDO",
}),
ACCESS_TEAM_DOMAIN: accessTeamDomain,
ACCESS_AUD: accessAud,
ACCESS_NAME_CLAIM: "name",
ACCESS_GROUPS_CLAIM: "groups",
ADMIN_EMAILS: adminEmails,
ENABLE_DEV_AUTH: "false",
SELF_HOSTED_ORG_ID: organizationId,
SELF_HOSTED_ORG_NAME: organizationName,
...(organizationSlug === undefined ? {} : { SELF_HOSTED_ORG_SLUG: organizationSlug }),
...(allowLocalNetwork === undefined ? {} : { ALLOW_LOCAL_NETWORK: allowLocalNetwork }),
...(publicSiteUrl === undefined ? {} : { VITE_PUBLIC_SITE_URL: publicSiteUrl }),
EXECUTOR_SECRET_KEY: executorSecretKey,
},
});

return {
url: worker.url,
workerName: worker.workerName,
databaseName: database.databaseName,
bucketName: blobs.bucketName,
};
}),
);
30 changes: 30 additions & 0 deletions apps/host-cloudflare-alchemy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@executor-js/host-cloudflare-alchemy",
"private": true,
"type": "module",
"scripts": {
"auth": "alchemy login --configure",
"auth:status": "alchemy profile show",
"deploy": "alchemy deploy --stage prod",
"deploy:adopt": "alchemy deploy --stage prod --adopt",
"deploy:validation": "alchemy deploy --stage validation",
"destroy": "alchemy destroy --stage prod",
"destroy:validation": "alchemy destroy --stage validation",
"plan": "alchemy plan --stage prod",
"plan:adopt": "alchemy deploy --stage prod --adopt --dry-run",
"plan:validation": "alchemy plan --stage validation",
"state:bootstrap": "alchemy cloudflare bootstrap",
"token:create": "alchemy cloudflare create-token",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@effect/platform-bun": "4.0.0-beta.102",
"@executor-js/host-cloudflare": "workspace:*",
"alchemy": "2.0.0-beta.66",
"effect": "4.0.0-beta.102"
},
"devDependencies": {
"@types/bun": "1.3.11",
"typescript": "catalog:"
}
}
11 changes: 11 additions & 0 deletions apps/host-cloudflare-alchemy/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"include": ["alchemy.run.ts"],
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"declarationMap": false,
"types": ["bun"],
"rootDir": "."
}
}
Loading