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
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// ---------------------------------------------------------------------------
apps/cloud/src/services/autumn.ts+2−3
@@ -2,12 +2,11 @@ · 1 unmodified line
2// Autumn billing service — wraps the autumn-js SDK with Effect2// Autumn billing service — wraps the autumn-js SDK with Effect
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";
5import * as Sentry from "@sentry/cloudflare";6import * as Sentry from "@sentry/cloudflare";
6import { Autumn } from "autumn-js";7import { Autumn } from "autumn-js";
7import { Context, Data, Effect, Layer } from "effect";8import { Context, Data, Effect, Layer } from "effect";
8 9
9import { server } from "../env";
10
11// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
12// Errors11// Errors
13// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
@@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines
36// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
37 36
38const make = Effect.sync(() => {37const 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(
apps/cloud/src/services/db.ts+1−5
@@ -18,7 +18,6 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 17 unmodified lines
18import postgres, { type Sql } from "postgres";18import postgres, { type Sql } from "postgres";
19import * as cloudSchema from "./schema";19import * as cloudSchema from "./schema";
20import * as executorSchema from "./executor-schema";20import * as executorSchema from "./executor-schema";
21import { server } from "../env";
22 21
23// Exported so every drizzle() call in the cloud app shares one schema22// Exported so every drizzle() call in the cloud app shares one schema
24// object. Historically `mcp-session.ts` built its own and forgot to spread23// 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 to37 // 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
47const makeSql = (): Sql =>43const makeSql = (): Sql =>
apps/cloud/src/services/execution-stack.ts+10−4
@@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines
14import { AutumnService } from "./autumn";14import { AutumnService } from "./autumn";
15import { createScopedExecutor } from "./executor";15import { createScopedExecutor } from "./executor";
16 16
17export const makeExecutionStack = (organizationId: string, organizationName: string) =>17export 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(
apps/cloud/src/services/executor.ts+31−14
@@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines
25import { graphqlPlugin } from "@executor/plugin-graphql";25import { graphqlPlugin } from "@executor/plugin-graphql";
26import { workosVaultPlugin } from "@executor/plugin-workos-vault";26import { workosVaultPlugin } from "@executor/plugin-workos-vault";
27 27
28import { env } from "cloudflare:workers";
28import { DbService } from "./db";29import { DbService } from "./db";
29import { server } from "../env";
30 30
31// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
32// Plugin list — one place, used for both the runtime and the CLI config32// 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 scope57// Scope stack is `[userOrgScope, orgScope]` — innermost first. The
58// level the cloud app exposes. When workspace / user scopes land, this58// 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
63export const createScopedExecutor = (69export 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 edge94 // the opaque `InternalError({ traceId })` happens at the HTTP edge
83 // via `withCapture` (see `api/protected-layers.ts`). That's95 // 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 });
apps/cloud/src/services/telemetry.ts+5−6
@@ -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";
56import { env } from "cloudflare:workers";
56import { Effect, Layer } from "effect";57import { Effect, Layer } from "effect";
57 58
58import { server } from "../env";
59
60const SERVICE_NAME = "executor-cloud";59const SERVICE_NAME = "executor-cloud";
61const SERVICE_VERSION = "1.0.0";60const SERVICE_VERSION = "1.0.0";
62 61
@@ -71,7 +70,7 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 8 unmodified lines
71let installed = false;70let installed = false;
72const ensureGlobalTracerProvider = (): boolean => {71const 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 ),