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/src/services (6 of 25 files). Show all

Unified · Split

6 files
  1. apps/cloud/src/services/__test-harness__/api-harness.ts +1 −1
  2. apps/cloud/src/services/autumn.ts +2 −3
  3. apps/cloud/src/services/db.ts +1 −5
  4. apps/cloud/src/services/execution-stack.ts +10 −4
  5. apps/cloud/src/services/executor.ts +31 −14
  6. apps/cloud/src/services/telemetry.ts +5 −6
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 ),