History

cloud/local/examples: thread per-user ScopeStack through HTTP + MCP

- 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].

Rhys Sullivan committed 6 months ago

Commit d4e311e1 parent 427699c25 files · +320 −174Browse files

Showing the changes to apps/cloud (19 of 25 files). Show all

Unified · Split

19 files
  1. apps/cloud/src/api/autumn.ts +2 −2
  2. apps/cloud/src/api/protected.ts +12 −6
  3. apps/cloud/src/auth/handlers.ts +2 −2
  4. apps/cloud/src/auth/workos.ts +4 −4
  5. apps/cloud/src/env-augment.d.ts +33 −0
  6. apps/cloud/src/env.ts +0 −91
  7. apps/cloud/src/mcp-session.e2e.node.test.ts +1 −1
  8. apps/cloud/src/mcp-session.ts +17 −6
  9. apps/cloud/src/mcp.ts +5 −4
  10. apps/cloud/src/observability.ts +3 −0
  11. apps/cloud/src/org/handlers.ts +2 −2
  12. apps/cloud/src/server.ts +33 −16
  13. apps/cloud/src/services/__test-harness__/api-harness.ts +1 −1
  14. apps/cloud/src/services/autumn.ts +2 −3
  15. apps/cloud/src/services/db.ts +1 −5
  16. apps/cloud/src/services/execution-stack.ts +10 −4
  17. apps/cloud/src/services/executor.ts +31 −14
  18. apps/cloud/src/services/telemetry.ts +5 −6
  19. apps/cloud/vite.config.ts +29 −1
apps/cloud/src/api/autumn.ts+2−2
@@ -1,9 +1,9 @@
1+import { env } from "cloudflare:workers";
12 import { Effect } from "effect";
23 import { HttpServerRequest, HttpServerResponse } from "@effect/platform";
34 import { autumnHandler } from "autumn-js/backend";
45
56 import { WorkOSAuth } from "../auth/workos";
6−import { server } from "../env";
77 import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
88 import { SharedServices } from "./layers";
99
@@ -59,7 +59,7 @@ export const AutumnApiApp = Effect.gen(function* () { · 49 unmodified lines
5959 email: session.email,
6060 },
6161 clientOptions: {
62− secretKey: server.AUTUMN_SECRET_KEY,
62+ secretKey: env.AUTUMN_SECRET_KEY ?? "",
6363 },
6464 pathPrefix: "/autumn",
6565 }),
apps/cloud/src/api/protected.ts+12−6
@@ -27,12 +27,18 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 26 unmodified lines
2727 const session = yield* workos.authenticateRequest(webRequest);
2828 if (!session || !session.organizationId) return null;
2929
30− return yield* authorizeOrganization(session.userId, session.organizationId);
30+ const org = yield* authorizeOrganization(session.userId, session.organizationId);
31+ if (!org) return null;
32+ return { org, userId: session.userId };
3133 });
3234
33−const createProtectedApp = (organizationId: string, organizationName: string) =>
35+const createProtectedApp = (userId: string, organizationId: string, organizationName: string) =>
3436 Effect.gen(function* () {
35− const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
37+ const { executor, engine } = yield* makeExecutionStack(
38+ userId,
39+ organizationId,
40+ organizationName,
41+ );
3642
3743 const requestServices = Layer.mergeAll(
3844 Layer.succeed(ExecutorService, executor),
@@ -58,8 +64,8 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 19 unmodified lines
5864
5965 export const ProtectedApiApp = Effect.gen(function* () {
6066 const request = yield* HttpServerRequest.HttpServerRequest;
61− const org = yield* lookupOrgForRequest(request);
62− if (!org) {
67+ const session = yield* lookupOrgForRequest(request);
68+ if (!session) {
6369 return yield* Effect.fail(
6470 new HttpResponseError({
6571 status: 403,
@@ -69,7 +75,7 @@ export const ProtectedApiApp = Effect.gen(function* () { · 3 unmodified lines
6975 );
7076 }
7177
72− const app = yield* createProtectedApp(org.id, org.name);
78+ const app = yield* createProtectedApp(session.userId, session.org.id, session.org.name);
7379 return yield* app;
7480 }).pipe(
7581 Effect.provide(SharedServices),
apps/cloud/src/auth/handlers.ts+2−2
@@ -6,9 +6,9 @@ import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; · 5 unmodified lines
66 import { SessionContext } from "./middleware";
77 import { UserStoreService } from "./context";
88 import { authorizeOrganization } from "./authorize-organization";
9+import { env } from "cloudflare:workers";
910 import { WorkOSError } from "./errors";
1011 import { WorkOSAuth } from "./workos";
11−import { server } from "../env";
1212
1313 const COOKIE_OPTIONS = {
1414 path: "/",
@@ -40,7 +40,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 25 unmodified lines
4040 // Use the explicit public site URL — in dev, the request's Host
4141 // header points at the internal proxy target, not the public URL
4242 // WorkOS needs to redirect back to.
43− const origin = server.VITE_PUBLIC_SITE_URL;
43+ const origin = env.VITE_PUBLIC_SITE_URL ?? "";
4444 const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);
4545 return HttpServerResponse.redirect(url, { status: 302 });
4646 }),
apps/cloud/src/auth/workos.ts+4−4
@@ -2,10 +2,10 @@ · 1 unmodified line
22 // WorkOS AuthKit — Effect-native sealed session management
33 // ---------------------------------------------------------------------------
44
5+import { env } from "cloudflare:workers";
56 import { Context, Effect, Layer } from "effect";
67 import { WorkOS } from "@workos-inc/node/worker";
78 import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
8−import { server } from "../env";
99
1010 const COOKIE_NAME = "wos-session";
1111
@@ -14,9 +14,9 @@ const COOKIE_NAME = "wos-session"; · 2 unmodified lines
1414 // ---------------------------------------------------------------------------
1515
1616 const make = Effect.gen(function* () {
17− const apiKey = server.WORKOS_API_KEY;
18− const clientId = server.WORKOS_CLIENT_ID;
19− const cookiePassword = server.WORKOS_COOKIE_PASSWORD;
17+ const apiKey = env.WORKOS_API_KEY;
18+ const clientId = env.WORKOS_CLIENT_ID;
19+ const cookiePassword = env.WORKOS_COOKIE_PASSWORD;
2020
2121 if (!cookiePassword || cookiePassword.length < 32) {
2222 return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters"));
apps/cloud/src/env-augment.d.ts+33−0
@@ -0,0 +1,33 @@
1+// Augment the wrangler-generated `Cloudflare.Env` with secrets / vars set at
2+// deploy time (via `wrangler secret put`, dashboard, or `.dev.vars`) that
3+// don't show up in `wrangler types` output because they aren't declared in
4+// wrangler.jsonc, but are what `env.X` resolves to at runtime.
5+declare global {
6+ namespace Cloudflare {
7+ interface Env {
8+ // Observability
9+ AXIOM_TOKEN?: string;
10+ AXIOM_DATASET?: string;
11+ AXIOM_TRACES_URL?: string;
12+ AXIOM_TRACES_SAMPLE_RATIO?: string;
13+ SENTRY_DSN?: string;
14+
15+ // Datastore (dev only — prod uses HYPERDRIVE binding)
16+ DATABASE_URL?: string;
17+
18+ // Billing
19+ AUTUMN_SECRET_KEY?: string;
20+
21+ // MCP
22+ MCP_SESSION_REQUEST_SCOPED_RUNTIME?: string;
23+ EXECUTOR_MCP_DEBUG?: string;
24+ MCP_AUTHKIT_DOMAIN?: string;
25+ MCP_RESOURCE_ORIGIN?: string;
26+
27+ // Shared with frontend
28+ VITE_PUBLIC_SITE_URL?: string;
29+ }
30+ }
31+}
32+
33+export {};
apps/cloud/src/env.ts+0−91
@@ -1,91 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Env accessors for apps/cloud.
3−// ---------------------------------------------------------------------------
4−//
5−// `server` / `shared` are lazy Proxies over `cloudflare:workers` `env`, with
6−// a `process.env` fallback for the node-pool tests (which stub
7−// `cloudflare:workers` to an empty `env` and rely on vitest to populate
8−// `process.env`). Reads happen at the call site, not at module load, so
9−// secrets land correctly in both the edge isolate and the Durable Object
10−// isolate — the latter previously captured empty strings at env.ts
11−// module-load time and silently broke `DoTelemetryLive`, `AutumnService`
12−// billing writes, and likely other DO-reachable secret readers.
13−//
14−// Types below mirror the prior `createEnv(...)`-derived shapes so every
15−// caller (`server.X`) keeps the same typed surface.
16−// ---------------------------------------------------------------------------
17−
18−import { env } from "cloudflare:workers";
19−
20−type SharedEnv = Readonly<{
21− NODE_ENV: "development" | "test" | "production";
22−}>;
23−
24−type ServerEnv = SharedEnv &
25− Readonly<{
26− DATABASE_URL: string;
27− MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false" | "true";
28− WORKOS_API_KEY: string;
29− WORKOS_CLIENT_ID: string;
30− WORKOS_COOKIE_PASSWORD: string;
31− VITE_PUBLIC_SITE_URL: string;
32− MCP_AUTHKIT_DOMAIN: string;
33− MCP_RESOURCE_ORIGIN: string;
34− AUTUMN_SECRET_KEY: string;
35− SENTRY_DSN: string;
36− AXIOM_TOKEN: string;
37− AXIOM_DATASET: string;
38− AXIOM_TRACES_URL: string;
39− AXIOM_TRACES_SAMPLE_RATIO: string;
40− }>;
41−
42−type WebEnv = Readonly<Record<string, never>>;
43−
44−const SERVER_DEFAULTS: Record<keyof ServerEnv, string> = {
45− NODE_ENV: "development",
46− DATABASE_URL: "",
47− MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false",
48− WORKOS_API_KEY: "",
49− WORKOS_CLIENT_ID: "",
50− WORKOS_COOKIE_PASSWORD: "",
51− VITE_PUBLIC_SITE_URL: "",
52− MCP_AUTHKIT_DOMAIN: "https://signin.executor.sh",
53− MCP_RESOURCE_ORIGIN: "https://executor.sh",
54− AUTUMN_SECRET_KEY: "",
55− SENTRY_DSN: "",
56− AXIOM_TOKEN: "",
57− AXIOM_DATASET: "executor-cloud",
58− AXIOM_TRACES_URL: "https://api.axiom.co/v1/traces",
59− AXIOM_TRACES_SAMPLE_RATIO: "1",
60−};
61−
62−const SHARED_DEFAULTS: Record<keyof SharedEnv, string> = {
63− NODE_ENV: "development",
64−};
65−
66−const readFromWorkerEnv = (key: string): string | undefined => {
67− const bag = env as unknown as Record<string, unknown>;
68− const v = bag[key];
69− return typeof v === "string" && v.length > 0 ? v : undefined;
70−};
71−
72−const readFromProcessEnv = (key: string): string | undefined => {
73− if (typeof process === "undefined" || !process.env) return undefined;
74− const v = process.env[key];
75− return typeof v === "string" && v.length > 0 ? v : undefined;
76−};
77−
78−const read = (key: string): string | undefined =>
79− readFromWorkerEnv(key) ?? readFromProcessEnv(key);
80−
81−const makeEnvProxy = <T extends object>(defaults: Record<string, string>): T =>
82− new Proxy({} as T, {
83− get: (_target, key) => {
84− if (typeof key !== "string") return undefined;
85− return read(key) ?? defaults[key] ?? "";
86− },
87− });
88−
89−export const shared: SharedEnv = makeEnvProxy<SharedEnv>(SHARED_DEFAULTS);
90−export const server: ServerEnv = makeEnvProxy<ServerEnv>(SERVER_DEFAULTS);
91−export const web: WebEnv = {} as WebEnv;
apps/cloud/src/mcp-session.e2e.node.test.ts+1−1
@@ -122,7 +122,7 @@ const buildScopedExecutor = ( · 121 unmodified lines
122122 name: scopeName,
123123 createdAt: new Date(),
124124 });
125− return yield* createExecutor({ scope, adapter, blobs, plugins });
125+ return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins });
126126 });
127127
128128 // Builds a scope, wires a real execution engine + MCP server, and yields
apps/cloud/src/mcp-session.ts+17−6
@@ -25,7 +25,6 @@ import type { DrizzleDb, DbServiceShape } from "./services/db"; · 24 unmodified lines
2525 import { CoreSharedServices } from "./api/core-shared-services";
2626 import { UserStoreService } from "./auth/context";
2727 import { resolveOrganization } from "./auth/resolve-organization";
28−import { server } from "./env";
2928 import { DbService, combinedSchema } from "./services/db";
3029 import { makeExecutionStack } from "./services/execution-stack";
3130 import { DoTelemetryLive } from "./services/telemetry";
@@ -36,6 +35,7 @@ import { DoTelemetryLive } from "./services/telemetry"; · 4 unmodified lines
3635
3736 export type McpSessionInit = {
3837 organizationId: string;
38+ userId: string;
3939 };
4040
4141 export type IncomingTraceHeaders = {
@@ -109,6 +109,7 @@ type DbHandle = DbServiceShape & { end: () => Promise<void> }; · 67 unmodified lines
109109 type SessionMeta = {
110110 readonly organizationId: string;
111111 readonly organizationName: string;
112+ readonly userId: string;
112113 };
113114
114115 /**
@@ -124,7 +125,12 @@ const makeDbHandle = (options: { · 9 unmodified lines
124125 readonly idleTimeout: number;
125126 readonly maxLifetime: number;
126127 }): DbHandle => {
127− const connectionString = env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL;
128+ // Prefer DATABASE_URL so local dev connects directly to PGlite and
129+ // bypasses Miniflare's Hyperdrive proxy, which resolves to
130+ // `*.hyperdrive.local:5432` and times out — the resulting ECONNRESET
131+ // surfaces as an unhandled socket error that kills the vite dev server.
132+ // Matches the priority in `services/db.ts`.
133+ const connectionString = env.DATABASE_URL || env.HYPERDRIVE?.connectionString || "";
128134 const sql = postgres(connectionString, {
129135 max: 1,
130136 idle_timeout: options.idleTimeout,
@@ -159,6 +165,7 @@ const makeSessionServices = (dbHandle: DbHandle) => · 28 unmodified lines
159165
160166 const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* (
161167 organizationId: string,
168+ userId: string,
162169 ) {
163170 const org = yield* resolveOrganization(organizationId);
164171 if (!org) {
@@ -167,10 +174,11 @@ const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function · 2 unmodified lines
167174 return {
168175 organizationId: org.id,
169176 organizationName: org.name,
177+ userId,
170178 } satisfies SessionMeta;
171179 });
172180
173−const requestScopedRuntimeEnabled = server.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true";
181+const requestScopedRuntimeEnabled = env.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true";
174182
175183 // ---------------------------------------------------------------------------
176184 // Durable Object
@@ -238,6 +246,7 @@ export class McpSessionDO extends DurableObject { · 61 unmodified lines
238246 const self = this;
239247 return Effect.gen(function* () {
240248 const { executor, engine } = yield* makeExecutionStack(
249+ sessionMeta.userId,
241250 sessionMeta.organizationId,
242251 sessionMeta.organizationName,
243252 );
@@ -251,6 +260,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines
251260 engine,
252261 description,
253262 parentSpan: () => self.currentRequestSpan ?? undefined,
263+ debug: env.EXECUTOR_MCP_DEBUG === "true",
254264 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
255265 const transport = new WorkerTransport({
256266 sessionIdGenerator: () => self.ctx.id.toString(),
@@ -272,9 +282,10 @@ export class McpSessionDO extends DurableObject { · 15 unmodified lines
272282 return Effect.gen(function* () {
273283 const dbHandle = makeRequestScopedDb();
274284 try {
275− const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe(
276− Effect.provide(makeResolveOrganizationServices(dbHandle)),
277− );
285+ const sessionMeta = yield* resolveSessionMeta(
286+ token.organizationId,
287+ token.userId,
288+ ).pipe(Effect.provide(makeResolveOrganizationServices(dbHandle)));
278289 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
279290 Effect.withSpan("mcp.session.save_meta"),
280291 );
apps/cloud/src/mcp.ts+5−4
@@ -20,15 +20,14 @@ import * as Sentry from "@sentry/cloudflare"; · 19 unmodified lines
2020 import { Context, Effect, Layer, Option, Schema } from "effect";
2121 import { createRemoteJWKSet, jwtVerify } from "jose";
2222
23−import { server } from "./env";
2423 import { TelemetryLive } from "./services/telemetry";
2524
2625 // ---------------------------------------------------------------------------
2726 // Constants
2827 // ---------------------------------------------------------------------------
2928
30−const AUTHKIT_DOMAIN = server.MCP_AUTHKIT_DOMAIN;
31−const RESOURCE_ORIGIN = server.MCP_RESOURCE_ORIGIN;
29+const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh";
30+const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh";
3231
3332 const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`));
3433
@@ -526,7 +525,9 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 491 unmodified lines
526525 const ns = env.MCP_SESSION;
527526 const stub = ns.get(ns.newUniqueId());
528527 const propagation = yield* currentPropagationHeaders(request);
529− yield* Effect.promise(() => stub.init({ organizationId }, propagation));
528+ yield* Effect.promise(() =>
529+ stub.init({ organizationId, userId: token.accountId }, propagation),
530+ );
530531 const propagated = withPropagationHeaders(request, propagation);
531532 const raw = yield* Effect.promise(
532533 () => stub.handleRequest(propagated) as Promise<Response>,
apps/cloud/src/observability.ts+3−0
@@ -21,6 +21,9 @@ export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( · 20 unmodified lines
2121 captureException: (cause) =>
2222 Effect.sync(() => {
2323 const squashed = Cause.squash(cause);
24+ // Mirror to console so dev terminals see the defect; Sentry gets the
25+ // full structured cause for production correlation.
26+ console.error("[api] unhandled cause:", Cause.pretty(cause));
2427 const eventId = Sentry.captureException(squashed, (scope) => {
2528 scope.setExtra("cause", Cause.pretty(cause));
2629 return scope;
apps/cloud/src/org/handlers.ts+2−2
@@ -3,8 +3,8 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { UserStoreService } from "../auth/context";
55 import { AuthContext } from "../auth/middleware";
6+import { env } from "cloudflare:workers";
67 import { WorkOSAuth } from "../auth/workos";
7−import { server } from "../env";
88 import { AutumnService } from "../services/autumn";
99 import { OrgHttpApi } from "./compose";
1010 import { Forbidden } from "./api";
@@ -144,7 +144,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 133 unmodified lines
144144 const workos = yield* WorkOSAuth;
145145 const { link } = yield* workos.generateDomainVerificationPortalLink(
146146 auth.organizationId,
147− server.VITE_PUBLIC_SITE_URL ? `${server.VITE_PUBLIC_SITE_URL}/org` : "/org",
147+ env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org",
148148 );
149149 return { link };
150150 }),
apps/cloud/src/server.ts+33−16
@@ -3,7 +3,6 @@ import handler from "@tanstack/react-start/server-entry"; · 2 unmodified lines
33 import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers";
44
55 import { McpSessionDO as McpSessionDOBase } from "./mcp-session";
6−import { server } from "./env";
76
87 // ---------------------------------------------------------------------------
98 // OTEL config for the main fetch handler — `otel-cf-workers` owns the global
@@ -15,40 +14,36 @@ import { server } from "./env"; · 5 unmodified lines
1514 // DOMException "Illegal invocation".
1615 // ---------------------------------------------------------------------------
1716
18−const parseSampleRatio = (value: string): number => {
17+const parseSampleRatio = (value: string | undefined): number => {
1918 const n = Number(value);
2019 if (!Number.isFinite(n)) return 1;
2120 return Math.min(1, Math.max(0, n));
2221 };
2322
24−const otelConfig: TraceConfig = {
23+const otelConfig = (env: Env): TraceConfig => ({
2524 service: { name: "executor-cloud", version: "1.0.0" },
2625 exporter: {
27− url: server.AXIOM_TRACES_URL,
26+ url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
2827 headers: {
29− Authorization: `Bearer ${server.AXIOM_TOKEN}`,
30− "X-Axiom-Dataset": server.AXIOM_DATASET,
28+ Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`,
29+ "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
3130 },
3231 },
3332 sampling: {
3433 headSampler: {
3534 // Keep remote parent decisions and make local sampling policy explicit.
3635 acceptRemote: true,
37− ratio: parseSampleRatio(server.AXIOM_TRACES_SAMPLE_RATIO),
36+ ratio: parseSampleRatio(env.AXIOM_TRACES_SAMPLE_RATIO),
3837 },
3938 },
40−};
39+});
4140
4241 // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim
4342 // registers a ProxyTracerProvider of its own, which prevents otel-cf-workers
4443 // from finding its WorkerTracer and breaks the whole request path with
4544 // "global tracer is not of type WorkerTracer".
46−//
47−// The `_env` parameter is unused — `server` from `./env` already gives us
48−// typed access to every secret. It's only in the signature so Sentry's
49−// generics infer the DO's `Env` type correctly.
50−const sentryOptions = (_env: Env) => ({
51− dsn: server.SENTRY_DSN,
45+const sentryOptions = (env: Env) => ({
46+ dsn: env.SENTRY_DSN,
5247 tracesSampleRate: 0,
5348 enableLogs: true,
5449 sendDefaultPii: true,
@@ -76,6 +71,28 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( · 21 unmodified lines
7671 // Worker fetch handler
7772 // ---------------------------------------------------------------------------
7873
79−const instrumentedHandler = instrument({ fetch: handler.fetch }, otelConfig);
74+// Skip OTLP wiring when no Axiom token is configured (dev without secrets).
75+// Otherwise the exporter ships every span with `Bearer ` (empty), which
76+// returns 401 on every batch and eventually drops the keep-alive socket —
77+// the Node http agent's unhandled `'error'` then crashes the process with
78+// ECONNRESET. It also registers otel-cf-workers' `WorkerTracer` as the
79+// global tracer; spans started outside its config ALS then die with
80+// "Config is undefined". Matches the gate in `DoTelemetryLive`.
81+// `instrument()` mutates the handler it's given (replaces `.fetch` with the
82+// proxied version), so capture the raw fetch first and then build the
83+// instrumented handler from a separate object.
84+const rawFetch = handler.fetch;
85+const instrumentedHandler = instrument({ fetch: rawFetch }, otelConfig);
86+
87+const dispatchHandler = {
88+ fetch: (request: Request, env: Env, ctx: unknown) => {
89+ const fn = env.AXIOM_TOKEN ? instrumentedHandler.fetch! : rawFetch;
90+ return (fn as (req: Request, env: Env, ctx: unknown) => Response | Promise<Response>)(
91+ request,
92+ env,
93+ ctx,
94+ );
95+ },
96+};
8097
81−export default Sentry.withSentry(sentryOptions, instrumentedHandler);
98+export default Sentry.withSentry(sentryOptions, dispatchHandler);
apps/cloud/src/services/__test-harness__/api-harness.ts+1−1
@@ -181,7 +181,7 @@ const createTestScopedExecutor = (scopeId: string, scopeName: string) => · 180 unmodified lines
181181 name: scopeName,
182182 createdAt: new Date(),
183183 });
184− return yield* createExecutor({ scope, adapter, blobs, plugins });
184+ return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins });
185185 });
186186
187187 // ---------------------------------------------------------------------------
apps/cloud/src/services/autumn.ts+2−3
@@ -2,12 +2,11 @@ · 1 unmodified line
22 // Autumn billing service — wraps the autumn-js SDK with Effect
33 // ---------------------------------------------------------------------------
44
5+import { env } from "cloudflare:workers";
56 import * as Sentry from "@sentry/cloudflare";
67 import { Autumn } from "autumn-js";
78 import { Context, Data, Effect, Layer } from "effect";
89
9−import { server } from "../env";
10−
1110 // ---------------------------------------------------------------------------
1211 // Errors
1312 // ---------------------------------------------------------------------------
@@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines
3635 // ---------------------------------------------------------------------------
3736
3837 const make = Effect.sync(() => {
39− const secretKey = server.AUTUMN_SECRET_KEY;
38+ const secretKey = env.AUTUMN_SECRET_KEY;
4039
4140 if (!secretKey) {
4241 const notConfigured = Effect.die(
apps/cloud/src/services/db.ts+1−5
@@ -18,7 +18,6 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 17 unmodified lines
1818 import postgres, { type Sql } from "postgres";
1919 import * as cloudSchema from "./schema";
2020 import * as executorSchema from "./executor-schema";
21−import { server } from "../env";
2221
2322 // Exported so every drizzle() call in the cloud app shares one schema
2423 // object. Historically `mcp-session.ts` built its own and forgot to spread
@@ -38,10 +37,7 @@ const resolveConnectionString = () => { · 13 unmodified lines
3837 // In local dev prefer an explicit DATABASE_URL (direct connection to
3938 // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy.
4039 // In production fall back to the Hyperdrive binding.
41− if (server.DATABASE_URL) {
42− return server.DATABASE_URL;
43− }
44− return env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL;
40+ return env.DATABASE_URL || env.HYPERDRIVE?.connectionString || "";
4541 };
4642
4743 const makeSql = (): Sql =>
apps/cloud/src/services/execution-stack.ts+10−4
@@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines
1414 import { AutumnService } from "./autumn";
1515 import { createScopedExecutor } from "./executor";
1616
17−export const makeExecutionStack = (organizationId: string, organizationName: string) =>
17+export const makeExecutionStack = (
18+ userId: string,
19+ organizationId: string,
20+ organizationName: string,
21+) =>
1822 Effect.gen(function* () {
19− const executor = yield* createScopedExecutor(organizationId, organizationName).pipe(
20− Effect.withSpan("McpSessionDO.createScopedExecutor"),
21− );
23+ const executor = yield* createScopedExecutor(
24+ userId,
25+ organizationId,
26+ organizationName,
27+ ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor"));
2228 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
2329 const autumn = yield* AutumnService;
2430 const engine = withExecutionUsageTracking(
apps/cloud/src/services/executor.ts+31−14
@@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines
2525 import { graphqlPlugin } from "@executor/plugin-graphql";
2626 import { workosVaultPlugin } from "@executor/plugin-workos-vault";
2727
28+import { env } from "cloudflare:workers";
2829 import { DbService } from "./db";
29−import { server } from "../env";
3030
3131 // ---------------------------------------------------------------------------
3232 // Plugin list — one place, used for both the runtime and the CLI config
@@ -45,24 +45,31 @@ const createOrgPlugins = () => · 12 unmodified lines
4545 graphqlPlugin(),
4646 workosVaultPlugin({
4747 credentials: {
48− apiKey: server.WORKOS_API_KEY,
49− clientId: server.WORKOS_CLIENT_ID,
48+ apiKey: env.WORKOS_API_KEY,
49+ clientId: env.WORKOS_CLIENT_ID,
5050 },
5151 }),
5252 ] as const;
5353
5454 // ---------------------------------------------------------------------------
55−// Create a fresh executor for a scope (stateless, per-request).
55+// Create a fresh executor for a (user, org) pair (stateless, per-request).
5656 //
57−// Today "scope" is the WorkOS organization — orgs are the only scope
58−// level the cloud app exposes. When workspace / user scopes land, this
59−// function grows to accept a `ScopeStack` instead of a single scope id,
60−// and nothing downstream (executor, plugins, storage) has to change.
57+// Scope stack is `[userOrgScope, orgScope]` — innermost first. The
58+// user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally
59+// includes the org id so the same WorkOS user in a different org gets a
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.
6167 // ---------------------------------------------------------------------------
6268
6369 export const createScopedExecutor = (
64− scopeId: string,
65− scopeName: string,
70+ userId: string,
71+ organizationId: string,
72+ organizationName: string,
6673 ) =>
6774 Effect.gen(function* () {
6875 const { db } = yield* DbService;
@@ -72,9 +79,14 @@ export const createScopedExecutor = ( · 3 unmodified lines
7279 const adapter = makePostgresAdapter({ db, schema });
7380 const blobs = makePostgresBlobStore({ db });
7481
75− const scope = new Scope({
76− id: ScopeId.make(scopeId),
77− name: scopeName,
82+ const orgScope = new Scope({
83+ id: ScopeId.make(organizationId),
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}`,
7890 createdAt: new Date(),
7991 });
8092
@@ -82,5 +94,10 @@ export const createScopedExecutor = ( · 1 unmodified line
8294 // the opaque `InternalError({ traceId })` happens at the HTTP edge
8395 // via `withCapture` (see `api/protected-layers.ts`). That's
8496 // 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+ });
86103 });
apps/cloud/src/services/telemetry.ts+5−6
@@ -53,10 +53,9 @@ import { · 52 unmodified lines
5353 ATTR_SERVICE_NAME,
5454 ATTR_SERVICE_VERSION,
5555 } from "@opentelemetry/semantic-conventions";
56+import { env } from "cloudflare:workers";
5657 import { Effect, Layer } from "effect";
5758
58−import { server } from "../env";
59−
6059 const SERVICE_NAME = "executor-cloud";
6160 const SERVICE_VERSION = "1.0.0";
6261
@@ -71,7 +70,7 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 8 unmodified lines
7170 let installed = false;
7271 const ensureGlobalTracerProvider = (): boolean => {
7372 if (installed) return true;
74− if (!server.AXIOM_TOKEN) return false;
73+ if (!env.AXIOM_TOKEN) return false;
7574 const provider = new WebTracerProvider({
7675 resource: resourceFromAttributes({
7776 [ATTR_SERVICE_NAME]: SERVICE_NAME,
@@ -80,10 +79,10 @@ const ensureGlobalTracerProvider = (): boolean => { · 2 unmodified lines
8079 spanProcessors: [
8180 new SimpleSpanProcessor(
8281 new OTLPTraceExporter({
83− url: server.AXIOM_TRACES_URL,
82+ url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
8483 headers: {
85− Authorization: `Bearer ${server.AXIOM_TOKEN}`,
86− "X-Axiom-Dataset": server.AXIOM_DATASET,
84+ Authorization: `Bearer ${env.AXIOM_TOKEN}`,
85+ "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
8786 },
8887 }),
8988 ),
apps/cloud/vite.config.ts+29−1
@@ -1,12 +1,40 @@
1−import { defineConfig } from "vite";
1+import { defineConfig, type Plugin } from "vite";
22 import { cloudflare } from "@cloudflare/vite-plugin";
33 import { tanstackStart } from "@tanstack/react-start/plugin/vite";
44 import react from "@vitejs/plugin-react";
55 import tailwindcss from "@tailwindcss/vite";
66
7+// Dev-only: the cloudflare vite-plugin bridges outbound fetches (JWKS,
8+// OAuth metadata proxy, etc.) through node undici in the host process. If
9+// a pooled keep-alive socket gets RST'd while no listener is attached, the
10+// `'error'` emit is unhandled and tears down the whole dev server. Log
11+// enough to identify the offender and keep the server alive.
12+const devCrashGuard = (): Plugin => {
13+ let installed = false;
14+ const install = () => {
15+ if (installed) return;
16+ installed = true;
17+ process.on("uncaughtException", (err, origin) => {
18+ console.error(
19+ `[dev-crash-guard] uncaughtException (origin=${origin}):`,
20+ err,
21+ );
22+ });
23+ process.on("unhandledRejection", (reason, promise) => {
24+ console.error("[dev-crash-guard] unhandledRejection:", reason, promise);
25+ });
26+ };
27+ return {
28+ name: "dev-crash-guard",
29+ apply: "serve",
30+ configureServer: install,
31+ };
32+};
33+
734 export default defineConfig({
835 resolve: { tsconfigPaths: true },
936 plugins: [
37+ devCrashGuard(),
1038 tailwindcss(),
1139 cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }),
1240 tanstackStart(),