- cloud: resolves userId from the authenticated session and builds a [userOrgScope, orgScope] stack via createScopedExecutor(userId, orgId, orgName). userId threaded through execution-stack, mcp-session, and MCP dispatch init. - cloud env refactor: deletes the lazy Proxy apps/cloud/src/env.ts in favor of cloudflare:workers env imported at call sites; adds env-augment.d.ts to type secrets not present in generated wrangler types. - cloud/server.ts: env-guarded OTEL wiring (skips instrument() when AXIOM_TOKEN absent); captures rawFetch before instrument() mutates it so dev requests still land on the Hono app. - cloud/services/db.ts: prefers DATABASE_URL over the hyperdrive proxy so local dev works without the tunnel. - vite.config.ts: devCrashGuard plugin that prints uncaught Worker errors with a stable exit. - hosts/mcp: optional debug flag surfaces verbose session logging, wired up from cloud mcp-session. - local/examples/marketing: migrated to scopes: [scope].
Showing the changes to apps/cloud/src/services (6 of 25 files). Show all
Unified · Split
| @@ -181,7 +181,7 @@ const createTestScopedExecutor = (scopeId: string, scopeName: string) => · 180 unmodified lines | |||
| 181 | name: scopeName, | 181 | name: scopeName, |
| 182 | createdAt: new Date(), | 182 | createdAt: new Date(), |
| 183 | }); | 183 | }); |
| 184 | return yield* createExecutor({ scope, adapter, blobs, plugins }); | 184 | return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins }); |
| 185 | }); | 185 | }); |
| 186 | 186 | ||
| 187 | // --------------------------------------------------------------------------- | 187 | // --------------------------------------------------------------------------- |
| @@ -2,12 +2,11 @@ · 1 unmodified line | |||
| 2 | // Autumn billing service — wraps the autumn-js SDK with Effect | 2 | // Autumn billing service — wraps the autumn-js SDK with Effect |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | ||
| 5 | import * as Sentry from "@sentry/cloudflare"; | 6 | import * as Sentry from "@sentry/cloudflare"; |
| 6 | import { Autumn } from "autumn-js"; | 7 | import { Autumn } from "autumn-js"; |
| 7 | import { Context, Data, Effect, Layer } from "effect"; | 8 | import { Context, Data, Effect, Layer } from "effect"; |
| 8 | 9 | ||
| 9 | import { server } from "../env"; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 12 | // Errors | 11 | // Errors |
| 13 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| @@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines | |||
| 36 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 37 | 36 | ||
| 38 | const make = Effect.sync(() => { | 37 | const make = Effect.sync(() => { |
| 39 | const secretKey = server.AUTUMN_SECRET_KEY; | 38 | const secretKey = env.AUTUMN_SECRET_KEY; |
| 40 | 39 | ||
| 41 | if (!secretKey) { | 40 | if (!secretKey) { |
| 42 | const notConfigured = Effect.die( | 41 | const notConfigured = Effect.die( |
| @@ -18,7 +18,6 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 17 unmodified lines | |||
| 18 | import postgres, { type Sql } from "postgres"; | 18 | import postgres, { type Sql } from "postgres"; |
| 19 | import * as cloudSchema from "./schema"; | 19 | import * as cloudSchema from "./schema"; |
| 20 | import * as executorSchema from "./executor-schema"; | 20 | import * as executorSchema from "./executor-schema"; |
| 21 | import { server } from "../env"; | ||
| 22 | 21 | ||
| 23 | // Exported so every drizzle() call in the cloud app shares one schema | 22 | // Exported so every drizzle() call in the cloud app shares one schema |
| 24 | // object. Historically `mcp-session.ts` built its own and forgot to spread | 23 | // object. Historically `mcp-session.ts` built its own and forgot to spread |
| @@ -38,10 +37,7 @@ const resolveConnectionString = () => { · 13 unmodified lines | |||
| 38 | // In local dev prefer an explicit DATABASE_URL (direct connection to | 37 | // In local dev prefer an explicit DATABASE_URL (direct connection to |
| 39 | // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy. | 38 | // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy. |
| 40 | // In production fall back to the Hyperdrive binding. | 39 | // In production fall back to the Hyperdrive binding. |
| 41 | if (server.DATABASE_URL) { | 40 | return env.DATABASE_URL || env.HYPERDRIVE?.connectionString || ""; |
| 42 | return server.DATABASE_URL; | ||
| 43 | } | ||
| 44 | return env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL; | ||
| 45 | }; | 41 | }; |
| 46 | 42 | ||
| 47 | const makeSql = (): Sql => | 43 | const makeSql = (): Sql => |
| @@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines | |||
| 14 | import { AutumnService } from "./autumn"; | 14 | import { AutumnService } from "./autumn"; |
| 15 | import { createScopedExecutor } from "./executor"; | 15 | import { createScopedExecutor } from "./executor"; |
| 16 | 16 | ||
| 17 | export const makeExecutionStack = (organizationId: string, organizationName: string) => | 17 | export const makeExecutionStack = ( |
| 18 | userId: string, | ||
| 19 | organizationId: string, | ||
| 20 | organizationName: string, | ||
| 21 | ) => | ||
| 18 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 19 | const executor = yield* createScopedExecutor(organizationId, organizationName).pipe( | 23 | const executor = yield* createScopedExecutor( |
| 20 | Effect.withSpan("McpSessionDO.createScopedExecutor"), | 24 | userId, |
| 21 | ); | 25 | organizationId, |
| 26 | organizationName, | ||
| 27 | ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor")); | ||
| 22 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); | 28 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); |
| 23 | const autumn = yield* AutumnService; | 29 | const autumn = yield* AutumnService; |
| 24 | const engine = withExecutionUsageTracking( | 30 | const engine = withExecutionUsageTracking( |
| @@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines | |||
| 25 | import { graphqlPlugin } from "@executor/plugin-graphql"; | 25 | import { graphqlPlugin } from "@executor/plugin-graphql"; |
| 26 | import { workosVaultPlugin } from "@executor/plugin-workos-vault"; | 26 | import { workosVaultPlugin } from "@executor/plugin-workos-vault"; |
| 27 | 27 | ||
| 28 | import { env } from "cloudflare:workers"; | ||
| 28 | import { DbService } from "./db"; | 29 | import { DbService } from "./db"; |
| 29 | import { server } from "../env"; | ||
| 30 | 30 | ||
| 31 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 32 | // Plugin list — one place, used for both the runtime and the CLI config | 32 | // Plugin list — one place, used for both the runtime and the CLI config |
| @@ -45,24 +45,31 @@ const createOrgPlugins = () => · 12 unmodified lines | |||
| 45 | graphqlPlugin(), | 45 | graphqlPlugin(), |
| 46 | workosVaultPlugin({ | 46 | workosVaultPlugin({ |
| 47 | credentials: { | 47 | credentials: { |
| 48 | apiKey: server.WORKOS_API_KEY, | 48 | apiKey: env.WORKOS_API_KEY, |
| 49 | clientId: server.WORKOS_CLIENT_ID, | 49 | clientId: env.WORKOS_CLIENT_ID, |
| 50 | }, | 50 | }, |
| 51 | }), | 51 | }), |
| 52 | ] as const; | 52 | ] as const; |
| 53 | 53 | ||
| 54 | // --------------------------------------------------------------------------- | 54 | // --------------------------------------------------------------------------- |
| 55 | // Create a fresh executor for a scope (stateless, per-request). | 55 | // Create a fresh executor for a (user, org) pair (stateless, per-request). |
| 56 | // | 56 | // |
| 57 | // Today "scope" is the WorkOS organization — orgs are the only scope | 57 | // Scope stack is `[userOrgScope, orgScope]` — innermost first. The |
| 58 | // level the cloud app exposes. When workspace / user scopes land, this | 58 | // user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally |
| 59 | // function grows to accept a `ScopeStack` instead of a single scope id, | 59 | // includes the org id so the same WorkOS user in a different org gets a |
| 60 | // and nothing downstream (executor, plugins, storage) has to change. | 60 | // distinct scope row; future workspace scopes can slot in between without |
| 61 | // conflicting with a hypothetical global user scope. | ||
| 62 | // | ||
| 63 | // OAuth tokens land at `ctx.scopes[0]` (the user-org scope) by default, so | ||
| 64 | // a member's access/refresh tokens can't leak to other members via | ||
| 65 | // `secrets.list`, while source rows and org-wide credentials live on the | ||
| 66 | // outer scope. | ||
| 61 | // --------------------------------------------------------------------------- | 67 | // --------------------------------------------------------------------------- |
| 62 | 68 | ||
| 63 | export const createScopedExecutor = ( | 69 | export const createScopedExecutor = ( |
| 64 | scopeId: string, | 70 | userId: string, |
| 65 | scopeName: string, | 71 | organizationId: string, |
| 72 | organizationName: string, | ||
| 66 | ) => | 73 | ) => |
| 67 | Effect.gen(function* () { | 74 | Effect.gen(function* () { |
| 68 | const { db } = yield* DbService; | 75 | const { db } = yield* DbService; |
| @@ -72,9 +79,14 @@ export const createScopedExecutor = ( · 3 unmodified lines | |||
| 72 | const adapter = makePostgresAdapter({ db, schema }); | 79 | const adapter = makePostgresAdapter({ db, schema }); |
| 73 | const blobs = makePostgresBlobStore({ db }); | 80 | const blobs = makePostgresBlobStore({ db }); |
| 74 | 81 | ||
| 75 | const scope = new Scope({ | 82 | const orgScope = new Scope({ |
| 76 | id: ScopeId.make(scopeId), | 83 | id: ScopeId.make(organizationId), |
| 77 | name: scopeName, | 84 | name: organizationName, |
| 85 | createdAt: new Date(), | ||
| 86 | }); | ||
| 87 | const userOrgScope = new Scope({ | ||
| 88 | id: ScopeId.make(`user-org:${userId}:${organizationId}`), | ||
| 89 | name: `Personal · ${organizationName}`, | ||
| 78 | createdAt: new Date(), | 90 | createdAt: new Date(), |
| 79 | }); | 91 | }); |
| 80 | 92 | ||
| @@ -82,5 +94,10 @@ export const createScopedExecutor = ( · 1 unmodified line | |||
| 82 | // the opaque `InternalError({ traceId })` happens at the HTTP edge | 94 | // the opaque `InternalError({ traceId })` happens at the HTTP edge |
| 83 | // via `withCapture` (see `api/protected-layers.ts`). That's | 95 | // via `withCapture` (see `api/protected-layers.ts`). That's |
| 84 | // where `ErrorCaptureLive` (Sentry) gets wired in. | 96 | // where `ErrorCaptureLive` (Sentry) gets wired in. |
| 85 | return yield* createExecutor({ scope, adapter, blobs, plugins }); | 97 | return yield* createExecutor({ |
| 98 | scopes: [userOrgScope, orgScope], | ||
| 99 | adapter, | ||
| 100 | blobs, | ||
| 101 | plugins, | ||
| 102 | }); | ||
| 86 | }); | 103 | }); |
| @@ -53,10 +53,9 @@ import { · 52 unmodified lines | |||
| 53 | ATTR_SERVICE_NAME, | 53 | ATTR_SERVICE_NAME, |
| 54 | ATTR_SERVICE_VERSION, | 54 | ATTR_SERVICE_VERSION, |
| 55 | } from "@opentelemetry/semantic-conventions"; | 55 | } from "@opentelemetry/semantic-conventions"; |
| 56 | import { env } from "cloudflare:workers"; | ||
| 56 | import { Effect, Layer } from "effect"; | 57 | import { Effect, Layer } from "effect"; |
| 57 | 58 | ||
| 58 | import { server } from "../env"; | ||
| 59 | |||
| 60 | const SERVICE_NAME = "executor-cloud"; | 59 | const SERVICE_NAME = "executor-cloud"; |
| 61 | const SERVICE_VERSION = "1.0.0"; | 60 | const SERVICE_VERSION = "1.0.0"; |
| 62 | 61 | ||
| @@ -71,7 +70,7 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 8 unmodified lines | |||
| 71 | let installed = false; | 70 | let installed = false; |
| 72 | const ensureGlobalTracerProvider = (): boolean => { | 71 | const ensureGlobalTracerProvider = (): boolean => { |
| 73 | if (installed) return true; | 72 | if (installed) return true; |
| 74 | if (!server.AXIOM_TOKEN) return false; | 73 | if (!env.AXIOM_TOKEN) return false; |
| 75 | const provider = new WebTracerProvider({ | 74 | const provider = new WebTracerProvider({ |
| 76 | resource: resourceFromAttributes({ | 75 | resource: resourceFromAttributes({ |
| 77 | [ATTR_SERVICE_NAME]: SERVICE_NAME, | 76 | [ATTR_SERVICE_NAME]: SERVICE_NAME, |
| @@ -80,10 +79,10 @@ const ensureGlobalTracerProvider = (): boolean => { · 2 unmodified lines | |||
| 80 | spanProcessors: [ | 79 | spanProcessors: [ |
| 81 | new SimpleSpanProcessor( | 80 | new SimpleSpanProcessor( |
| 82 | new OTLPTraceExporter({ | 81 | new OTLPTraceExporter({ |
| 83 | url: server.AXIOM_TRACES_URL, | 82 | url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", |
| 84 | headers: { | 83 | headers: { |
| 85 | Authorization: `Bearer ${server.AXIOM_TOKEN}`, | 84 | Authorization: `Bearer ${env.AXIOM_TOKEN}`, |
| 86 | "X-Axiom-Dataset": server.AXIOM_DATASET, | 85 | "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud", |
| 87 | }, | 86 | }, |
| 88 | }), | 87 | }), |
| 89 | ), | 88 | ), |