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
6 changes: 6 additions & 0 deletions .changeset/bright-graphql-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/plugin-graphql": patch
"@executor-js/execution": patch
---

Expose bounded GraphQL return shapes and teach agents to request nested rows with explicit `select` clauses.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ scratch/
# Throwaway UX prototype (not part of the app)
ux-demo/

apps/host-cloudflare/assets/erxes-introspection.json
5 changes: 5 additions & 0 deletions apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Now visiting the Worker prompts an Access login; the Worker validates the issued
JWT on every request. Unauthenticated requests return 401. MCP clients present
an Access JWT or `Cf-Access-Client-Id`/`-Secret` service-token headers.

A Cloudflare OS deployment can also set the same `CLOUDFLARE_OS_AUTH_SECRET`
secret on both Workers. Exempt only `/os/*` from the Access application: those
routes accept short-lived, signed per-user assertions and expose only Erxes
connection provisioning plus MCP.

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.
Expand Down
Empty file.
2 changes: 1 addition & 1 deletion apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build && node scripts/assert-shell-asset.mjs",
"build": "node scripts/fetch-erxes-introspection.mjs && vite build && node scripts/assert-shell-asset.mjs && cp assets/erxes-introspection.json dist/erxes-introspection.json",
"deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): The package deploy script still runs only vite build and does not fetch or copy erxes-introspection.json into dist, so deployments through bun run deploy omit the offline snapshot and first connection provisioning falls back to live GraphQL introspection.

Triggers: When the Worker is deployed through the package's deploy script rather than the bespoke scripts/deploy.sh flow.

Suggested fix: Make deploy invoke the same snapshot-fetching build or explicitly run fetch-erxes-introspection.mjs and copy the asset before wrangler deploy.

Suggested change
"deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy",
"deploy": "bun run build && wrangler deploy",

"dev": "wrangler dev",
"dev:web": "vite dev",
Expand Down
165 changes: 101 additions & 64 deletions apps/host-cloudflare/scripts/deploy.sh
Original file line number Diff line number Diff line change
@@ -1,92 +1,129 @@
#!/usr/bin/env bash
# One-shot deploy for the Executor Cloudflare host.
# Deploy one Executor Cloudflare host for a Cloudflare OS instance.
#
# Provisions everything a fresh account needs and deploys the Worker:
# 1. verifies wrangler is logged in
# 2. creates (or reuses) the `executor` D1 database and writes its id into
# 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: configure the Cloudflare Access application
# The default is the internal erxes demo:
# worker: erxes-os-internal-executor
# domain: executor.os.erxes.io
# D1: erxes-os-internal-executor
# R2: erxes-os-internal-executor-blobs
#
# Idempotent — safe to re-run. Run from anywhere:
# bash apps/host-cloudflare/scripts/deploy.sh
# Set INSTANCE_SLUG and EXECUTOR_DOMAIN for another tenant. Every resource name
# follows INSTANCE_SLUG so multiple installations can share one CF account.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG="$APP_DIR/wrangler.jsonc"
SOURCE_CONFIG="$APP_DIR/wrangler.jsonc"
CONFIG="$APP_DIR/wrangler.instance.jsonc"
SECRETS_FILE="${EXECUTOR_SECRETS_FILE:-$APP_DIR/deploy-secrets.json}"
cd "$APP_DIR"

EXPECTED_ACCOUNT_ID="7c8392aff8ac4518aa06dfa4b6337ef2"
INSTANCE_SLUG="${INSTANCE_SLUG:-erxes-os-internal}"
EXECUTOR_DOMAIN="${EXECUTOR_DOMAIN:-executor.os.erxes.io}"
WORKER_NAME="${INSTANCE_SLUG}-executor"
DATABASE_NAME="$WORKER_NAME"
BUCKET_NAME="${WORKER_NAME}-blobs"

step() { printf '\n\033[1;36m==> %s\033[0m\n' "$1"; }
info() { printf ' %s\n' "$1"; }
die() { printf 'deploy: %s\n' "$1" >&2; exit 1; }

[ "${CLOUDFLARE_ACCOUNT_ID:-}" = "$EXPECTED_ACCOUNT_ID" ] || die \
"CLOUDFLARE_ACCOUNT_ID must be pinned to erxes Inc ($EXPECTED_ACCOUNT_ID)"

step "Checking wrangler login"
if ! bunx wrangler whoami >/dev/null 2>&1; then
info "Not logged in. Run: bunx wrangler login"
exit 1
bunx wrangler whoami >/dev/null 2>&1 || die "not logged in; run bunx wrangler login"
info "account: erxes Inc ($EXPECTED_ACCOUNT_ID)"

step "Provisioning D1 database '$DATABASE_NAME'"
DB_ID="$(bunx wrangler d1 list --json 2>/dev/null \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const r=JSON.parse(s.slice(s.indexOf("["))).find(d=>d.name===process.argv[1]);process.stdout.write(r?r.uuid:"")}catch{}})' "$DATABASE_NAME")"
if [ -z "$DB_ID" ]; then
CREATE_OUT="$(bunx wrangler d1 create "$DATABASE_NAME" 2>&1)"
DB_ID="$(printf '%s' "$CREATE_OUT" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)"
info "created: $DB_ID"
else
info "reusing: $DB_ID"
fi
info "Logged in."

step "Provisioning D1 database 'executor'"
# `d1 create` is non-idempotent (errors if it exists), so list first.
EXISTING_ID="$(bunx wrangler d1 list --json 2>/dev/null \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const r=JSON.parse(s).find(d=>d.name==="executor");process.stdout.write(r?r.uuid:"")}catch{}})')"
if [ -n "$EXISTING_ID" ]; then
DB_ID="$EXISTING_ID"
info "Reusing existing database: $DB_ID"
[ -n "$DB_ID" ] || die "failed to resolve D1 database id"

step "Provisioning R2 bucket '$BUCKET_NAME'"
if bunx wrangler r2 bucket list 2>/dev/null | grep -q "$BUCKET_NAME"; then
info "reusing existing bucket"
else
CREATE_OUT="$(bunx wrangler d1 create executor 2>&1)"
DB_ID="$(printf '%s' "$CREATE_OUT" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)"
info "Created database: $DB_ID"
bunx wrangler r2 bucket create "$BUCKET_NAME" >/dev/null
info "created"
fi
[ -n "$DB_ID" ] || { echo "Failed to resolve D1 database id" >&2; exit 1; }

step "Writing D1 id into wrangler.jsonc"
# Replace whatever database_id is present (placeholder or a prior id).
node -e '
const fs=require("fs"),p=process.argv[1],id=process.argv[2];
let t=fs.readFileSync(p,"utf8");
t=t.replace(/("database_id":\s*")[^"]*(")/, `$1${id}$2`);
fs.writeFileSync(p,t);
' "$CONFIG" "$DB_ID"
info "wrangler.jsonc -> $DB_ID"

step "Ensuring EXECUTOR_SECRET_KEY secret"
if bunx wrangler secret list 2>/dev/null | grep -q EXECUTOR_SECRET_KEY; then
info "Secret already set — leaving it."

step "Generating instance config"
cp "$SOURCE_CONFIG" "$CONFIG"
node - "$CONFIG" "$DB_ID" "$DATABASE_NAME" "$BUCKET_NAME" "$WORKER_NAME" "$EXECUTOR_DOMAIN" <<'NODE'
const fs = require("node:fs");
const [path, dbId, dbName, bucketName, workerName, domain] = process.argv.slice(2);
let text = fs.readFileSync(path, "utf8");
text = text.replace(/("name":\s*")[^"]*(")/, `$1${workerName}$2`);
text = text.replace(/("database_name":\s*")[^"]*(")/, `$1${dbName}$2`);
text = text.replace(/("database_id":\s*")[^"]*(")/, `$1${dbId}$2`);
text = text.replace(/("bucket_name":\s*")[^"]*(")/, `$1${bucketName}$2`);
text = text.replace(
/(\s*"main":\s*"src\/worker\.ts",)/,
`$1\n "routes": [{ "pattern": "${domain}", "custom_domain": true }],`,
);
text = text.replace(
/(\s*"vars":\s*{)/,
`$1\n // Direct UI stays fail-closed until an erxes Access app replaces these values.\n` +
` // Signed /os/* calls from Cloudflare OS verify before Access and work immediately.\n` +
` "ACCESS_TEAM_DOMAIN": "invalid.cloudflareaccess.com",\n` +
` "ACCESS_AUD": "not-configured",\n` +
` "ADMIN_EMAILS": "amaraaamka0404@gmail.com",`,
);
fs.writeFileSync(path, text);
NODE
info "$CONFIG"

step "Ensuring deployment secrets"
if [ ! -f "$SECRETS_FILE" ]; then
node - "$SECRETS_FILE" <<'NODE'
const { randomBytes } = require("node:crypto");
const { writeFileSync } = require("node:fs");
const path = process.argv[2];
writeFileSync(path, JSON.stringify({
EXECUTOR_SECRET_KEY: randomBytes(32).toString("hex"),
CLOUDFLARE_OS_AUTH_SECRET: randomBytes(32).toString("hex"),
}, null, 2) + "\n", { mode: 0o600 });
NODE
info "generated $SECRETS_FILE"
else
SECRET="$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))')"
printf '%s' "$SECRET" | bunx wrangler secret put EXECUTOR_SECRET_KEY >/dev/null
info "Generated + uploaded a fresh 32-byte key."
info "reusing $SECRETS_FILE"
fi
chmod 600 "$SECRETS_FILE"

step "Fetching Erxes introspection snapshot"
node scripts/fetch-erxes-introspection.mjs

step "Building the web SPA"
bunx vite build
node scripts/assert-shell-asset.mjs
cp assets/erxes-introspection.json dist/erxes-introspection.json
info "bundled erxes-introspection.json into dist/"

step "Deploying Worker"
bunx wrangler deploy

cat <<'NEXT'

==> One manual step left: turn on Cloudflare Access (the auth layer)
if [ "${1:-}" = "--dry-run" ]; then
info "dry-run: skipped Worker deploy and secret upload"
exit 0
fi

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:
step "Deploying '$WORKER_NAME'"
bunx wrangler deploy -c "$CONFIG"

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 \
--var ADMIN_EMAILS:<admin@example.com>
step "Uploading secrets"
bunx wrangler secret bulk -c "$CONFIG" < "$SECRETS_FILE" >/dev/null
info "EXECUTOR_SECRET_KEY and CLOUDFLARE_OS_AUTH_SECRET uploaded"

Wrangler preserves these live variables during later code deploys.
cat <<NEXT

That's it. Visiting the Worker URL now prompts a Cloudflare Access login,
and the Worker validates the issued JWT on every request.
Executor is live at https://$EXECUTOR_DOMAIN

Direct browser access remains closed until an erxes Cloudflare Access app is configured.
Cloudflare OS calls to /os/* are ready now via the generated shared secret.
NEXT
102 changes: 102 additions & 0 deletions apps/host-cloudflare/scripts/fetch-erxes-introspection.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
// Fetch the OfficeNext GraphQL introspection snapshot for offline tool production.
// Writes apps/host-cloudflare/assets/erxes-introspection.json (wrapper: { data: ... }).
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const ENDPOINT = process.env.ERXES_GRAPHQL_URL ?? "https://officenext.erxes.io/gateway/graphql";
const OUT = join(dirname(fileURLToPath(import.meta.url)), "../assets/erxes-introspection.json");

const INTROSPECTION_QUERY = `
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
types {
kind
name
description
fields(includeDeprecated: false) {
name
description
args {
name
description
type { ...TypeRef }
defaultValue
}
type { ...TypeRef }
}
inputFields {
name
description
type { ...TypeRef }
defaultValue
}
interfaces { ...TypeRef }
enumValues(includeDeprecated: false) {
name
description
}
possibleTypes { ...TypeRef }
}
}
}

fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}
`;

const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: INTROSPECTION_QUERY }),
});
if (!response.ok) {
console.error(`Introspection failed: HTTP ${response.status}`);
process.exit(1);
}
const payload = await response.json();
if (payload.errors?.length) {
console.error("Introspection errors:", payload.errors);
process.exit(1);
}
if (!payload.data?.__schema) {
console.error("Introspection response missing schema");
process.exit(1);
}

mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(payload));
console.log(`Wrote ${OUT} (${(JSON.stringify(payload).length / 1_048_576).toFixed(2)} MiB)`);
35 changes: 35 additions & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export const principalFromAccessClaims = (
*/
export const makeAccessVerifier = (config: CloudflareConfig) => {
const issuer = `https://${config.accessTeamDomain}`;
const cloudflareOsKey = config.cloudflareOsAuthSecret
? new TextEncoder().encode(config.cloudflareOsAuthSecret)
: null;
// Cached, lazily-fetched team signing keys; jose handles rotation + caching.
const jwks = config.enableDevAuth
? null
Expand All @@ -82,6 +85,38 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {

const verify = (request: Request): Effect.Effect<Principal | null> =>
Effect.gen(function* () {
if (cloudflareOsKey) {
const authorization = request.headers.get("Authorization");
const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : null;
if (token) {
const verified = yield* Effect.tryPromise({
try: () =>
jwtVerify(token, cloudflareOsKey, {
issuer: "cloudflare-os",
audience: "executor",
algorithms: ["HS256"],
}),
catch: () => "invalid cloudflare os assertion",
}).pipe(Effect.orElseSucceed(() => null));
const subject = verified?.payload.sub;
const organizationId = verified?.payload.org;
const email = typeof verified?.payload.email === "string" ? verified.payload.email : "";
if (typeof subject === "string" && typeof organizationId === "string") {
return {
kind: "member",
accountId: subject,
organizationId,
organizationName: organizationId,
organizationSlug: config.organizationSlug,
email,
name: email || null,
avatarUrl: null,
roles: ["member"],
};
}
}
}

if (config.enableDevAuth) return devPrincipal;
if (!jwks) return null;
const token = request.headers.get("Cf-Access-Jwt-Assertion");
Expand Down
Loading