History

Migrate cloud integration tests into e2e scenarios (#944)

* Migrate cloud integration tests into e2e scenarios; delete the api-harness Eleven cloud test files (api-harness/in-process based) become ten e2e scenario files driven over the wire against the dev server + emulators: account API (incl. a minted key authorizing the protected API), auth session edges (CSRF state, forged callbacks, logout), surface reachability (swagger/openapi/billing proxy/404s/anonymous contracts), connections credentials CRUD (real vault path), tenant + owner isolation (real multi-user orgs via invite/accept/switch flows instead of faked headers), auth tool failures through the real execution stack, sources CRUD/refresh (OpenAPI/MCP/GraphQL upstreams as live in-scenario servers, incl. the 16MB Cloudflare-spec storage canary), and the full OAuth connection flow (now through complete, which the source never reached). Cloud keeps 17 pure unit tests + the workerd-coupled mcp-flow/miniflare suites; api-harness.ts is deleted (no consumers remain). e2e projects run sequentially and cloud files serially — PGlite's socket server is single-connection and parallel per-request sockets crash it (swap in real Postgres if wall-clock ever matters). 55/55 scenarios green from cold. * Kill the wrangler test configs: MCP coverage now lives in e2e The dev server runs the production wrangler.jsonc topology (real workerd, real McpSessionDO), so MCP session behavior is product behavior over the wire. mcp-flow (workers pool) and mcp-miniflare (unstable_dev) become two e2e files — 19 scenarios with hand-rolled JSON-RPC for the wire contract (CORS, discovery, 401/403 envelopes, session riding, colliding request ids) and the real @modelcontextprotocol/sdk Client for session behavior (resume via sessionId across connections, concurrency, paused elicitation resumed by a new client). Bearer minting (discovery → DCR → PKCE authorize → token) is a framework surface now: ctx.mcp.mintBearer. Deleted: wrangler.test.jsonc, wrangler.miniflare.jsonc, the workers-pool vitest config, vitest.node.config.ts, test-worker.ts, test-bearer.ts, and the @cloudflare/vitest-pool-workers dependency. Cloud's remaining 17 unit files run in ONE plain node vitest config — enabled by extracting the pure JsonRpcRequestIdQueue out of worker-transport.ts (whose agents-SDK import chain can't load outside workerd) into its own exported module. Dropped with intent (DO-clock/test-seam internals, not expressible black- box): forced eviction/idle-alarm restore, alarm lifecycle, revoked-org session clearing, transient-503 and expired-bearer seams, OTLP span assertions. Full e2e cold: 74/74. * Add the e2e MCP suites and request-id-queue module the previous commit described 3df05f463 deleted the wrangler test configs and narrated their e2e replacements but did not stage them: the mcp-protocol and mcp-client-sessions scenario files, ctx.mcp.mintBearer on the MCP surface, the @modelcontextprotocol/sdk dependency, and the JsonRpcRequestIdQueue extraction out of worker-transport.ts that lets cloud's unit suite run in a plain node vitest config. This commit is those files. * Drop orphaned @cloudflare/vitest-pool-workers type references The workers test pool was removed with the wrangler test configs (cloud unit tests now run in a plain node pool; integration coverage lives in e2e/), but the tsconfig types entry and test-types.d.ts still referenced the now-absent package, breaking typecheck (TS2688). Remove both.

Rhys Sullivan committed 5 months ago

Commit 39761001 parent 098bfcb49 files · +3100 −4765Browse files

Unified · Split

49 files
  1. .oxlintrc.jsonc +1 −7
  2. apps/cloud/package.json +1 −3
  3. apps/cloud/scripts/test-globalsetup.ts +1 −1
  4. apps/cloud/src/account/workos-account-service.test.ts +0 −192
  5. apps/cloud/src/api.test.ts +0 −312
  6. apps/cloud/src/api/secrets-api.node.test.ts +0 −196
  7. apps/cloud/src/api/sources-api.node.test.ts +0 −585
  8. apps/cloud/src/api/sources-refresh.node.test.ts +0 −138
  9. apps/cloud/src/api/tenant-isolation.node.test.ts +0 −196
  10. apps/cloud/src/auth/auth-tool-failures.node.test.ts +0 −125
  11. apps/cloud/src/auth/handlers.node.test.ts +0 −272
  12. apps/cloud/src/engine/execution-stack.ts +2 −2
  13. apps/cloud/src/extensions-reachability.test.ts +0 −73
  14. apps/cloud/src/mcp-flow.test.ts +0 −759
  15. apps/cloud/src/mcp-miniflare.e2e.node.test.ts +0 −895
  16. apps/cloud/src/mcp/jwt.ts +0 −1
  17. apps/cloud/src/mcp/mcp-oauth.node.test.ts +0 −142
  18. apps/cloud/src/mcp/worker-transport.test.ts +1 −1
  19. apps/cloud/src/secrets-isolation.e2e.node.test.ts +0 −204
  20. apps/cloud/src/testing/api-harness.ts +0 −224
  21. apps/cloud/src/testing/test-bearer.ts +0 −22
  22. apps/cloud/src/testing/test-worker.ts +0 −146
  23. apps/cloud/test-types.d.ts +0 −1
  24. apps/cloud/tsconfig.json +2 −2
  25. apps/cloud/vitest.config.ts +12 −28
  26. apps/cloud/vitest.node.config.ts +0 −29
  27. apps/cloud/wrangler.miniflare.jsonc +0 −27
  28. apps/cloud/wrangler.test.jsonc +0 −23
  29. bun.lock +19 −19
  30. e2e/cloud/account-api.test.ts +111 −0
  31. e2e/cloud/auth-session.test.ts +127 −0
  32. e2e/cloud/auth-tool-failures.test.ts +109 −0
  33. e2e/cloud/connection-owner-isolation.test.ts +289 −0
  34. e2e/cloud/connections-credentials.test.ts +207 −0
  35. e2e/cloud/mcp-client-sessions.test.ts +273 −0
  36. e2e/cloud/mcp-protocol.test.ts +561 −0
  37. e2e/cloud/oauth-connections.test.ts +231 −0
  38. e2e/cloud/org-limit.test.ts +19 −10
  39. e2e/cloud/org-switcher.test.ts +18 −6
  40. e2e/cloud/sources-api.test.ts +469 −0
  41. e2e/cloud/sources-refresh.test.ts +187 −0
  42. e2e/cloud/surface-reachability.test.ts +111 −0
  43. e2e/cloud/tenant-isolation.test.ts +137 −0
  44. e2e/package.json +2 −1
  45. e2e/src/surfaces/mcp.ts +72 −0
  46. e2e/vitest.config.ts +4 −1
  47. packages/hosts/cloudflare/package.json +4 −0
  48. packages/hosts/cloudflare/src/mcp/request-id-queue.ts +125 −0
  49. packages/hosts/cloudflare/src/mcp/worker-transport.ts +5 −122
.oxlintrc.jsonc+1−7
@@ -72,13 +72,7 @@ · 71 unmodified lines
72 // drivers (Playwright, PTY, mcporter, raw HTTP) plus build tooling (the72 // drivers (Playwright, PTY, mcporter, raw HTTP) plus build tooling (the
73 // run viewer); they normalize untyped failures into the recorded73 // run viewer); they normalize untyped failures into the recorded
74 // transcript. Scenario code (e2e/scenarios, e2e/cloud, …) stays strict.74 // transcript. Scenario code (e2e/scenarios, e2e/cloud, …) stays strict.
75 "files": [75 "files": ["e2e/**/*.{ts,tsx}"],
76 "e2e/src/**/*.{ts,tsx}",
77 "e2e/setup/**/*.{ts,tsx}",
78 "e2e/targets/**/*.{ts,tsx}",
79 "e2e/scripts/**/*.{ts,tsx}",
80 "e2e/viewer/**/*.{ts,tsx}",
81 ],
82 "rules": {76 "rules": {
83 // The viewer is standalone tooling — it deliberately doesn't pull the77 // The viewer is standalone tooling — it deliberately doesn't pull the
84 // product design system or Effect in.78 // product design system or Effect in.
apps/cloud/package.json+1−3
@@ -23,9 +23,8 @@ · 22 unmodified lines
23 "deploy": "op run --env-file=.env.production -- bash -lc 'bun run build && wrangler deploy -c dist/server/wrangler.json'",23 "deploy": "op run --env-file=.env.production -- bash -lc 'bun run build && wrangler deploy -c dist/server/wrangler.json'",
24 "cf-typegen": "wrangler types",24 "cf-typegen": "wrangler types",
25 "typecheck": "tsgo --noEmit",25 "typecheck": "tsgo --noEmit",
26 "test": "node ../../node_modules/vitest/vitest.mjs run && node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts",26 "test": "node ../../node_modules/vitest/vitest.mjs run",
27 "test:watch": "node ../../node_modules/vitest/vitest.mjs",27 "test:watch": "node ../../node_modules/vitest/vitest.mjs",
28 "test:node": "node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts",
29 "typecheck:slow": "tsc --noEmit"28 "typecheck:slow": "tsc --noEmit"
30 },29 },
31 "dependencies": {30 "dependencies": {
@@ -72,7 +71,6 @@ · 40 unmodified lines
72 "sonner": "^2.0.7"71 "sonner": "^2.0.7"
73 },72 },
74 "devDependencies": {73 "devDependencies": {
75 "@cloudflare/vitest-pool-workers": "^0.15.0",
76 "@cloudflare/workers-types": "^4.20250620.0",74 "@cloudflare/workers-types": "^4.20250620.0",
77 "@effect/platform-node": "catalog:",75 "@effect/platform-node": "catalog:",
78 "@effect/vitest": "catalog:",76 "@effect/vitest": "catalog:",
apps/cloud/scripts/test-globalsetup.ts+1−1
@@ -1,7 +1,7 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Vitest globalSetup — starts an in-process PGlite socket server so tests2// Vitest globalSetup — starts an in-process PGlite socket server so tests
3// running in the Cloudflare Workers runtime can connect to a real Postgres3// running in the Cloudflare Workers runtime can connect to a real Postgres
4// via postgres.js. Port must match DATABASE_URL in wrangler.test.jsonc.4// via postgres.js. Port must match DATABASE_URL in vitest.config.ts.
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6 6
7import { PGlite } from "@electric-sql/pglite";7import { PGlite } from "@electric-sql/pglite";
apps/cloud/src/account/workos-account-service.test.ts+0−192
@@ -1,192 +0,0 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpRouter, HttpServer } from "effect/unstable/http";
3import { describe, expect, it } from "@effect/vitest";
4import { Effect, Layer } from "effect";
5
6import { AccountHttpApi } from "@executor-js/api";
7import { AccountHandlers } from "@executor-js/api/server";
8
9import { ApiKeyService } from "../auth/api-keys";
10import { UserStoreService } from "../auth/context";
11import type { Session } from "../auth/middleware";
12import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
13import { AutumnService } from "../extensions/billing/service";
14import { AccountCaller, workosAccountProvider } from "./workos-account-service";
15
16// ---------------------------------------------------------------------------
17// Mounts the SHARED, provider-neutral AccountHandlers over the cloud WorkOS
18// AccountProvider and drives the routes through a web handler, proving that
19// `/account/me` (authenticated) and `/account/api-keys` return the neutral
20// shapes. The caller is now resolved ONCE by the cookie-only session
21// middleware (account-api.ts) and injected as `AccountCaller`; these tests
22// drive the service with that resolved caller directly. The shared React
23// `AccountApiClient` hits these exact paths.
24// ---------------------------------------------------------------------------
25
26const authedSession: Session = {
27 accountId: "user_1",
28 email: "user@test.com",
29 name: "Test User",
30 avatarUrl: null,
31 organizationId: "org_1",
32 sealedSession: "sealed_session",
33 refreshedSession: null,
34};
35
36const orgLessSession: Session = { ...authedSession, organizationId: null };
37
38const stubWorkOS = (overrides: Partial<WorkOSClientService> = {}) =>
39 Layer.succeed(
40 WorkOSClient,
41 new Proxy({} as WorkOSClientService, {
42 get: (_target, prop) => {
43 if (typeof prop === "string" && prop in overrides) {
44 return overrides[prop as keyof WorkOSClientService];
45 }
46 return () => Effect.void;
47 },
48 }),
49 );
50
51// User store stub — `resolveOrganization` reads `getOrganization` first, so
52// returning the mirrored org short-circuits the WorkOS fallback.
53const stubUserStore = Layer.succeed(UserStoreService)({
54 use: (<A>(fn: (s: unknown) => Promise<A>) =>
55 Effect.promise(() =>
56 fn({
57 getOrganization: () => Promise.resolve({ id: "org_1", name: "Test Org" }),
58 upsertOrganization: (org: { id: string; name: string }) => Promise.resolve(org),
59 }),
60 )) as UserStoreService["Service"]["use"],
61});
62
63const stubApiKeys = Layer.succeed(ApiKeyService)({
64 validate: () => Effect.succeed(null),
65 listUserKeys: () =>
66 Effect.succeed([
67 {
68 id: "key_1",
69 name: "Local CLI",
70 obfuscatedValue: "exk_…a1b2",
71 createdAt: "2026-04-01T00:00:00Z",
72 updatedAt: "2026-04-01T00:00:00Z",
73 lastUsedAt: null,
74 },
75 ]),
76 createUserKey: () =>
77 Effect.succeed({
78 id: "key_2",
79 name: "New key",
80 obfuscatedValue: "exk_…c3d4",
81 createdAt: "2026-04-02T00:00:00Z",
82 updatedAt: "2026-04-02T00:00:00Z",
83 lastUsedAt: null,
84 value: "exk_secret_value",
85 }),
86 revokeUserKey: () => Effect.void,
87} satisfies ApiKeyService["Service"]);
88
89const stubAutumn = Layer.succeed(AutumnService)({
90 use: (() => Effect.succeed({ subscriptions: [] })) as AutumnService["Service"]["use"],
91 trackExecution: () => Effect.void,
92} satisfies AutumnService["Service"]);
93
94const makeFetch = (caller: Session | null, workos: Partial<WorkOSClientService> = {}) => {
95 const serviceLive = workosAccountProvider.pipe(
96 Layer.provide(stubWorkOS(workos)),
97 Layer.provide(stubApiKeys),
98 Layer.provide(stubAutumn),
99 Layer.provide(stubUserStore),
100 Layer.provide(Layer.succeed(AccountCaller)({ session: caller })),
101 );
102 const apiLayer = HttpApiBuilder.layer(AccountHttpApi).pipe(
103 Layer.provide(AccountHandlers),
104 Layer.provideMerge(serviceLive),
105 Layer.provideMerge(HttpServer.layerServices),
106 Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
107 );
108 const web = HttpRouter.toWebHandler(apiLayer, { disableLogger: true });
109 return web.handler as (request: Request) => Promise<Response>;
110};
111
112// The service only reads `data[*].organizationId` + `data[*].status`, so stub
113// the minimal membership-list shape matching that contract rather than the full
114// WorkOS SDK types — same approach as `auth/handlers.node.test.ts`.
115const stubMemberships = (
116 data: ReadonlyArray<{ organizationId: string; status: string }>,
117): WorkOSClientService["listUserMemberships"] =>
118 // oxlint-disable-next-line executor/no-double-cast -- test stub: minimal contract shape, not the full SDK list type
119 (() => Effect.succeed({ data })) as unknown as WorkOSClientService["listUserMemberships"];
120
121describe("Cloud Account API (neutral surface, WorkOS-backed)", () => {
122 it.effect("GET /account/me returns the neutral user + organization for an authed session", () =>
123 Effect.gen(function* () {
124 const fetch = makeFetch(authedSession, {
125 listUserMemberships: stubMemberships([{ organizationId: "org_1", status: "active" }]),
126 });
127
128 const response = yield* Effect.promise(() =>
129 fetch(new Request("http://test.local/account/me")),
130 );
131 expect(response.status).toBe(200);
132 const body = yield* Effect.promise(() => response.json());
133 expect(body).toEqual({
134 user: {
135 id: "user_1",
136 email: "user@test.com",
137 name: "Test User",
138 avatarUrl: null,
139 },
140 organization: { id: "org_1", name: "Test Org" },
141 });
142 }),
143 );
144
145 it.effect("GET /account/me returns 401 when there is no valid session", () =>
146 Effect.gen(function* () {
147 const fetch = makeFetch(null);
148
149 const response = yield* Effect.promise(() =>
150 fetch(new Request("http://test.local/account/me")),
151 );
152 expect(response.status).toBe(401);
153 }),
154 );
155
156 it.effect("GET /account/api-keys returns the caller's keys in the neutral shape", () =>
157 Effect.gen(function* () {
158 const fetch = makeFetch(authedSession, {
159 listUserMemberships: stubMemberships([{ organizationId: "org_1", status: "active" }]),
160 });
161
162 const response = yield* Effect.promise(() =>
163 fetch(new Request("http://test.local/account/api-keys")),
164 );
165 expect(response.status).toBe(200);
166 const body = (yield* Effect.promise(() => response.json())) as {
167 apiKeys: ReadonlyArray<{ id: string; name: string }>;
168 };
169 expect(body.apiKeys).toEqual([
170 {
171 id: "key_1",
172 name: "Local CLI",
173 obfuscatedValue: "exk_…a1b2",
174 createdAt: "2026-04-01T00:00:00Z",
175 updatedAt: "2026-04-01T00:00:00Z",
176 lastUsedAt: null,
177 },
178 ]);
179 }),
180 );
181
182 it.effect("GET /account/api-keys returns 403 when the session has no organization", () =>
183 Effect.gen(function* () {
184 const fetch = makeFetch(orgLessSession);
185
186 const response = yield* Effect.promise(() =>
187 fetch(new Request("http://test.local/account/api-keys")),
188 );
189 expect(response.status).toBe(403);
190 }),
191 );
192});
apps/cloud/src/api.test.ts+0−312
@@ -1,312 +0,0 @@
1import {
2 HttpApi,
3 HttpApiBuilder,
4 HttpApiClient,
5 HttpApiEndpoint,
6 HttpApiGroup,
7 HttpApiSwagger,
8 OpenApi,
9} from "effect/unstable/httpapi";
10import {
11 FetchHttpClient,
12 HttpClient,
13 HttpRouter,
14 HttpServer,
15 HttpServerResponse,
16} from "effect/unstable/http";
17import { expect, layer } from "@effect/vitest";
18import { Cause, Effect, Layer, Schema } from "effect";
19import { RouterConfigLive } from "@executor-js/api/server";
20import { toErrorServerResponse } from "./api/error-response";
21
22const SourceResponse = Schema.Struct({ source: Schema.String });
23
24// ---------------------------------------------------------------------------
25// Test APIs — mirror the prod paths but with stub handlers.
26// ---------------------------------------------------------------------------
27
28const OrgGroup = HttpApiGroup.make("org").add(
29 HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }),
30);
31const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup);
32
33const AuthGroup = HttpApiGroup.make("auth").add(
34 HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }),
35);
36const AuthTestApi = HttpApi.make("authApi").add(AuthGroup);
37
38const ProtectedGroup = HttpApiGroup.make("protected")
39 .add(HttpApiEndpoint.get("scope", "/scope", { success: SourceResponse }))
40 .add(
41 HttpApiEndpoint.get("sources", "/scopes/:scopeId/sources", {
42 params: { scopeId: Schema.String },
43 success: SourceResponse,
44 }),
45 )
46 .add(
47 HttpApiEndpoint.post("resume", "/executions/:executionId/resume", {
48 params: { executionId: Schema.String },
49 success: SourceResponse,
50 }),
51 );
52const ProtectedTestApi = HttpApi.make("protectedApi").add(ProtectedGroup);
53const DocumentationTestApi = ProtectedTestApi.add(AuthGroup).add(OrgGroup);
54
55// ---------------------------------------------------------------------------
56// Stub handlers
57// ---------------------------------------------------------------------------
58
59const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) =>
60 handlers.handle("ping", () => Effect.succeed({ source: "org" })),
61);
62
63const AuthHandlers = HttpApiBuilder.group(AuthTestApi, "auth", (handlers) =>
64 handlers.handle("me", () => Effect.succeed({ source: "auth" })),
65);
66
67const ProtectedHandlers = HttpApiBuilder.group(ProtectedTestApi, "protected", (handlers) =>
68 handlers
69 .handle("scope", () => Effect.succeed({ source: "protected" }))
70 .handle("sources", ({ params }) => Effect.succeed({ source: params.scopeId }))
71 .handle("resume", () => Effect.succeed({ source: "protected" })),
72);
73
74// ---------------------------------------------------------------------------
75// Per-test mode switch — controls a router-level gate that mirrors the prod
76// `ExecutionStackMiddleware`'s short-circuit branches without standing up
77// the full WorkOS / executor stack.
78// ---------------------------------------------------------------------------
79
80type ProtectedMode = "ok" | "none" | "error" | "bad-status" | "defect";
81const testState: { mode: ProtectedMode } = { mode: "ok" };
82const resetState = () => {
83 testState.mode = "ok";
84};
85
86// `Effect.suspend` so `testState.mode` is read per request — the
87// middleware function itself runs once at addAll time, wrapping each
88// route's handler. Without `suspend`, only the build-time mode ("ok")
89// would be observed.
90const TestProtectedGate = HttpRouter.middleware()((httpEffect) =>
91 Effect.suspend(() => {
92 if (testState.mode === "none") {
93 return Effect.succeed(
94 HttpServerResponse.jsonUnsafe(
95 { error: "No organization in session", code: "no_organization" },
96 { status: 403 },
97 ),
98 );
99 }
100 if (testState.mode === "error") {
101 return Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }));
102 }
103 if (testState.mode === "bad-status") {
104 return Effect.succeed(
105 HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }),
106 );
107 }
108 if (testState.mode === "defect") {
109 return Effect.succeed(toErrorServerResponse(Cause.die("secret-bearing failure detail")));
110 }
111 return httpEffect;
112 }),
113).layer;
114
115// ---------------------------------------------------------------------------
116// Wire test APIs as route layers + autumn route, mirroring prod's structure.
117// ---------------------------------------------------------------------------
118
119const OrgTestLive = HttpApiBuilder.layer(OrgTestApi).pipe(Layer.provide(OrgTestHandlers));
120const AuthTestLive = HttpApiBuilder.layer(AuthTestApi).pipe(Layer.provide(AuthHandlers));
121const ProtectedTestLive = HttpApiBuilder.layer(ProtectedTestApi).pipe(
122 Layer.provide(ProtectedHandlers),
123 Layer.provide(TestProtectedGate),
124);
125
126const AutumnTestRoutesLive = HttpRouter.add(
127 "*",
128 "/autumn/*",
129 Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })),
130);
131
132const TestDocsLive = Layer.mergeAll(
133 HttpApiSwagger.layer(DocumentationTestApi, { path: "/docs" }),
134 HttpRouter.add(
135 "GET",
136 "/openapi.json",
137 Effect.succeed(HttpServerResponse.jsonUnsafe(OpenApi.fromApi(DocumentationTestApi))),
138 ),
139);
140
141const TestApiLive = Layer.mergeAll(
142 OrgTestLive,
143 AuthTestLive,
144 TestDocsLive,
145 ProtectedTestLive,
146 AutumnTestRoutesLive,
147).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(HttpServer.layerServices));
148
149const requestHandler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;
150
151// ---------------------------------------------------------------------------
152// Client setup — route HttpClient calls through the web handler in-process
153// so the suite runs in any runtime (workerd's `NodeHttpServer.layerTest`
154// crashes the isolate).
155// ---------------------------------------------------------------------------
156
157const TestApi = HttpApi.make("testApi")
158 .add(OrgGroup)
159 .add(AuthGroup)
160 .add(
161 HttpApiGroup.make("autumn").add(
162 HttpApiEndpoint.get("plans", "/autumn/plans", { success: SourceResponse }),
163 ),
164 )
165 .add(ProtectedGroup);
166
167const TEST_BASE_URL = "http://test.local";
168const fetchViaHandler: typeof globalThis.fetch = (input, init) =>
169 requestHandler(input instanceof Request ? input : new Request(input, init));
170
171const TestClientLayer = FetchHttpClient.layer.pipe(
172 Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchViaHandler)),
173);
174
175const getClient = () => HttpApiClient.make(TestApi, { baseUrl: TEST_BASE_URL });
176
177layer(TestClientLayer)("handleApiRequest", (it) => {
178 it.effect("routes /org/* to the org API handler", () =>
179 Effect.gen(function* () {
180 resetState();
181 const client = yield* getClient();
182 const result = yield* client.org.ping();
183 expect(result).toEqual({ source: "org" });
184 }),
185 );
186
187 it.effect("routes /auth/* to the auth API handler", () =>
188 Effect.gen(function* () {
189 resetState();
190 const client = yield* getClient();
191 const result = yield* client.auth.me();
192 expect(result).toEqual({ source: "auth" });
193 }),
194 );
195
196 it.effect("routes /autumn/* to the autumn handler", () =>
197 Effect.gen(function* () {
198 resetState();
199 const client = yield* getClient();
200 const result = yield* client.autumn.plans();
201 expect(result).toEqual({ source: "autumn" });
202 }),
203 );
204
205 it.effect("serves docs without the protected gate", () =>
206 Effect.gen(function* () {
207 resetState();
208 testState.mode = "none";
209
210 const response = yield* HttpClient.get(`${TEST_BASE_URL}/docs`);
211 expect(response.status).toBe(200);
212 const body = yield* response.text;
213 expect(body).toContain("swagger-ui");
214 expect(body).toContain("/auth/me");
215 expect(body).toContain("/org/ping");
216 expect(body).toContain("/scope");
217 }),
218 );
219
220 it.effect("serves raw OpenAPI JSON without the protected gate", () =>
221 Effect.gen(function* () {
222 resetState();
223 testState.mode = "none";
224
225 const response = yield* HttpClient.get(`${TEST_BASE_URL}/openapi.json`);
226 expect(response.status).toBe(200);
227 const body = (yield* response.json) as { readonly paths?: Record<string, unknown> };
228 expect(body.paths).toHaveProperty("/auth/me");
229 expect(body.paths).toHaveProperty("/org/ping");
230 expect(body.paths).toHaveProperty("/scope");
231 }),
232 );
233
234 it.effect("routes non-auth paths to protected handler", () =>
235 Effect.gen(function* () {
236 resetState();
237 const client = yield* getClient();
238 const result = yield* client.protected.scope();
239 expect(result).toEqual({ source: "protected" });
240 }),
241 );
242
243 it.effect("returns 403 when protected gate short-circuits with no organization", () =>
244 Effect.gen(function* () {
245 resetState();
246 testState.mode = "none";
247
248 const response = yield* HttpClient.get(`${TEST_BASE_URL}/scope`);
249 expect(response.status).toBe(403);
250 const body = yield* response.json;
251 expect(body).toEqual({
252 error: "No organization in session",
253 code: "no_organization",
254 });
255 }),
256 );
257
258 it.effect("routes resume paths to protected handler", () =>
259 Effect.gen(function* () {
260 resetState();
261 const client = yield* getClient();
262 const result = yield* client.protected.resume({ params: { executionId: "exec_1" } });
263 expect(result).toEqual({ source: "protected" });
264 }),
265 );
266
267 it.effect("preserves protected path params", () =>
268 Effect.gen(function* () {
269 resetState();
270 const client = yield* getClient();
271 const result = yield* client.protected.sources({ params: { scopeId: "org_1" } });
272 expect(result).toEqual({ source: "org_1" });
273 }),
274 );
275
276 it.effect("returns protected response status as-is", () =>
277 Effect.gen(function* () {
278 resetState();
279 testState.mode = "bad-status";
280
281 const response = yield* HttpClient.post(`${TEST_BASE_URL}/executions/exec_1/resume`);
282 expect(response.status).toBe(400);
283 }),
284 );
285
286 it.effect("returns 500 JSON when protected gate returns 500", () =>
287 Effect.gen(function* () {
288 resetState();
289 testState.mode = "error";
290
291 const response = yield* HttpClient.get(`${TEST_BASE_URL}/scope`);
292 expect(response.status).toBe(500);
293 const body = yield* response.json;
294 expect(body).toEqual({ error: "boom" });
295 }),
296 );
297
298 it.effect("maps protected defects to the generic API error body", () =>
299 Effect.gen(function* () {
300 resetState();
301 testState.mode = "defect";
302
303 const response = yield* HttpClient.get(`${TEST_BASE_URL}/scope`);
304 expect(response.status).toBe(500);
305 const body = yield* response.json;
306 expect(body).toEqual({
307 error: "Internal server error",
308 code: "internal_server_error",
309 });
310 }),
311 );
312});
apps/cloud/src/api/secrets-api.node.test.ts+0−196
@@ -1,196 +0,0 @@
1// Connection endpoints — create / list / get / remove round-trip and error
2// fidelity within a single org (v2).
3//
4// Ports the v1 "secrets api" suite. In v2 a connection IS the credential:
5// owner-scoped, bound 1:1 to an integration, identified by (owner, integration,
6// name). There is no scope id and no separate secret value endpoint — the value
7// is stored through the connection's provider and never echoed back.
8
9import { describe, expect, it } from "@effect/vitest";
10import { Effect, Result } from "effect";
11import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
12import { Schema } from "effect";
13
14import {
15 AuthTemplateSlug,
16 ConnectionName,
17 IntegrationSlug,
18 connectionIdentifier,
19} from "@executor-js/sdk";
20import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
21
22import { asOrg } from "../testing/api-harness";
23
24const PingApi = HttpApi.make("connectionsApiTest")
25 .add(
26 HttpApiGroup.make("default", { topLevel: true }).add(
27 HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
28 ),
29 )
30 .annotateMerge(OpenApi.annotations({ title: "Connections API Test", version: "1.0.0" }));
31
32const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
33const canonicalConnectionName = (name: ConnectionName): ConnectionName =>
34 connectionIdentifier(String(name));
35
36// Registers a minimal openapi integration so connections have something to bind
37// to, then returns its slug.
38const registerIntegration = (org: string) =>
39 Effect.gen(function* () {
40 const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`);
41 yield* asOrg(org, (client) =>
42 client.openapi.addSpec({
43 payload: makeOpenApiHttpApiTestAddSpecPayload(PingApi, {
44 slug,
45 baseUrl: "http://example.com",
46 }),
47 }),
48 );
49 return slug;
50 });
51
52describe("connections api (HTTP)", () => {
53 it.effect("create → list → get returns connection metadata without the value", () =>
54 Effect.gen(function* () {
55 const org = `org_${crypto.randomUUID()}`;
56 const integration = yield* registerIntegration(org);
57 const name = ConnectionName.make(`conn_${crypto.randomUUID().slice(0, 8)}`);
58 const storedName = canonicalConnectionName(name);
59
60 const secretValue = "sk-test-abc";
61 const created = yield* asOrg(org, (client) =>
62 client.connections.create({
63 payload: {
64 owner: "org",
65 name,
66 integration,
67 template: TEMPLATE_API_KEY,
68 identityLabel: "My API Token",
69 value: secretValue,
70 },
71 }),
72 );
73 expect(created.name).toBe(storedName);
74 expect(created.owner).toBe("org");
75 expect(JSON.stringify(created)).not.toContain(secretValue);
76
77 const list = yield* asOrg(org, (client) =>
78 client.connections.list({ query: { integration } }),
79 );
80 expect(list.find((c) => c.name === storedName)?.identityLabel).toBe("My API Token");
81 expect(JSON.stringify(list)).not.toContain(secretValue);
82
83 const fetched = yield* asOrg(org, (client) =>
84 client.connections.get({ params: { owner: "org", integration, name: storedName } }),
85 );
86 expect(fetched.name).toBe(storedName);
87 expect(fetched.integration).toBe(integration);
88 }),
89 );
90
91 it.effect("get on an unknown connection fails with ConnectionNotFoundError", () =>
92 Effect.gen(function* () {
93 const org = `org_${crypto.randomUUID()}`;
94 const integration = yield* registerIntegration(org);
95 const missing = ConnectionName.make(`missing_${crypto.randomUUID().slice(0, 8)}`);
96
97 const result = yield* asOrg(org, (client) =>
98 client.connections
99 .get({ params: { owner: "org", integration, name: missing } })
100 .pipe(Effect.result),
101 );
102 expect(Result.isFailure(result)).toBe(true);
103 }),
104 );
105
106 it.effect("remove deletes the connection; list drops it and get fails", () =>
107 Effect.gen(function* () {
108 const org = `org_${crypto.randomUUID()}`;
109 const integration = yield* registerIntegration(org);
110 const name = ConnectionName.make(`conn_${crypto.randomUUID().slice(0, 8)}`);
111 const storedName = canonicalConnectionName(name);
112
113 yield* asOrg(org, (client) =>
114 Effect.gen(function* () {
115 yield* client.connections.create({
116 payload: { owner: "org", name, integration, template: TEMPLATE_API_KEY, value: "v" },
117 });
118 const removed = yield* client.connections.remove({
119 params: { owner: "org", integration, name: storedName },
120 });
121 expect(removed.removed).toBe(true);
122 }),
123 );
124
125 const list = yield* asOrg(org, (client) =>
126 client.connections.list({ query: { integration } }),
127 );
128 expect(list.map((c) => c.name)).not.toContain(storedName);
129
130 const afterGet = yield* asOrg(org, (client) =>
131 client.connections
132 .get({ params: { owner: "org", integration, name: storedName } })
133 .pipe(Effect.result),
134 );
135 expect(Result.isFailure(afterGet)).toBe(true);
136 }),
137 );
138
139 it.effect("remove on an unknown connection fails with ConnectionNotFoundError", () =>
140 Effect.gen(function* () {
141 const org = `org_${crypto.randomUUID()}`;
142 const integration = yield* registerIntegration(org);
143 const missing = ConnectionName.make(`missing_${crypto.randomUUID().slice(0, 8)}`);
144
145 const result = yield* asOrg(org, (client) =>
146 client.connections
147 .remove({ params: { owner: "org", integration, name: missing } })
148 .pipe(Effect.result),
149 );
150 expect(Result.isFailure(result)).toBe(true);
151 }),
152 );
153
154 it.effect("create with the same (owner, integration, name) twice updates the metadata", () =>
155 Effect.gen(function* () {
156 const org = `org_${crypto.randomUUID()}`;
157 const integration = yield* registerIntegration(org);
158 const name = ConnectionName.make(`conn_${crypto.randomUUID().slice(0, 8)}`);
159 const storedName = canonicalConnectionName(name);
160
161 const first = yield* asOrg(org, (client) =>
162 Effect.gen(function* () {
163 yield* client.connections.create({
164 payload: {
165 owner: "org",
166 name,
167 integration,
168 template: TEMPLATE_API_KEY,
169 identityLabel: "first",
170 value: "first-value",
171 },
172 });
173 return yield* client.connections.list({ query: { integration } });
174 }),
175 );
176 expect(first.find((c) => c.name === storedName)?.identityLabel).toBe("first");
177
178 const second = yield* asOrg(org, (client) =>
179 Effect.gen(function* () {
180 yield* client.connections.create({
181 payload: {
182 owner: "org",
183 name,
184 integration,
185 template: TEMPLATE_API_KEY,
186 identityLabel: "updated",
187 value: "second-value",
188 },
189 });
190 return yield* client.connections.list({ query: { integration } });
191 }),
192 );
193 expect(second.find((c) => c.name === storedName)?.identityLabel).toBe("updated");
194 }),
195 );
196});
apps/cloud/src/api/sources-api.node.test.ts+0−585
@@ -1,585 +0,0 @@
1// Integration + connection endpoints — CRUD through HttpApiClient (v2).
2//
3// Ports the v1 "sources api" suite onto the v2 catalog surface: integrations
4// are the tenant-shared catalog (was `sources`), connections are the owner-
5// scoped credentials (was `secrets` + credential bindings), and tools are
6// per-connection and address-keyed. The plugin extension routes
7// (`openapi.addSpec`, `mcp.addServer`, `graphql.addIntegration`) register an
8// integration; `connections.create` mints the per-connection tools; execution
9// invokes them by their dotted address.
10
11import { describe, expect, it } from "@effect/vitest";
12import { Effect, Schema } from "effect";
13import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
14import { readFileSync } from "node:fs";
15import { resolve } from "node:path";
16
17import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
18import {
19 serveGraphqlTestServer,
20 makeGreetingGraphqlSchema,
21} from "@executor-js/plugin-graphql/testing";
22import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
23import {
24 makeOpenApiHttpApiTestAddSpecPayload,
25 makeOpenApiHttpApiTestSpecPayload,
26 serveOpenApiEchoTestServer,
27} from "@executor-js/plugin-openapi/testing";
28
29import { asOrg, asUser } from "../testing/api-harness";
30
31const isJsonObject = (value: unknown): value is Record<string, unknown> =>
32 typeof value === "object" && value !== null && !Array.isArray(value);
33
34const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add(
35 HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
36);
37
38const MinimalSourceApi = HttpApi.make("sourcesApiTest")
39 .add(PingGroup)
40 .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" }));
41
42const makeMinimalOpenApiSpecPayload = (
43 slug: string,
44 options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "slug"> = {},
45) =>
46 makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, {
47 slug,
48 ...options,
49 });
50
51const makeMinimalOpenApiPreviewPayload = () => makeOpenApiHttpApiTestSpecPayload(MinimalSourceApi);
52
53const randomSlug = (prefix: string) =>
54 IntegrationSlug.make(`${prefix}_${crypto.randomUUID().replace(/-/g, "_")}`);
55
56const NAME_MAIN = ConnectionName.make("main");
57const NAME_PERSONAL = ConnectionName.make("personal");
58const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
59const TEMPLATE_NONE = AuthTemplateSlug.make("none");
60
61// The Cloudflare OpenAPI spec is the biggest real spec we care about:
62// 16MB, 2700+ operations, thousands of shared schemas. Exercising
63// addSpec end-to-end on it through the real Drizzle/FumaDB path is the
64// load-bearing check that any storage regression (per-row `createMany`,
65// accidental N+1 reads, transaction snapshots that copy too much) will show up
66// as a test failure instead of a prod incident.
67const CLOUDFLARE_SPEC_PATH = resolve(
68 __dirname,
69 "../../../../packages/plugins/openapi/fixtures/cloudflare.json",
70);
71const CLOUDFLARE_SPEC = readFileSync(CLOUDFLARE_SPEC_PATH, "utf-8");
72
73describe("integrations api (HTTP)", () => {
74 it.effect("addSpec → integrations.list includes the new slug", () =>
75 Effect.gen(function* () {
76 const org = `org_${crypto.randomUUID()}`;
77 const slug = randomSlug("ns");
78
79 yield* asOrg(org, (client) =>
80 Effect.gen(function* () {
81 const result = yield* client.openapi.addSpec({
82 payload: makeMinimalOpenApiSpecPayload(slug),
83 });
84 expect(result.slug).toBe(slug);
85 expect(result.toolCount).toBeGreaterThan(0);
86 }),
87 );
88
89 const integrations = yield* asOrg(org, (client) => client.integrations.list({}));
90 expect(integrations.map((s) => s.slug)).toContain(slug);
91 }),
92 );
93
94 it.effect("openapi.getIntegration returns the stored integration after addSpec", () =>
95 Effect.gen(function* () {
96 const org = `org_${crypto.randomUUID()}`;
97 const slug = randomSlug("ns");
98
99 yield* asOrg(org, (client) =>
100 client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) }),
101 );
102
103 const fetched = yield* asOrg(org, (client) =>
104 client.openapi.getIntegration({ params: { slug } }),
105 );
106 expect(fetched).not.toBeNull();
107 expect(fetched?.slug).toBe(slug);
108 }),
109 );
110
111 it.effect("openapi.previewSpec returns class-backed preview metadata over HTTP", () =>
112 Effect.gen(function* () {
113 const org = `org_${crypto.randomUUID()}`;
114 const preview = yield* asOrg(org, (client) =>
115 client.openapi.previewSpec({ payload: makeMinimalOpenApiPreviewPayload() }),
116 );
117
118 expect(preview).toMatchObject({
119 operationCount: 1,
120 operations: [
121 expect.objectContaining({
122 operationId: "ping",
123 method: "get",
124 path: "/ping",
125 }),
126 ],
127 });
128 }),
129 );
130
131 it.effect("openapi.addSpec accepts a public HTTP baseUrl in local mode", () =>
132 Effect.gen(function* () {
133 const org = `org_${crypto.randomUUID()}`;
134
135 const result = yield* asOrg(org, (client) =>
136 client.openapi.addSpec({
137 payload: makeMinimalOpenApiSpecPayload(randomSlug("ns"), {
138 baseUrl: "http://example.com",
139 }),
140 }),
141 );
142
143 expect(result.toolCount).toBe(1);
144 }),
145 );
146
147 it.effect("added OpenAPI integration can be connected, listed, and invoked via execution", () =>
148 Effect.gen(function* () {
149 const server = yield* serveOpenApiEchoTestServer({
150 transformSpec: (spec) => ({
151 ...spec,
152 info: { title: "Invocable Source API", version: "1.0.0" },
153 paths: {
154 "/echo/{message}": isJsonObject(spec.paths) ? spec.paths["/echo/{message}"] : {},
155 },
156 }),
157 });
158 const org = `org_${crypto.randomUUID()}`;
159 const slug = randomSlug("ns");
160
161 const addResult = yield* asOrg(org, (client) =>
162 client.openapi.addSpec({
163 payload: {
164 spec: { kind: "blob", value: server.specJson },
165 slug,
166 description: "Invocable Source API",
167 baseUrl: server.baseUrl,
168 },
169 }),
170 );
171 expect(addResult.slug).toBe(slug);
172
173 const fetched = yield* asOrg(org, (client) =>
174 client.openapi.getIntegration({ params: { slug } }),
175 );
176 expect(fetched).toMatchObject({ slug, kind: "openapi" });
177
178 // Mint a connection so the per-connection tools are stamped + persisted.
179 yield* asOrg(org, (client) =>
180 client.connections.create({
181 payload: {
182 owner: "org",
183 name: NAME_MAIN,
184 integration: slug,
185 template: TEMPLATE_API_KEY,
186 value: "static-token",
187 },
188 }),
189 );
190
191 const tools = yield* asOrg(org, (client) =>
192 client.tools.list({ query: { integration: slug } }),
193 );
194 const toolAddress = `tools.${slug}.org.main.echo.echoMessage`;
195 expect(tools.map((tool) => tool.address)).toContain(toolAddress);
196
197 const execution = yield* asOrg(org, (client) =>
198 client.executions.execute({
199 payload: {
200 code: [
201 `const result = await ${toolAddress}({ message: "hello", suffix: "world" });`,
202 "return result;",
203 ].join("\n"),
204 },
205 }),
206 );
207
208 expect(execution.status).toBe("completed");
209 if (execution.status !== "completed") return;
210 expect(execution.isError).toBe(false);
211 expect(execution.structured).toMatchObject({
212 result: {
213 ok: true,
214 data: {
215 status: 200,
216 data: {
217 message: "hello",
218 suffix: "world",
219 path: "/echo/hello",
220 },
221 },
222 },
223 });
224 expect(yield* server.requests).toContainEqual(
225 expect.objectContaining({ path: "/echo/hello" }),
226 );
227 }),
228 );
229
230 it.effect("mcp.addServer persists the integration without dialing (discovery is deferred)", () =>
231 Effect.gen(function* () {
232 const org = `org_${crypto.randomUUID()}`;
233 const slug = randomSlug("mcp");
234
235 // v2: addServer only registers the integration catalog row — it does NOT
236 // dial the server (discovery happens later at connection time). So a dead
237 // endpoint still registers successfully and getServer returns the row.
238 const addResult = yield* asOrg(org, (client) =>
239 client.mcp.addServer({
240 payload: {
241 transport: "remote",
242 name: "Broken MCP",
243 endpoint: "http://127.0.0.1:1/mcp",
244 remoteTransport: "auto",
245 slug,
246 },
247 }),
248 );
249 expect(addResult.slug).toBe(slug);
250
251 const fetched = yield* asOrg(org, (client) => client.mcp.getServer({ params: { slug } }));
252 expect(fetched).toMatchObject({
253 slug,
254 config: {
255 transport: "remote",
256 endpoint: "http://127.0.0.1:1/mcp",
257 remoteTransport: "auto",
258 },
259 });
260 }),
261 );
262
263 it.effect("added GraphQL integration can be inspected and invoked through execution", () =>
264 Effect.gen(function* () {
265 const server = yield* serveGraphqlTestServer({
266 schema: makeGreetingGraphqlSchema({ includeMutation: false }),
267 });
268 const org = `org_${crypto.randomUUID()}`;
269 const slug = randomSlug("gql");
270
271 const added = yield* asOrg(org, (client) =>
272 client.graphql.addIntegration({
273 payload: {
274 endpoint: server.endpoint,
275 slug,
276 name: "Cloud GraphQL",
277 },
278 }),
279 );
280 expect(added.slug).toBe(slug);
281
282 yield* asOrg(org, (client) =>
283 client.connections.create({
284 payload: {
285 owner: "org",
286 name: NAME_MAIN,
287 integration: slug,
288 template: TEMPLATE_NONE,
289 value: "unused",
290 },
291 }),
292 );
293
294 const tools = yield* asOrg(org, (client) =>
295 client.tools.list({ query: { integration: slug } }),
296 );
297 const toolAddress = `tools.${slug}.org.main.query.hello`;
298 expect(tools.map((tool) => tool.address)).toContain(toolAddress);
299
300 const execution = yield* asOrg(org, (client) =>
301 client.executions.execute({
302 payload: {
303 code: [`const result = await ${toolAddress}({ name: "Ada" });`, "return result;"].join(
304 "\n",
305 ),
306 },
307 }),
308 );
309
310 expect(execution.status).toBe("completed");
311 if (execution.status !== "completed") return;
312 expect(execution.isError).toBe(false);
313 expect(execution.structured).toMatchObject({
314 result: { ok: true, data: { hello: "Hello Ada" } },
315 });
316 const requests = yield* server.requests;
317 expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);
318 expect(requests).toContainEqual(
319 expect.objectContaining({
320 payload: expect.objectContaining({ variables: { name: "Ada" } }),
321 }),
322 );
323 }),
324 );
325
326 it.effect("added MCP integration can be inspected and invoked through execution", () =>
327 Effect.gen(function* () {
328 const server = yield* serveMcpServer(() =>
329 makeGreetingMcpServer({
330 name: "cloud-e2e-mcp",
331 toolDescription: "Echoes from the cloud e2e MCP server",
332 text: "cloud-mcp-ok",
333 }),
334 );
335 const org = `org_${crypto.randomUUID()}`;
336 const slug = randomSlug("mcp");
337
338 const added = yield* asOrg(org, (client) =>
339 client.mcp.addServer({
340 payload: {
341 transport: "remote",
342 name: "Cloud MCP",
343 endpoint: server.endpoint,
344 remoteTransport: "streamable-http",
345 slug,
346 },
347 }),
348 );
349 expect(added.slug).toBe(slug);
350
351 const fetched = yield* asOrg(org, (client) => client.mcp.getServer({ params: { slug } }));
352 expect(fetched).toMatchObject({
353 slug,
354 config: {
355 transport: "remote",
356 endpoint: server.endpoint,
357 remoteTransport: "streamable-http",
358 },
359 });
360
361 yield* asOrg(org, (client) =>
362 client.connections.create({
363 payload: {
364 owner: "org",
365 name: NAME_MAIN,
366 integration: slug,
367 template: TEMPLATE_NONE,
368 value: "unused",
369 },
370 }),
371 );
372
373 const tools = yield* asOrg(org, (client) =>
374 client.tools.list({ query: { integration: slug } }),
375 );
376 const toolAddress = `tools.${slug}.org.main.simple_echo`;
377 expect(tools.map((tool) => tool.address)).toContain(toolAddress);
378
379 const execution = yield* asOrg(org, (client) =>
380 client.executions.execute({
381 payload: {
382 code: [`const result = await ${toolAddress}({});`, "return result;"].join("\n"),
383 },
384 }),
385 );
386
387 expect(execution.status).toBe("completed");
388 if (execution.status !== "completed") return;
389 expect(execution.isError).toBe(false);
390 expect(execution.structured).toMatchObject({
391 result: {
392 ok: true,
393 data: { content: [{ type: "text", text: "cloud-mcp-ok" }] },
394 },
395 });
396 expect((yield* server.requests).length).toBeGreaterThanOrEqual(2);
397 }),
398 );
399
400 it.effect("connection refresh updates MCP per-connection tool rows", () =>
401 Effect.gen(function* () {
402 let toolName = "before_refresh";
403 const server = yield* serveMcpServer(() =>
404 makeGreetingMcpServer({
405 name: "cloud-refresh-mcp",
406 toolName,
407 text: "refresh-ok",
408 }),
409 );
410 const org = `org_${crypto.randomUUID()}`;
411 const slug = randomSlug("mcp");
412
413 yield* asOrg(org, (client) =>
414 client.mcp.addServer({
415 payload: {
416 transport: "remote",
417 name: "Cloud Refresh MCP",
418 endpoint: server.endpoint,
419 remoteTransport: "streamable-http",
420 slug,
421 },
422 }),
423 );
424
425 yield* asOrg(org, (client) =>
426 client.connections.create({
427 payload: {
428 owner: "org",
429 name: NAME_MAIN,
430 integration: slug,
431 template: TEMPLATE_NONE,
432 value: "unused",
433 },
434 }),
435 );
436
437 const beforeTools = yield* asOrg(org, (client) =>
438 client.tools.list({ query: { integration: slug } }),
439 );
440 expect(beforeTools.map((tool) => tool.address)).toContain(
441 `tools.${slug}.org.main.before_refresh`,
442 );
443
444 toolName = "after_refresh";
445 yield* asOrg(org, (client) =>
446 client.connections.refresh({
447 params: { owner: "org", integration: slug, name: NAME_MAIN },
448 }),
449 );
450
451 const afterTools = yield* asOrg(org, (client) =>
452 client.tools.list({ query: { integration: slug } }),
453 );
454 expect(afterTools.map((tool) => tool.address)).not.toContain(
455 `tools.${slug}.org.main.before_refresh`,
456 );
457 expect(afterTools.map((tool) => tool.address)).toContain(
458 `tools.${slug}.org.main.after_refresh`,
459 );
460 }),
461 );
462
463 it.effect("integrations.remove deletes the integration and drops off the list", () =>
464 Effect.gen(function* () {
465 const org = `org_${crypto.randomUUID()}`;
466 const slug = randomSlug("ns");
467
468 yield* asOrg(org, (client) =>
469 Effect.gen(function* () {
470 yield* client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) });
471 yield* client.integrations.remove({ params: { slug } });
472 }),
473 );
474
475 const after = yield* asOrg(org, (client) => client.integrations.list({}));
476 expect(after.map((s) => s.slug)).not.toContain(slug);
477 }),
478 );
479
480 it.effect("integrations.update round-trips a description change", () =>
481 Effect.gen(function* () {
482 const org = `org_${crypto.randomUUID()}`;
483 const slug = randomSlug("ns");
484
485 yield* asOrg(org, (client) =>
486 Effect.gen(function* () {
487 yield* client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) });
488 yield* client.integrations.update({
489 params: { slug },
490 payload: { description: "Renamed API" },
491 });
492 }),
493 );
494
495 const fetched = yield* asOrg(org, (client) => client.integrations.get({ params: { slug } }));
496 expect(fetched?.description).toBe("Renamed API");
497 }),
498 );
499
500 it.effect("org + user connections produce distinct addresses with isolated values", () =>
501 Effect.gen(function* () {
502 const organizationId = `org_${crypto.randomUUID()}`;
503 const aliceId = `user_${crypto.randomUUID().slice(0, 8)}`;
504 const bobId = `user_${crypto.randomUUID().slice(0, 8)}`;
505 const slug = randomSlug("ns");
506
507 yield* asOrg(organizationId, (client) =>
508 client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) }),
509 );
510
511 // Alice's personal (`owner: "user"`) connection.
512 yield* asUser(aliceId, organizationId, (client) =>
513 client.connections.create({
514 payload: {
515 owner: "user",
516 name: NAME_PERSONAL,
517 integration: slug,
518 template: TEMPLATE_API_KEY,
519 value: "alice-secret",
520 },
521 }),
522 );
523
524 // Bob's personal connection under the same org + integration.
525 yield* asUser(bobId, organizationId, (client) =>
526 client.connections.create({
527 payload: {
528 owner: "user",
529 name: NAME_PERSONAL,
530 integration: slug,
531 template: TEMPLATE_API_KEY,
532 value: "bob-secret",
533 },
534 }),
535 );
536
537 // Each user sees only their own user-owned connection (no shadowing).
538 const aliceConnections = yield* asUser(aliceId, organizationId, (client) =>
539 client.connections.list({ query: { integration: slug, owner: "user" } }),
540 );
541 expect(aliceConnections.map((c) => c.owner)).toEqual(["user"]);
542
543 const bobConnections = yield* asUser(bobId, organizationId, (client) =>
544 client.connections.list({ query: { integration: slug, owner: "user" } }),
545 );
546 expect(bobConnections.map((c) => c.owner)).toEqual(["user"]);
547 }),
548 );
549
550 it.effect(
551 "addSpec persists the full Cloudflare spec through the real Drizzle/FumaDB path",
552 () =>
553 Effect.gen(function* () {
554 const org = `org_${crypto.randomUUID()}`;
555 const slug = randomSlug("ns");
556
557 const result = yield* asOrg(org, (client) =>
558 client.openapi.addSpec({
559 payload: {
560 spec: { kind: "blob", value: CLOUDFLARE_SPEC },
561 slug,
562 description: slug,
563 baseUrl: "https://api.cloudflare.com/client/v4",
564 },
565 }),
566 );
567 expect(result.slug).toBe(slug);
568 expect(result.toolCount).toBeGreaterThan(1000);
569
570 const integrations = yield* asOrg(org, (client) => client.integrations.list({}));
571 expect(integrations.map((s) => s.slug)).toContain(slug);
572
573 // removeSpec on the same size must also land cleanly — catches
574 // symmetrical regressions on the delete side (e.g. deleteMany
575 // fanning out to per-row deletes).
576 yield* asOrg(org, (client) => client.integrations.remove({ params: { slug } }));
577 const after = yield* asOrg(org, (client) => client.integrations.list({}));
578 expect(after.map((s) => s.slug)).not.toContain(slug);
579 }),
580 // 60s is generous for a correct O(1) write path on local PGlite;
581 // a per-row regression would take minutes and hit this ceiling
582 // long before the suite would tolerate it.
583 { timeout: 60_000 },
584 );
585});
apps/cloud/src/api/sources-refresh.node.test.ts+0−138
@@ -1,138 +0,0 @@
1// Connection refresh endpoint — covers `connections.refresh(ref)` (v2).
2//
3// In v2 tools are produced per-connection by the owning plugin's `resolveTools`.
4// `connections.refresh` re-runs that hook: for MCP it re-dials the live server
5// (so a server-side tool change is picked up), and the integration's
6// `canRefresh` flag reflects whether the catalog row can be refreshed at all
7// (openapi-from-URL → true; openapi-from-blob → false).
8
9import { describe, expect, it } from "@effect/vitest";
10import { Effect, Schema } from "effect";
11import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
12
13import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
14import {
15 makeOpenApiHttpApiTestSpecPayload,
16 serveMutableOpenApiSpecTestServer,
17} from "@executor-js/plugin-openapi/testing";
18import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
19
20import { asOrg } from "../testing/api-harness";
21
22const PingEndpoint = HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown });
23
24const RefreshApi = HttpApi.make("refreshFixture")
25 .add(HttpApiGroup.make("default", { topLevel: true }).add(PingEndpoint))
26 .annotateMerge(OpenApi.annotations({ title: "Refresh Fixture", version: "1.0.0" }));
27
28const makeRefreshSpecText = () => makeOpenApiHttpApiTestSpecPayload(RefreshApi).spec;
29
30const NAME_MAIN = ConnectionName.make("main");
31const TEMPLATE_NONE = AuthTemplateSlug.make("none");
32
33describe("connections.refresh (HTTP)", () => {
34 it.effect("refresh re-dials the MCP server and updates per-connection tools", () =>
35 Effect.scoped(
36 Effect.gen(function* () {
37 let toolName = "before_refresh";
38 const server = yield* serveMcpServer(() =>
39 makeGreetingMcpServer({ name: "refresh-mcp", toolName, text: "ok" }),
40 );
41 const org = `org_${crypto.randomUUID()}`;
42 const slug = IntegrationSlug.make(`mcp_${crypto.randomUUID().replace(/-/g, "_")}`);
43
44 yield* asOrg(org, (client) =>
45 client.mcp.addServer({
46 payload: {
47 transport: "remote",
48 name: "Refresh MCP",
49 endpoint: server.endpoint,
50 remoteTransport: "streamable-http",
51 slug,
52 },
53 }),
54 );
55
56 yield* asOrg(org, (client) =>
57 client.connections.create({
58 payload: {
59 owner: "org",
60 name: NAME_MAIN,
61 integration: slug,
62 template: TEMPLATE_NONE,
63 value: "unused",
64 },
65 }),
66 );
67
68 const before = yield* asOrg(org, (client) =>
69 client.tools.list({ query: { integration: slug } }),
70 );
71 expect(before.map((t) => t.address)).toContain(`tools.${slug}.org.main.before_refresh`);
72 expect(before.map((t) => t.address)).not.toContain(`tools.${slug}.org.main.after_refresh`);
73
74 // Flip the live server's tool name and refresh the connection.
75 toolName = "after_refresh";
76 const refreshed = yield* asOrg(org, (client) =>
77 client.connections.refresh({
78 params: { owner: "org", integration: slug, name: NAME_MAIN },
79 }),
80 );
81 expect(refreshed.some((t) => t.address.endsWith(".after_refresh"))).toBe(true);
82
83 const after = yield* asOrg(org, (client) =>
84 client.tools.list({ query: { integration: slug } }),
85 );
86 expect(after.map((t) => t.address)).not.toContain(`tools.${slug}.org.main.before_refresh`);
87 expect(after.map((t) => t.address)).toContain(`tools.${slug}.org.main.after_refresh`);
88 }),
89 ),
90 );
91
92 it.effect("openapi-from-URL integration reports canRefresh:true", () =>
93 Effect.scoped(
94 Effect.gen(function* () {
95 const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: RefreshApi });
96 const org = `org_${crypto.randomUUID()}`;
97 const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`);
98
99 yield* asOrg(org, (client) =>
100 client.openapi.addSpec({
101 payload: {
102 spec: { kind: "url", url: server.specUrl },
103 slug,
104 baseUrl: server.baseUrl,
105 },
106 }),
107 );
108
109 const integration = yield* asOrg(org, (client) =>
110 client.integrations.get({ params: { slug } }),
111 );
112 expect(integration?.canRefresh).toBe(true);
113 }),
114 ),
115 );
116
117 it.effect("openapi-from-blob integration reports canRefresh:false", () =>
118 Effect.gen(function* () {
119 const org = `org_${crypto.randomUUID()}`;
120 const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`);
121
122 yield* asOrg(org, (client) =>
123 client.openapi.addSpec({
124 payload: {
125 spec: { kind: "blob", value: makeRefreshSpecText() },
126 slug,
127 baseUrl: "https://api.example.test",
128 },
129 }),
130 );
131
132 const integration = yield* asOrg(org, (client) =>
133 client.integrations.get({ params: { slug } }),
134 );
135 expect(integration?.canRefresh).toBe(false);
136 }),
137 );
138});
apps/cloud/src/api/tenant-isolation.node.test.ts+0−196
@@ -1,196 +0,0 @@
1// Tenant isolation integration test (v2). Runs in plain node (not workerd)
2// via vitest.node.config.ts — workerd's dev-mode compile stack crashes
3// on the full cloud module graph.
4//
5// In v2 the per-request executor binds `{ tenant: organizationId, subject:
6// accountId }` from auth; there is no scopeId path param, so a request can no
7// longer even name a foreign org. The invariant under test is therefore the
8// tenant partition itself: integrations, connections, and tools written under
9// one org's executor are invisible to another org's executor.
10
11import { describe, expect, it } from "@effect/vitest";
12import { Effect, Schema } from "effect";
13import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
14
15import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
16import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
17
18import { asOrg } from "../testing/api-harness";
19
20const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add(
21 HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
22);
23
24const TenantIsolationApi = HttpApi.make("tenantIsolationTest")
25 .add(PingGroup)
26 .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" }));
27
28const makeTenantOpenApiSpecPayload = (
29 slug: string,
30 options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "slug"> = {},
31) =>
32 makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, {
33 slug,
34 baseUrl: "http://example.com",
35 ...options,
36 });
37
38const randomSlug = () => IntegrationSlug.make(`a_${crypto.randomUUID().replace(/-/g, "_")}`);
39const NAME_MAIN = ConnectionName.make("main");
40const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
41
42describe("tenant isolation (HTTP)", () => {
43 it.effect("integrations.list is scoped to the caller org", () =>
44 Effect.gen(function* () {
45 const orgA = `org_${crypto.randomUUID()}`;
46 const orgB = `org_${crypto.randomUUID()}`;
47 const slugA = randomSlug();
48
49 yield* asOrg(orgA, (client) =>
50 client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }),
51 );
52
53 const orgBIntegrations = yield* asOrg(orgB, (client) => client.integrations.list({}));
54 expect(orgBIntegrations.map((s) => s.slug)).not.toContain(slugA);
55 }),
56 );
57
58 it.effect("tools.list is scoped to the caller org", () =>
59 Effect.gen(function* () {
60 const orgA = `org_${crypto.randomUUID()}`;
61 const orgB = `org_${crypto.randomUUID()}`;
62 const slugA = randomSlug();
63
64 yield* asOrg(orgA, (client) =>
65 Effect.gen(function* () {
66 yield* client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) });
67 yield* client.connections.create({
68 payload: {
69 owner: "org",
70 name: NAME_MAIN,
71 integration: slugA,
72 template: TEMPLATE_API_KEY,
73 value: "v",
74 },
75 });
76 }),
77 );
78
79 const orgBTools = yield* asOrg(orgB, (client) => client.tools.list({ query: {} }));
80 for (const address of orgBTools.map((t) => t.address)) {
81 expect(address).not.toContain(slugA);
82 }
83 }),
84 );
85
86 it.effect("openapi.getIntegration cannot reach another org's integration", () =>
87 Effect.gen(function* () {
88 const orgA = `org_${crypto.randomUUID()}`;
89 const orgB = `org_${crypto.randomUUID()}`;
90 const slugA = randomSlug();
91
92 yield* asOrg(orgA, (client) =>
93 client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }),
94 );
95
96 const integration = yield* asOrg(orgB, (client) =>
97 client.openapi.getIntegration({ params: { slug: slugA } }),
98 );
99
100 expect(integration).toBeNull();
101 }),
102 );
103
104 it.effect("connections.list is scoped to the caller org", () =>
105 Effect.gen(function* () {
106 const orgA = `org_${crypto.randomUUID()}`;
107 const orgB = `org_${crypto.randomUUID()}`;
108 const slugA = randomSlug();
109 const name = ConnectionName.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`);
110
111 yield* asOrg(orgA, (client) =>
112 Effect.gen(function* () {
113 yield* client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) });
114 yield* client.connections.create({
115 payload: {
116 owner: "org",
117 name,
118 integration: slugA,
119 template: TEMPLATE_API_KEY,
120 value: "super-secret-a",
121 },
122 });
123 }),
124 );
125
126 const orgBConnections = yield* asOrg(orgB, (client) =>
127 client.connections.list({ query: {} }),
128 );
129 expect(orgBConnections.map((c) => c.name)).not.toContain(name);
130 }),
131 );
132
133 it.effect("connection metadata and value are not visible across orgs", () =>
134 Effect.gen(function* () {
135 const orgA = `org_${crypto.randomUUID()}`;
136 const orgB = `org_${crypto.randomUUID()}`;
137 const slugA = randomSlug();
138 const name = ConnectionName.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`);
139
140 yield* asOrg(orgA, (client) =>
141 Effect.gen(function* () {
142 yield* client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) });
143 yield* client.connections.create({
144 payload: {
145 owner: "org",
146 name,
147 integration: slugA,
148 template: TEMPLATE_API_KEY,
149 value: "super-secret-a",
150 },
151 });
152 }),
153 );
154
155 const list = yield* asOrg(orgB, (client) => client.connections.list({ query: {} }));
156 expect(list.map((c) => c.name)).not.toContain(name);
157 expect(JSON.stringify(list)).not.toContain("super-secret-a");
158 }),
159 );
160
161 it.effect("same-slug integration in two orgs are independent rows", () =>
162 Effect.gen(function* () {
163 const orgA = `org_${crypto.randomUUID()}`;
164 const orgB = `org_${crypto.randomUUID()}`;
165 const slug = IntegrationSlug.make(`shared_${crypto.randomUUID().replace(/-/g, "_")}`);
166
167 yield* asOrg(orgA, (client) =>
168 client.openapi.addSpec({
169 payload: { ...makeTenantOpenApiSpecPayload(slug), description: "Org A API" },
170 }),
171 );
172 yield* asOrg(orgB, (client) =>
173 client.openapi.addSpec({
174 payload: { ...makeTenantOpenApiSpecPayload(slug), description: "Org B API" },
175 }),
176 );
177
178 // Updating org A's row must not mutate org B's same-slug row.
179 yield* asOrg(orgA, (client) =>
180 client.integrations.update({
181 params: { slug },
182 payload: { description: "Org A Updated API" },
183 }),
184 );
185
186 const orgAIntegration = yield* asOrg(orgA, (client) =>
187 client.integrations.get({ params: { slug } }),
188 );
189 const orgBIntegration = yield* asOrg(orgB, (client) =>
190 client.integrations.get({ params: { slug } }),
191 );
192 expect(orgAIntegration?.description).toBe("Org A Updated API");
193 expect(orgBIntegration?.description).toBe("Org B API");
194 }),
195 );
196});
apps/cloud/src/auth/auth-tool-failures.node.test.ts+0−125
@@ -1,125 +0,0 @@
1// ---------------------------------------------------------------------------
2// Cloud app auth failure propagation (v2)
3// ---------------------------------------------------------------------------
4//
5// Exercises the cloud HTTP API boundary:
6//
7// test -> HttpApiClient -> ProtectedCloudApi -> execution engine
8// -> sandbox code -> OpenAPI tool invocation
9//
10// v2: a connection IS the credential. `addSpec` registers the integration with
11// an apiKey auth template; a connection is then created whose value cannot
12// resolve (a `from` reference to a WorkOS Vault item that was never stored).
13// Invoking one of that connection's tools surfaces `connection_value_missing`
14// to the model instead of an opaque internal tool error.
15// ---------------------------------------------------------------------------
16
17import { describe, expect, it } from "@effect/vitest";
18import { Effect, Schema } from "effect";
19import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
20
21import {
22 AuthTemplateSlug,
23 ConnectionName,
24 IntegrationSlug,
25 ProviderItemId,
26 ProviderKey,
27} from "@executor-js/sdk";
28import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
29
30import { ProtectedCloudApi, asOrg } from "../testing/api-harness";
31
32const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add(
33 HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
34);
35
36const MissingAuthSourceApi = HttpApi.make("cloudAuthFailureSource").add(PingGroup);
37
38const API_KEY_TEMPLATE = "apiKey";
39// The cloud default credential provider is the WorkOS Vault; a `from` reference
40// to an item id that was never stored resolves to `null`.
41const VAULT_PROVIDER = ProviderKey.make("workos-vault");
42
43type CloudApiShape = HttpApiClient.ForApi<typeof ProtectedCloudApi>;
44type EffectSuccess<T> = T extends Effect.Effect<infer A, unknown, unknown> ? A : never;
45type ExecuteResult = EffectSuccess<ReturnType<CloudApiShape["executions"]["execute"]>>;
46
47const expectModelVisibleAuthFailure = (execution: ExecuteResult) => {
48 expect(execution.status).toBe("completed");
49 if (execution.status !== "completed") return;
50 expect(execution.isError).toBe(false);
51 expect(JSON.stringify(execution.structured)).not.toContain("Internal tool error");
52 expect(JSON.stringify(execution.structured)).not.toContain("Internal Tool Error");
53 expect(execution.structured).toMatchObject({
54 status: "completed",
55 result: {
56 ok: false,
57 error: {
58 code: "connection_value_missing",
59 details: {
60 category: "authentication",
61 },
62 },
63 },
64 });
65};
66
67describe("cloud auth tool failures", () => {
68 it.effect("cloud propagates a missing credential value as a model-visible auth failure", () =>
69 Effect.gen(function* () {
70 const org = `org_${crypto.randomUUID()}`;
71 const integration = IntegrationSlug.make(`auth_${crypto.randomUUID().replace(/-/g, "_")}`);
72 const connection = ConnectionName.make("main");
73
74 yield* asOrg(org, (client) =>
75 client.openapi.addSpec({
76 payload: {
77 ...makeOpenApiHttpApiTestAddSpecPayload(MissingAuthSourceApi, {
78 slug: integration,
79 authenticationTemplate: [
80 {
81 slug: AuthTemplateSlug.make(API_KEY_TEMPLATE),
82 type: "apiKey",
83 headers: {
84 Authorization: ["Bearer ", { type: "variable", name: "token" }],
85 },
86 },
87 ],
88 }),
89 baseUrl: "https://api.example.test",
90 },
91 }),
92 );
93
94 // Create an org connection whose value cannot resolve: a `from` reference
95 // to a vault item that was never stored resolves to null.
96 yield* asOrg(org, (client) =>
97 client.connections.create({
98 payload: {
99 owner: "org",
100 name: connection,
101 integration,
102 template: AuthTemplateSlug.make(API_KEY_TEMPLATE),
103 from: {
104 provider: VAULT_PROVIDER,
105 id: ProviderItemId.make(`${integration}-missing`),
106 },
107 },
108 }),
109 );
110
111 const execution = yield* asOrg(org, (client) =>
112 client.executions.execute({
113 payload: {
114 code: [
115 `const result = await tools.${integration}.org.${connection}.default.ping({});`,
116 "return result;",
117 ].join("\n"),
118 },
119 }),
120 );
121
122 expectModelVisibleAuthFailure(execution);
123 }),
124 );
125});
apps/cloud/src/auth/handlers.node.test.ts+0−272
@@ -1,272 +0,0 @@
1import { HttpApiBuilder, HttpApi } from "effect/unstable/httpapi";
2import { HttpRouter, HttpServer } from "effect/unstable/http";
3import { describe, expect, it } from "@effect/vitest";
4import { Data, Effect, Layer } from "effect";
5import type { Effect as EffectType } from "effect/Effect";
6
7import { CloudAuthPublicApi } from "./api";
8import { CloudAuthPublicHandlers } from "./handlers";
9import { UserStoreService } from "./context";
10import { WorkOSError } from "./errors";
11import { WorkOSClient } from "./workos";
12
13const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi);
14type EffectSuccess<T> = T extends EffectType<infer A, infer _E, infer _R> ? A : never;
15type AuthenticateWithCodeResult = EffectSuccess<
16 ReturnType<WorkOSClient["Service"]["authenticateWithCode"]>
17>;
18const fakeUser: AuthenticateWithCodeResult["user"] = {
19 object: "user",
20 id: "user_1",
21 email: "test@example.com",
22 emailVerified: true,
23 profilePictureUrl: null,
24 firstName: null,
25 lastName: null,
26 lastSignInAt: null,
27 locale: null,
28 createdAt: "2026-01-01T00:00:00.000Z",
29 updatedAt: "2026-01-01T00:00:00.000Z",
30 externalId: null,
31 metadata: {},
32};
33
34class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{
35 method: string;
36}> {}
37
38const makeAuthFetch = (workos: Partial<WorkOSClient["Service"]>) => {
39 const WorkOSTest = Layer.succeed(
40 WorkOSClient,
41 new Proxy(workos as WorkOSClient["Service"], {
42 get: (target, prop) => {
43 if (prop in target) return target[prop as keyof typeof target];
44 return () =>
45 Effect.fail(
46 new UnstubbedWorkOSMethod({
47 method: typeof prop === "string" ? prop : (prop.description ?? "symbol"),
48 }),
49 );
50 },
51 }),
52 );
53 const UserStoreTest = Layer.succeed(UserStoreService)({
54 use: <A>() => Effect.sync(() => undefined as A),
55 });
56 const apiLayer = HttpApiBuilder.layer(TestAuthPublicApi).pipe(
57 Layer.provide(CloudAuthPublicHandlers),
58 Layer.provideMerge(WorkOSTest),
59 Layer.provideMerge(UserStoreTest),
60 Layer.provideMerge(HttpServer.layerServices),
61 Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
62 );
63 const web = HttpRouter.toWebHandler(apiLayer, { disableLogger: true });
64 return web.handler as (request: Request) => Promise<Response>;
65};
66
67describe("Auth callback handlers", () => {
68 it.effect("routes login", () =>
69 Effect.gen(function* () {
70 let observedState: string | undefined;
71 const fetch = makeAuthFetch({
72 getAuthorizationUrl: (_redirectUri: string, state?: string) => {
73 observedState = state;
74 return `https://auth.example.test?state=${state}`;
75 },
76 });
77 const response = yield* Effect.promise(() =>
78 fetch(new Request("http://test.local/auth/login")),
79 );
80 expect(response.status).toBe(302);
81 expect(observedState).toMatch(/^[0-9a-f]{64}$/);
82 expect(response.headers.get("location")).toBe(
83 `https://auth.example.test?state=${observedState}`,
84 );
85 const setCookie = response.headers.get("set-cookie") ?? "";
86 expect(setCookie).toContain(`wos-login-state=${observedState}`);
87 expect(setCookie).toContain("Max-Age=600");
88 }),
89 );
90
91 it.effect("rejects callback state without the matching login state cookie", () =>
92 Effect.gen(function* () {
93 let authenticateCalls = 0;
94 const fetch = makeAuthFetch({
95 authenticateWithCode: () =>
96 Effect.sync(() => {
97 authenticateCalls++;
98 const result: AuthenticateWithCodeResult = {
99 user: fakeUser,
100 accessToken: "access_token",
101 refreshToken: "refresh_token",
102 sealedSession: "sealed_session",
103 };
104 return result;
105 }),
106 });
107
108 const response = yield* Effect.promise(() =>
109 fetch(
110 new Request("http://test.local/auth/callback?code=attacker-code&state=attacker-state"),
111 ),
112 );
113
114 expect(response.status).toBe(400);
115 expect(authenticateCalls).toBe(0);
116 }),
117 );
118
119 // Some WorkOS-initiated redirects don't include a state parameter.
120 // The schema treats state as optional; the handler skips the CSRF
121 // check when state is absent and proceeds with the code exchange.
122 it.effect("accepts a callback without state", () =>
123 Effect.gen(function* () {
124 const fetch = makeAuthFetch({
125 authenticateWithCode: () =>
126 Effect.succeed({
127 user: fakeUser,
128 accessToken: "access_token",
129 refreshToken: "refresh_token",
130 organizationId: "org_1",
131 sealedSession: "sealed_session_no_state",
132 } satisfies AuthenticateWithCodeResult),
133 });
134
135 const response = yield* Effect.promise(() =>
136 fetch(new Request("http://test.local/auth/callback?code=stateless-code")),
137 );
138
139 expect(response.status).toBe(302);
140 expect(response.headers.get("location")).toBe("/");
141 const setCookie = response.headers.get("set-cookie") ?? "";
142 expect(setCookie).toContain("wos-session=sealed_session_no_state");
143 }),
144 );
145
146 it.effect("sets the session cookie and clears login state on matching callback state", () =>
147 Effect.gen(function* () {
148 const fetch = makeAuthFetch({
149 authenticateWithCode: () =>
150 Effect.succeed({
151 user: fakeUser,
152 accessToken: "access_token",
153 refreshToken: "refresh_token",
154 organizationId: "org_1",
155 sealedSession: "sealed_session",
156 } satisfies AuthenticateWithCodeResult),
157 });
158
159 const response = yield* Effect.promise(() =>
160 fetch(
161 new Request("http://test.local/auth/callback?code=code&state=state_1", {
162 headers: { cookie: "wos-login-state=state_1" },
163 }),
164 ),
165 );
166
167 expect(response.status).toBe(302);
168 expect(response.headers.get("location")).toBe("/");
169 const setCookie = response.headers.get("set-cookie") ?? "";
170 expect(setCookie).toContain("wos-session=sealed_session");
171 expect(setCookie).toContain("Max-Age=604800");
172 expect(setCookie).toContain("wos-login-state=");
173 expect(setCookie).toContain("Max-Age=0");
174 }),
175 );
176
177 // Regression: an invited user signing in for the first time has only a
178 // *pending* membership (the WorkOS-side representation of an unaccepted
179 // invitation). The callback used to pick `data[0]` regardless of status
180 // and call refreshSession into that org, which 400s and surfaces as
181 // WorkOSError → 500. We now skip pending memberships entirely.
182 it.effect("does not refresh into a pending membership; leaves session org-less", () =>
183 Effect.gen(function* () {
184 let refreshCalls = 0;
185 const fetch = makeAuthFetch({
186 authenticateWithCode: () =>
187 Effect.succeed({
188 user: fakeUser,
189 accessToken: "access_token",
190 refreshToken: "refresh_token",
191 sealedSession: "sealed_session_no_org",
192 } satisfies AuthenticateWithCodeResult),
193 // The handler only reads `data[*].organizationId` and
194 // `data[*].status`, so we stub a minimal shape matching that
195 // contract instead of hand-rolling the full WorkOS SDK types.
196 // oxlint-disable-next-line executor/no-double-cast
197 listUserMemberships: (() =>
198 Effect.succeed({
199 data: [
200 {
201 organizationId: "org_pending",
202 status: "pending",
203 },
204 ],
205 })) as unknown as WorkOSClient["Service"]["listUserMemberships"],
206 refreshSession: () =>
207 Effect.sync(() => {
208 refreshCalls++;
209 return null;
210 }),
211 });
212
213 const response = yield* Effect.promise(() =>
214 fetch(
215 new Request("http://test.local/auth/callback?code=code&state=state_2", {
216 headers: { cookie: "wos-login-state=state_2" },
217 }),
218 ),
219 );
220
221 expect(response.status).toBe(302);
222 expect(response.headers.get("location")).toBe("/");
223 expect(refreshCalls).toBe(0);
224 const setCookie = response.headers.get("set-cookie") ?? "";
225 expect(setCookie).toContain("wos-session=sealed_session_no_org");
226 }),
227 );
228
229 // Regression: even with an active membership, if WorkOS rejects the
230 // refresh (e.g. membership just revoked, brief race), the callback
231 // should still succeed with an org-less session rather than 500.
232 it.effect("falls through to org-less session when refresh fails", () =>
233 Effect.gen(function* () {
234 const fetch = makeAuthFetch({
235 authenticateWithCode: () =>
236 Effect.succeed({
237 user: fakeUser,
238 accessToken: "access_token",
239 refreshToken: "refresh_token",
240 sealedSession: "sealed_session_fallback",
241 } satisfies AuthenticateWithCodeResult),
242 // Same minimal-shape stub as above — see comment in the
243 // pending-membership test.
244 // oxlint-disable-next-line executor/no-double-cast
245 listUserMemberships: (() =>
246 Effect.succeed({
247 data: [
248 {
249 organizationId: "org_active",
250 status: "active",
251 },
252 ],
253 })) as unknown as WorkOSClient["Service"]["listUserMemberships"],
254 refreshSession: (() =>
255 Effect.fail(new WorkOSError())) as WorkOSClient["Service"]["refreshSession"],
256 });
257
258 const response = yield* Effect.promise(() =>
259 fetch(
260 new Request("http://test.local/auth/callback?code=code&state=state_3", {
261 headers: { cookie: "wos-login-state=state_3" },
262 }),
263 ),
264 );
265
266 expect(response.status).toBe(302);
267 expect(response.headers.get("location")).toBe("/");
268 const setCookie = response.headers.get("set-cookie") ?? "";
269 expect(setCookie).toContain("wos-session=sealed_session_fallback");
270 }),
271 );
272});
apps/cloud/src/engine/execution-stack.ts+2−2
@@ -79,8 +79,8 @@ export const CLOUD_MOUNT_PREFIX = "/api" as const; · 78 unmodified lines
79export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({79export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
80 // SSRF / private-network egress guard. Config-driven, NOT a test flag:80 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
81 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);81 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
82 // the test workers (`wrangler.test.jsonc` / `wrangler.miniflare.jsonc`) opt in82 // the e2e dev-server env opts in with `"true"` so in-scenario fixture
83 // with `"true"` so fixtures can reach localhost. See `hosted-http-client.ts`.83 // servers on localhost are reachable. See `hosted-http-client.ts`.
84 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",84 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
85 webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",85 webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
86 // `oauthCallbackPath` is NOT set here — `ExecutorApp.make` derives it from the86 // `oauthCallbackPath` is NOT set here — `ExecutorApp.make` derives it from the
apps/cloud/src/extensions-reachability.test.ts+0−73
@@ -1,73 +0,0 @@
1// ---------------------------------------------------------------------------
2// Realistic reachability smoke test for the composed cloud handler.
3//
4// Boots the ACTUAL `cloudApiHandler` — `ExecutorApp.make`'s `toWebHandler`, the
5// exact handler `start.ts` forwards app-owned requests to — and drives it with
6// raw `Request`s to prove every served surface is REACHED, not dropped into a
7// 404 / the SPA fallback. This is the integration complement to
8// `app-paths.test.ts` (which guards the `start.ts` dispatch decision): together
9// they cover both halves of the billing-404 class —
10// - app-paths.test.ts: "does start.ts forward the /api surface to the handler?"
11// - this file: "does the handler actually serve billing + docs?"
12//
13// It catches a route being dropped from `makeCloudExtensionRoutes`, the Autumn
14// proxy / Swagger being unmounted, the `/api` prefix wiring regressing, etc.
15//
16// Runs in the workers pool (real workerd) because the composed app transitively
17// imports `agents/mcp` (the MCP envelope), which is workerd-only. The asserted
18// surfaces short-circuit before any real network I/O: the billing proxy 401s
19// before calling Autumn, the protected API 401/403s at the auth gate, and the
20// spec / Swagger / discovery docs are static.
21// ---------------------------------------------------------------------------
22
23import { describe, expect, it } from "@effect/vitest";
24
25import { cloudApiHandler } from "./app";
26
27const handler = cloudApiHandler().handler;
28
29const call = (method: string, path: string, init: RequestInit = {}) =>
30 handler(new Request(`http://test.local${path}`, { method, ...init }));
31
32describe("cloud composed-handler reachability", () => {
33 it("serves the Autumn billing proxy (401 JSON, NOT a 404 SPA fallback)", async () => {
34 const res = await call("POST", "/api/billing/customer", {
35 headers: { "content-type": "application/json" },
36 body: "{}",
37 });
38 // The regression returned the TanStack SPA fallback (200 text/html). The real
39 // handler reaches the billing route and rejects the unauthenticated call.
40 expect(res.status).toBe(401);
41 expect(res.headers.get("content-type")).toContain("application/json");
42 expect(await res.json()).toEqual({ error: "Unauthorized", code: "unauthorized" });
43 });
44
45 it("serves Swagger UI at /api/docs", async () => {
46 const res = await call("GET", "/api/docs");
47 expect(res.status).toBe(200);
48 expect(res.headers.get("content-type")).toContain("text/html");
49 expect((await res.text()).toLowerCase()).toContain("swagger");
50 });
51
52 it("serves the OpenAPI spec at /api/openapi.json", async () => {
53 const res = await call("GET", "/api/openapi.json");
54 expect(res.status).toBe(200);
55 expect(res.headers.get("content-type")).toContain("application/json");
56 const spec = (await res.json()) as { paths?: Record<string, unknown> };
57 expect(spec.paths).toBeDefined();
58 // The spec is prefixed with /api, so a real v2 route like integrations is present.
59 expect(Object.keys(spec.paths ?? {}).some((p) => p.includes("/integrations"))).toBe(true);
60 });
61
62 it("reaches the protected API auth gate at /api/integrations (error JSON, NOT SPA HTML)", async () => {
63 const res = await call("GET", "/api/integrations");
64 expect([401, 403]).toContain(res.status);
65 expect(res.headers.get("content-type")).toContain("application/json");
66 expect(await res.json()).toHaveProperty("code");
67 });
68
69 // (The MCP envelope + its /.well-known/* discovery docs are exercised by the
70 // mcp-flow / mcp-miniflare suites; the dispatch half is pinned in
71 // app-paths.test.ts. They proxy to WorkOS, unreachable from this isolate, so
72 // they are not re-asserted here.)
73});
apps/cloud/src/mcp-flow.test.ts+0−759
@@ -1,759 +0,0 @@
1// ---------------------------------------------------------------------------
2// /mcp — end-to-end tests via SELF.fetch into the workerd test pool
3// ---------------------------------------------------------------------------
4//
5// These tests drive the real pipeline, not a stub:
6//
7// SELF.fetch
8// → test-worker's default.fetch
9// → HttpApp.toWebHandler(mcpApp, { McpAuth: test })
10// → mcpApp: CORS / OAuth metadata / auth / dispatch
11// → env.MCP_SESSION.idFromString() → stub.handleRequest()
12// → the real McpSessionDO stale-session path
13//
14// Two auth seams are faked: `McpAuth.verifyBearer` and the live WorkOS
15// membership check. The real bearer impl calls WorkOS's JWKS endpoint,
16// which we can't reach from the test isolate.
17// Test bearer format is `test-accept::<accountId>::<organizationId|none>`
18// (see `makeTestBearer` in test-worker.ts).
19//
20// The node-pool test (`mcp-session.e2e.node.test.ts`) covers the DO's
21// internal wiring with an InMemoryTransport and skips HTTP entirely.
22// This suite is its complement: it drives edge behavior that workerd can
23// exercise without violating its cross-request I/O guard. Multi-request live
24// MCP session coverage lives in `mcp-miniflare.e2e.node.test.ts`.
25// ---------------------------------------------------------------------------
26
27import { env, runDurableObjectAlarm, runInDurableObject, SELF } from "cloudflare:test";
28import { Effect } from "effect";
29import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
30
31import { makeTestBearer } from "./testing/test-bearer";
32
33// ---------------------------------------------------------------------------
34// Constants
35// ---------------------------------------------------------------------------
36
37const BASE = "https://test-resource.example.com";
38const MCP_URL = `${BASE}/mcp`;
39const OAUTH_RESOURCE_URL = `${BASE}/.well-known/oauth-protected-resource/mcp`;
40
41const JSON_AND_SSE = "application/json, text/event-stream";
42const CONTENT_TYPE_JSON = "application/json";
43const HEARTBEAT_MS = 30 * 1000;
44const SESSION_TIMEOUT_MS = 5 * 60 * 1000;
45const SESSION_META_KEY = "session-meta";
46const LAST_ACTIVITY_KEY = "last-activity-ms";
47
48const doRuntimeControls = (
49 instance: unknown,
50): {
51 closeRuntime: () => Effect.Effect<void>;
52} => instance as { closeRuntime: () => Effect.Effect<void> };
53
54const doActivityState = (instance: unknown): { lastActivityMs: number } =>
55 instance as { lastActivityMs: number };
56
57const INITIALIZE_REQUEST = {
58 jsonrpc: "2.0" as const,
59 id: 1,
60 method: "initialize",
61 params: {
62 protocolVersion: "2025-03-26",
63 capabilities: {},
64 clientInfo: { name: "mcp-flow-e2e", version: "0.0.1" },
65 },
66};
67
68const TOOLS_LIST_REQUEST = {
69 jsonrpc: "2.0" as const,
70 id: 2,
71 method: "tools/list",
72 params: {},
73};
74
75const INITIALIZED_NOTIFICATION = {
76 jsonrpc: "2.0" as const,
77 method: "notifications/initialized",
78};
79
80const nextOrgId = (() => {
81 let seq = 0;
82 return () => `org_mcp_flow_${++seq}_${crypto.randomUUID().slice(0, 8)}`;
83})();
84
85const nextAccountId = (() => {
86 let seq = 0;
87 return () => `user_mcp_flow_${++seq}_${crypto.randomUUID().slice(0, 8)}`;
88})();
89
90// ---------------------------------------------------------------------------
91// Request helpers
92// ---------------------------------------------------------------------------
93
94type McpPostInit = {
95 readonly bearer?: string;
96 readonly sessionId?: string | null;
97 readonly body: unknown;
98 readonly accept?: string;
99};
100
101const mcpPostTo = (url: string, init: McpPostInit): Promise<Response> => {
102 const headers: Record<string, string> = {
103 "content-type": CONTENT_TYPE_JSON,
104 accept: init.accept ?? JSON_AND_SSE,
105 };
106 if (init.bearer) headers.authorization = `Bearer ${init.bearer}`;
107 if (init.sessionId) headers["mcp-session-id"] = init.sessionId;
108 return SELF.fetch(url, {
109 method: "POST",
110 headers,
111 body: JSON.stringify(init.body),
112 });
113};
114
115const mcpPost = (init: McpPostInit): Promise<Response> => mcpPostTo(MCP_URL, init);
116
117const orgScopedMcpUrl = (organizationId: string): string => `${BASE}/${organizationId}/mcp`;
118
119const orgScopedResourceUrl = (organizationId: string): string =>
120 `${BASE}/.well-known/oauth-protected-resource/${organizationId}/mcp`;
121
122const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): Promise<Response> =>
123 SELF.fetch(MCP_URL, {
124 method: "GET",
125 headers: {
126 accept: "text/event-stream",
127 authorization: `Bearer ${init.bearer}`,
128 "mcp-session-id": init.sessionId,
129 },
130 });
131
132const seedOrg = async (id: string, name = "MCP Flow Org"): Promise<void> => {
133 const response = await SELF.fetch(`${BASE}/__test__/seed-org`, {
134 method: "POST",
135 headers: { "content-type": CONTENT_TYPE_JSON },
136 body: JSON.stringify({ id, name }),
137 });
138 expect(response.status).toBe(204);
139};
140
141// ---------------------------------------------------------------------------
142// Lifecycle
143// ---------------------------------------------------------------------------
144
145beforeAll(() => {
146 // Env presence guard — avoids confusing errors downstream if the test
147 // wrangler forgot to bind something the DO needs.
148 expect(env.MCP_SESSION, "MCP_SESSION binding missing from test wrangler").toBeDefined();
149});
150
151afterAll(() => undefined);
152
153// ---------------------------------------------------------------------------
154// 1. OPTIONS preflight on /mcp
155// ---------------------------------------------------------------------------
156
157describe("/mcp CORS preflight", () => {
158 it("returns 204 with the expected CORS headers", async () => {
159 const response = await SELF.fetch(MCP_URL, {
160 method: "OPTIONS",
161 headers: {
162 origin: "https://claude.ai",
163 "access-control-request-method": "POST",
164 "access-control-request-headers": "authorization, content-type, mcp-session-id",
165 },
166 });
167
168 expect(response.status).toBe(204);
169 expect(response.headers.get("access-control-allow-origin")).toBe("*");
170 expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS");
171 const allowedHeaders = response.headers.get("access-control-allow-headers") ?? "";
172 expect(allowedHeaders).toContain("mcp-session-id");
173 expect(allowedHeaders).toContain("authorization");
174 expect(allowedHeaders).toContain("content-type");
175 // Envelope canonical CORS superset: expose-headers now includes
176 // WWW-Authenticate alongside mcp-session-id.
177 const exposeHeaders = response.headers.get("access-control-expose-headers") ?? "";
178 expect(exposeHeaders).toContain("mcp-session-id");
179 });
180});
181
182describe("/mcp method handling", () => {
183 it("returns 405 JSON-RPC -32001 for a method the transport doesn't serve", async () => {
184 // PUT/PATCH are not GET/POST/DELETE/OPTIONS — the envelope rejects them
185 // BEFORE dispatch so no session engine spins up (the OLD mcpApp 405).
186 for (const method of ["PUT", "PATCH"] as const) {
187 const response = await SELF.fetch(MCP_URL, {
188 method,
189 headers: {
190 authorization: `Bearer ${makeTestBearer(nextAccountId(), nextOrgId())}`,
191 "content-type": CONTENT_TYPE_JSON,
192 },
193 body: JSON.stringify(TOOLS_LIST_REQUEST),
194 });
195 expect(response.status, `${method} should be 405`).toBe(405);
196 const body = (await response.json()) as {
197 jsonrpc: string;
198 error: { code: number; message: string };
199 };
200 expect(body.jsonrpc).toBe("2.0");
201 expect(body.error.code).toBe(-32001);
202 expect(body.error.message).toMatch(/method not allowed/i);
203 }
204 });
205});
206
207// ---------------------------------------------------------------------------
208// 2. OAuth protected resource metadata
209// ---------------------------------------------------------------------------
210
211describe("/.well-known/oauth-protected-resource", () => {
212 it("returns the protected resource metadata with CORS", async () => {
213 const response = await SELF.fetch(OAUTH_RESOURCE_URL);
214 expect(response.status).toBe(200);
215 expect(response.headers.get("access-control-allow-origin")).toBe("*");
216
217 const body = (await response.json()) as Record<string, unknown>;
218 expect(body).toEqual({
219 resource: "https://test-resource.example.com/mcp",
220 authorization_servers: ["https://test-authkit.example.com"],
221 bearer_methods_supported: ["header"],
222 scopes_supported: [],
223 });
224 });
225
226 it("answers an OPTIONS CORS preflight on the discovery path with 204 + CORS", async () => {
227 // OLD mcpApp answered OPTIONS for ALL mcp paths (incl /.well-known/*) before
228 // the route switch; the envelope now registers an OPTIONS preflight per
229 // discovery path, not only /mcp.
230 const response = await SELF.fetch(OAUTH_RESOURCE_URL, {
231 method: "OPTIONS",
232 headers: {
233 origin: "https://claude.ai",
234 "access-control-request-method": "GET",
235 "access-control-request-headers": "authorization",
236 },
237 });
238 expect(response.status).toBe(204);
239 expect(response.headers.get("access-control-allow-origin")).toBe("*");
240 expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS");
241 const allowedHeaders = response.headers.get("access-control-allow-headers") ?? "";
242 expect(allowedHeaders).toContain("authorization");
243 });
244});
245
246// ---------------------------------------------------------------------------
247// 2b. Org-scoped routing: /<org_id>/mcp
248// ---------------------------------------------------------------------------
249//
250// The active org can be pinned in the URL instead of relying on the token's
251// `org_id`. Membership is still verified per-request, so the URL is a selector,
252// not a trust boundary. Only `org_…`-shaped segments are claimed; anything else
253// falls through to TanStack routing.
254// ---------------------------------------------------------------------------
255
256describe("/:orgId/mcp org-scoped routing", () => {
257 it("serves protected-resource metadata pointing at the org-scoped resource", async () => {
258 const orgId = nextOrgId();
259 const response = await SELF.fetch(orgScopedResourceUrl(orgId));
260 expect(response.status).toBe(200);
261 expect(await response.json()).toEqual({
262 resource: `${BASE}/${orgId}/mcp`,
263 authorization_servers: ["https://test-authkit.example.com"],
264 bearer_methods_supported: ["header"],
265 scopes_supported: [],
266 });
267 });
268
269 it("takes the active org from the URL even when the token carries no org", async () => {
270 const orgId = nextOrgId();
271 const accountId = nextAccountId();
272 await seedOrg(orgId);
273
274 // A no-org token is a 403 on bare /mcp (see "without org" below); here the
275 // URL supplies the org, so the session initializes.
276 const response = await mcpPostTo(orgScopedMcpUrl(orgId), {
277 bearer: makeTestBearer(accountId, null),
278 body: INITIALIZE_REQUEST,
279 });
280 expect(response.status).toBe(200);
281 expect(response.headers.get("mcp-session-id")).toBeTruthy();
282 await response.text();
283 }, 15_000);
284
285 it("rejects a URL org the user is not a member of before creating a session", async () => {
286 const organizationId = `org_revoked_${crypto.randomUUID().slice(0, 8)}`;
287 const response = await mcpPostTo(orgScopedMcpUrl(organizationId), {
288 bearer: makeTestBearer(nextAccountId(), null),
289 body: INITIALIZE_REQUEST,
290 });
291 expect(response.status).toBe(403);
292 const body = (await response.json()) as {
293 jsonrpc: string;
294 error: { code: number; message: string };
295 };
296 expect(body.jsonrpc).toBe("2.0");
297 expect(body.error.code).toBe(-32001);
298 expect(body.error.message).toMatch(/No organization/i);
299 });
300
301 it("does not claim a non-org-shaped /<seg>/mcp path", async () => {
302 // `/settings/mcp` must fall through to TanStack rather than be swallowed by
303 // the MCP filter; the test worker surfaces that as its own 404.
304 const response = await mcpPostTo(`${BASE}/settings/mcp`, {
305 bearer: makeTestBearer(nextAccountId(), nextOrgId()),
306 body: INITIALIZE_REQUEST,
307 });
308 expect(response.status).toBe(404);
309 });
310});
311
312// ---------------------------------------------------------------------------
313// 3. POST /mcp without Authorization
314// ---------------------------------------------------------------------------
315
316describe("/mcp unauthorized", () => {
317 it("returns 401 with www-authenticate and an error body", async () => {
318 const response = await mcpPost({ body: INITIALIZE_REQUEST });
319 expect(response.status).toBe(401);
320 const wwwAuth = response.headers.get("www-authenticate") ?? "";
321 expect(wwwAuth).toContain("Bearer resource_metadata=");
322 expect(wwwAuth).not.toContain("error=");
323 expect(wwwAuth).toContain('resource_metadata="');
324 expect(wwwAuth).toContain(
325 "https://test-resource.example.com/.well-known/oauth-protected-resource/mcp",
326 );
327 // Envelope canonicalizes the 401 body to a JSON-RPC error (the legacy
328 // `{ error: "unauthorized" }` body cannot be overridden through the shared
329 // envelope, which only lets the provider set the WWW-Authenticate challenge).
330 expect(await response.json()).toEqual({
331 jsonrpc: "2.0",
332 error: { code: -32001, message: "Unauthorized" },
333 id: null,
334 });
335 });
336});
337
338// ---------------------------------------------------------------------------
339// 4. POST /mcp with a valid bearer but no org in the token
340// ---------------------------------------------------------------------------
341
342describe("/mcp verified token without org", () => {
343 it("returns JSON-RPC -32001", async () => {
344 const response = await mcpPost({
345 bearer: makeTestBearer(nextAccountId(), null),
346 body: INITIALIZE_REQUEST,
347 });
348 expect(response.status).toBe(403);
349 const body = (await response.json()) as {
350 jsonrpc: string;
351 error: { code: number; message: string };
352 };
353 expect(body.jsonrpc).toBe("2.0");
354 expect(body.error.code).toBe(-32001);
355 expect(body.error.message).toMatch(/No organization/i);
356 });
357});
358
359describe("/mcp transient auth failure", () => {
360 it("returns a retryable JSON-RPC error instead of a generic 500", async () => {
361 const response = await mcpPost({
362 bearer: "test-system-error",
363 body: TOOLS_LIST_REQUEST,
364 });
365 expect(response.status).toBe(503);
366 expect(response.headers.get("access-control-allow-origin")).toBe("*");
367 const body = (await response.json()) as {
368 jsonrpc: string;
369 error: { code: number; message: string };
370 };
371 expect(body.jsonrpc).toBe("2.0");
372 expect(body.error.code).toBe(-32001);
373 expect(body.error.message).toMatch(/temporarily unavailable/i);
374 });
375});
376
377describe("/mcp verified token without live org access", () => {
378 it("returns JSON-RPC -32001 before creating a session", async () => {
379 const response = await mcpPost({
380 bearer: makeTestBearer(nextAccountId(), `revoked_${nextOrgId()}`),
381 body: INITIALIZE_REQUEST,
382 });
383 expect(response.status).toBe(403);
384 const body = (await response.json()) as {
385 jsonrpc: string;
386 error: { code: number; message: string };
387 };
388 expect(body.jsonrpc).toBe("2.0");
389 expect(body.error.code).toBe(-32001);
390 expect(body.error.message).toMatch(/No organization/i);
391 });
392});
393
394// ---------------------------------------------------------------------------
395// 5. POST /mcp on an unknown session-id
396// ---------------------------------------------------------------------------
397//
398// A DO id that was never initialized behaves just like a timed-out
399// session — `handleRequest` short-circuits on `!this.initialized`. The
400// DO id must be a valid hex id for the namespace or `idFromString`
401// throws; generate a fresh unique one (never used) rather than hand-rolling.
402// ---------------------------------------------------------------------------
403
404describe("/mcp unknown session id", () => {
405 it("returns the session-timeout JSON-RPC error", async () => {
406 // No seedOrg needed — the DO never reaches init() (its `initialized`
407 // flag is still false), so `resolveOrganization` never runs.
408 const bearer = makeTestBearer(nextAccountId(), nextOrgId());
409
410 const staleSessionId = env.MCP_SESSION.newUniqueId().toString();
411
412 const response = await mcpPost({
413 bearer,
414 sessionId: staleSessionId,
415 body: TOOLS_LIST_REQUEST,
416 });
417 expect(response.status).toBe(404);
418 const body = (await response.json()) as {
419 jsonrpc: string;
420 error: { code: number; message: string };
421 };
422 expect(body.jsonrpc).toBe("2.0");
423 expect(body.error.code).toBe(-32001);
424 expect(body.error.message).toMatch(/timed out/i);
425 });
426});
427
428describe("/mcp notification responses", () => {
429 it("returns 202 with an empty body for notifications/initialized", async () => {
430 const organizationId = nextOrgId();
431 const accountId = nextAccountId();
432 await seedOrg(organizationId);
433
434 const initializeResponse = await mcpPost({
435 bearer: makeTestBearer(accountId, organizationId),
436 body: INITIALIZE_REQUEST,
437 });
438 expect(initializeResponse.status).toBe(200);
439 const sessionId = initializeResponse.headers.get("mcp-session-id");
440 expect(sessionId).toBeTruthy();
441
442 const notificationResponse = await mcpPost({
443 bearer: makeTestBearer(accountId, organizationId),
444 sessionId,
445 body: INITIALIZED_NOTIFICATION,
446 });
447
448 expect(notificationResponse.status).toBe(202);
449 expect(notificationResponse.headers.get("content-type")).toBeNull();
450 expect(await notificationResponse.text()).toBe("");
451 // 15s like the other session-spinning tests here — the default 5s is
452 // borderline for DO runtime cold-start under full-suite scheduler load.
453 }, 15_000);
454});
455
456describe("/mcp session restore", () => {
457 it("restores an initialized SDK transport from durable storage", async () => {
458 const organizationId = nextOrgId();
459 const accountId = nextAccountId();
460 await seedOrg(organizationId);
461
462 const initializeResponse = await mcpPost({
463 bearer: makeTestBearer(accountId, organizationId),
464 body: INITIALIZE_REQUEST,
465 });
466 expect(initializeResponse.status).toBe(200);
467 const sessionId = initializeResponse.headers.get("mcp-session-id");
468 expect(sessionId).toBeTruthy();
469
470 const ns = env.MCP_SESSION;
471 const stub = ns.get(ns.idFromString(sessionId!));
472 await runInDurableObject(stub, async (instance) => {
473 await Effect.runPromise(doRuntimeControls(instance).closeRuntime());
474 });
475
476 const response = await mcpPost({
477 bearer: makeTestBearer(accountId, organizationId),
478 sessionId,
479 body: TOOLS_LIST_REQUEST,
480 });
481 expect(response.status).toBe(200);
482 const body = (await response.json()) as {
483 readonly jsonrpc: string;
484 readonly result?: { readonly tools?: ReadonlyArray<{ readonly name: string }> };
485 };
486 expect(body.jsonrpc).toBe("2.0");
487 expect(body.result?.tools?.some((tool) => tool.name === "execute")).toBe(true);
488 }, 15_000);
489
490 it("keeps JSON POST responses after a session is restored by a GET reconnect", async () => {
491 const organizationId = nextOrgId();
492 const accountId = nextAccountId();
493 const bearer = makeTestBearer(accountId, organizationId);
494 await seedOrg(organizationId);
495
496 const initializeResponse = await mcpPost({
497 bearer,
498 body: INITIALIZE_REQUEST,
499 });
500 expect(initializeResponse.status).toBe(200);
501 const sessionId = initializeResponse.headers.get("mcp-session-id");
502 expect(sessionId).toBeTruthy();
503
504 const ns = env.MCP_SESSION;
505 const stub = ns.get(ns.idFromString(sessionId!));
506 await runInDurableObject(stub, async (instance) => {
507 await Effect.runPromise(doRuntimeControls(instance).closeRuntime());
508 });
509
510 const getResponse = await mcpGet({ bearer, sessionId: sessionId! });
511 expect(getResponse.status).toBe(200);
512 expect(getResponse.headers.get("content-type") ?? "").toContain("text/event-stream");
513 const responseBody = getResponse.body;
514 if (responseBody) {
515 await Effect.runPromise(
516 Effect.ignore(
517 Effect.tryPromise({
518 try: () => responseBody.cancel(),
519 catch: () => "ResponseBodyCancelFailed" as const,
520 }),
521 ),
522 );
523 }
524
525 const postResult = await Promise.race([
526 mcpPost({
527 bearer,
528 sessionId,
529 body: TOOLS_LIST_REQUEST,
530 }).then((response) => ({ kind: "response" as const, response })),
531 new Promise<{ readonly kind: "timeout" }>((resolve) =>
532 setTimeout(() => resolve({ kind: "timeout" }), 5_000),
533 ),
534 ]);
535 expect(postResult).toEqual(expect.objectContaining({ kind: "response" }));
536 if (postResult.kind !== "response") return;
537
538 const response = postResult.response;
539 expect(response.status).toBe(200);
540 expect(response.headers.get("content-type") ?? "").toContain("application/json");
541 const body = (await response.json()) as {
542 readonly jsonrpc: string;
543 readonly result?: { readonly tools?: ReadonlyArray<{ readonly name: string }> };
544 };
545 expect(body.jsonrpc).toBe("2.0");
546 expect(body.result?.tools?.some((tool) => tool.name === "execute")).toBe(true);
547 }, 15_000);
548
549 it("restores an initialized session after the idle alarm suspends the runtime", async () => {
550 const organizationId = nextOrgId();
551 const accountId = nextAccountId();
552 const bearer = makeTestBearer(accountId, organizationId);
553 await seedOrg(organizationId);
554
555 const initializeResponse = await mcpPost({
556 bearer,
557 body: INITIALIZE_REQUEST,
558 });
559 expect(initializeResponse.status).toBe(200);
560 const sessionId = initializeResponse.headers.get("mcp-session-id");
561 expect(sessionId).toBeTruthy();
562
563 const ns = env.MCP_SESSION;
564 const stub = ns.get(ns.idFromString(sessionId!));
565 await runInDurableObject(stub, async (instance, state) => {
566 doActivityState(instance).lastActivityMs = 0;
567 await state.storage.put(LAST_ACTIVITY_KEY, Date.now() - SESSION_TIMEOUT_MS - 1_000);
568 await state.storage.setAlarm(Date.now() - 1);
569 });
570
571 await runDurableObjectAlarm(stub);
572
573 const response = await mcpPost({
574 bearer,
575 sessionId,
576 body: TOOLS_LIST_REQUEST,
577 });
578 expect(response.status).toBe(200);
579 const body = (await response.json()) as {
580 readonly jsonrpc: string;
581 readonly result?: { readonly tools?: ReadonlyArray<{ readonly name: string }> };
582 };
583 expect(body.jsonrpc).toBe("2.0");
584 expect(body.result?.tools?.some((tool) => tool.name === "execute")).toBe(true);
585 }, 15_000);
586
587 it("reproduces cross-account session reuse via leaked mcp-session-id", async () => {
588 const victimOrgId = nextOrgId();
589 const attackerOrgId = nextOrgId();
590 const victimAccountId = nextAccountId();
591 const attackerAccountId = nextAccountId();
592 await seedOrg(victimOrgId);
593
594 const initializeResponse = await mcpPost({
595 bearer: makeTestBearer(victimAccountId, victimOrgId),
596 body: INITIALIZE_REQUEST,
597 });
598 expect(initializeResponse.status).toBe(200);
599 const sessionId = initializeResponse.headers.get("mcp-session-id");
600 expect(sessionId).toBeTruthy();
601
602 const ns = env.MCP_SESSION;
603 const stub = ns.get(ns.idFromString(sessionId!));
604 await runInDurableObject(stub, async (instance) => {
605 await Effect.runPromise(doRuntimeControls(instance).closeRuntime());
606 });
607
608 const response = await mcpPost({
609 bearer: makeTestBearer(attackerAccountId, attackerOrgId),
610 sessionId,
611 body: TOOLS_LIST_REQUEST,
612 });
613
614 expect(response.status).toBe(403);
615 const body = (await response.json()) as {
616 readonly jsonrpc: string;
617 readonly error?: { readonly code: number; readonly message: string };
618 };
619 expect(body.jsonrpc).toBe("2.0");
620 expect(body.error?.code).toBe(-32003);
621 expect(body.error?.message).toMatch(/does not belong/i);
622 }, 15_000);
623
624 it("clears an existing session when live org access is revoked", async () => {
625 const organizationId = `revoked_${nextOrgId()}`;
626 const accountId = nextAccountId();
627 const stub = env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId());
628 const sessionId = stub.id.toString();
629
630 await runInDurableObject(stub, async (_instance, state) => {
631 await state.storage.put(SESSION_META_KEY, {
632 organizationId,
633 organizationName: "Revoked Org",
634 userId: accountId,
635 });
636 await state.storage.put(LAST_ACTIVITY_KEY, Date.now());
637 await state.storage.setAlarm(Date.now() + HEARTBEAT_MS);
638 });
639
640 const revokedResponse = await mcpPost({
641 bearer: makeTestBearer(accountId, organizationId),
642 sessionId,
643 body: TOOLS_LIST_REQUEST,
644 });
645 expect(revokedResponse.status).toBe(403);
646 const body = (await revokedResponse.json()) as {
647 readonly jsonrpc: string;
648 readonly error?: { readonly code: number; readonly message: string };
649 };
650 expect(body.jsonrpc).toBe("2.0");
651 expect(body.error?.code).toBe(-32001);
652 expect(body.error?.message).toMatch(/No organization/i);
653
654 const stored = await runInDurableObject(stub, async (_instance, state) => ({
655 sessionMeta: await state.storage.get(SESSION_META_KEY),
656 lastActivity: await state.storage.get(LAST_ACTIVITY_KEY),
657 alarm: await state.storage.getAlarm(),
658 }));
659 expect(stored.sessionMeta).toBeUndefined();
660 expect(stored.lastActivity).toBeUndefined();
661 expect(stored.alarm).toBeNull();
662 }, 15_000);
663});
664
665describe("McpSessionDO alarm lifecycle", () => {
666 it("rejects browser approval reads and resumes from a different signed-in user", async () => {
667 const stub = env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId());
668 const sessionMeta = {
669 organizationId: "org_browser_resume_owner",
670 organizationName: "Browser Resume Owner",
671 userId: "user_browser_resume_owner",
672 };
673
674 await runInDurableObject(stub, async (_instance, state) => {
675 await state.storage.put(SESSION_META_KEY, sessionMeta);
676 await state.storage.put(LAST_ACTIVITY_KEY, Date.now());
677 });
678
679 const attackerIdentity = {
680 accountId: "user_browser_resume_attacker",
681 organizationId: sessionMeta.organizationId,
682 };
683
684 await expect(
685 stub.getPausedExecutionForApproval("exec_approval", attackerIdentity),
686 ).resolves.toEqual({
687 status: "forbidden",
688 });
689
690 await expect(
691 stub.resumeExecutionForApproval("exec_approval", attackerIdentity, { action: "accept" }),
692 ).resolves.toEqual({
693 status: "forbidden",
694 });
695 });
696
697 it("keeps a recently active session after a cold-started alarm", async () => {
698 const stub = env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId());
699 const sessionMeta = {
700 organizationId: "org_alarm_recent",
701 organizationName: "Alarm Recent",
702 userId: "user_alarm_recent",
703 };
704
705 await runInDurableObject(stub, async (_instance, state) => {
706 const now = Date.now();
707 await state.storage.put(SESSION_META_KEY, sessionMeta);
708 await state.storage.put(LAST_ACTIVITY_KEY, now);
709 await state.storage.setAlarm(now - 1);
710 });
711 await runInDurableObject(stub, (instance) => {
712 doActivityState(instance).lastActivityMs = 0;
713 });
714
715 await expect(runDurableObjectAlarm(stub)).resolves.toBe(true);
716
717 const stored = await runInDurableObject(stub, async (_instance, state) => ({
718 sessionMeta: await state.storage.get(SESSION_META_KEY),
719 lastActivity: await state.storage.get<number>(LAST_ACTIVITY_KEY),
720 alarm: await state.storage.getAlarm(),
721 }));
722
723 expect(stored.sessionMeta).toEqual(sessionMeta);
724 expect(stored.lastActivity).toBeGreaterThan(Date.now() - SESSION_TIMEOUT_MS);
725 expect(stored.alarm).toBeGreaterThan(Date.now());
726 expect(stored.alarm).toBeLessThanOrEqual(Date.now() + HEARTBEAT_MS + 1_000);
727 });
728
729 it("suspends an expired session after a cold-started alarm", async () => {
730 const stub = env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId());
731 const sessionMeta = {
732 organizationId: "org_alarm_expired",
733 organizationName: "Alarm Expired",
734 userId: "user_alarm_expired",
735 };
736 const lastActivity = Date.now() - SESSION_TIMEOUT_MS - 1_000;
737
738 await runInDurableObject(stub, async (_instance, state) => {
739 await state.storage.put(SESSION_META_KEY, sessionMeta);
740 await state.storage.put(LAST_ACTIVITY_KEY, lastActivity);
741 await state.storage.setAlarm(Date.now() - 1);
742 });
743 await runInDurableObject(stub, (instance) => {
744 doActivityState(instance).lastActivityMs = 0;
745 });
746
747 await runDurableObjectAlarm(stub);
748
749 const stored = await runInDurableObject(stub, async (_instance, state) => ({
750 sessionMeta: await state.storage.get(SESSION_META_KEY),
751 lastActivity: await state.storage.get(LAST_ACTIVITY_KEY),
752 alarm: await state.storage.getAlarm(),
753 }));
754
755 expect(stored.sessionMeta).toEqual(sessionMeta);
756 expect(stored.lastActivity).toBe(lastActivity);
757 expect(stored.alarm).toBeNull();
758 });
759});
apps/cloud/src/mcp-miniflare.e2e.node.test.ts+0−895
@@ -1,895 +0,0 @@
1// ---------------------------------------------------------------------------
2// Real-port Miniflare e2e for the cloud MCP server.
3// ---------------------------------------------------------------------------
4//
5// wrangler's `unstable_dev` boots the test-worker.ts entry on a real local
6// port via Miniflare. The MCP SDK `Client` + `StreamableHTTPClientTransport`
7// then drive `/mcp` exactly like a production client would — no hand-rolled
8// JSON-RPC, no workerd-pool cross-request I/O workaround.
9//
10// `mcp-flow.test.ts` (workerd pool) rebuilds the DO runtime per request
11// because workerd-pool's strict cross-request I/O check rejects a long-lived
12// postgres socket; Miniflare on a real port has no such check, so this
13// suite exercises the actual long-lived-socket DO runtime.
14//
15// Elicitation coverage uses the openapi plugin: a tiny Effect HttpApi
16// upstream is stood up in-process, its generated spec is handed to
17// `tools.executor.openapi.addSource`, and the cloud engine invokes a POST operation.
18// `requiresApproval: true` fires the executor's approval elicitation, which
19// round-trips back to the SDK Client's `ElicitRequestSchema` handler.
20// ---------------------------------------------------------------------------
21
22import { expect, layer } from "@effect/vitest";
23import { resolve } from "node:path";
24import { createServer } from "node:http";
25import type { AddressInfo } from "node:net";
26
27import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
28import { Context, Data, Effect, Exit, Layer, Option, Schema, Scope } from "effect";
29
30import { Client } from "@modelcontextprotocol/sdk/client/index.js";
31import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
32import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
33import { unstable_dev, type Unstable_DevWorker } from "wrangler";
34import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing";
35
36import { makeTestBearer } from "./testing/test-bearer";
37
38// ---------------------------------------------------------------------------
39// Upstream test API — declared once via Effect's `HttpApi` so the spec the
40// cloud engine consumes is derived from the same types the handlers use.
41// ---------------------------------------------------------------------------
42
43const ApprovedResponse = Schema.Struct({
44 approved: Schema.Boolean,
45});
46type ApprovedResponse = typeof ApprovedResponse.Type;
47
48const ApproveGroup = HttpApiGroup.make("approve").add(
49 HttpApiEndpoint.post("approveThing", "/approve", {
50 success: ApprovedResponse,
51 }),
52);
53
54const UpstreamApi = HttpApi.make("approveApi").add(ApproveGroup);
55
56const ApproveHandlers = HttpApiBuilder.group(UpstreamApi, "approve", (h) =>
57 h.handle("approveThing", () => Effect.succeed(ApprovedResponse.make({ approved: true }))),
58);
59
60// ---------------------------------------------------------------------------
61// Services
62// ---------------------------------------------------------------------------
63
64class Upstream extends Context.Service<
65 Upstream,
66 { readonly baseUrl: string; readonly specJson: string }
67>()("MiniflareE2E/Upstream") {}
68
69class Worker extends Context.Service<
70 Worker,
71 {
72 readonly baseUrl: URL;
73 readonly seedOrg: (id: string, name: string) => Promise<void>;
74 }
75>()("MiniflareE2E/Worker") {}
76
77type CapturedSpan = {
78 readonly name: string;
79 readonly traceId: string;
80 readonly spanId: string;
81 readonly parentSpanId: string | null;
82 readonly attributes: Record<string, unknown>;
83};
84
85class TelemetryReceiver extends Context.Service<
86 TelemetryReceiver,
87 {
88 readonly tracesUrl: string;
89 readonly spans: () => ReadonlyArray<CapturedSpan>;
90 readonly waitForSpan: (
91 predicate: (span: CapturedSpan) => boolean,
92 timeoutMs?: number,
93 ) => Promise<CapturedSpan>;
94 }
95>()("MiniflareE2E/TelemetryReceiver") {}
96
97class MiniflareE2ETestError extends Data.TaggedError("MiniflareE2ETestError")<{
98 readonly message: string;
99 readonly cause?: unknown;
100}> {}
101
102const UpstreamLive = Layer.effect(
103 Upstream,
104 Effect.acquireRelease(
105 Effect.gen(function* () {
106 const scope = yield* Scope.make();
107 const server = yield* serveOpenApiHttpApiTestServer({
108 api: UpstreamApi,
109 handlersLayer: ApproveHandlers,
110 }).pipe(Scope.provide(scope));
111 return { server, scope };
112 }),
113 ({ scope }) => Scope.close(scope, Exit.void),
114 ).pipe(Effect.map(({ server }) => ({ baseUrl: server.baseUrl, specJson: server.specJson }))),
115);
116
117// ---------------------------------------------------------------------------
118// Telemetry receiver — a node HTTP server on a random port that speaks
119// OTLP/JSON. The Effect OTLPTraceExporter in `observability/telemetry.ts`
120// posts JSON bodies to it (confirmed via
121// `@opentelemetry/exporter-trace-otlp-http` — `Content-Type:
122// application/json` + `JsonTraceSerializer`). We parse resourceSpans →
123// scopeSpans → spans → attributes so tests can assert the DO actually
124// reported the expected spans, not just that the exporter was called.
125// ---------------------------------------------------------------------------
126
127const OtlpAttributeValue = Schema.Struct({
128 stringValue: Schema.optional(Schema.String),
129 intValue: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
130 doubleValue: Schema.optional(Schema.Number),
131 boolValue: Schema.optional(Schema.Boolean),
132});
133type OtlpAttributeValue = typeof OtlpAttributeValue.Type;
134
135const OtlpPayloadFromJson = Schema.fromJsonString(
136 Schema.Struct({
137 resourceSpans: Schema.optional(
138 Schema.Array(
139 Schema.Struct({
140 scopeSpans: Schema.optional(
141 Schema.Array(
142 Schema.Struct({
143 spans: Schema.optional(
144 Schema.Array(
145 Schema.Struct({
146 name: Schema.String,
147 traceId: Schema.optional(Schema.String),
148 spanId: Schema.optional(Schema.String),
149 parentSpanId: Schema.optional(Schema.String),
150 attributes: Schema.optional(
151 Schema.Array(
152 Schema.Struct({
153 key: Schema.String,
154 value: Schema.optional(OtlpAttributeValue),
155 }),
156 ),
157 ),
158 }),
159 ),
160 ),
161 }),
162 ),
163 ),
164 }),
165 ),
166 ),
167 }),
168);
169
170const decodeOtlpPayload = Schema.decodeUnknownOption(OtlpPayloadFromJson);
171
172const unwrapAttrValue = (v?: OtlpAttributeValue): unknown => {
173 if (!v) return undefined;
174 if (v.stringValue !== undefined) return v.stringValue;
175 if (v.intValue !== undefined) return Number(v.intValue);
176 if (v.doubleValue !== undefined) return v.doubleValue;
177 if (v.boolValue !== undefined) return v.boolValue;
178 return undefined;
179};
180
181const TelemetryReceiverLive = Layer.effect(TelemetryReceiver)(
182 Effect.acquireRelease(
183 Effect.callback<
184 {
185 readonly tracesUrl: string;
186 readonly spans: ReadonlyArray<CapturedSpan>;
187 readonly store: Array<CapturedSpan>;
188 readonly close: () => Promise<void>;
189 },
190 never
191 >((resume) => {
192 const store: Array<CapturedSpan> = [];
193 const server = createServer((req, res) => {
194 if (req.method !== "POST" || !req.url?.endsWith("/v1/traces")) {
195 res.statusCode = 404;
196 res.end();
197 return;
198 }
199 let body = "";
200 req.on("data", (chunk) => {
201 body += chunk;
202 });
203 req.on("end", () => {
204 const maybePayload = decodeOtlpPayload(body);
205 if (Option.isSome(maybePayload)) {
206 const payload = maybePayload.value;
207 for (const rs of payload.resourceSpans ?? []) {
208 for (const ss of rs.scopeSpans ?? []) {
209 for (const sp of ss.spans ?? []) {
210 const attrs: Record<string, unknown> = {};
211 for (const a of sp.attributes ?? []) {
212 attrs[a.key] = unwrapAttrValue(a.value);
213 }
214 store.push({
215 name: sp.name,
216 traceId: sp.traceId ?? "",
217 spanId: sp.spanId ?? "",
218 parentSpanId: sp.parentSpanId ? sp.parentSpanId : null,
219 attributes: attrs,
220 });
221 }
222 }
223 }
224 }
225 res.writeHead(200, { "content-type": "application/json" });
226 res.end("{}");
227 });
228 });
229 server.listen(0, "127.0.0.1", () => {
230 const addr = server.address() as AddressInfo;
231 resume(
232 Effect.succeed({
233 tracesUrl: `http://127.0.0.1:${addr.port}/v1/traces`,
234 spans: store,
235 store,
236 close: () => new Promise<void>((r) => server.close(() => r())),
237 }),
238 );
239 });
240 }),
241 (t) => Effect.promise(() => t.close()),
242 ).pipe(
243 Effect.map((t) => ({
244 tracesUrl: t.tracesUrl,
245 spans: () => [...t.store],
246 waitForSpan: (predicate: (s: CapturedSpan) => boolean, timeoutMs = 5_000) =>
247 Effect.gen(function* () {
248 const poll = Effect.gen(function* () {
249 for (;;) {
250 const hit = t.store.find(predicate);
251 if (hit) return hit;
252 yield* Effect.sleep("50 millis");
253 }
254 });
255 return yield* poll.pipe(
256 Effect.timeoutOrElse({
257 duration: `${timeoutMs} millis`,
258 orElse: () =>
259 Effect.fail(
260 new MiniflareE2ETestError({
261 message: `Timed out waiting for span. Captured ${t.store.length}: ${t.store.map((s) => s.name).join(", ") || "<none>"}`,
262 }),
263 ),
264 }),
265 );
266 }).pipe(Effect.runPromise),
267 })),
268 ),
269);
270
271const WorkerLive = Layer.effect(Worker)(
272 Effect.gen(function* () {
273 const receiver = yield* TelemetryReceiver;
274 // AXIOM_TOKEN activates DoTelemetryLive inside the worker; AXIOM_TRACES_URL
275 // redirects the exporter at our in-process OTLP/JSON receiver so spans
276 // become observable in the test process.
277 return yield* Effect.acquireRelease(
278 Effect.promise(() =>
279 unstable_dev(resolve(__dirname, "./testing/test-worker.ts"), {
280 config: resolve(__dirname, "../wrangler.miniflare.jsonc"),
281 experimental: { disableExperimentalWarning: true },
282 ip: "127.0.0.1",
283 logLevel: "info",
284 vars: {
285 AXIOM_TOKEN: "test-token",
286 AXIOM_TRACES_URL: receiver.tracesUrl,
287 },
288 }),
289 ),
290 (w) => Effect.promise(() => w.stop()),
291 ).pipe(
292 Effect.map((w: Unstable_DevWorker) => ({
293 baseUrl: new URL(`http://${w.address}:${w.port}`),
294 seedOrg: async (id: string, name: string) => {
295 const res = await w.fetch("/__test__/seed-org", {
296 method: "POST",
297 headers: { "content-type": "application/json" },
298 body: JSON.stringify({ id, name }),
299 });
300 if (res.status !== 204) {
301 return Effect.runPromise(
302 Effect.fail(
303 new MiniflareE2ETestError({
304 message: `seed-org failed: ${res.status} ${await res.text()}`,
305 }),
306 ),
307 );
308 }
309 },
310 })),
311 );
312 }),
313);
314
315const TestEnv = Layer.mergeAll(UpstreamLive, WorkerLive).pipe(
316 Layer.provideMerge(TelemetryReceiverLive),
317);
318
319// ---------------------------------------------------------------------------
320// Client helpers
321// ---------------------------------------------------------------------------
322
323let accountCounter = 0;
324let orgCounter = 0;
325const nextAccountId = () => `acct_miniflare_${++accountCounter}`;
326const nextOrgId = () => `org_miniflare_${++orgCounter}`;
327
328const connectClient = async (
329 baseUrl: URL,
330 bearer: string,
331 options: {
332 withElicitation?: boolean;
333 elicitationMode?: "browser" | "model" | "native";
334 } = {},
335): Promise<Client> => {
336 const client = new Client(
337 { name: "mcp-miniflare-e2e", version: "0.0.1" },
338 {
339 capabilities: options.withElicitation ? { elicitation: { form: {} } } : {},
340 },
341 );
342 const endpoint = new URL("/mcp", baseUrl);
343 if (options.elicitationMode)
344 endpoint.searchParams.set("elicitation_mode", options.elicitationMode);
345 const transport = new StreamableHTTPClientTransport(endpoint, {
346 requestInit: { headers: { authorization: `Bearer ${bearer}` } },
347 });
348 await client.connect(transport);
349 return client;
350};
351
352const ignoreCancelBody = (body: ReadableStream<Uint8Array> | null): Effect.Effect<void> =>
353 body
354 ? Effect.ignore(
355 Effect.tryPromise({
356 try: () => body.cancel(),
357 catch: (cause) =>
358 new MiniflareE2ETestError({ message: "Failed to cancel response body", cause }),
359 }),
360 )
361 : Effect.void;
362
363const ignoreCancelReader = (
364 reader: ReadableStreamDefaultReader<Uint8Array> | undefined,
365): Effect.Effect<void> =>
366 reader
367 ? Effect.ignore(
368 Effect.tryPromise({
369 try: () => reader.cancel(),
370 catch: (cause) =>
371 new MiniflareE2ETestError({ message: "Failed to cancel response reader", cause }),
372 }),
373 )
374 : Effect.void;
375
376const withTestTimeout = <A, E, R>(
377 self: Effect.Effect<A, E, R>,
378 message: string,
379): Effect.Effect<A, E | MiniflareE2ETestError, R> =>
380 self.pipe(
381 Effect.timeoutOrElse({
382 duration: "5 seconds",
383 orElse: () => Effect.fail(new MiniflareE2ETestError({ message })),
384 }),
385 );
386
387const initializeSession = async (baseUrl: URL, bearer: string): Promise<string> => {
388 const response = await fetch(new URL("/mcp", baseUrl), {
389 method: "POST",
390 headers: {
391 accept: "application/json, text/event-stream",
392 authorization: `Bearer ${bearer}`,
393 "content-type": "application/json",
394 },
395 body: JSON.stringify({
396 jsonrpc: "2.0",
397 id: "init",
398 method: "initialize",
399 params: {
400 protocolVersion: "2025-11-25",
401 capabilities: {},
402 clientInfo: { name: "mcp-miniflare-manual", version: "0" },
403 },
404 }),
405 });
406 expect(response.status).toBe(200);
407 const sessionId = response.headers.get("mcp-session-id");
408 expect(sessionId).toEqual(expect.any(String));
409 await response.text();
410
411 const initialized = await fetch(new URL("/mcp", baseUrl), {
412 method: "POST",
413 headers: {
414 accept: "application/json, text/event-stream",
415 authorization: `Bearer ${bearer}`,
416 "content-type": "application/json",
417 "mcp-session-id": sessionId ?? "",
418 },
419 body: JSON.stringify({
420 jsonrpc: "2.0",
421 method: "notifications/initialized",
422 }),
423 });
424 expect(initialized.status).toBe(202);
425 await initialized.text();
426
427 return sessionId ?? "";
428};
429
430// ---------------------------------------------------------------------------
431// Tests
432// ---------------------------------------------------------------------------
433
434layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) => {
435 it.effect(
436 "returns 401 for malformed bearer tokens through the production auth layer",
437 () =>
438 Effect.gen(function* () {
439 const { baseUrl } = yield* Worker;
440 const response = yield* Effect.promise(() =>
441 fetch(new URL("/__test__/real-auth-mcp", baseUrl), {
442 method: "POST",
443 headers: {
444 accept: "application/json, text/event-stream",
445 authorization: "Bearer bogus.bogus.bogus",
446 "content-type": "application/json",
447 },
448 body: JSON.stringify({
449 jsonrpc: "2.0",
450 id: 1,
451 method: "initialize",
452 params: {
453 protocolVersion: "2025-06-18",
454 capabilities: {},
455 clientInfo: { name: "mcp-miniflare-invalid-bearer", version: "0" },
456 },
457 }),
458 }),
459 );
460 expect(response.status).toBe(401);
461 const wwwAuth = response.headers.get("www-authenticate") ?? "";
462 expect(wwwAuth).toContain('Bearer error="invalid_token"');
463 expect(wwwAuth).toContain('error_description="The access token is invalid"');
464 expect(wwwAuth).toContain(
465 "https://test-resource.example.com/.well-known/oauth-protected-resource/mcp",
466 );
467 const body = yield* Effect.promise(() => response.json());
468 // Envelope canonicalizes the 401 body to a JSON-RPC error; the
469 // WWW-Authenticate challenge (asserted above) stays byte-for-byte via
470 // the provider's reason-sensitive Unauthorized.challenge.
471 expect(body).toEqual({
472 jsonrpc: "2.0",
473 error: { code: -32001, message: "Unauthorized" },
474 id: null,
475 });
476 }),
477 30_000,
478 );
479
480 it.effect(
481 "completes the initialize handshake via SDK",
482 () =>
483 Effect.gen(function* () {
484 const { baseUrl, seedOrg } = yield* Worker;
485 const organizationId = nextOrgId();
486 yield* Effect.promise(() => seedOrg(organizationId, "Miniflare Org"));
487 const client = yield* Effect.promise(() =>
488 connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)),
489 );
490 expect(client.getServerVersion()?.name).toBe("executor");
491 yield* Effect.promise(() => client.close());
492 }),
493 30_000,
494 );
495
496 it.effect(
497 "lists the execute tool after handshake",
498 () =>
499 Effect.gen(function* () {
500 const { baseUrl, seedOrg } = yield* Worker;
501 const organizationId = nextOrgId();
502 yield* Effect.promise(() => seedOrg(organizationId, "List Tools Org"));
503 const client = yield* Effect.promise(() =>
504 connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)),
505 );
506 const { tools } = yield* Effect.promise(() => client.listTools());
507 expect(tools.map((t) => t.name)).toContain("execute");
508 yield* Effect.promise(() => client.close());
509 }),
510 30_000,
511 );
512
513 it.effect(
514 "executes code end-to-end via tools/call",
515 () =>
516 Effect.gen(function* () {
517 const { baseUrl, seedOrg } = yield* Worker;
518 const organizationId = nextOrgId();
519 yield* Effect.promise(() => seedOrg(organizationId, "Execute Org"));
520 const client = yield* Effect.promise(() =>
521 connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)),
522 );
523 const result = yield* Effect.promise(() =>
524 client.callTool({ name: "execute", arguments: { code: "return 1 + 2" } }),
525 );
526 expect(result.isError).not.toBe(true);
527 const content = (result.content ?? []) as Array<{ type: string; text?: string }>;
528 expect(content.find((c) => c.type === "text")?.text ?? "").toContain("3");
529 yield* Effect.promise(() => client.close());
530 }),
531 30_000,
532 );
533
534 it.effect(
535 "replaces duplicate standalone SSE GET streams for the same session",
536 () =>
537 Effect.gen(function* () {
538 const { baseUrl, seedOrg } = yield* Worker;
539 const organizationId = nextOrgId();
540 const bearer = makeTestBearer(nextAccountId(), organizationId);
541 yield* Effect.promise(() => seedOrg(organizationId, "Duplicate SSE Org"));
542 const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer));
543
544 const getHeaders = {
545 accept: "text/event-stream",
546 authorization: `Bearer ${bearer}`,
547 "mcp-protocol-version": "2025-11-25",
548 "mcp-session-id": sessionId,
549 };
550 const first = yield* Effect.promise(() =>
551 fetch(new URL("/mcp", baseUrl), { method: "GET", headers: getHeaders }),
552 );
553 expect(first.status).toBe(200);
554
555 const second = yield* Effect.promise(() =>
556 fetch(new URL("/mcp", baseUrl), { method: "GET", headers: getHeaders }),
557 );
558 expect(second.status).toBe(200);
559 expect(second.headers.get("content-type") ?? "").toContain("text/event-stream");
560
561 yield* ignoreCancelBody(first.body);
562 yield* ignoreCancelBody(second.body);
563 }),
564 30_000,
565 );
566
567 it.effect(
568 "does not replace the active standalone SSE stream for an invalid GET",
569 () =>
570 Effect.gen(function* () {
571 const { baseUrl, seedOrg } = yield* Worker;
572 const organizationId = nextOrgId();
573 const bearer = makeTestBearer(nextAccountId(), organizationId);
574 yield* Effect.promise(() => seedOrg(organizationId, "Invalid SSE Replacement Org"));
575 const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer));
576
577 const getHeaders = {
578 accept: "text/event-stream",
579 authorization: `Bearer ${bearer}`,
580 "mcp-protocol-version": "2025-11-25",
581 "mcp-session-id": sessionId,
582 };
583 const first = yield* Effect.promise(() =>
584 fetch(new URL("/mcp", baseUrl), { method: "GET", headers: getHeaders }),
585 );
586 expect(first.status).toBe(200);
587 const firstReader = first.body?.getReader();
588 expect(firstReader).toBeDefined();
589
590 const invalid = yield* Effect.promise(() =>
591 fetch(new URL("/mcp", baseUrl), {
592 method: "GET",
593 headers: { ...getHeaders, "mcp-protocol-version": "1999-01-01" },
594 }),
595 );
596 expect(invalid.status).toBe(400);
597 yield* Effect.promise(() => invalid.text());
598
599 const firstRead = yield* Effect.promise(() =>
600 Promise.race([
601 firstReader!.read().then(() => "closed"),
602 new Promise<"open">((resolve) => setTimeout(() => resolve("open"), 100)),
603 ]),
604 );
605 expect(firstRead).toBe("open");
606
607 yield* ignoreCancelReader(firstReader);
608 }),
609 30_000,
610 );
611
612 it.effect(
613 "returns tools/call results while standalone SSE GET reconnects churn",
614 () =>
615 Effect.gen(function* () {
616 const { baseUrl, seedOrg } = yield* Worker;
617 const organizationId = nextOrgId();
618 const bearer = makeTestBearer(nextAccountId(), organizationId);
619 yield* Effect.promise(() => seedOrg(organizationId, "SSE Reconnect Churn Org"));
620 const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer));
621
622 const getHeaders = {
623 accept: "text/event-stream",
624 authorization: `Bearer ${bearer}`,
625 "mcp-protocol-version": "2025-11-25",
626 "mcp-session-id": sessionId,
627 };
628
629 const openSse = () =>
630 fetch(new URL("/mcp", baseUrl), { method: "GET", headers: getHeaders });
631
632 const postResult = fetch(new URL("/mcp", baseUrl), {
633 method: "POST",
634 headers: {
635 accept: "application/json, text/event-stream",
636 authorization: `Bearer ${bearer}`,
637 "content-type": "application/json",
638 "mcp-protocol-version": "2025-11-25",
639 "mcp-session-id": sessionId,
640 },
641 body: JSON.stringify({
642 jsonrpc: "2.0",
643 id: "search",
644 method: "tools/call",
645 params: {
646 name: "execute",
647 arguments: {
648 code: [
649 "await new Promise((resolve) => setTimeout(resolve, 1_000));",
650 'return await tools.search({ namespace: "vercel_api", query: "list domains", limit: 8 });',
651 ].join("\n"),
652 },
653 },
654 }),
655 });
656
657 const reconnects = yield* Effect.promise(async () => {
658 const responses: Array<Response> = [];
659 for (let i = 0; i < 35; i++) {
660 const response = await openSse();
661 expect(response.status).toBe(200);
662 responses.push(response);
663 await new Promise((resolve) => setTimeout(resolve, 10));
664 }
665 return responses;
666 });
667
668 const response = yield* withTestTimeout(
669 Effect.promise(() => postResult),
670 "tools/call did not return during SSE churn",
671 );
672 expect(response.status).toBe(200);
673 const body = (yield* Effect.promise(() => response.json())) as {
674 readonly jsonrpc?: string;
675 readonly id?: string;
676 readonly result?: { readonly content?: ReadonlyArray<{ readonly text?: string }> };
677 readonly error?: unknown;
678 };
679 expect(body).toMatchObject({ jsonrpc: "2.0", id: "search" });
680 expect(body.error).toBeUndefined();
681 expect(body.result).toBeDefined();
682
683 yield* Effect.all(
684 reconnects.map((response) => ignoreCancelBody(response.body)),
685 {
686 concurrency: "unbounded",
687 },
688 );
689 }),
690 30_000,
691 );
692
693 it.effect(
694 "returns both overlapping tools/call responses when JSON-RPC ids collide",
695 () =>
696 Effect.gen(function* () {
697 const { baseUrl, seedOrg } = yield* Worker;
698 const organizationId = nextOrgId();
699 const bearer = makeTestBearer(nextAccountId(), organizationId);
700 yield* Effect.promise(() => seedOrg(organizationId, "Overlapping Request Id Org"));
701 const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer));
702
703 const postExecute = (code: string) =>
704 fetch(new URL("/mcp", baseUrl), {
705 method: "POST",
706 headers: {
707 accept: "application/json, text/event-stream",
708 authorization: `Bearer ${bearer}`,
709 "content-type": "application/json",
710 "mcp-protocol-version": "2025-11-25",
711 "mcp-session-id": sessionId,
712 },
713 body: JSON.stringify({
714 jsonrpc: "2.0",
715 id: 1,
716 method: "tools/call",
717 params: { name: "execute", arguments: { code } },
718 }),
719 });
720
721 const first = postExecute(
722 ["await new Promise((resolve) => setTimeout(resolve, 500));", 'return "first";'].join(
723 "\n",
724 ),
725 );
726 yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50)));
727 const second = postExecute('return "second";');
728
729 const responses = yield* withTestTimeout(
730 Effect.promise(() => Promise.all([first, second])),
731 "overlapping tools/call requests did not both return",
732 );
733
734 expect(responses.map((response) => response.status)).toEqual([200, 200]);
735 const bodies = yield* Effect.promise(() =>
736 Promise.all(
737 responses.map(
738 (response) =>
739 response.json() as Promise<{
740 readonly result?: {
741 readonly content?: ReadonlyArray<{ readonly text?: string }>;
742 };
743 readonly error?: unknown;
744 }>,
745 ),
746 ),
747 );
748 expect(
749 bodies.some((body) => body.result?.content?.some((item) => item.text?.includes("first"))),
750 ).toBe(true);
751 expect(
752 bodies.some((body) =>
753 body.result?.content?.some((item) => item.text?.includes("second")),
754 ),
755 ).toBe(true);
756 expect(bodies.every((body) => body.error === undefined)).toBe(true);
757 }),
758 30_000,
759 );
760
761 it.effect(
762 "round-trips approval elicitation for adding an OpenAPI integration",
763 () =>
764 Effect.gen(function* () {
765 const { baseUrl, seedOrg } = yield* Worker;
766 const { baseUrl: upstreamBaseUrl, specJson } = yield* Upstream;
767 const organizationId = nextOrgId();
768 yield* Effect.promise(() => seedOrg(organizationId, "Elicit Org"));
769
770 const client = yield* Effect.promise(() =>
771 connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId), {
772 withElicitation: true,
773 elicitationMode: "native",
774 }),
775 );
776
777 let elicitCount = 0;
778 client.setRequestHandler(ElicitRequestSchema, async () => {
779 elicitCount++;
780 return { action: "accept" as const, content: {} };
781 });
782
783 // User code inside `execute` registers the upstream as an OpenAPI
784 // integration. The v2 static control tool is annotated
785 // `requiresApproval: true`, which fires `enforceApproval` in the
786 // executor; that goes through the MCP elicitation handler and lands on
787 // `client.setRequestHandler` above. Per-connection POST approval is
788 // covered in the plugin/SDK tests where the test memory provider exists.
789 const code = [
790 `await tools.executor.openapi.addSpec({ baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, slug: "approveapi" });`,
791 `return await tools.executor.coreTools.integrations.list();`,
792 ].join("\n");
793 const result = yield* Effect.promise(() =>
794 client.callTool({ name: "execute", arguments: { code } }),
795 );
796 expect(result.isError).not.toBe(true);
797 expect(elicitCount).toBeGreaterThan(0);
798 const text =
799 ((result.content ?? []) as Array<{ type: string; text?: string }>).find(
800 (c) => c.type === "text",
801 )?.text ?? "";
802 expect(text).toContain("approveapi");
803
804 yield* Effect.promise(() => client.close());
805 }),
806 30_000,
807 );
808
809 it.effect(
810 "reports the McpSessionDO.handleRequest span via the OTLP exporter",
811 () =>
812 Effect.gen(function* () {
813 const { baseUrl, seedOrg } = yield* Worker;
814 const receiver = yield* TelemetryReceiver;
815 const organizationId = nextOrgId();
816 yield* Effect.promise(() => seedOrg(organizationId, "Telemetry Org"));
817 const client = yield* Effect.promise(() =>
818 connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)),
819 );
820 // Trigger the DO through a multi-step flow so we can assert that
821 // handleRequest spans are reported for every DO hit, not just init.
822 yield* Effect.promise(() => client.listTools());
823 yield* Effect.promise(() =>
824 client.callTool({ name: "execute", arguments: { code: "return 1 + 2" } }),
825 );
826 yield* Effect.promise(() => client.close());
827
828 // The initialize POST carries no session-id; subsequent requests do.
829 // Assert on one of the session-id'd handleRequest spans so we verify
830 // attribute propagation beyond the degenerate init case.
831 const handleSpan = yield* Effect.promise(() =>
832 receiver.waitForSpan(
833 (s) =>
834 s.name === "McpSessionDO.handleRequest" &&
835 s.attributes["mcp.request.session_id_present"] === true,
836 ),
837 );
838 expect(handleSpan.attributes["mcp.request.method"]).toBeDefined();
839 // 200 for normal POSTs, 202 for notifications/initialized.
840 expect([200, 202]).toContain(handleSpan.attributes["mcp.response.status_code"]);
841 expect(handleSpan.attributes["mcp.response.content_type"]).toEqual(expect.any(String));
842 expect(handleSpan.attributes["mcp.transport.enable_json_response"]).toBe(true);
843
844 // init runs once per new session and should appear on the initialize POST.
845 yield* Effect.promise(() => receiver.waitForSpan((s) => s.name === "McpSessionDO.init"));
846 }),
847 30_000,
848 );
849});
850
851layer(TestEnv, { timeout: 60_000 })("cloud MCP request-id telemetry", (it) => {
852 it.effect(
853 "exports MCP JSON-RPC request ids for request-shaped ids",
854 () =>
855 Effect.gen(function* () {
856 const { baseUrl, seedOrg } = yield* Worker;
857 const receiver = yield* TelemetryReceiver;
858 const organizationId = nextOrgId();
859 const accountId = nextAccountId();
860 const requestId = `req_${crypto.randomUUID().replace(/-/g, "")}`;
861 yield* Effect.promise(() => seedOrg(organizationId, "Request Id Org"));
862
863 const response = yield* Effect.promise(() =>
864 fetch(new URL("/mcp", baseUrl), {
865 method: "POST",
866 headers: {
867 accept: "application/json, text/event-stream",
868 authorization: `Bearer ${makeTestBearer(accountId, organizationId)}`,
869 "content-type": "application/json",
870 },
871 body: JSON.stringify({
872 jsonrpc: "2.0",
873 id: requestId,
874 method: "initialize",
875 params: {
876 protocolVersion: "2025-06-18",
877 capabilities: {},
878 clientInfo: { name: "mcp-request-id-e2e", version: "0" },
879 },
880 }),
881 }),
882 );
883 expect(response.status).toBe(200);
884 yield* Effect.promise(() => response.text());
885
886 const annotateSpan = yield* Effect.promise(() =>
887 receiver.waitForSpan(
888 (s) => s.name === "mcp.request.annotate" && s.attributes["mcp.rpc.id"] === requestId,
889 ),
890 );
891 expect(annotateSpan.attributes["mcp.rpc.method"]).toBe("initialize");
892 }),
893 30_000,
894 );
895});
apps/cloud/src/mcp/jwt.ts+0−1
@@ -3,7 +3,6 @@ · 2 unmodified lines
3//3//
4// Kept as its own `cloudflare:workers`-free leaf: the node-pool test4// Kept as its own `cloudflare:workers`-free leaf: the node-pool test
5// (mcp-auth.node.test.ts) imports these verifiers at runtime, and5// (mcp-auth.node.test.ts) imports these verifiers at runtime, and
6// `test-bearer.ts` (shared with node tests) imports `VerifiedToken` from here.
7// `mcp/auth.ts` (which DOES read `cloudflare:workers` env) imports this leaf;6// `mcp/auth.ts` (which DOES read `cloudflare:workers` env) imports this leaf;
8// the dependency points one way only.7// the dependency points one way only.
9// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
apps/cloud/src/mcp/mcp-oauth.node.test.ts+0−142
@@ -1,142 +0,0 @@
1// ---------------------------------------------------------------------------
2// Cloud API × OAuth — real HTTP end-to-end (v2)
3// ---------------------------------------------------------------------------
4//
5// Drives the ProtectedCloudApi through the node-pool harness against the shared
6// real in-process OAuth test server. Every layer between the test and the
7// plugin is real:
8//
9// test → HttpApiClient → in-process webHandler → ProtectedCloudApi
10// → Core OAuthHandlers → executor.oauth.{probe,createClient,start,cancel}
11//
12// v2: OAuth is a credential mechanism, not an integration type. `probe`
13// discovers an authorization server's metadata; `createClient` registers an
14// owner-scoped OAuth app; `start` runs the flow to mint a Connection.
15//
16// v2: `start` runs an `authorization_code` client's flow by persisting an
17// `oauth_session` and returning a `redirect` result whose `authorizationUrl`
18// points at the OAuth server's authorize endpoint (the popup visits it; the
19// callback later calls `complete`). The wired surface (`probe`, `createClient`,
20// `start`, `cancel`) is exercised for real.
21// ---------------------------------------------------------------------------
22
23import { describe, expect, it } from "@effect/vitest";
24
25import { Effect } from "effect";
26import {
27 AuthTemplateSlug,
28 ConnectionName,
29 IntegrationSlug,
30 OAuthClientSlug,
31 OAuthState,
32} from "@executor-js/sdk";
33import { serveOAuthTestServer } from "@executor-js/sdk/testing";
34
35import { asOrg } from "../testing/api-harness";
36
37describe("oauth end-to-end (node pool, real OAuth server)", () => {
38 it.effect(
39 "probe discovers the authorization server's metadata",
40 () =>
41 Effect.scoped(
42 Effect.gen(function* () {
43 const oauth = yield* serveOAuthTestServer();
44 const org = `org_${crypto.randomUUID()}`;
45
46 const probed = yield* asOrg(org, (client) =>
47 client.oauth.probe({ payload: { url: oauth.issuerUrl } }),
48 );
49
50 expect(probed.authorizationUrl).toBe(oauth.authorizationEndpoint);
51 expect(probed.tokenUrl).toBe(oauth.tokenEndpoint);
52 }),
53 ),
54 30_000,
55 );
56
57 it.effect(
58 "createClient registers an owner-scoped OAuth app",
59 () =>
60 Effect.scoped(
61 Effect.gen(function* () {
62 const oauth = yield* serveOAuthTestServer();
63 const org = `org_${crypto.randomUUID()}`;
64 const slug = OAuthClientSlug.make(`client_${crypto.randomUUID().slice(0, 8)}`);
65
66 const created = yield* asOrg(org, (client) =>
67 client.oauth.createClient({
68 payload: {
69 owner: "org",
70 slug,
71 authorizationUrl: oauth.authorizationEndpoint,
72 tokenUrl: oauth.tokenEndpoint,
73 grant: "authorization_code",
74 clientId: "test-client",
75 clientSecret: "test-secret",
76 },
77 }),
78 );
79 expect(created.client).toBe(slug);
80 }),
81 ),
82 30_000,
83 );
84
85 it.effect(
86 "start returns a redirect to the authorization endpoint",
87 () =>
88 Effect.scoped(
89 Effect.gen(function* () {
90 const oauth = yield* serveOAuthTestServer();
91 const org = `org_${crypto.randomUUID()}`;
92 const slug = OAuthClientSlug.make(`client_${crypto.randomUUID().slice(0, 8)}`);
93
94 yield* asOrg(org, (client) =>
95 client.oauth.createClient({
96 payload: {
97 owner: "org",
98 slug,
99 authorizationUrl: oauth.authorizationEndpoint,
100 tokenUrl: oauth.tokenEndpoint,
101 grant: "authorization_code",
102 clientId: "test-client",
103 clientSecret: "test-secret",
104 },
105 }),
106 );
107
108 const result = yield* asOrg(org, (client) =>
109 client.oauth.start({
110 payload: {
111 client: slug,
112 clientOwner: "org",
113 owner: "org",
114 name: ConnectionName.make("main"),
115 integration: IntegrationSlug.make("some-integration"),
116 template: AuthTemplateSlug.make("oauth"),
117 },
118 }),
119 );
120
121 expect(result).toMatchObject({
122 status: "redirect",
123 authorizationUrl: expect.stringContaining(oauth.authorizationEndpoint),
124 state: expect.stringMatching(/.+/),
125 });
126 }),
127 ),
128 30_000,
129 );
130
131 it.effect("cancel is idempotent for an unknown session", () =>
132 Effect.gen(function* () {
133 const org = `org_${crypto.randomUUID()}`;
134 const cancelled = yield* asOrg(org, (client) =>
135 client.oauth.cancel({
136 payload: { state: OAuthState.make("oauth2_session_does_not_exist") },
137 }),
138 );
139 expect(cancelled.cancelled).toBe(true);
140 }),
141 );
142});
apps/cloud/src/mcp/worker-transport.test.ts+1−1
@@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; · 2 unmodified lines
3import {3import {
4 JsonRpcRequestIdQueue,4 JsonRpcRequestIdQueue,
5 PREVIOUS_REQUEST_TIMEOUT_MS,5 PREVIOUS_REQUEST_TIMEOUT_MS,
6} from "@executor-js/cloudflare/mcp/worker-transport";6} from "@executor-js/cloudflare/mcp/request-id-queue";
7 7
8const jsonRpcRequest = (body: unknown): Request =>8const jsonRpcRequest = (body: unknown): Request =>
9 new Request("https://example.invalid/mcp", {9 new Request("https://example.invalid/mcp", {
apps/cloud/src/secrets-isolation.e2e.node.test.ts+0−204
@@ -1,204 +0,0 @@
1// End-to-end coverage for connection (credential) isolation *through the real
2// HTTP API* (v2).
3//
4// Complements tenant-isolation.node.test.ts (plain cross-org isolation) by
5// exercising the owner model the cloud app actually ships: the per-request
6// executor binds `{ tenant: organizationId, subject: accountId }`, and every
7// connection is filed under `owner: "org"` (tenant-shared) or `owner: "user"`
8// (this subject's own). Every request goes through `HttpApiClient` → `fetch` →
9// the real `ProtectedCloudApi` → the real Drizzle/FumaDB path.
10//
11// Invariants the product is staking on:
12//
13// 1. Users in different orgs can't see each other's org connections.
14// 2. Users in the same org can't see each other's user-owned connections
15// (per-user OAuth tokens etc. don't leak to co-workers).
16// 3. Org-owned connections ARE visible to every user in that org — an admin
17// writing a shared API key serves the whole tenant.
18// 4. The same user id in different orgs is a different tenant binding — a
19// user connection written in org A is invisible in org B.
20
21import { describe, expect, it } from "@effect/vitest";
22import { Effect } from "effect";
23import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
24import { Schema } from "effect";
25
26import {
27 AuthTemplateSlug,
28 ConnectionName,
29 IntegrationSlug,
30 connectionIdentifier,
31} from "@executor-js/sdk";
32import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
33
34import { asUser } from "./testing/api-harness";
35
36const uniq = () => crypto.randomUUID().slice(0, 8);
37const nextOrgId = () => `org_iso_${uniq()}`;
38const nextUserId = () => `user_iso_${uniq()}`;
39
40const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
41const canonicalConnectionName = (name: ConnectionName): ConnectionName =>
42 connectionIdentifier(String(name));
43
44const PingApi = HttpApi.make("isolationApiTest")
45 .add(
46 HttpApiGroup.make("default", { topLevel: true }).add(
47 HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
48 ),
49 )
50 .annotateMerge(OpenApi.annotations({ title: "Isolation API Test", version: "1.0.0" }));
51
52// Registers a minimal openapi integration under `org` (acting as `userId`) so
53// connections have an integration to bind to. Returns the slug.
54const registerIntegration = (userId: string, org: string) =>
55 Effect.gen(function* () {
56 const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`);
57 yield* asUser(userId, org, (client) =>
58 client.openapi.addSpec({
59 payload: makeOpenApiHttpApiTestAddSpecPayload(PingApi, {
60 slug,
61 baseUrl: "http://example.com",
62 }),
63 }),
64 );
65 return slug;
66 });
67
68describe("cloud connection isolation (HTTP, owner model)", () => {
69 it.effect("users in different orgs cannot read each other's org connections", () =>
70 Effect.gen(function* () {
71 const orgA = nextOrgId();
72 const orgB = nextOrgId();
73 const alice = nextUserId();
74 const charlie = nextUserId();
75 const name = ConnectionName.make(`conn_${uniq()}`);
76 const storedName = canonicalConnectionName(name);
77
78 const integrationA = yield* registerIntegration(alice, orgA);
79 yield* asUser(alice, orgA, (client) =>
80 client.connections.create({
81 payload: {
82 owner: "org",
83 name,
84 integration: integrationA,
85 template: TEMPLATE_API_KEY,
86 value: "alice-org-secret",
87 },
88 }),
89 );
90
91 // Charlie in a different org sees no connections under his (empty) catalog.
92 const charlieList = yield* asUser(charlie, orgB, (client) =>
93 client.connections.list({ query: {} }),
94 );
95 expect(charlieList.map((c) => c.name)).not.toContain(storedName);
96 }),
97 );
98
99 it.effect("users in same org cannot read each other's user-owned connections", () =>
100 Effect.gen(function* () {
101 const organizationId = nextOrgId();
102 const aliceId = nextUserId();
103 const bobId = nextUserId();
104 const name = ConnectionName.make(`conn_${uniq()}`);
105 const storedName = canonicalConnectionName(name);
106
107 const integration = yield* registerIntegration(aliceId, organizationId);
108
109 // Alice writes her personal (`owner: "user"`) connection.
110 yield* asUser(aliceId, organizationId, (client) =>
111 client.connections.create({
112 payload: {
113 owner: "user",
114 name,
115 integration,
116 template: TEMPLATE_API_KEY,
117 value: "alice-token-value",
118 },
119 }),
120 );
121
122 // Bob is in the same org — his subject differs. He must not see Alice's
123 // user connection in a user-owner list.
124 const bobUserList = yield* asUser(bobId, organizationId, (client) =>
125 client.connections.list({ query: { integration, owner: "user" } }),
126 );
127 expect(bobUserList.map((c) => c.name)).not.toContain(storedName);
128
129 // And Alice still sees her own connection.
130 const aliceUserList = yield* asUser(aliceId, organizationId, (client) =>
131 client.connections.list({ query: { integration, owner: "user" } }),
132 );
133 expect(aliceUserList.map((c) => c.name)).toContain(storedName);
134 }),
135 );
136
137 it.effect("org-owned connections are visible to every user in that org", () =>
138 Effect.gen(function* () {
139 const organizationId = nextOrgId();
140 const adminId = nextUserId();
141 const memberId = nextUserId();
142 const name = ConnectionName.make(`conn_${uniq()}`);
143 const storedName = canonicalConnectionName(name);
144
145 const integration = yield* registerIntegration(adminId, organizationId);
146 yield* asUser(adminId, organizationId, (client) =>
147 client.connections.create({
148 payload: {
149 owner: "org",
150 name,
151 integration,
152 template: TEMPLATE_API_KEY,
153 value: "shared-org-key",
154 },
155 }),
156 );
157
158 const adminList = yield* asUser(adminId, organizationId, (client) =>
159 client.connections.list({ query: { integration, owner: "org" } }),
160 );
161 const memberList = yield* asUser(memberId, organizationId, (client) =>
162 client.connections.list({ query: { integration, owner: "org" } }),
163 );
164 expect(adminList.map((c) => c.name)).toContain(storedName);
165 expect(memberList.map((c) => c.name)).toContain(storedName);
166 }),
167 );
168
169 it.effect("same userId in different orgs is a distinct tenant binding", () =>
170 Effect.gen(function* () {
171 const userId = nextUserId();
172 const orgA = nextOrgId();
173 const orgB = nextOrgId();
174 const name = ConnectionName.make(`conn_${uniq()}`);
175 const storedName = canonicalConnectionName(name);
176
177 const integrationA = yield* registerIntegration(userId, orgA);
178 yield* asUser(userId, orgA, (client) =>
179 client.connections.create({
180 payload: {
181 owner: "user",
182 name,
183 integration: integrationA,
184 template: TEMPLATE_API_KEY,
185 value: "value-in-a",
186 },
187 }),
188 );
189
190 // Same user id, different org → different tenant. Org A's connection (and
191 // its integration) must not be visible in org B.
192 const listInB = yield* asUser(userId, orgB, (client) =>
193 client.connections.list({ query: {} }),
194 );
195 expect(listInB.map((c) => c.name)).not.toContain(storedName);
196
197 // Sanity: still visible under org A's user-owner list.
198 const listInA = yield* asUser(userId, orgA, (client) =>
199 client.connections.list({ query: { integration: integrationA, owner: "user" } }),
200 );
201 expect(listInA.map((c) => c.name)).toContain(storedName);
202 }),
203 );
204});
apps/cloud/src/testing/api-harness.ts+0−224
@@ -1,224 +0,0 @@
1// Shared HTTP test harness for node-pool integration tests.
2//
3// Stands up the real ProtectedCloudApi against a real DbService and
4// every real plugin (openapi / mcp / graphql / workos-vault), with
5// two test-only swaps:
6//
7// - Auth is faked: the executor binds `{ tenant, subject }` read off the
8// `x-test-org-id` / `x-test-user-id` headers instead of the WorkOS cookie.
9// - `workos-vault` is configured with an in-memory `WorkOSVaultClient`
10// so connection writes never reach WorkOS's real API.
11//
12// Tests get a `fetchForOrg(organizationId)` they can hand to `FetchHttpClient`
13// and then call `HttpApiClient.make(ProtectedCloudApi)` against it.
14// Each test picks its own org id (usually a random UUID) so rows don't
15// collide across tests.
16//
17// v2: the executor is bound to a tenant (the organization id) and a subject
18// (the account id). The org-shared catalog is `owner: "org"`; a member's own
19// connections are `owner: "user"`. There is no scope stack and no scope id.
20
21import { Effect, Layer } from "effect";
22import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";
23import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";
24
25import {
26 ExecutionEngineService,
27 ExecutorService,
28 collectTables,
29 providePluginExtensions,
30 type PluginExtensionServices,
31} from "@executor-js/api/server";
32import { createExecutionEngine } from "@executor-js/execution";
33import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
34import { createExecutor, Subject, Tenant } from "@executor-js/sdk";
35import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
36
37import executorConfig from "../../executor.config";
38import { AuthContext, RouterConfigLive } from "@executor-js/api/server";
39
40import { ProtectedCloudApi, ProtectedCloudApiHandlers } from "../api/layers";
41import { DbService } from "../db/db";
42import { createDrizzleFumaDb } from "../db/fuma";
43
44export const TEST_BASE_URL = "http://test.local";
45export const TEST_ORG_HEADER = "x-test-org-id";
46export const TEST_USER_HEADER = "x-test-user-id";
47
48// `asOrg(organizationId, …)` callers don't care which specific user they are,
49// only that the executor has a bound subject so `owner: "user"` operations work.
50// We give each org a stable default subject so list/get operations remain
51// deterministic across calls within a single test.
52const defaultUserFor = (organizationId: string) => `default_user_${organizationId}`;
53
54// ---------------------------------------------------------------------------
55// Executor factory — mirrors `makeScopedExecutor` (binds `{ tenant, subject }`)
56// but with an in-memory test vault client (see
57// `@executor-js/plugin-workos-vault/testing`).
58// ---------------------------------------------------------------------------
59
60const fakeVault = makeTestWorkOSVaultClient();
61const testPlugins = executorConfig.plugins({
62 workosVaultClient: fakeVault,
63});
64const testHttpClientLayer = FetchHttpClient.layer;
65
66const createTestScopedExecutor = (userId: string, organizationId: string) =>
67 Effect.gen(function* () {
68 const { db } = yield* DbService;
69 const plugins = testPlugins;
70 const fuma = createDrizzleFumaDb({
71 db,
72 tables: collectTables(),
73 namespace: "executor_cloud",
74 provider: "postgresql",
75 });
76 return yield* createExecutor({
77 tenant: Tenant.make(organizationId),
78 subject: Subject.make(userId),
79 db: fuma.db,
80 plugins,
81 httpClientLayer: testHttpClientLayer,
82 onElicitation: "accept-all",
83 // EXPLICIT OAuth callback — production derives
84 // `${webBaseUrl}${CLOUD_MOUNT_PREFIX}/oauth/callback` in `makeScopedExecutor`
85 // (the cloud mounts the API under `/api`); the harness wires the matching
86 // stable test equivalent so the OAuth `start` (authorization_code) flow
87 // returns a redirect instead of failing loudly on the now-required redirectUri.
88 redirectUri: "https://test.executor.sh/api/oauth/callback",
89 });
90 });
91
92// ---------------------------------------------------------------------------
93// HTTP plumbing
94// ---------------------------------------------------------------------------
95
96// Test version of the production `ExecutionStackMiddleware` — reads the
97// `x-test-org-id` (and optional `x-test-user-id`) header, builds a
98// test-scoped executor against the live postgres test db with a fake
99// WorkOS vault, and provides `AuthContext` + the executor services to the
100// handler. Mirrors prod's HttpRouter middleware but with test-mode
101// constructors.
102const TestExecutionStackMiddleware = HttpRouter.middleware<{
103 provides:
104 | AuthContext
105 | ExecutorService
106 | ExecutionEngineService
107 | PluginExtensionServices<typeof testPlugins>;
108}>()(
109 // Layer-time setup — captures `DbService` so the per-request function
110 // only depends on `HttpRouter`-Provided context. See `api/protected.ts`
111 // for the same pattern.
112 Effect.gen(function* () {
113 const context = yield* Effect.context<DbService>();
114 const provideExecutorExtensions = providePluginExtensions(testPlugins);
115 return (httpEffect) =>
116 Effect.gen(function* () {
117 const request = yield* HttpServerRequest.HttpServerRequest;
118 const organizationId = request.headers[TEST_ORG_HEADER];
119 if (!organizationId || typeof organizationId !== "string") {
120 // oxlint-disable-next-line executor/no-effect-escape-hatch, executor/no-error-constructor -- boundary: test HTTP harness has no request context without x-test-org-id
121 return yield* Effect.die(new Error("missing x-test-org-id"));
122 }
123 const userHeader = request.headers[TEST_USER_HEADER];
124 const userId =
125 typeof userHeader === "string" && userHeader.length > 0
126 ? userHeader
127 : defaultUserFor(organizationId);
128 const executor = yield* createTestScopedExecutor(userId, organizationId);
129 const engine = createExecutionEngine({
130 executor,
131 codeExecutor: makeQuickJsExecutor(),
132 });
133 return yield* httpEffect.pipe(
134 Effect.provideService(
135 AuthContext,
136 AuthContext.of({
137 accountId: userId,
138 organizationId,
139 email: "test@example.com",
140 name: "Test User",
141 avatarUrl: null,
142 roles: [],
143 }),
144 ),
145 Effect.provideService(ExecutorService, executor),
146 Effect.provideService(ExecutionEngineService, engine),
147 provideExecutorExtensions(executor),
148 );
149 }).pipe(Effect.provideContext(context));
150 }),
151).layer;
152
153const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(
154 Layer.provide(ProtectedCloudApiHandlers),
155 Layer.provide(TestExecutionStackMiddleware),
156 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
157 Layer.provideMerge(RouterConfigLive),
158 Layer.provideMerge(DbService.Live),
159 Layer.provideMerge(HttpServer.layerServices),
160);
161
162const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;
163
164export const fetchForOrg = (organizationId: string): typeof globalThis.fetch =>
165 ((input: RequestInfo | URL, init?: RequestInit) => {
166 const base = input instanceof Request ? input : new Request(input, init);
167 const req = new Request(base, {
168 headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: organizationId },
169 });
170 return handler(req);
171 }) as typeof globalThis.fetch;
172
173export const fetchForUser = (userId: string, organizationId: string): typeof globalThis.fetch =>
174 ((input: RequestInfo | URL, init?: RequestInit) => {
175 const base = input instanceof Request ? input : new Request(input, init);
176 const req = new Request(base, {
177 headers: {
178 ...Object.fromEntries(base.headers),
179 [TEST_ORG_HEADER]: organizationId,
180 [TEST_USER_HEADER]: userId,
181 },
182 });
183 return handler(req);
184 }) as typeof globalThis.fetch;
185
186export const clientLayerForOrg = (organizationId: string) =>
187 FetchHttpClient.layer.pipe(
188 Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForOrg(organizationId))),
189 );
190
191export const clientLayerForUser = (userId: string, organizationId: string) =>
192 FetchHttpClient.layer.pipe(
193 Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForUser(userId, organizationId))),
194 );
195
196// Constructs an HttpApiClient bound to the given org, hands it to `body`,
197// and provides the org-scoped fetch layer in one step. Keeps per-test
198// Effect blocks focused on the actual assertions.
199type ApiShape = HttpApiClient.ForApi<typeof ProtectedCloudApi>;
200
201export const asOrg = <A, E>(
202 organizationId: string,
203 body: (client: ApiShape) => Effect.Effect<A, E>,
204): Effect.Effect<A, E> =>
205 Effect.gen(function* () {
206 const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL });
207 return yield* body(client);
208 }).pipe(Effect.provide(clientLayerForOrg(organizationId))) as Effect.Effect<A, E>;
209
210// Same as `asOrg` but also threads a specific user id through the fake auth, so
211// the built executor's bound subject is `userId`. Use this for tests that care
212// about per-user isolation (`owner: "user"` connections) inside the same org.
213export const asUser = <A, E>(
214 userId: string,
215 organizationId: string,
216 body: (client: ApiShape) => Effect.Effect<A, E>,
217): Effect.Effect<A, E> =>
218 Effect.gen(function* () {
219 const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL });
220 return yield* body(client);
221 }).pipe(Effect.provide(clientLayerForUser(userId, organizationId))) as Effect.Effect<A, E>;
222
223// Re-exports so call sites don't need a second import.
224export { ProtectedCloudApi };
apps/cloud/src/testing/test-bearer.ts+0−22
@@ -1,22 +0,0 @@
1// Shared test bearer format between the test worker (runs inside workerd /
2// Miniflare) and node-pool tests (which import it directly). Kept in its own
3// zero-dependency module so node tests can pull it without dragging in the
4// worker entry, which imports `cloudflare:workers`.
5
6import type { VerifiedToken } from "../mcp/jwt";
7
8export const TEST_BEARER_PREFIX = "test-accept::";
9export const NO_ORG_SENTINEL = "none";
10
11export const makeTestBearer = (accountId: string, organizationId: string | null): string =>
12 `${TEST_BEARER_PREFIX}${accountId}::${organizationId ?? NO_ORG_SENTINEL}`;
13
14export const parseTestBearer = (token: string): VerifiedToken | null => {
15 if (!token.startsWith(TEST_BEARER_PREFIX)) return null;
16 const [accountId, organizationId] = token.slice(TEST_BEARER_PREFIX.length).split("::", 2);
17 if (!accountId || !organizationId) return null;
18 return {
19 accountId,
20 organizationId: organizationId === NO_ORG_SENTINEL ? null : organizationId,
21 };
22};
apps/cloud/src/testing/test-worker.ts+0−146
@@ -1,146 +0,0 @@
1// ---------------------------------------------------------------------------
2// vitest-pool-workers test entry
3// ---------------------------------------------------------------------------
4//
5// Re-exports the real McpSessionDO and drives /mcp + /.well-known/* through
6// the same Effect HttpEffect the prod worker uses. Only the `McpAuth` service
7// is swapped: the real impl calls WorkOS's JWKS endpoint, which can't be
8// reached from the test isolate.
9//
10// `stdio`-transport branch of plugin-mcp is now dynamically imported (see
11// packages/plugins/mcp/src/sdk/connection.ts), so `@modelcontextprotocol/
12// sdk/client/stdio.js` no longer touches `node:child_process` at module
13// load — that was SIGSEGV-ing workerd during test instantiation.
14// ---------------------------------------------------------------------------
15
16import { Effect, Layer } from "effect";
17import { drizzle } from "drizzle-orm/postgres-js";
18import postgres, { type Sql } from "postgres";
19
20import {
21 McpAuth,
22 McpAuthLive,
23 McpJwtVerificationError,
24 McpOrganizationAuth,
25 McpOrganizationAuthLive,
26 mcpAuthorized,
27 mcpUnauthorized,
28} from "../mcp/auth";
29import { classifyMcpPath, makeMcpWebHandler, prepareMcpOrgScope } from "../mcp/mount";
30import { cloudMcpAuthProviderLayer } from "../mcp/auth-provider";
31import { ApiKeyService } from "../auth/api-keys";
32import { organizations } from "../db/schema";
33import { parseTestBearer } from "./test-bearer";
34import { DoTelemetryLive } from "../observability/telemetry";
35import { CoreSharedServices } from "../auth/workos";
36
37export { McpSessionDO } from "../mcp/session-durable-object";
38
39const TestMcpAuthLive = Layer.succeed(McpAuth)({
40 verifyBearer: (request) =>
41 Effect.gen(function* () {
42 const header = request.headers.get("authorization");
43 if (!header?.startsWith("Bearer ")) return mcpUnauthorized("missing_bearer");
44 const rawToken = header.slice("Bearer ".length);
45 if (rawToken === "test-system-error") {
46 return yield* new McpJwtVerificationError({
47 cause: "simulated_jwks_fetch_failure",
48 reason: "system",
49 });
50 }
51 const token = parseTestBearer(rawToken);
52 return token ? mcpAuthorized(token) : mcpUnauthorized("invalid_token");
53 }),
54});
55
56const TestMcpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({
57 // Deny on any org id containing "revoked" (rather than a strict prefix) so a
58 // routable, prefix-valid org id like `org_revoked_…` still exercises the
59 // membership-denied path through the `/org_xxx/mcp` URL route.
60 authorize: (_accountId, organizationId) => Effect.succeed(!organizationId.includes("revoked")),
61});
62
63// ---------------------------------------------------------------------------
64// Test seed endpoint
65// ---------------------------------------------------------------------------
66//
67// Exposed at POST /__test__/seed-org. Tests call it via SELF.fetch to insert
68// organization rows into the same PGlite-backed database the DO reads from. Doing
69// the insert from inside the test worker avoids pulling postgres.js into the
70// test file's top-level imports (which segfaulted workerd during test
71// module instantiation).
72// ---------------------------------------------------------------------------
73
74const seedConnectionString = (envArg: Record<string, unknown>) =>
75 (envArg.DATABASE_URL as string | undefined) ??
76 "postgresql://postgres:postgres@127.0.0.1:5434/postgres";
77
78// Per-request postgres connection. Sharing a `Sql` across requests breaks
79// mid-suite — vitest-pool-workers' isolate resets tear down the socket and
80// the next insert errors with "read end of pipe was aborted". Open + close
81// per request; the test DO runtime does the same to avoid workerd's
82// cross-request I/O guard.
83const handleSeedOrg = async (
84 request: Request,
85 envArg: Record<string, unknown>,
86): Promise<Response> => {
87 const body = (await request.json()) as { id: string; name: string };
88 const sql: Sql = postgres(seedConnectionString(envArg), {
89 max: 1,
90 idle_timeout: 0,
91 max_lifetime: 30,
92 connect_timeout: 10,
93 onnotice: () => undefined,
94 });
95 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: worker seed endpoint keeps postgres cleanup in native async finalization
96 try {
97 await drizzle(sql, { schema: { organizations } })
98 .insert(organizations)
99 .values({ id: body.id, name: body.name })
100 .onConflictDoUpdate({
101 target: organizations.id,
102 set: { name: body.name },
103 });
104 } finally {
105 // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort postgres close during worker seed endpoint cleanup
106 await sql.end({ timeout: 0 }).catch(() => undefined);
107 }
108 return new Response(null, { status: 204 });
109};
110
111// Build the same shared host-mcp envelope handler the prod worker uses, with
112// the test auth seams swapped in. A WebSdk-backed tracer (DoTelemetryLive) is
113// provided to the whole router so the `mcp.request` / `mcp.request.annotate`
114// spans get reported to the OTLP receiver.
115const testMcpFetch = makeMcpWebHandler({
116 authProvider: cloudMcpAuthProviderLayer,
117 seamsRequirements: Layer.mergeAll(TestMcpAuthLive, TestMcpOrganizationAuthLive),
118 runtime: DoTelemetryLive,
119});
120
121const realAuthMcpFetch = makeMcpWebHandler({
122 authProvider: cloudMcpAuthProviderLayer,
123 seamsRequirements: Layer.mergeAll(
124 McpAuthLive.pipe(Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)))),
125 McpOrganizationAuthLive,
126 ),
127 runtime: DoTelemetryLive,
128});
129
130export default {
131 async fetch(request: Request, envArg: Record<string, unknown>): Promise<Response> {
132 const url = new URL(request.url);
133 if (url.pathname === "/__test__/seed-org" && request.method === "POST") {
134 return handleSeedOrg(request, envArg);
135 }
136 if (url.pathname === "/__test__/real-auth-mcp") {
137 const mcpUrl = new URL(request.url);
138 mcpUrl.pathname = "/mcp";
139 return realAuthMcpFetch(new Request(mcpUrl, request));
140 }
141 if (classifyMcpPath(url.pathname) !== null) {
142 return testMcpFetch(prepareMcpOrgScope(request));
143 }
144 return new Response("not found", { status: 404 });
145 },
146};
apps/cloud/test-types.d.ts+0−1
@@ -1 +0,0 @@
1/// <reference types="@cloudflare/vitest-pool-workers/types" />
apps/cloud/tsconfig.json+2−2
@@ -6,7 +6,7 @@ · 5 unmodified lines
6 "strict": true,6 "strict": true,
7 "esModuleInterop": true,7 "esModuleInterop": true,
8 "skipLibCheck": true,8 "skipLibCheck": true,
9 "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"],9 "types": ["@cloudflare/workers-types"],
10 "outDir": "dist",10 "outDir": "dist",
11 "rootDir": ".",11 "rootDir": ".",
12 "declaration": false,12 "declaration": false,
@@ -22,5 +22,5 @@ · 9 unmodified lines
22 }22 }
23 ]23 ]
24 },24 },
25 "include": ["src", "vite.config.ts", "worker-configuration.d.ts", "test-types.d.ts"]25 "include": ["src", "vite.config.ts", "worker-configuration.d.ts"]
26}26}
apps/cloud/vitest.config.ts+12−28
@@ -1,28 +1,15 @@
1// All of cloud's unit tests in one plain node pool. Integration coverage
2// lives in e2e/ (over the dev server, which runs the production
3// wrangler.jsonc topology — real workerd, real Durable Objects).
1import { resolve } from "node:path";4import { resolve } from "node:path";
2
3import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
4import { defineConfig } from "vitest/config";5import { defineConfig } from "vitest/config";
5 6
6// `auth/handlers.ts` imports `setCookie`/`deleteCookie` from
7// `@tanstack/react-start/server`. That barrel transitively pulls in
8// `@tanstack/start-server-core`, which does `import("#tanstack-start-entry")`
9// (+ `#tanstack-router-entry` / `#tanstack-start-plugin-adapters`). Those
10// `imports`-field specifiers are declared on `@tanstack/start-client-core`,
11// not on `start-server-core`, so Vite's resolver (used by the workerd pool)
12// can't resolve them relative to the importing package and the module graph
13// fails to load. Alias the three specifiers to a no-op stub so the workerd
14// pool can load any module that transitively imports react-start. Cloud only
15// uses the cookie helpers, never the SSR handler path the stub shims out.
16const tanstackStartEntryStub = resolve(__dirname, "./test-stubs/tanstack-start-entry.ts");7const tanstackStartEntryStub = resolve(__dirname, "./test-stubs/tanstack-start-entry.ts");
17 8
18export default defineConfig({9export default defineConfig({
19 plugins: [
20 cloudflareTest({
21 wrangler: { configPath: "./wrangler.test.jsonc" },
22 }),
23 ],
24 resolve: {10 resolve: {
25 alias: {11 alias: {
12 "cloudflare:workers": resolve(__dirname, "./test-stubs/cloudflare-workers.ts"),
26 "#tanstack-start-entry": tanstackStartEntryStub,13 "#tanstack-start-entry": tanstackStartEntryStub,
27 "#tanstack-router-entry": tanstackStartEntryStub,14 "#tanstack-router-entry": tanstackStartEntryStub,
28 "#tanstack-start-plugin-adapters": tanstackStartEntryStub,15 "#tanstack-start-plugin-adapters": tanstackStartEntryStub,
@@ -30,18 +17,15 @@ export default defineConfig({ · 1 unmodified line
30 },17 },
31 test: {18 test: {
32 include: ["src/**/*.test.ts"],19 include: ["src/**/*.test.ts"],
33 exclude: ["src/**/*.node.test.ts", "**/node_modules/**"],20 exclude: ["**/node_modules/**"],
34 globalSetup: ["./scripts/test-globalsetup.ts"],21 globalSetup: ["./scripts/test-globalsetup.ts"],
35 // postgres.js's Cloudflare polyfill leaves a couple of `.then()` chains22 fileParallelism: false,
36 // on `writer.ready` uncaught when the socket tears down before the23 env: {
37 // writer settles (DbService scope close). The rejection is benign —24 DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5434/postgres",
38 // the socket is closing anyway — so filter it out rather than fail25 WORKOS_API_KEY: "test_api_key",
39 // the run with noise.26 WORKOS_CLIENT_ID: "test_client_id",
40 onUnhandledError(error) {27 WORKOS_COOKIE_PASSWORD: "test_cookie_password_at_least_32_chars!",
41 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: Vitest unhandled-error hook receives unknown host errors28 NODE_ENV: "test",
42 if (error && (error as Error).message === "Stream was cancelled.") {
43 return false;
44 }
45 },29 },
46 },30 },
47});31});
apps/cloud/vitest.node.config.ts+0−29
@@ -1,29 +0,0 @@
1// Vitest config for node-pool integration tests. These run real DbService,
2// real plugins, and HttpApiClient through an in-process handler, but outside
3// workerd. Node can use Drizzle's PGlite driver directly; the workerd suite
4// keeps the PGlite socket path because Workers code reaches Postgres through
5// postgres.js.
6
7import { resolve } from "node:path";
8import { defineConfig } from "vitest/config";
9
10export default defineConfig({
11 resolve: {
12 alias: {
13 "cloudflare:workers": resolve(__dirname, "./test-stubs/cloudflare-workers.ts"),
14 },
15 },
16 test: {
17 include: ["src/**/*.node.test.ts"],
18 globalSetup: ["./scripts/test-globalsetup.ts"],
19 // Keep files serialized so tests share one deterministic PGlite state.
20 fileParallelism: false,
21 env: {
22 DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5434/postgres",
23 WORKOS_API_KEY: "test_api_key",
24 WORKOS_CLIENT_ID: "test_client_id",
25 WORKOS_COOKIE_PASSWORD: "test_cookie_password_at_least_32_chars!",
26 NODE_ENV: "test",
27 },
28 },
29});
apps/cloud/wrangler.miniflare.jsonc+0−27
@@ -1,27 +0,0 @@
1// Real-port Miniflare harness config used by mcp-miniflare.e2e.node.test.ts.
2// Same bindings as wrangler.test.jsonc, exercised through Miniflare on a real
3// port so the MCP session Durable Object uses the same long-lived runtime path
4// that ships to Cloudflare.
5{
6 "$schema": "node_modules/wrangler/config-schema.json",
7 "name": "executor-cloud-miniflare",
8 "compatibility_date": "2025-06-01",
9 "compatibility_flags": ["nodejs_compat"],
10 "main": "src/testing/test-worker.ts",
11 "vars": {
12 "DATABASE_URL": "postgresql://postgres:postgres@127.0.0.1:5434/postgres",
13 "EXECUTOR_DIRECT_DATABASE_URL": "true",
14 "WORKOS_API_KEY": "test_api_key",
15 "WORKOS_CLIENT_ID": "test_client_id",
16 "WORKOS_COOKIE_PASSWORD": "test_cookie_password_at_least_32_chars!",
17 "MCP_AUTHKIT_DOMAIN": "https://test-authkit.example.com",
18 "MCP_RESOURCE_ORIGIN": "https://test-resource.example.com",
19 "NODE_ENV": "test",
20 "ALLOW_LOCAL_NETWORK": "true",
21 },
22 "durable_objects": {
23 "bindings": [{ "name": "MCP_SESSION", "class_name": "McpSessionDO" }],
24 },
25 "worker_loaders": [{ "binding": "LOADER" }],
26 "migrations": [{ "tag": "v1", "new_classes": ["McpSessionDO"] }],
27}
apps/cloud/wrangler.test.jsonc+0−23
@@ -1,23 +0,0 @@
1{
2 "$schema": "node_modules/wrangler/config-schema.json",
3 "name": "executor-cloud-test",
4 "compatibility_date": "2025-06-01",
5 "compatibility_flags": ["nodejs_compat"],
6 "main": "src/testing/test-worker.ts",
7 "vars": {
8 "DATABASE_URL": "postgresql://postgres:postgres@127.0.0.1:5434/postgres",
9 "EXECUTOR_DIRECT_DATABASE_URL": "true",
10 "WORKOS_API_KEY": "test_api_key",
11 "WORKOS_CLIENT_ID": "test_client_id",
12 "WORKOS_COOKIE_PASSWORD": "test_cookie_password_at_least_32_chars!",
13 "MCP_AUTHKIT_DOMAIN": "https://test-authkit.example.com",
14 "MCP_RESOURCE_ORIGIN": "https://test-resource.example.com",
15 "NODE_ENV": "test",
16 "ALLOW_LOCAL_NETWORK": "true",
17 },
18 "durable_objects": {
19 "bindings": [{ "name": "MCP_SESSION", "class_name": "McpSessionDO" }],
20 },
21 "worker_loaders": [{ "binding": "LOADER" }],
22 "migrations": [{ "tag": "v1", "new_classes": ["McpSessionDO"] }],
23}
bun.lock+19−19
@@ -99,7 +99,6 @@ · 98 unmodified lines
99 "sonner": "^2.0.7",99 "sonner": "^2.0.7",
100 },100 },
101 "devDependencies": {101 "devDependencies": {
102 "@cloudflare/vitest-pool-workers": "^0.15.0",
103 "@cloudflare/workers-types": "^4.20250620.0",102 "@cloudflare/workers-types": "^4.20250620.0",
104 "@effect/platform-node": "catalog:",103 "@effect/platform-node": "catalog:",
105 "@effect/vitest": "catalog:",104 "@effect/vitest": "catalog:",
@@ -328,6 +327,7 @@ · 222 unmodified lines
328 "@executor-js/plugin-openapi": "workspace:*",327 "@executor-js/plugin-openapi": "workspace:*",
329 "@executor-js/sdk": "workspace:*",328 "@executor-js/sdk": "workspace:*",
330 "@kitlangton/terminal-control": "^0.3.0",329 "@kitlangton/terminal-control": "^0.3.0",
330 "@modelcontextprotocol/sdk": "^1.29.0",
331 "effect": "catalog:",331 "effect": "catalog:",
332 "monaco-editor": "^0.55.1",332 "monaco-editor": "^0.55.1",
333 "playwright": "^1.60.0",333 "playwright": "^1.60.0",
@@ -4037,7 +4037,7 @@ · 3703 unmodified lines
4037 4037
4038 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],4038 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
4039 4039
4040 "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="],4040 "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="],
4041 4041
4042 "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],4042 "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
4043 4043
@@ -5085,6 +5085,8 @@ · 1041 unmodified lines
5085 5085
5086 "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],5086 "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
5087 5087
5088 "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="],
5089
5088 "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="],5090 "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="],
5089 5091
5090 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],5092 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -5523,7 +5525,7 @@ · 432 unmodified lines
5523 5525
5524 "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],5526 "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
5525 5527
5526 "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],5528 "miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
5527 5529
5528 "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],5530 "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
5529 5531
@@ -5639,8 +5641,6 @@ · 109 unmodified lines
5639 5641
5640 "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],5642 "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
5641 5643
5642 "wrangler/miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="],
5643
5644 "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],5644 "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
5645 5645
5646 "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],5646 "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -5719,6 +5719,10 @@ · 72 unmodified lines
5719 5719
5720 "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],5720 "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
5721 5721
5722 "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
5723
5724 "@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],
5725
5722 "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],5726 "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
5723 5727
5724 "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],5728 "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],
@@ -6097,16 +6101,6 @@ · 372 unmodified lines
6097 6101
6098 "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],6102 "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
6099 6103
6100 "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
6101
6102 "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
6103
6104 "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="],
6105
6106 "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="],
6107
6108 "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="],
6109
6110 "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],6104 "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
6111 6105
6112 "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],6106 "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -6203,10 +6197,6 @@ · 90 unmodified lines
6203 6197
6204 "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],6198 "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
6205 6199
6206 "wrangler/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
6207
6208 "wrangler/miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
6209
6210 "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],6200 "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
6211 6201
6212 "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],6202 "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -6287,6 +6277,16 @@ · 74 unmodified lines
6287 6277
6288 "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="],6278 "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="],
6289 6279
6280 "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
6281
6282 "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
6283
6284 "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="],
6285
6286 "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="],
6287
6288 "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="],
6289
6290 "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],6290 "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
6291 6291
6292 "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],6292 "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
e2e/cloud/account-api.test.ts+111−0
@@ -0,0 +1,111 @@
1// Cloud-only: the neutral /api/account surface — the exact contract the shared
2// React shell + api-keys page consume. Happy paths go through the typed
3// AccountHttpApi client (the same contract the UI's AccountApiClient uses);
4// refusal edges and the bearer cross-check use raw fetch with the identity's
5// real sealed-session cookie (the org-limit cross-check pattern).
6import { expect } from "@effect/vitest";
7import { Effect } from "effect";
8import { AccountHttpApi } from "@executor-js/api";
9
10import { scenario } from "../src/scenario";
11
12scenario(
13 "Account · /account/me reflects the signed-in user and their organization",
14 { needs: ["api"] },
15 (ctx) =>
16 Effect.gen(function* () {
17 const identity = yield* ctx.target.newIdentity();
18 const client = yield* ctx.api.client(AccountHttpApi, identity);
19
20 const me = yield* client.account.me();
21 expect(me.user.email, "the session's own user is reported").toBe(identity.label);
22 expect(me.user.id, "the user has a stable id").toMatch(/^user_/);
23 // newIdentity() created this identity's org as "Org <local-part>".
24 const orgName = `Org ${identity.label.split("@")[0]}`;
25 expect(me.organization?.name, "the active organization is the identity's own").toBe(orgName);
26 }),
27);
28
29scenario(
30 "Account · an API key is minted once, authorizes the API as a bearer, and can be revoked",
31 { needs: ["api"] },
32 (ctx) =>
33 Effect.gen(function* () {
34 const identity = yield* ctx.target.newIdentity();
35 const client = yield* ctx.api.client(AccountHttpApi, identity);
36
37 // Create: the plaintext secret is returned exactly once, masked everywhere else.
38 const created = yield* client.account.createApiKey({ payload: { name: "e2e key" } });
39 expect(created.name, "the key carries the requested name").toBe("e2e key");
40 expect(created.value, "create returns the one-time plaintext secret").not.toBe("");
41 expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe(
42 created.value,
43 );
44
45 // The created secret is a working credential for the protected API.
46 const bearer = yield* Effect.promise(() =>
47 fetch(new URL("/api/integrations", ctx.target.baseUrl), {
48 headers: { authorization: `Bearer ${created.value}` },
49 }),
50 );
51 expect(bearer.status, "the bearer authenticates the protected API").toBe(200);
52 const integrations = (yield* Effect.promise(() => bearer.json())) as ReadonlyArray<{
53 slug: string;
54 }>;
55 expect(
56 integrations.map((i) => i.slug),
57 "the authorized call sees the workspace's integrations",
58 ).toContain("executor");
59
60 // List: the key shows up masked; the plaintext secret is never re-served.
61 const listed = yield* client.account.listApiKeys();
62 const mine = listed.apiKeys.find((key) => key.id === created.id);
63 expect(mine?.name, "the created key appears in the list").toBe("e2e key");
64 expect(JSON.stringify(listed), "the list never leaks the plaintext secret").not.toContain(
65 created.value,
66 );
67
68 // Revoke: the key disappears from the account.
69 const revoked = yield* client.account.revokeApiKey({ params: { apiKeyId: created.id } });
70 expect(revoked.success, "revoke reports success").toBe(true);
71 const after = yield* client.account.listApiKeys();
72 expect(
73 after.apiKeys.map((key) => key.id),
74 "the revoked key is gone from the list",
75 ).not.toContain(created.id);
76 }),
77);
78
79scenario(
80 "Account · the account surface refuses anonymous and organization-less callers",
81 { needs: ["api"] },
82 (ctx) =>
83 Effect.gen(function* () {
84 // Anonymous: no session cookie at all → 401.
85 const anonymous = yield* Effect.promise(() =>
86 fetch(new URL("/api/account/me", ctx.target.baseUrl)),
87 );
88 expect(anonymous.status, "no session → unauthorized").toBe(401);
89
90 // A real session that has not joined an organization yet: identity is
91 // visible (drives onboarding), but org-scoped resources are forbidden.
92 const orgless = yield* ctx.target.newIdentity({ org: false });
93 const me = yield* Effect.promise(() =>
94 fetch(new URL("/api/account/me", ctx.target.baseUrl), { headers: orgless.headers ?? {} }),
95 );
96 expect(me.status, "an org-less session can still see itself").toBe(200);
97 const body = (yield* Effect.promise(() => me.json())) as {
98 user: { email: string };
99 organization: unknown;
100 };
101 expect(body.user.email, "it is the signed-in user").toBe(orgless.label);
102 expect(body.organization, "no organization is active yet").toBeNull();
103
104 const keys = yield* Effect.promise(() =>
105 fetch(new URL("/api/account/api-keys", ctx.target.baseUrl), {
106 headers: orgless.headers ?? {},
107 }),
108 );
109 expect(keys.status, "org-scoped API keys are forbidden without an organization").toBe(403);
110 }),
111);
e2e/cloud/auth-session.test.ts+127−0
@@ -0,0 +1,127 @@
1// Cloud-only: the WorkOS login → AuthKit → callback session contract, driven
2// over the wire against the real handlers (AuthKit is the emulator). The happy
3// path itself is what `newIdentity()` performs on every scenario — these
4// scenarios pin the EDGE guarantees around it: the CSRF state handshake, the
5// callback refusing forged/incomplete redirects, the sealed-session cookie
6// actually authorizing the session API, and logout dropping the cookie.
7import { expect } from "@effect/vitest";
8import { Effect } from "effect";
9
10import { scenario } from "../src/scenario";
11
12/** First `Set-Cookie` header for `name`, as the raw header string. */
13const setCookieFor = (response: Response, name: string): string => {
14 for (const header of response.headers.getSetCookie()) {
15 if (header.startsWith(`${name}=`)) return header;
16 }
17 return "";
18};
19
20scenario(
21 "Auth · login redirects to hosted AuthKit carrying a short-lived CSRF state cookie",
22 { needs: ["api"] },
23 (ctx) =>
24 Effect.gen(function* () {
25 const response = yield* Effect.promise(() =>
26 fetch(new URL("/api/auth/login", ctx.target.baseUrl), { redirect: "manual" }),
27 );
28 expect(response.status, "login hands the browser to AuthKit").toBe(302);
29
30 const authorizeUrl = new URL(response.headers.get("location") ?? "");
31 const state = authorizeUrl.searchParams.get("state") ?? "";
32 expect(state, "the redirect carries an unguessable CSRF state").toMatch(/^[0-9a-f]{64}$/);
33 expect(
34 authorizeUrl.searchParams.get("redirect_uri"),
35 "AuthKit is told to come back to this deployment's callback",
36 ).toBe(new URL("/api/auth/callback", ctx.target.baseUrl).toString());
37
38 const stateCookie = setCookieFor(response, "wos-login-state");
39 expect(stateCookie, "the same state is pinned in a cookie for the callback").toContain(
40 `wos-login-state=${state}`,
41 );
42 expect(stateCookie, "the login state expires quickly").toContain("Max-Age=600");
43 expect(stateCookie, "the login state is not readable by page scripts").toContain("HttpOnly");
44 }),
45);
46
47scenario(
48 "Auth · the callback rejects forged or incomplete redirects without exchanging the code",
49 { needs: ["api"] },
50 (ctx) =>
51 Effect.gen(function* () {
52 const callback = (search: string, cookie?: string) =>
53 Effect.promise(() =>
54 fetch(new URL(`/api/auth/callback${search}`, ctx.target.baseUrl), {
55 redirect: "manual",
56 ...(cookie ? { headers: { cookie } } : {}),
57 }),
58 );
59
60 // A state that matches no login-state cookie (no login ever started).
61 const noCookie = yield* callback("?code=attacker-code&state=attacker-state");
62 expect(noCookie.status, "a state with no matching login cookie is refused").toBe(400);
63 expect(
64 setCookieFor(noCookie, "wos-session"),
65 "no session is minted from the forged redirect",
66 ).toBe("");
67
68 // A state that contradicts the login-state cookie the browser holds.
69 const mismatched = yield* callback(
70 "?code=attacker-code&state=attacker-state",
71 "wos-login-state=0000000000000000000000000000000000000000000000000000000000000000",
72 );
73 expect(mismatched.status, "a state contradicting the login cookie is refused").toBe(400);
74 expect(
75 setCookieFor(mismatched, "wos-session"),
76 "no session is minted from the mismatched redirect",
77 ).toBe("");
78
79 // A redirect that never carried the authorization code at all.
80 const noCode = yield* callback("");
81 expect(noCode.status, "a callback without a code is refused").toBe(400);
82 }),
83);
84
85scenario(
86 "Auth · a completed login's session cookie authorizes the session API",
87 { needs: ["api"] },
88 (ctx) =>
89 Effect.gen(function* () {
90 // newIdentity() IS the full product login (login → AuthKit → callback);
91 // its headers carry the genuine sealed-session cookie the callback set.
92 const identity = yield* ctx.target.newIdentity();
93
94 const response = yield* Effect.promise(() =>
95 fetch(new URL("/api/auth/me", ctx.target.baseUrl), { headers: identity.headers ?? {} }),
96 );
97 expect(response.status, "the sealed-session cookie is accepted").toBe(200);
98 const me = (yield* Effect.promise(() => response.json())) as {
99 user: { email: string };
100 organization: { id: string } | null;
101 };
102 expect(me.user.email, "the session belongs to the user who logged in").toBe(identity.label);
103 expect(me.organization?.id, "the session carries the active organization").toMatch(/^org_/);
104 }),
105);
106
107scenario(
108 "Auth · logout sends the user home and tells the browser to drop the session",
109 { needs: ["api"] },
110 (ctx) =>
111 Effect.gen(function* () {
112 const identity = yield* ctx.target.newIdentity();
113
114 const response = yield* Effect.promise(() =>
115 fetch(new URL("/api/auth/logout", ctx.target.baseUrl), {
116 method: "POST",
117 redirect: "manual",
118 headers: identity.headers ?? {},
119 }),
120 );
121 expect(response.status, "logout redirects back to the landing page").toBe(302);
122 expect(response.headers.get("location"), "the destination is home").toBe("/");
123 const cleared = setCookieFor(response, "wos-session");
124 expect(cleared, "the session cookie is expired immediately").toContain("Max-Age=0");
125 expect(cleared, "the cookie value is wiped").toContain("wos-session=;");
126 }),
127);
e2e/cloud/auth-tool-failures.test.ts+109−0
@@ -0,0 +1,109 @@
1// Cloud-only: auth failure propagation through the full execution stack —
2// HTTP API → execution engine → sandbox code → OpenAPI tool invocation. When a
3// connection's credential value cannot resolve (a `from` reference to a vault
4// item that was never stored), invoking one of its tools must surface a
5// structured, model-visible `connection_value_missing` auth failure — not an
6// opaque internal tool error — so an agent can tell the user to re-connect.
7import { randomBytes } from "node:crypto";
8
9import { expect } from "@effect/vitest";
10import { Effect } from "effect";
11import { composePluginApi } from "@executor-js/api/server";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import {
14 AuthTemplateSlug,
15 ConnectionName,
16 IntegrationSlug,
17 ProviderItemId,
18} from "@executor-js/sdk/shared";
19
20import { scenario } from "../src/scenario";
21
22const api = composePluginApi([openApiHttpPlugin()] as const);
23
24/** Minimal OpenAPI spec with a single GET /ping. The base URL is never
25 * reached: credential resolution fails before any HTTP call is made. */
26const pingSpec = JSON.stringify({
27 openapi: "3.0.3",
28 info: { title: "Ping API", version: "1.0.0" },
29 paths: {
30 "/ping": {
31 get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
32 },
33 },
34});
35
36scenario(
37 "Executions · a tool call with an unresolvable credential fails with connection_value_missing",
38 { needs: ["api"] },
39 (ctx) =>
40 Effect.gen(function* () {
41 const identity = yield* ctx.target.newIdentity();
42 const client = yield* ctx.api.client(api, identity);
43
44 // Identifier-safe slug: it becomes a property path in the sandbox code.
45 const slug = IntegrationSlug.make(`authscn${randomBytes(4).toString("hex")}`);
46 yield* client.openapi.addSpec({
47 payload: {
48 spec: { kind: "blob", value: pingSpec },
49 slug,
50 baseUrl: "https://api.example.test",
51 authenticationTemplate: [
52 {
53 slug: "apiKey",
54 type: "apiKey",
55 headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
56 },
57 ],
58 },
59 });
60
61 // A connection whose value cannot resolve: a `from` reference into the
62 // real credential provider, pointing at an item that was never stored.
63 const providers = yield* client.providers.list();
64 expect(providers.length, "a credential provider is registered").toBeGreaterThan(0);
65 yield* client.connections.create({
66 payload: {
67 owner: "org",
68 name: ConnectionName.make("main"),
69 integration: slug,
70 template: AuthTemplateSlug.make("apiKey"),
71 from: { provider: providers[0]!, id: ProviderItemId.make(`${slug}-never-stored`) },
72 },
73 });
74
75 // The tool is in the catalog; its address is the sandbox call path.
76 const tools = yield* client.tools.list();
77 const tool = tools.find((entry) => String(entry.integration) === String(slug));
78 expect(tool?.address, "the integration's tool is in the catalog").toBeDefined();
79
80 const execution = yield* client.executions.execute({
81 payload: {
82 code: [`const result = await ${tool!.address}({});`, "return result;"].join("\n"),
83 },
84 });
85
86 expect(execution.status, "the execution itself completes").toBe("completed");
87 if (execution.status !== "completed") return; // unreachable — narrowing only
88 expect(execution.isError, "an auth failure is a model-visible result, not a tool error").toBe(
89 false,
90 );
91 expect(
92 JSON.stringify(execution.structured).toLowerCase(),
93 "no opaque internal tool error reaches the model",
94 ).not.toContain("internal tool error");
95 expect(
96 execution.structured,
97 "the model sees a structured authentication failure it can act on",
98 ).toMatchObject({
99 status: "completed",
100 result: {
101 ok: false,
102 error: {
103 code: "connection_value_missing",
104 details: { category: "authentication" },
105 },
106 },
107 });
108 }),
109);
e2e/cloud/connection-owner-isolation.test.ts+289−0
@@ -0,0 +1,289 @@
1// Cloud-only: the connection OWNER model, with real multi-user organizations.
2// Every connection is filed under `owner: "org"` (shared with the whole tenant)
3// or `owner: "user"` (this subject's own). The org membership is built through
4// the real product flows — invite → accept-invitation, create-organization,
5// switch-organization — so the guarantees hold for genuine sessions:
6//
7// 1. A user-owned connection is private to its creator, even from co-workers
8// in the same org (personal OAuth tokens don't leak to colleagues).
9// 2. An org-owned connection IS visible to every member of that org — one
10// admin pasting a shared API key serves the whole tenant.
11// 3. The same account in two orgs is two separate credential spaces.
12import { randomBytes } from "node:crypto";
13
14import { expect } from "@effect/vitest";
15import { Effect } from "effect";
16import type { HttpApiClient } from "effect/unstable/httpapi";
17import { composePluginApi } from "@executor-js/api/server";
18import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20
21import { scenario } from "../src/scenario";
22import type { Identity, Target } from "../src/target";
23
24const api = composePluginApi([openApiHttpPlugin()] as const);
25type Client = HttpApiClient.ForApi<typeof api>;
26
27const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
28
29/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
30const pingSpec = JSON.stringify({
31 openapi: "3.0.3",
32 info: { title: "Ping API", version: "1.0.0" },
33 paths: {
34 "/ping": {
35 get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
36 },
37 },
38});
39
40/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
41const registerIntegration = (client: Client) =>
42 Effect.gen(function* () {
43 const slug = IntegrationSlug.make(`owner-scn-${randomBytes(4).toString("hex")}`);
44 yield* client.openapi.addSpec({
45 payload: {
46 spec: { kind: "blob", value: pingSpec },
47 slug,
48 baseUrl: "http://127.0.0.1:59999", // never contacted during registration
49 authenticationTemplate: [
50 {
51 slug: "apiKey",
52 type: "apiKey",
53 headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
54 },
55 ],
56 },
57 });
58 return slug;
59 });
60
61const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
62
63// ── Session plumbing over the real auth endpoints ───────────────────────────
64// These mirror what the product web app does: cookie-authenticated calls whose
65// responses re-seal the session when the active org changes.
66
67const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
68
69const postJson = (target: Target, path: string, identity: Identity, body: unknown) =>
70 Effect.promise(async () => {
71 const response = await fetch(new URL(path, target.baseUrl), {
72 method: "POST",
73 headers: {
74 "content-type": "application/json",
75 origin: new URL(target.baseUrl).origin,
76 cookie: cookieOf(identity),
77 },
78 body: JSON.stringify(body),
79 });
80 if (!response.ok) {
81 throw new Error(`${path} failed (${response.status}): ${await response.text()}`);
82 }
83 return response;
84 });
85
86/** The identity re-bound to the refreshed session cookie a response set. */
87const withRefreshedSession = (identity: Identity, response: Response): Identity => {
88 const refreshed = (response.headers.getSetCookie?.() ?? [])
89 .find((header) => header.startsWith("wos-session="))
90 ?.split(";")[0];
91 if (!refreshed) throw new Error("response did not refresh the session cookie");
92 return { ...identity, headers: { cookie: refreshed } };
93};
94
95/** Invite `member` into `admin`'s org and accept — the real invite flow.
96 * Returns the member identity with its session re-bound to that org. */
97const joinOrg = (target: Target, admin: Identity, member: Identity) =>
98 Effect.gen(function* () {
99 const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
100 email: member.credentials?.email,
101 });
102 const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };
103 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
104 invitationId: invitation.id,
105 });
106 return withRefreshedSession(member, acceptResponse);
107 });
108
109/** Create another org for this account; returns the identity bound to it. */
110const createAnotherOrg = (target: Target, identity: Identity, name: string) =>
111 Effect.gen(function* () {
112 const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });
113 return withRefreshedSession(identity, response);
114 });
115
116/** Switch this account's active org; returns the identity bound to it. */
117const switchOrg = (target: Target, identity: Identity, organizationId: string) =>
118 Effect.gen(function* () {
119 const response = yield* postJson(target, "/api/auth/switch-organization", identity, {
120 organizationId,
121 });
122 return withRefreshedSession(identity, response);
123 });
124
125/** The org this identity's session is currently bound to. */
126const activeOrganizationId = (target: Target, identity: Identity) =>
127 Effect.promise(async () => {
128 const response = await fetch(new URL("/api/auth/me", target.baseUrl), {
129 headers: { cookie: cookieOf(identity) },
130 });
131 if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
132 const body = (await response.json()) as { organization: { id: string } | null };
133 if (!body.organization) throw new Error("identity has no active organization");
134 return body.organization.id;
135 });
136
137scenario(
138 "Connections · a user-owned connection is private to its creator, even inside the same org",
139 { needs: ["api"] },
140 (ctx) =>
141 Effect.gen(function* () {
142 const admin = yield* ctx.target.newIdentity();
143 const invitee = yield* ctx.target.newIdentity({ org: false });
144 const colleague = yield* joinOrg(ctx.target, admin, invitee);
145
146 const adminClient = yield* ctx.api.client(api, admin);
147 const colleagueClient = yield* ctx.api.client(api, colleague);
148
149 const integration = yield* registerIntegration(adminClient);
150 const name = freshConnectionName();
151 const secretValue = `personal-token-${randomBytes(8).toString("hex")}`;
152
153 // The admin stores a PERSONAL (user-owned) credential.
154 yield* adminClient.connections.create({
155 payload: {
156 owner: "user",
157 name,
158 integration,
159 template: TEMPLATE_API_KEY,
160 value: secretValue,
161 },
162 });
163
164 // A colleague in the SAME org sees neither the connection nor its bytes.
165 const colleagueUserList = yield* colleagueClient.connections.list({
166 query: { integration, owner: "user" },
167 });
168 expect(
169 colleagueUserList.map((connection) => connection.name),
170 "a co-worker's user-owned list has no trace of the admin's personal connection",
171 ).not.toContain(name);
172
173 const colleagueAll = yield* colleagueClient.connections.list({ query: {} });
174 expect(
175 colleagueAll.map((connection) => connection.name),
176 "the personal connection is absent from the co-worker's full list too",
177 ).not.toContain(name);
178 expect(
179 JSON.stringify(colleagueAll),
180 "the personal secret appears nowhere in the co-worker's view",
181 ).not.toContain(secretValue);
182
183 // And the creator still sees their own connection.
184 const adminUserList = yield* adminClient.connections.list({
185 query: { integration, owner: "user" },
186 });
187 expect(
188 adminUserList.map((connection) => connection.name),
189 "the creator still sees their own personal connection",
190 ).toContain(name);
191 }),
192);
193
194scenario(
195 "Connections · an org-owned connection is shared with every member of the org",
196 { needs: ["api"] },
197 (ctx) =>
198 Effect.gen(function* () {
199 const admin = yield* ctx.target.newIdentity();
200 const invitee = yield* ctx.target.newIdentity({ org: false });
201 const member = yield* joinOrg(ctx.target, admin, invitee);
202
203 const adminClient = yield* ctx.api.client(api, admin);
204 const memberClient = yield* ctx.api.client(api, member);
205
206 const integration = yield* registerIntegration(adminClient);
207 const name = freshConnectionName();
208
209 // The admin stores a SHARED (org-owned) credential.
210 yield* adminClient.connections.create({
211 payload: {
212 owner: "org",
213 name,
214 integration,
215 template: TEMPLATE_API_KEY,
216 value: "shared-org-key",
217 },
218 });
219
220 const adminOrgList = yield* adminClient.connections.list({
221 query: { integration, owner: "org" },
222 });
223 const memberOrgList = yield* memberClient.connections.list({
224 query: { integration, owner: "org" },
225 });
226 expect(
227 adminOrgList.map((connection) => connection.name),
228 "the admin sees the shared org connection",
229 ).toContain(name);
230 expect(
231 memberOrgList.map((connection) => connection.name),
232 "an invited member sees the shared org connection too",
233 ).toContain(name);
234 }),
235);
236
237scenario(
238 "Connections · the same account in two orgs gets two separate credential spaces",
239 { needs: ["api"] },
240 (ctx) =>
241 Effect.gen(function* () {
242 const userInOrgA = yield* ctx.target.newIdentity();
243 const orgAId = yield* activeOrganizationId(ctx.target, userInOrgA);
244 const clientA = yield* ctx.api.client(api, userInOrgA);
245
246 const integration = yield* registerIntegration(clientA);
247 const name = freshConnectionName();
248 yield* clientA.connections.create({
249 payload: {
250 owner: "user",
251 name,
252 integration,
253 template: TEMPLATE_API_KEY,
254 value: "value-in-org-a",
255 },
256 });
257
258 // The SAME account creates and switches into a second org.
259 const userInOrgB = yield* createAnotherOrg(
260 ctx.target,
261 userInOrgA,
262 `Second Org ${randomBytes(3).toString("hex")}`,
263 );
264 const clientB = yield* ctx.api.client(api, userInOrgB);
265
266 const orgBIntegrations = yield* clientB.integrations.list();
267 expect(
268 orgBIntegrations.map((entry) => entry.slug),
269 "the new org does not see org A's integration",
270 ).not.toContain(integration);
271
272 const orgBConnections = yield* clientB.connections.list({ query: {} });
273 expect(
274 orgBConnections.map((connection) => connection.name),
275 "the user's org-A connection is invisible from their second org",
276 ).not.toContain(name);
277
278 // Switching back to org A, the connection is still theirs.
279 const backInOrgA = yield* switchOrg(ctx.target, userInOrgB, orgAId);
280 const clientABack = yield* ctx.api.client(api, backInOrgA);
281 const orgAUserList = yield* clientABack.connections.list({
282 query: { integration, owner: "user" },
283 });
284 expect(
285 orgAUserList.map((connection) => connection.name),
286 "back in org A, the user-owned connection is still there",
287 ).toContain(name);
288 }),
289);
e2e/cloud/connections-credentials.test.ts+207−0
@@ -0,0 +1,207 @@
1// Cloud-only: the connection (credential) lifecycle over the real wire. In v2 a
2// connection IS the credential — owner-scoped, bound 1:1 to an integration,
3// identified by (owner, integration, name), with its value stored through the
4// real vault. The product promises under test: the secret goes in but NEVER
5// comes back out of any endpoint; metadata round-trips; re-creating the same
6// connection replaces it instead of duplicating; removal really removes; and
7// unknown connections fail with a typed not-found error.
8import { randomBytes } from "node:crypto";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12import type { HttpApiClient } from "effect/unstable/httpapi";
13import { composePluginApi } from "@executor-js/api/server";
14import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
15import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16
17import { scenario } from "../src/scenario";
18
19const api = composePluginApi([openApiHttpPlugin()] as const);
20type Client = HttpApiClient.ForApi<typeof api>;
21
22const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
23
24/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
25const pingSpec = JSON.stringify({
26 openapi: "3.0.3",
27 info: { title: "Ping API", version: "1.0.0" },
28 paths: {
29 "/ping": {
30 get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
31 },
32 },
33});
34
35/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
36const registerIntegration = (client: Client) =>
37 Effect.gen(function* () {
38 const slug = IntegrationSlug.make(`conn-scn-${randomBytes(4).toString("hex")}`);
39 yield* client.openapi.addSpec({
40 payload: {
41 spec: { kind: "blob", value: pingSpec },
42 slug,
43 baseUrl: "http://127.0.0.1:59999", // never contacted during registration
44 authenticationTemplate: [
45 {
46 slug: "apiKey",
47 type: "apiKey",
48 headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
49 },
50 ],
51 },
52 });
53 return slug;
54 });
55
56// Already in canonical identifier form, so the name round-trips unchanged.
57const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
58
59scenario(
60 "Connections · a stored credential round-trips as metadata and never echoes its value",
61 { needs: ["api"] },
62 (ctx) =>
63 Effect.gen(function* () {
64 const identity = yield* ctx.target.newIdentity();
65 const client = yield* ctx.api.client(api, identity);
66 const integration = yield* registerIntegration(client);
67 const name = freshConnectionName();
68 const secretValue = `sk-test-${randomBytes(8).toString("hex")}`;
69
70 const created = yield* client.connections.create({
71 payload: {
72 owner: "org",
73 name,
74 integration,
75 template: TEMPLATE_API_KEY,
76 identityLabel: "My API Token",
77 value: secretValue,
78 },
79 });
80 expect(created.name, "create returns the stored connection name").toBe(name);
81 expect(created.owner, "the connection is filed under its owner").toBe("org");
82 expect(JSON.stringify(created), "the create response never carries the secret").not.toContain(
83 secretValue,
84 );
85
86 const list = yield* client.connections.list({ query: { integration } });
87 const listed = list.find((connection) => connection.name === name);
88 expect(listed?.identityLabel, "the listed connection keeps its label").toBe("My API Token");
89 expect(JSON.stringify(list), "the list never carries the secret").not.toContain(secretValue);
90
91 const fetched = yield* client.connections.get({
92 params: { owner: "org", integration, name },
93 });
94 expect(fetched.name, "get returns the connection by its identifier").toBe(name);
95 expect(fetched.integration, "get returns the connection bound to its integration").toBe(
96 integration,
97 );
98 expect(JSON.stringify(fetched), "get never carries the secret").not.toContain(secretValue);
99 }),
100);
101
102scenario(
103 "Connections · re-creating the same connection replaces it instead of duplicating",
104 { needs: ["api"] },
105 (ctx) =>
106 Effect.gen(function* () {
107 const identity = yield* ctx.target.newIdentity();
108 const client = yield* ctx.api.client(api, identity);
109 const integration = yield* registerIntegration(client);
110 const name = freshConnectionName();
111
112 yield* client.connections.create({
113 payload: {
114 owner: "org",
115 name,
116 integration,
117 template: TEMPLATE_API_KEY,
118 identityLabel: "first key",
119 value: "first-value",
120 },
121 });
122 const first = yield* client.connections.list({ query: { integration } });
123 expect(
124 first.filter((connection) => connection.name === name).map((c) => c.identityLabel),
125 "the first create stores one row with its label",
126 ).toEqual(["first key"]);
127
128 yield* client.connections.create({
129 payload: {
130 owner: "org",
131 name,
132 integration,
133 template: TEMPLATE_API_KEY,
134 identityLabel: "rotated key",
135 value: "second-value",
136 },
137 });
138 const second = yield* client.connections.list({ query: { integration } });
139 expect(
140 second.filter((connection) => connection.name === name).map((c) => c.identityLabel),
141 "re-creating the same (owner, integration, name) updates the row in place",
142 ).toEqual(["rotated key"]);
143 }),
144);
145
146scenario(
147 "Connections · a removed connection disappears from both list and get",
148 { needs: ["api"] },
149 (ctx) =>
150 Effect.gen(function* () {
151 const identity = yield* ctx.target.newIdentity();
152 const client = yield* ctx.api.client(api, identity);
153 const integration = yield* registerIntegration(client);
154 const name = freshConnectionName();
155
156 yield* client.connections.create({
157 payload: { owner: "org", name, integration, template: TEMPLATE_API_KEY, value: "v" },
158 });
159
160 const removed = yield* client.connections.remove({
161 params: { owner: "org", integration, name },
162 });
163 expect(removed.removed, "remove acknowledges the deletion").toBe(true);
164
165 const list = yield* client.connections.list({ query: { integration } });
166 expect(
167 list.map((connection) => connection.name),
168 "the removed connection is gone from the list",
169 ).not.toContain(name);
170
171 const error = yield* client.connections
172 .get({ params: { owner: "org", integration, name } })
173 .pipe(Effect.flip);
174 expect(
175 (error as { _tag?: string })._tag,
176 "get after remove fails with the typed not-found error",
177 ).toBe("ConnectionNotFoundError");
178 }),
179);
180
181scenario(
182 "Connections · reading or removing an unknown connection fails with not-found",
183 { needs: ["api"] },
184 (ctx) =>
185 Effect.gen(function* () {
186 const identity = yield* ctx.target.newIdentity();
187 const client = yield* ctx.api.client(api, identity);
188 const integration = yield* registerIntegration(client);
189 const missing = freshConnectionName();
190
191 const getError = yield* client.connections
192 .get({ params: { owner: "org", integration, name: missing } })
193 .pipe(Effect.flip);
194 expect(
195 (getError as { _tag?: string })._tag,
196 "get on an unknown connection fails with the typed not-found error",
197 ).toBe("ConnectionNotFoundError");
198
199 const removeError = yield* client.connections
200 .remove({ params: { owner: "org", integration, name: missing } })
201 .pipe(Effect.flip);
202 expect(
203 (removeError as { _tag?: string })._tag,
204 "remove on an unknown connection fails with the typed not-found error",
205 ).toBe("ConnectionNotFoundError");
206 }),
207);
e2e/cloud/mcp-client-sessions.test.ts+273−0
@@ -0,0 +1,273 @@
1// Cloud: MCP sessions driven by the REAL @modelcontextprotocol/sdk Client over
2// StreamableHTTP — exactly the code path Claude/Cursor run. The dev server is
3// the production wrangler topology (real workerd, real McpSessionDO), so
4// session continuity here is real Durable Object state surviving across
5// client connections, not a stub.
6//
7// Ported from apps/cloud/src/mcp-miniflare.e2e.node.test.ts (unstable_dev +
8// test-seam bearers) onto the e2e dev server with real OAuth bearers.
9// Telemetry-span assertions from that file required injecting an OTLP
10// receiver into the worker env and were NOT carried (not black-box).
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { composePluginApi } from "@executor-js/api/server";
15import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17
18import { scenario } from "../src/scenario";
19import type { Identity } from "../src/target";
20
21const coreApi = composePluginApi([] as const);
22
23const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
24
25interface Connected {
26 readonly client: Client;
27 readonly transport: StreamableHTTPClientTransport;
28}
29
30/** A real SDK client over StreamableHTTP; `sessionId` resumes an existing session. */
31const connectClient = async (
32 mcpUrl: string,
33 bearer: string,
34 sessionId?: string,
35): Promise<Connected> => {
36 const client = new Client(
37 { name: "executor-e2e-sessions", version: "0.0.1" },
38 { capabilities: {} },
39 );
40 const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
41 requestInit: { headers: { authorization: `Bearer ${bearer}` } },
42 ...(sessionId === undefined ? {} : { sessionId }),
43 });
44 await client.connect(transport);
45 return { client, transport };
46};
47
48const textOf = (result: { content?: unknown }): string =>
49 ((result.content ?? []) as Array<{ type: string; text?: string }>)
50 .filter((part) => part.type === "text")
51 .map((part) => part.text)
52 .join("\n");
53
54const closeQuietly = (connected: Connected): Effect.Effect<void> =>
55 Effect.promise(() => connected.client.close().catch(() => undefined));
56
57scenario(
58 "MCP sessions · a real MCP client connects, lists tools, and executes code",
59 { needs: ["mcp-oauth"] },
60 (ctx) =>
61 Effect.gen(function* () {
62 const identity = yield* ctx.target.newIdentity();
63 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
64 const session = yield* Effect.promise(() => connectClient(ctx.target.mcpUrl, bearer));
65 yield* Effect.gen(function* () {
66 expect(
67 session.client.getServerVersion()?.name,
68 "the handshake reports the product server",
69 ).toBe("executor");
70 expect(session.transport.sessionId, "the transport holds a session id").toEqual(
71 expect.any(String),
72 );
73
74 const { tools } = yield* Effect.promise(() => session.client.listTools());
75 expect(
76 tools.map((tool) => tool.name),
77 "the execute tool is advertised",
78 ).toContain("execute");
79 expect(
80 tools.map((tool) => tool.name),
81 "the resume tool is advertised",
82 ).toContain("resume");
83
84 const result = yield* Effect.promise(() =>
85 session.client.callTool({ name: "execute", arguments: { code: "return 6 * 7;" } }),
86 );
87 expect(result.isError, "the call succeeds").not.toBe(true);
88 expect(textOf(result), "the sandbox returns the value").toContain("42");
89 }).pipe(Effect.ensuring(closeQuietly(session)));
90 }),
91);
92
93scenario(
94 "MCP sessions · a second client resuming the session id continues the session",
95 { needs: ["mcp-oauth"] },
96 (ctx) =>
97 Effect.gen(function* () {
98 const identity = yield* ctx.target.newIdentity();
99 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
100
101 // First connection establishes the session, does real work, then goes
102 // away — the laptop-closed / process-restarted case.
103 const first = yield* Effect.promise(() => connectClient(ctx.target.mcpUrl, bearer));
104 const sessionId = first.transport.sessionId;
105 expect(sessionId, "the first client got a session id").toEqual(expect.any(String));
106 const before = yield* Effect.promise(() =>
107 first.client.callTool({ name: "execute", arguments: { code: 'return "before";' } }),
108 ).pipe(Effect.ensuring(closeQuietly(first)));
109 expect(textOf(before), "the first client's call succeeded").toContain("before");
110
111 // A brand-new client resumes with nothing but the session id. The
112 // session's Durable Object state persists across connections — this is
113 // the restore guarantee.
114 const second = yield* Effect.promise(() =>
115 connectClient(ctx.target.mcpUrl, bearer, sessionId),
116 );
117 yield* Effect.gen(function* () {
118 expect(second.transport.sessionId, "the session id is preserved, not reissued").toBe(
119 sessionId,
120 );
121 const { tools } = yield* Effect.promise(() => second.client.listTools());
122 expect(
123 tools.map((tool) => tool.name),
124 "the resumed session serves requests",
125 ).toContain("execute");
126 const after = yield* Effect.promise(() =>
127 second.client.callTool({ name: "execute", arguments: { code: 'return "after";' } }),
128 );
129 expect(after.isError, "the resumed session executes code").not.toBe(true);
130 expect(textOf(after), "the resumed session returns results").toContain("after");
131 }).pipe(Effect.ensuring(closeQuietly(second)));
132 }),
133);
134
135scenario(
136 "MCP sessions · an unknown session id fails fast with a clean error, not a hang",
137 { needs: ["mcp-oauth"] },
138 (ctx) =>
139 Effect.gen(function* () {
140 const identity = yield* ctx.target.newIdentity();
141 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
142 // A session id the server never issued (right shape, never minted).
143 const session = yield* Effect.promise(() =>
144 connectClient(ctx.target.mcpUrl, bearer, "0".repeat(64)),
145 );
146 const failure = yield* Effect.flip(
147 Effect.tryPromise({
148 try: () => session.client.listTools(),
149 catch: (cause) => cause,
150 }),
151 ).pipe(
152 Effect.timeoutOrElse({
153 duration: "15 seconds",
154 orElse: () =>
155 Effect.die(new Error("listTools on an unknown session hung instead of failing")),
156 }),
157 Effect.ensuring(closeQuietly(session)),
158 );
159 expect(String(failure), "the server answered with a JSON-RPC error envelope").toContain(
160 "jsonrpc",
161 );
162 }),
163);
164
165scenario(
166 "MCP sessions · two concurrent clients hold isolated sessions that don't interfere",
167 { needs: ["mcp-oauth"] },
168 (ctx) =>
169 Effect.gen(function* () {
170 const identity = yield* ctx.target.newIdentity();
171 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
172 const [alpha, beta] = yield* Effect.promise(() =>
173 Promise.all([
174 connectClient(ctx.target.mcpUrl, bearer),
175 connectClient(ctx.target.mcpUrl, bearer),
176 ]),
177 );
178 yield* Effect.gen(function* () {
179 expect(alpha.transport.sessionId, "each client gets its own session").not.toBe(
180 beta.transport.sessionId,
181 );
182
183 const [alphaResult, betaResult] = yield* Effect.promise(() =>
184 Promise.all([
185 alpha.client.callTool({
186 name: "execute",
187 arguments: {
188 code: 'await new Promise((resolve) => setTimeout(resolve, 300));\nreturn "alpha-result";',
189 },
190 }),
191 beta.client.callTool({
192 name: "execute",
193 arguments: { code: 'return "beta-result";' },
194 }),
195 ]),
196 );
197 expect(textOf(alphaResult), "the first session got its own answer").toContain(
198 "alpha-result",
199 );
200 expect(textOf(alphaResult), "no cross-talk into the first session").not.toContain(
201 "beta-result",
202 );
203 expect(textOf(betaResult), "the second session got its own answer").toContain(
204 "beta-result",
205 );
206 expect(textOf(betaResult), "no cross-talk into the second session").not.toContain(
207 "alpha-result",
208 );
209 }).pipe(Effect.ensuring(closeQuietly(alpha)), Effect.ensuring(closeQuietly(beta)));
210 }),
211);
212
213const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
214
215const GATED_CODE = `
216const result = await tools.executor.coreTools.policies.list({});
217return JSON.stringify(result);
218`;
219
220scenario(
221 "MCP sessions · a paused approval survives the client reconnecting and resumes",
222 { needs: ["api", "mcp-oauth"] },
223 (ctx) =>
224 Effect.gen(function* () {
225 const identity = yield* ctx.target.newIdentity();
226 const api = yield* ctx.api.client(coreApi, identity);
227 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
228
229 // Gate a built-in tool behind human approval; the org is fresh, so the
230 // gate affects no other scenario, but remove it anyway on every exit.
231 const policy = yield* api.policies.create({
232 payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" },
233 });
234
235 yield* Effect.gen(function* () {
236 const first = yield* Effect.promise(() => connectClient(ctx.target.mcpUrl, bearer));
237 const sessionId = first.transport.sessionId;
238 const paused = yield* Effect.promise(() =>
239 first.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
240 ).pipe(Effect.ensuring(closeQuietly(first)));
241 const pausedText = textOf(paused);
242 expect(pausedText, "the gated call pauses instead of completing").toContain(
243 "Execution paused",
244 );
245 const executionId = /\bexecutionId:\s*(\S+)/.exec(pausedText)?.[1];
246 expect(executionId, "the paused result carries the executionId").toEqual(
247 expect.any(String),
248 );
249
250 // The user answers from a NEW client on the same session — the paused
251 // execution lives in the session, not the connection.
252 const second = yield* Effect.promise(() =>
253 connectClient(ctx.target.mcpUrl, bearer, sessionId),
254 );
255 const resumed = yield* Effect.promise(() =>
256 second.client.callTool({
257 name: "resume",
258 arguments: { executionId: executionId ?? "", action: "accept", content: "{}" },
259 }),
260 ).pipe(Effect.ensuring(closeQuietly(second)));
261 expect(resumed.isError, "the resumed execution completes").not.toBe(true);
262 expect(textOf(resumed), "the gated tool's result comes back after approval").toContain(
263 APPROVAL_TARGET_TOOL,
264 );
265 }).pipe(
266 Effect.ensuring(
267 api.policies
268 .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
269 .pipe(Effect.ignore),
270 ),
271 );
272 }),
273);
e2e/cloud/mcp-protocol.test.ts+561−0
@@ -0,0 +1,561 @@
1// Cloud: the raw /mcp wire protocol — CORS preflight, OAuth discovery
2// metadata, bearer-auth failures, org-scoped routing, and session lifecycle
3// errors — asserted with hand-rolled JSON-RPC over fetch, exactly the bytes an
4// MCP transport sends. Every hop is the production topology: real workerd,
5// real McpSessionDO, and real bearers minted from the authorization server the
6// product itself advertises (discovery → DCR → authorize → token).
7//
8// Ported from apps/cloud/src/mcp-flow.test.ts (workerd-pool SELF.fetch with
9// test-seam bearers). DO-internal coverage from that file (forced runtime
10// eviction, idle-alarm firing, alarm scheduling, storage seeding) is clock /
11// internals dependent and intentionally NOT carried — only black-box
12// guarantees live here.
13import { randomBytes, randomUUID } from "node:crypto";
14
15import { expect } from "@effect/vitest";
16import { Effect } from "effect";
17
18import { scenario } from "../src/scenario";
19import type { Identity } from "../src/target";
20
21const JSON_AND_SSE = "application/json, text/event-stream";
22const PROTOCOL_VERSION = "2025-03-26";
23const INITIALIZE_REQUEST = {
24 jsonrpc: "2.0" as const,
25 id: 1,
26 method: "initialize",
27 params: {
28 protocolVersion: PROTOCOL_VERSION,
29 capabilities: {},
30 clientInfo: { name: "executor-e2e-mcp-protocol", version: "0.0.1" },
31 },
32};
33
34const TOOLS_LIST_REQUEST = {
35 jsonrpc: "2.0" as const,
36 id: 2,
37 method: "tools/list",
38 params: {},
39};
40
41const INITIALIZED_NOTIFICATION = {
42 jsonrpc: "2.0" as const,
43 method: "notifications/initialized",
44};
45
46type JsonRpcError = {
47 readonly jsonrpc: string;
48 readonly error: { readonly code: number; readonly message: string };
49};
50
51const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
52
53/** The org the bearer is scoped to, read from the JWT's public claims. */
54const orgIdOf = (bearer: string): string => {
55 const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as {
56 readonly org_id?: string;
57 };
58 if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim");
59 return claims.org_id;
60};
61
62const mcpPost = (
63 url: string | URL,
64 init: { readonly bearer?: string; readonly sessionId?: string; readonly body: unknown },
65): Promise<Response> =>
66 fetch(url, {
67 method: "POST",
68 headers: {
69 accept: JSON_AND_SSE,
70 "content-type": "application/json",
71 ...(init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}),
72 ...(init.sessionId ? { "mcp-session-id": init.sessionId } : {}),
73 },
74 body: JSON.stringify(init.body),
75 });
76
77/** initialize → session id (asserted present) → notifications/initialized. */
78const openSession = async (mcpUrl: string, bearer: string): Promise<string> => {
79 const initialize = await mcpPost(mcpUrl, { bearer, body: INITIALIZE_REQUEST });
80 const sessionId = initialize.headers.get("mcp-session-id");
81 await initialize.text();
82 if (initialize.status !== 200 || !sessionId) {
83 throw new Error(`openSession: initialize failed (${initialize.status})`);
84 }
85 const initialized = await mcpPost(mcpUrl, {
86 bearer,
87 sessionId,
88 body: INITIALIZED_NOTIFICATION,
89 });
90 await initialized.text();
91 if (initialized.status !== 202) {
92 throw new Error(`openSession: notifications/initialized failed (${initialized.status})`);
93 }
94 return sessionId;
95};
96
97scenario(
98 "MCP protocol · /mcp answers a CORS preflight allowing the headers an MCP client sends",
99 {},
100 (ctx) =>
101 Effect.gen(function* () {
102 const response = yield* Effect.promise(() =>
103 fetch(new URL("/mcp", ctx.target.baseUrl), {
104 method: "OPTIONS",
105 headers: {
106 origin: "https://claude.ai",
107 "access-control-request-method": "POST",
108 "access-control-request-headers": "authorization, content-type, mcp-session-id",
109 },
110 }),
111 );
112 expect(response.status, "the preflight succeeds").toBe(204);
113 const allowedMethods = (response.headers.get("access-control-allow-methods") ?? "")
114 .split(",")
115 .map((method) => method.trim());
116 expect(allowedMethods, "POST (requests) is allowed").toContain("POST");
117 expect(allowedMethods, "GET (standalone SSE) is allowed").toContain("GET");
118 expect(allowedMethods, "DELETE (session termination) is allowed").toContain("DELETE");
119 const allowedHeaders = response.headers.get("access-control-allow-headers") ?? "";
120 expect(allowedHeaders, "the bearer header is allowed").toContain("authorization");
121 expect(allowedHeaders, "the JSON body header is allowed").toContain("content-type");
122 expect(allowedHeaders, "the session header is allowed").toContain("mcp-session-id");
123 }),
124);
125
126scenario(
127 "MCP protocol · protected-resource metadata advertises a discoverable authorization server",
128 {},
129 (ctx) =>
130 Effect.gen(function* () {
131 const response = yield* Effect.promise(() =>
132 fetch(new URL("/.well-known/oauth-protected-resource/mcp", ctx.target.baseUrl), {
133 headers: { origin: "https://claude.ai" },
134 }),
135 );
136 expect(response.status, "the discovery document is public").toBe(200);
137 expect(
138 response.headers.get("access-control-allow-origin"),
139 "discovery is readable cross-origin",
140 ).toBe("*");
141
142 const body = (yield* Effect.promise(() => response.json())) as {
143 readonly resource: string;
144 readonly authorization_servers: ReadonlyArray<string>;
145 readonly bearer_methods_supported: ReadonlyArray<string>;
146 readonly scopes_supported: ReadonlyArray<string>;
147 };
148 expect(body, "the metadata names the MCP resource and its auth server").toEqual({
149 resource: new URL("/mcp", ctx.target.baseUrl).toString(),
150 authorization_servers: [expect.any(String)],
151 bearer_methods_supported: ["header"],
152 scopes_supported: [],
153 });
154
155 // The advertised server must itself be discoverable — that is what lets
156 // an MCP client complete OAuth from nothing but the resource URL.
157 const issuer = body.authorization_servers[0] ?? "";
158 const authServer = (yield* Effect.promise(async () =>
159 (await fetch(new URL("/.well-known/oauth-authorization-server", issuer))).json(),
160 )) as Record<string, unknown>;
161 expect(
162 authServer["authorization_endpoint"],
163 "the auth server publishes its authorize endpoint",
164 ).toEqual(expect.any(String));
165 expect(authServer["token_endpoint"], "the auth server publishes its token endpoint").toEqual(
166 expect.any(String),
167 );
168 expect(
169 authServer["registration_endpoint"],
170 "dynamic client registration is available",
171 ).toEqual(expect.any(String));
172 }),
173);
174
175scenario(
176 "MCP protocol · org-scoped discovery metadata points at the org-scoped resource",
177 {},
178 (ctx) =>
179 Effect.gen(function* () {
180 const orgId = `org_e2e_${randomBytes(4).toString("hex")}`;
181 const [bare, scoped] = yield* Effect.promise(() =>
182 Promise.all([
183 fetch(new URL("/.well-known/oauth-protected-resource/mcp", ctx.target.baseUrl)).then(
184 (r) => r.json() as Promise<{ authorization_servers: ReadonlyArray<string> }>,
185 ),
186 fetch(new URL(`/.well-known/oauth-protected-resource/${orgId}/mcp`, ctx.target.baseUrl)),
187 ]),
188 );
189 expect(scoped.status, "the org-scoped discovery path is served").toBe(200);
190 const body = (yield* Effect.promise(() => scoped.json())) as Record<string, unknown>;
191 expect(body["resource"], "the resource is the org-scoped MCP URL").toBe(
192 new URL(`/${orgId}/mcp`, ctx.target.baseUrl).toString(),
193 );
194 expect(
195 body["authorization_servers"],
196 "the org-scoped variant points at the same auth server",
197 ).toEqual(bare.authorization_servers);
198 }),
199);
200
201scenario(
202 "MCP protocol · a request without a bearer is challenged with the resource metadata",
203 {},
204 (ctx) =>
205 Effect.gen(function* () {
206 const response = yield* Effect.promise(() =>
207 mcpPost(ctx.target.mcpUrl, { body: INITIALIZE_REQUEST }),
208 );
209 expect(response.status, "anonymous requests are rejected").toBe(401);
210 const wwwAuth = response.headers.get("www-authenticate") ?? "";
211 expect(wwwAuth, "the challenge is a Bearer challenge").toContain(
212 'Bearer resource_metadata="',
213 );
214 expect(wwwAuth, "the challenge points at the discovery document").toContain(
215 new URL("/.well-known/oauth-protected-resource/mcp", ctx.target.baseUrl).toString(),
216 );
217 expect(wwwAuth, "a missing token carries no error code (per RFC 6750)").not.toContain(
218 "error=",
219 );
220 expect(
221 response.headers.get("access-control-expose-headers") ?? "",
222 "browsers can read the challenge",
223 ).toContain("WWW-Authenticate");
224 expect(yield* Effect.promise(() => response.json()), "the body is a JSON-RPC error").toEqual({
225 jsonrpc: "2.0",
226 error: { code: -32001, message: "Unauthorized" },
227 id: null,
228 });
229 }),
230);
231
232scenario("MCP protocol · a garbage bearer is rejected as an invalid token", {}, (ctx) =>
233 Effect.gen(function* () {
234 const response = yield* Effect.promise(() =>
235 mcpPost(ctx.target.mcpUrl, { bearer: "bogus.bogus.bogus", body: INITIALIZE_REQUEST }),
236 );
237 expect(response.status, "an unverifiable token is rejected").toBe(401);
238 const wwwAuth = response.headers.get("www-authenticate") ?? "";
239 expect(wwwAuth, "the challenge names the invalid_token error").toContain(
240 'error="invalid_token"',
241 );
242 expect(wwwAuth, "the challenge still points at the discovery document").toContain(
243 new URL("/.well-known/oauth-protected-resource/mcp", ctx.target.baseUrl).toString(),
244 );
245 expect(yield* Effect.promise(() => response.json()), "the body is a JSON-RPC error").toEqual({
246 jsonrpc: "2.0",
247 error: { code: -32001, message: "Unauthorized" },
248 id: null,
249 });
250 }),
251);
252
253scenario(
254 "MCP protocol · a valid bearer whose user has no organization cannot open a session",
255 { needs: ["mcp-oauth"] },
256 (ctx) =>
257 Effect.gen(function* () {
258 // A brand-new user straight through OAuth — never visited the web app,
259 // never created or joined an org.
260 const bearer = yield* ctx.mcp.mintBearer(`no-org-${randomUUID().slice(0, 8)}@e2e.test`);
261 const response = yield* Effect.promise(() =>
262 mcpPost(ctx.target.mcpUrl, { bearer, body: INITIALIZE_REQUEST }),
263 );
264 expect(response.status, "the org gate fires before any session is created").toBe(403);
265 const body = (yield* Effect.promise(() => response.json())) as JsonRpcError;
266 expect(body.error.code, "the error is the MCP auth error code").toBe(-32001);
267 expect(body.error.message, "the error tells the user what is missing").toMatch(
268 /no organization/i,
269 );
270 }),
271);
272
273scenario(
274 "MCP protocol · the URL can pin the active org, but membership is enforced",
275 { needs: ["mcp-oauth"] },
276 (ctx) =>
277 Effect.gen(function* () {
278 const identity = yield* ctx.target.newIdentity();
279 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
280 const ownOrg = orgIdOf(bearer);
281
282 const member = yield* Effect.promise(() =>
283 mcpPost(new URL(`/${ownOrg}/mcp`, ctx.target.baseUrl), {
284 bearer,
285 body: INITIALIZE_REQUEST,
286 }),
287 );
288 expect(member.status, "the member's own org-scoped URL opens a session").toBe(200);
289 expect(
290 member.headers.get("mcp-session-id"),
291 "the org-scoped session is a real session",
292 ).toBeTruthy();
293 yield* Effect.promise(() => member.text());
294
295 const foreignOrg = `org_e2e_${randomBytes(4).toString("hex")}`;
296 const foreign = yield* Effect.promise(() =>
297 mcpPost(new URL(`/${foreignOrg}/mcp`, ctx.target.baseUrl), {
298 bearer,
299 body: INITIALIZE_REQUEST,
300 }),
301 );
302 expect(foreign.status, "an org the user is no member of is rejected").toBe(403);
303 const body = (yield* Effect.promise(() => foreign.json())) as JsonRpcError;
304 expect(body.error.code, "the rejection is the MCP auth error code").toBe(-32001);
305 expect(body.error.message, "the URL is a selector, not a trust boundary").toMatch(
306 /no organization/i,
307 );
308 }),
309);
310
311scenario(
312 "MCP protocol · a non-org path segment is not claimed by the MCP route",
313 { needs: ["mcp-oauth"] },
314 (ctx) =>
315 Effect.gen(function* () {
316 const identity = yield* ctx.target.newIdentity();
317 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
318 // `/settings/mcp` must fall through to app routing rather than being
319 // swallowed by the MCP filter — only `org_…`-shaped segments are claimed.
320 const response = yield* Effect.promise(() =>
321 mcpPost(new URL("/settings/mcp", ctx.target.baseUrl), {
322 bearer,
323 body: INITIALIZE_REQUEST,
324 }),
325 );
326 expect(
327 response.headers.get("mcp-session-id"),
328 "no MCP session is opened on an app route",
329 ).toBeNull();
330 const body = (yield* Effect.promise(() => response.text())) as string;
331 expect(body, "the response is not a JSON-RPC envelope").not.toContain('"jsonrpc"');
332 }),
333);
334
335scenario(
336 "MCP protocol · verbs outside the MCP transport are rejected with a JSON-RPC 405",
337 { needs: ["mcp-oauth"] },
338 (ctx) =>
339 Effect.gen(function* () {
340 const identity = yield* ctx.target.newIdentity();
341 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
342 const statuses: Array<{ method: string; status: number; body: JsonRpcError }> = [];
343 for (const method of ["PUT", "PATCH"]) {
344 const response = yield* Effect.promise(() =>
345 fetch(ctx.target.mcpUrl, {
346 method,
347 headers: {
348 accept: JSON_AND_SSE,
349 "content-type": "application/json",
350 authorization: `Bearer ${bearer}`,
351 },
352 body: JSON.stringify(TOOLS_LIST_REQUEST),
353 }),
354 );
355 statuses.push({
356 method,
357 status: response.status,
358 body: (yield* Effect.promise(() => response.json())) as JsonRpcError,
359 });
360 }
361 expect(
362 statuses.map(({ method, status }) => `${method}:${status}`),
363 "PUT and PATCH never reach the session engine",
364 ).toEqual(["PUT:405", "PATCH:405"]);
365 for (const { body } of statuses) {
366 expect(body.error.code, "the rejection is a JSON-RPC error").toBe(-32001);
367 expect(body.error.message, "the rejection names the problem").toMatch(
368 /method not allowed/i,
369 );
370 }
371 }),
372);
373
374scenario(
375 "MCP protocol · initialize opens a session and notifications are accepted with 202",
376 { needs: ["mcp-oauth"] },
377 (ctx) =>
378 Effect.gen(function* () {
379 const identity = yield* ctx.target.newIdentity();
380 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
381
382 const initialize = yield* Effect.promise(() =>
383 mcpPost(ctx.target.mcpUrl, { bearer, body: INITIALIZE_REQUEST }),
384 );
385 expect(initialize.status, "initialize succeeds").toBe(200);
386 const sessionId = initialize.headers.get("mcp-session-id");
387 expect(sessionId, "the server assigns a session id").toBeTruthy();
388 expect(
389 initialize.headers.get("access-control-expose-headers") ?? "",
390 "browser clients can read the session id",
391 ).toContain("mcp-session-id");
392 yield* Effect.promise(() => initialize.text());
393
394 const notification = yield* Effect.promise(() =>
395 mcpPost(ctx.target.mcpUrl, {
396 bearer,
397 sessionId: sessionId ?? "",
398 body: INITIALIZED_NOTIFICATION,
399 }),
400 );
401 expect(notification.status, "notifications are accepted, not answered").toBe(202);
402 expect(yield* Effect.promise(() => notification.text()), "the body is empty").toBe("");
403 }),
404);
405
406scenario(
407 "MCP protocol · a terminated session's id is rejected with a reconnect error",
408 { needs: ["mcp-oauth"] },
409 (ctx) =>
410 Effect.gen(function* () {
411 const identity = yield* ctx.target.newIdentity();
412 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
413 const sessionId = yield* Effect.promise(() => openSession(ctx.target.mcpUrl, bearer));
414
415 const terminate = yield* Effect.promise(() =>
416 fetch(ctx.target.mcpUrl, {
417 method: "DELETE",
418 headers: { authorization: `Bearer ${bearer}`, "mcp-session-id": sessionId },
419 }),
420 );
421 expect(terminate.status, "the client can terminate its session").toBe(200);
422 yield* Effect.promise(() => terminate.text());
423
424 const reuse = yield* Effect.promise(() =>
425 mcpPost(ctx.target.mcpUrl, { bearer, sessionId, body: TOOLS_LIST_REQUEST }),
426 );
427 expect(reuse.status, "the dead session id no longer works").toBe(404);
428 const body = (yield* Effect.promise(() => reuse.json())) as JsonRpcError;
429 expect(body.error.code, "the rejection is a JSON-RPC error").toBe(-32001);
430 expect(body.error.message, "the client is told to reconnect").toMatch(/timed out|reconnect/i);
431 }),
432);
433
434scenario(
435 "MCP protocol · a session cannot be ridden by a different account's bearer",
436 { needs: ["mcp-oauth"] },
437 (ctx) =>
438 Effect.gen(function* () {
439 const victim = yield* ctx.target.newIdentity();
440 const attacker = yield* ctx.target.newIdentity();
441 const victimBearer = yield* ctx.mcp.mintBearer(emailOf(victim));
442 const attackerBearer = yield* ctx.mcp.mintBearer(emailOf(attacker));
443 const sessionId = yield* Effect.promise(() => openSession(ctx.target.mcpUrl, victimBearer));
444
445 // The attacker authenticates fine — but with a leaked session id of a
446 // session that is not theirs.
447 const hijack = yield* Effect.promise(() =>
448 mcpPost(ctx.target.mcpUrl, {
449 bearer: attackerBearer,
450 sessionId,
451 body: TOOLS_LIST_REQUEST,
452 }),
453 );
454 expect(hijack.status, "the leaked session id is useless to another account").toBe(403);
455 const body = (yield* Effect.promise(() => hijack.json())) as JsonRpcError;
456 expect(body.error.code, "the rejection is the session-ownership error").toBe(-32003);
457 expect(body.error.message, "the error names the ownership violation").toMatch(
458 /does not belong/i,
459 );
460
461 // The owner is unaffected.
462 const owner = yield* Effect.promise(() =>
463 mcpPost(ctx.target.mcpUrl, { bearer: victimBearer, sessionId, body: TOOLS_LIST_REQUEST }),
464 );
465 expect(owner.status, "the rightful owner keeps using the session").toBe(200);
466 yield* Effect.promise(() => owner.text());
467 }),
468);
469
470scenario(
471 "MCP protocol · a dropped standalone SSE stream can be reopened",
472 { needs: ["mcp-oauth"] },
473 (ctx) =>
474 Effect.gen(function* () {
475 const identity = yield* ctx.target.newIdentity();
476 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
477 const sessionId = yield* Effect.promise(() => openSession(ctx.target.mcpUrl, bearer));
478
479 const openSse = () =>
480 fetch(ctx.target.mcpUrl, {
481 method: "GET",
482 headers: {
483 accept: "text/event-stream",
484 authorization: `Bearer ${bearer}`,
485 "mcp-protocol-version": PROTOCOL_VERSION,
486 "mcp-session-id": sessionId,
487 },
488 });
489
490 const first = yield* Effect.promise(openSse);
491 expect(first.status, "the standalone SSE stream opens").toBe(200);
492 expect(first.headers.get("content-type") ?? "", "the stream speaks SSE").toContain(
493 "text/event-stream",
494 );
495
496 // A client that lost its stream (network blip, laptop sleep) reconnects
497 // with the same session id — the server accepts the replacement.
498 const second = yield* Effect.promise(openSse);
499 expect(second.status, "a reconnect on the same session succeeds").toBe(200);
500 expect(second.headers.get("content-type") ?? "", "the reconnect speaks SSE").toContain(
501 "text/event-stream",
502 );
503
504 yield* Effect.promise(() => first.body?.cancel().catch(() => undefined) ?? Promise.resolve());
505 yield* Effect.promise(
506 () => second.body?.cancel().catch(() => undefined) ?? Promise.resolve(),
507 );
508 }),
509);
510
511scenario(
512 "MCP protocol · overlapping tools/call requests with colliding JSON-RPC ids both complete",
513 { needs: ["mcp-oauth"] },
514 (ctx) =>
515 Effect.gen(function* () {
516 const identity = yield* ctx.target.newIdentity();
517 const bearer = yield* ctx.mcp.mintBearer(emailOf(identity));
518 const sessionId = yield* Effect.promise(() => openSession(ctx.target.mcpUrl, bearer));
519
520 // Both calls use id 1 — a sloppy-but-legal client. Each POST must get
521 // its own response back; neither may be dropped or cross-wired.
522 const callExecute = (code: string) =>
523 mcpPost(ctx.target.mcpUrl, {
524 bearer,
525 sessionId,
526 body: {
527 jsonrpc: "2.0",
528 id: 1,
529 method: "tools/call",
530 params: { name: "execute", arguments: { code } },
531 },
532 });
533
534 const responses = yield* Effect.promise(() =>
535 Promise.all([
536 callExecute('await new Promise((resolve) => setTimeout(resolve, 500));\nreturn "first";'),
537 callExecute('return "second";'),
538 ]),
539 );
540 expect(
541 responses.map((response) => response.status),
542 "both overlapping calls return",
543 ).toEqual([200, 200]);
544
545 const bodies = (yield* Effect.promise(() =>
546 Promise.all(responses.map((response) => response.json())),
547 )) as Array<{
548 readonly result?: { readonly content?: ReadonlyArray<{ readonly text?: string }> };
549 readonly error?: unknown;
550 }>;
551 const texts = bodies.map(
552 (body) => body.result?.content?.map((item) => item.text).join("\n") ?? "",
553 );
554 expect(texts.join(" | "), "the slow call's result arrives").toContain("first");
555 expect(texts.join(" | "), "the fast call's result arrives").toContain("second");
556 expect(
557 bodies.map((body) => body.error),
558 "neither call errors",
559 ).toEqual([undefined, undefined]);
560 }),
561);
e2e/cloud/oauth-connections.test.ts+231−0
@@ -0,0 +1,231 @@
1// Cloud: OAuth as a credential mechanism, over the wire. `probe` discovers an
2// authorization server's metadata, `createClient` registers an owner-scoped
3// OAuth app, and the authorization-code flow (`start` → user consent →
4// `complete`) mints a Connection — every hop real: the typed client drives the
5// product API while a real OAuth authorization server runs inside the scenario
6// on 127.0.0.1 (the dev server exchanges the code against it directly).
7//
8// Ported from apps/cloud/src/mcp/mcp-oauth.node.test.ts, extended to cover
9// `complete` (the original stopped at the redirect).
10import { randomBytes } from "node:crypto";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { composePluginApi } from "@executor-js/api/server";
15import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
16import {
17 AuthTemplateSlug,
18 ConnectionName,
19 IntegrationSlug,
20 OAuthClientSlug,
21 OAuthState,
22} from "@executor-js/sdk/shared";
23import { serveOAuthTestServer } from "@executor-js/sdk/testing";
24
25import { scenario } from "../src/scenario";
26
27const api = composePluginApi([openApiHttpPlugin()] as const);
28
29const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
30
31/** Narrow a `start` result to the redirect arm, failing with what came back. */
32const redirected = <R extends { status: string }>(
33 result: R,
34): Extract<R, { status: "redirect" }> => {
35 if (result.status !== "redirect") {
36 throw new Error(`oauth.start did not redirect: ${JSON.stringify(result)}`);
37 }
38 return result as Extract<R, { status: "redirect" }>;
39};
40
41scenario(
42 "OAuth · probe discovers an authorization server's endpoints from its issuer URL",
43 { needs: ["api"] },
44 (ctx) =>
45 Effect.scoped(
46 Effect.gen(function* () {
47 const oauth = yield* serveOAuthTestServer();
48 const identity = yield* ctx.target.newIdentity();
49 const client = yield* ctx.api.client(api, identity);
50
51 const probed = yield* client.oauth.probe({ payload: { url: oauth.issuerUrl } });
52 expect(probed.authorizationUrl, "probe found the authorization endpoint").toBe(
53 oauth.authorizationEndpoint,
54 );
55 expect(probed.tokenUrl, "probe found the token endpoint").toBe(oauth.tokenEndpoint);
56 }),
57 ),
58);
59
60scenario(
61 "OAuth · a registered OAuth app is listed for its owner without leaking the secret",
62 { needs: ["api"] },
63 (ctx) =>
64 Effect.scoped(
65 Effect.gen(function* () {
66 const oauth = yield* serveOAuthTestServer();
67 const identity = yield* ctx.target.newIdentity();
68 const client = yield* ctx.api.client(api, identity);
69 const slug = OAuthClientSlug.make(unique("oauthc"));
70
71 const created = yield* client.oauth.createClient({
72 payload: {
73 owner: "org",
74 slug,
75 authorizationUrl: oauth.authorizationEndpoint,
76 tokenUrl: oauth.tokenEndpoint,
77 grant: "authorization_code",
78 clientId: "test-client",
79 clientSecret: "test-secret",
80 },
81 });
82 expect(created.client, "the app keeps the requested slug").toBe(slug);
83
84 const clients = yield* client.oauth.listClients();
85 const mine = clients.find((entry) => entry.slug === slug);
86 expect(mine, "the registered app appears in the owner's list").toMatchObject({
87 owner: "org",
88 slug,
89 grant: "authorization_code",
90 clientId: "test-client",
91 });
92 expect(
93 JSON.stringify(clients),
94 "the client secret never appears in the list projection",
95 ).not.toContain("test-secret");
96 }),
97 ),
98);
99
100scenario(
101 "OAuth · the authorization-code flow mints a connection (start → consent → complete)",
102 { needs: ["api"] },
103 (ctx) =>
104 Effect.scoped(
105 Effect.gen(function* () {
106 const oauth = yield* serveOAuthTestServer();
107 const identity = yield* ctx.target.newIdentity();
108 const client = yield* ctx.api.client(api, identity);
109
110 // An integration that declares an oauth auth template — the integration
111 // is what the minted connection attaches to.
112 const integration = IntegrationSlug.make(unique("oauthint"));
113 yield* client.openapi.addSpec({
114 payload: {
115 spec: {
116 kind: "blob",
117 value: JSON.stringify({
118 openapi: "3.0.3",
119 info: { title: "OAuth-protected API", version: "1.0.0" },
120 paths: {
121 "/me": {
122 get: {
123 operationId: "getMe",
124 tags: ["default"],
125 responses: { "200": { description: "the caller" } },
126 },
127 },
128 },
129 }),
130 },
131 slug: integration,
132 baseUrl: "http://127.0.0.1:59999",
133 authenticationTemplate: [
134 {
135 slug: "oauth",
136 type: "oauth",
137 authorizationUrl: oauth.authorizationEndpoint,
138 tokenUrl: oauth.tokenEndpoint,
139 scopes: ["read"],
140 },
141 ],
142 },
143 });
144
145 const clientSlug = OAuthClientSlug.make(unique("oauthc"));
146 yield* client.oauth.createClient({
147 payload: {
148 owner: "org",
149 slug: clientSlug,
150 authorizationUrl: oauth.authorizationEndpoint,
151 tokenUrl: oauth.tokenEndpoint,
152 grant: "authorization_code",
153 clientId: "test-client",
154 clientSecret: "test-secret",
155 },
156 });
157
158 // start: the product persists a session and hands back the authorize URL.
159 const started = redirected(
160 yield* client.oauth.start({
161 payload: {
162 client: clientSlug,
163 clientOwner: "org",
164 owner: "org",
165 name: ConnectionName.make("main"),
166 integration,
167 template: AuthTemplateSlug.make("oauth"),
168 },
169 }),
170 );
171 expect(
172 started.authorizationUrl,
173 "the redirect points at the authorization server",
174 ).toContain(oauth.authorizationEndpoint);
175
176 // The user consents on the authorization server (headless here): the
177 // authorize page bounces to the login form, and submitting credentials
178 // redirects back to the product's callback with an authorization code.
179 const authorize = yield* Effect.promise(() =>
180 fetch(started.authorizationUrl, { redirect: "manual" }),
181 );
182 expect(authorize.status, "the authorize endpoint sends the user to log in").toBe(302);
183 const consent = yield* Effect.promise(() =>
184 fetch(authorize.headers.get("location") ?? "", {
185 method: "POST",
186 redirect: "manual",
187 headers: {
188 authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
189 },
190 }),
191 );
192 expect(consent.status, "granting consent redirects back to the product").toBe(302);
193 const callback = new URL(consent.headers.get("location") ?? "");
194 expect(callback.searchParams.get("state"), "the callback carries the session's state").toBe(
195 String(started.state),
196 );
197 const code = callback.searchParams.get("code");
198 expect(code, "the callback carries an authorization code").not.toBeNull();
199
200 // complete: the product exchanges the code and mints the connection.
201 const connection = yield* client.oauth.complete({
202 payload: { state: started.state, code: code ?? "" },
203 });
204 expect(connection, "the minted connection is bound to the integration").toMatchObject({
205 owner: "org",
206 name: "main",
207 integration,
208 template: "oauth",
209 oauthClient: clientSlug,
210 });
211
212 const connections = yield* client.connections.list({ query: { integration } });
213 expect(
214 connections.map((c) => `${c.owner}/${String(c.name)}`),
215 "the connection is listed for the integration",
216 ).toContain("org/main");
217 }),
218 ),
219);
220
221scenario("OAuth · cancelling an unknown session is idempotent", { needs: ["api"] }, (ctx) =>
222 Effect.gen(function* () {
223 const identity = yield* ctx.target.newIdentity();
224 const client = yield* ctx.api.client(api, identity);
225
226 const cancelled = yield* client.oauth.cancel({
227 payload: { state: OAuthState.make("oauth2_session_does_not_exist") },
228 });
229 expect(cancelled.cancelled, "cancel reports success even for an unknown session").toBe(true);
230 }),
231);
e2e/cloud/org-limit.test.ts+19−10
@@ -40,16 +40,25 @@ scenario( · 39 unmodified lines
40 });40 });
41 41
42 const openCreateOrgModal = async (currentOrg: string) => {42 const openCreateOrgModal = async (currentOrg: string) => {
43 await page.getByRole("button", { name: /Test User/ }).click();43 // Under parallel-suite load the radix menu re-renders while the org
44 // The org entry is a radix submenu trigger; its content loads the44 // list loads; a click can land on a closing menu. Bounded retry with
45 // org list from the API, so wait for the sub-content to mount and45 // a clean reopen keeps the journey honest without 30s hangs.
46 // click "Create organization" scoped INSIDE it — clicking during46 for (let attempt = 1; ; attempt++) {
47 // the loading re-render dismisses the whole menu.47 try {
48 await page.getByRole("menuitem", { name: currentOrg }).click();48 await page.keyboard.press("Escape");
49 const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');49 await page.getByRole("button", { name: /Test User/ }).click();
50 await subContent.waitFor({ state: "visible" });50 await page.getByRole("menuitem", { name: currentOrg }).click({ timeout: 5_000 });
51 await subContent.getByText("Create organization", { exact: true }).click();51 const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');
52 await page.getByText("Add another organization").waitFor();52 await subContent.waitFor({ state: "visible", timeout: 5_000 });
53 await subContent
54 .getByText("Create organization", { exact: true })
55 .click({ timeout: 5_000 });
56 await page.getByText("Add another organization").waitFor({ timeout: 5_000 });
57 return;
58 } catch (error) {
59 if (attempt >= 3) throw error;
60 }
61 }
53 };62 };
54 63
55 for (let i = 2; i <= FREE_LIMIT; i++) {64 for (let i = 2; i <= FREE_LIMIT; i++) {
e2e/cloud/org-switcher.test.ts+18−6
@@ -43,12 +43,24 @@ scenario( · 42 unmodified lines
43 43
44 // ── Step 2: create the second org via the account-menu switcher ──44 // ── Step 2: create the second org via the account-menu switcher ──
45 await step('Open the org switcher and choose "Create organization"', async () => {45 await step('Open the org switcher and choose "Create organization"', async () => {
46 await page.getByRole("button", { name: /Test User/ }).click();46 // Bounded retry: under parallel-suite load the radix menu re-renders
47 await page.getByRole("menuitem", { name: ORG_1 }).click();47 // while the org list loads and a click can land on a closing menu.
48 const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');48 for (let attempt = 1; ; attempt++) {
49 await subContent.waitFor({ state: "visible" });49 try {
50 await subContent.getByText("Create organization", { exact: true }).click();50 await page.keyboard.press("Escape");
51 await page.getByText("Add another organization").waitFor();51 await page.getByRole("button", { name: /Test User/ }).click();
52 await page.getByRole("menuitem", { name: ORG_1 }).click({ timeout: 5_000 });
53 const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');
54 await subContent.waitFor({ state: "visible", timeout: 5_000 });
55 await subContent
56 .getByText("Create organization", { exact: true })
57 .click({ timeout: 5_000 });
58 await page.getByText("Add another organization").waitFor({ timeout: 5_000 });
59 break;
60 } catch (error) {
61 if (attempt >= 3) throw error;
62 }
63 }
52 });64 });
53 65
54 await step(`Create "${ORG_2}" via the org switcher modal`, async () => {66 await step(`Create "${ORG_2}" via the org switcher modal`, async () => {
e2e/cloud/sources-api.test.ts+469−0
@@ -0,0 +1,469 @@
1// Cloud: the integration catalog's full CRUD surface plus the "registered
2// integration becomes invokable tools" promise, for all three bring-your-own
3// protocols (OpenAPI / MCP / GraphQL) — entirely over the wire through the
4// typed client. Upstream APIs are real HTTP servers started inside the
5// scenario on 127.0.0.1 (the dev server allows local-network egress), so an
6// execution proves the whole chain: catalog row → connection → stamped tool →
7// QuickJS execution → live upstream request.
8//
9// Ported from apps/cloud/src/api/sources-api.node.test.ts. Cross-user
10// isolation of personal connections (alice/bob in one org) is NOT covered
11// here: minting a second member of an existing org has no public API surface.
12import { randomBytes } from "node:crypto";
13import { readFileSync } from "node:fs";
14import { fileURLToPath } from "node:url";
15
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18import { composePluginApi } from "@executor-js/api/server";
19import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
20import {
21 makeGreetingGraphqlSchema,
22 serveGraphqlTestServer,
23} from "@executor-js/plugin-graphql/testing";
24import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
25import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
26import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
27import { serveOpenApiEchoTestServer } from "@executor-js/plugin-openapi/testing";
28import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
29
30import { scenario } from "../src/scenario";
31
32const api = composePluginApi([openApiHttpPlugin(), mcpHttpPlugin(), graphqlHttpPlugin()] as const);
33
34/** Unique, JS-identifier-safe slug per run (tool addresses are dotted member
35 * expressions inside executed code, so no dashes). */
36const newSlug = (prefix: string) =>
37 IntegrationSlug.make(`${prefix}_${randomBytes(4).toString("hex")}`);
38
39const MAIN = ConnectionName.make("main");
40const PERSONAL = ConnectionName.make("personal");
41const API_KEY = AuthTemplateSlug.make("apiKey");
42const NONE = AuthTemplateSlug.make("none");
43
44/** Minimal OpenAPI 3 spec with a single GET /ping operation. */
45const pingSpec = JSON.stringify({
46 openapi: "3.0.3",
47 info: { title: "Ping API", version: "1.0.0" },
48 paths: {
49 "/ping": {
50 get: {
51 operationId: "ping",
52 summary: "Return a pong",
53 tags: ["default"],
54 responses: { "200": { description: "pong" } },
55 },
56 },
57 },
58});
59
60/** Narrow an execution result to "completed", failing with the run's text. */
61const completed = <R extends { status: string; text: string }>(
62 execution: R,
63): Extract<R, { status: "completed" }> => {
64 if (execution.status !== "completed") {
65 throw new Error(`execution did not complete (status=${execution.status}): ${execution.text}`);
66 }
67 return execution as Extract<R, { status: "completed" }>;
68};
69
70scenario(
71 "Integrations · an OpenAPI spec round-trips the catalog: add, fetch, update, remove",
72 { needs: ["api"] },
73 (ctx) =>
74 Effect.gen(function* () {
75 const identity = yield* ctx.target.newIdentity();
76 const client = yield* ctx.api.client(api, identity);
77 const slug = newSlug("srcapi");
78
79 const added = yield* client.openapi.addSpec({
80 payload: {
81 spec: { kind: "blob", value: pingSpec },
82 slug,
83 baseUrl: "http://127.0.0.1:59999", // never contacted during registration
84 },
85 });
86 expect(added.slug, "the integration keeps the requested slug").toBe(slug);
87 expect(added.toolCount, "the single operation was extracted as a tool").toBe(1);
88
89 const listed = yield* client.integrations.list();
90 expect(
91 listed.map((i) => String(i.slug)),
92 "the new integration is in the catalog",
93 ).toContain(slug);
94
95 const fetched = yield* client.openapi.getIntegration({ params: { slug } });
96 expect(fetched, "the stored integration is fetchable through the plugin route").toMatchObject(
97 { slug, kind: "openapi" },
98 );
99
100 yield* client.integrations.update({
101 params: { slug },
102 payload: { description: "Renamed API" },
103 });
104 const updated = yield* client.integrations.get({ params: { slug } });
105 expect(updated?.description, "the description change round-trips").toBe("Renamed API");
106
107 yield* client.integrations.remove({ params: { slug } });
108 const after = yield* client.integrations.list();
109 expect(
110 after.map((i) => String(i.slug)),
111 "the removed integration drops off the catalog",
112 ).not.toContain(slug);
113 }),
114);
115
116scenario(
117 "Integrations · previewSpec reports a spec's operations before anything is stored",
118 { needs: ["api"] },
119 (ctx) =>
120 Effect.gen(function* () {
121 const identity = yield* ctx.target.newIdentity();
122 const client = yield* ctx.api.client(api, identity);
123
124 const preview = yield* client.openapi.previewSpec({ payload: { spec: pingSpec } });
125 expect(preview.operationCount, "the preview counts the spec's operations").toBe(1);
126 expect(preview.operations, "the preview lists each operation's identity").toEqual([
127 expect.objectContaining({ operationId: "ping", method: "get", path: "/ping" }),
128 ]);
129 }),
130);
131
132scenario(
133 "Integrations · a connected OpenAPI operation executes against the live upstream API",
134 { needs: ["api"] },
135 (ctx) =>
136 Effect.scoped(
137 Effect.gen(function* () {
138 // A real upstream HTTP API on 127.0.0.1, closed by the scope's finalizer.
139 const server = yield* serveOpenApiEchoTestServer();
140 const identity = yield* ctx.target.newIdentity();
141 const client = yield* ctx.api.client(api, identity);
142 const slug = newSlug("srcapi");
143
144 yield* client.openapi.addSpec({
145 payload: {
146 spec: { kind: "blob", value: server.specJson },
147 slug,
148 description: "Invocable upstream API",
149 baseUrl: server.baseUrl,
150 },
151 });
152
153 // A connection mints + persists the per-connection tools.
154 yield* client.connections.create({
155 payload: {
156 owner: "org",
157 name: MAIN,
158 integration: slug,
159 template: API_KEY,
160 value: "static-token",
161 },
162 });
163
164 const tools = yield* client.tools.list({ query: { integration: slug } });
165 const address = `tools.${slug}.org.main.echo.echoMessage`;
166 expect(
167 tools.map((tool) => String(tool.address)),
168 "the operation is stamped as a per-connection tool",
169 ).toContain(address);
170
171 const execution = completed(
172 yield* client.executions.execute({
173 payload: {
174 code: [
175 `const result = await ${address}({ message: "hello", suffix: "world" });`,
176 "return result;",
177 ].join("\n"),
178 },
179 }),
180 );
181 expect(execution.isError, "the execution succeeded").toBe(false);
182 expect(execution.structured, "the tool returned the upstream's echo").toMatchObject({
183 result: {
184 ok: true,
185 data: {
186 status: 200,
187 data: { message: "hello", suffix: "world", path: "/echo/hello" },
188 },
189 },
190 });
191
192 const requests = yield* server.requests;
193 expect(
194 requests.map((request) => request.path),
195 "the upstream API actually received the call",
196 ).toContain("/echo/hello");
197 }),
198 ),
199);
200
201scenario(
202 "Integrations · MCP addServer registers the catalog row without dialing the endpoint",
203 { needs: ["api"] },
204 (ctx) =>
205 Effect.gen(function* () {
206 const identity = yield* ctx.target.newIdentity();
207 const client = yield* ctx.api.client(api, identity);
208 const slug = newSlug("srcmcp");
209
210 // Discovery is deferred to connection time, so a dead endpoint still
211 // registers successfully and is fetchable afterwards.
212 const added = yield* client.mcp.addServer({
213 payload: {
214 transport: "remote",
215 name: "Broken MCP",
216 endpoint: "http://127.0.0.1:1/mcp",
217 remoteTransport: "auto",
218 slug,
219 },
220 });
221 expect(added.slug, "the dead endpoint registered anyway").toBe(slug);
222
223 const fetched = yield* client.mcp.getServer({ params: { slug } });
224 expect(fetched, "the stored server keeps the submitted config").toMatchObject({
225 slug,
226 config: {
227 transport: "remote",
228 endpoint: "http://127.0.0.1:1/mcp",
229 remoteTransport: "auto",
230 },
231 });
232 }),
233);
234
235scenario(
236 "Integrations · a connected MCP server's tool executes through the execution surface",
237 { needs: ["api"] },
238 (ctx) =>
239 Effect.scoped(
240 Effect.gen(function* () {
241 const server = yield* serveMcpServer(() =>
242 makeGreetingMcpServer({
243 name: "e2e-mcp",
244 toolDescription: "Echoes from the e2e MCP server",
245 text: "mcp-ok",
246 }),
247 );
248 const identity = yield* ctx.target.newIdentity();
249 const client = yield* ctx.api.client(api, identity);
250 const slug = newSlug("srcmcp");
251
252 yield* client.mcp.addServer({
253 payload: {
254 transport: "remote",
255 name: "E2E MCP",
256 endpoint: server.endpoint,
257 remoteTransport: "streamable-http",
258 slug,
259 },
260 });
261
262 yield* client.connections.create({
263 payload: {
264 owner: "org",
265 name: MAIN,
266 integration: slug,
267 template: NONE,
268 value: "unused",
269 },
270 });
271
272 const tools = yield* client.tools.list({ query: { integration: slug } });
273 const address = `tools.${slug}.org.main.simple_echo`;
274 expect(
275 tools.map((tool) => String(tool.address)),
276 "the discovered MCP tool is stamped on the connection",
277 ).toContain(address);
278
279 const execution = completed(
280 yield* client.executions.execute({
281 payload: {
282 code: [`const result = await ${address}({});`, "return result;"].join("\n"),
283 },
284 }),
285 );
286 expect(execution.isError, "the execution succeeded").toBe(false);
287 expect(execution.structured, "the MCP tool's content came back").toMatchObject({
288 result: { ok: true, data: { content: [{ type: "text", text: "mcp-ok" }] } },
289 });
290
291 const requests = yield* server.requests;
292 expect(
293 requests.length,
294 "the live MCP server was dialed for discovery and again for the call",
295 ).toBeGreaterThanOrEqual(2);
296 }),
297 ),
298);
299
300scenario(
301 "Integrations · a connected GraphQL endpoint's query executes through the execution surface",
302 { needs: ["api"] },
303 (ctx) =>
304 Effect.scoped(
305 Effect.gen(function* () {
306 const server = yield* serveGraphqlTestServer({
307 schema: makeGreetingGraphqlSchema({ includeMutation: false }),
308 });
309 const identity = yield* ctx.target.newIdentity();
310 const client = yield* ctx.api.client(api, identity);
311 const slug = newSlug("srcgql");
312
313 const added = yield* client.graphql.addIntegration({
314 payload: { endpoint: server.endpoint, slug, name: "E2E GraphQL" },
315 });
316 expect(added.slug, "the integration keeps the requested slug").toBe(slug);
317
318 yield* client.connections.create({
319 payload: {
320 owner: "org",
321 name: MAIN,
322 integration: slug,
323 template: NONE,
324 value: "unused",
325 },
326 });
327
328 const tools = yield* client.tools.list({ query: { integration: slug } });
329 const address = `tools.${slug}.org.main.query.hello`;
330 expect(
331 tools.map((tool) => String(tool.address)),
332 "the introspected query is stamped as a tool",
333 ).toContain(address);
334
335 const execution = completed(
336 yield* client.executions.execute({
337 payload: {
338 code: [`const result = await ${address}({ name: "Ada" });`, "return result;"].join(
339 "\n",
340 ),
341 },
342 }),
343 );
344 expect(execution.isError, "the execution succeeded").toBe(false);
345 expect(execution.structured, "the resolver's greeting came back").toMatchObject({
346 result: { ok: true, data: { hello: "Hello Ada" } },
347 });
348
349 const requests = yield* server.requests;
350 expect(
351 requests.map((request) => request.payload.query ?? ""),
352 "the endpoint was introspected to build the tool catalog",
353 ).toContainEqual(expect.stringContaining("__schema"));
354 expect(
355 requests.map((request) => request.payload.variables),
356 "the invocation's arguments reached the upstream resolver",
357 ).toContainEqual({ name: "Ada" });
358 }),
359 ),
360);
361
362scenario(
363 "Connections · org-shared and personal connections coexist with distinct tool addresses",
364 { needs: ["api"] },
365 (ctx) =>
366 Effect.gen(function* () {
367 const identity = yield* ctx.target.newIdentity();
368 const client = yield* ctx.api.client(api, identity);
369 const slug = newSlug("srcapi");
370
371 yield* client.openapi.addSpec({
372 payload: {
373 spec: { kind: "blob", value: pingSpec },
374 slug,
375 baseUrl: "http://127.0.0.1:59999",
376 },
377 });
378
379 yield* client.connections.create({
380 payload: {
381 owner: "org",
382 name: MAIN,
383 integration: slug,
384 template: API_KEY,
385 value: "org-secret",
386 },
387 });
388 yield* client.connections.create({
389 payload: {
390 owner: "user",
391 name: PERSONAL,
392 integration: slug,
393 template: API_KEY,
394 value: "personal-secret",
395 },
396 });
397
398 // Each connection stamps its own address-keyed copy of the tool.
399 const tools = yield* client.tools.list({ query: { integration: slug } });
400 const addresses = tools.map((tool) => String(tool.address));
401 expect(addresses, "the org connection has its own tool address").toContain(
402 `tools.${slug}.org.main.default.ping`,
403 );
404 expect(addresses, "the personal connection has its own tool address").toContain(
405 `tools.${slug}.user.personal.default.ping`,
406 );
407
408 // Owner filters separate the two credentials cleanly.
409 const userConnections = yield* client.connections.list({
410 query: { integration: slug, owner: "user" },
411 });
412 expect(
413 userConnections.map((c) => `${c.owner}/${String(c.name)}`),
414 "the user filter returns only the personal connection",
415 ).toEqual(["user/personal"]);
416
417 const orgConnections = yield* client.connections.list({
418 query: { integration: slug, owner: "org" },
419 });
420 expect(
421 orgConnections.map((c) => `${c.owner}/${String(c.name)}`),
422 "the org filter returns only the shared connection",
423 ).toEqual(["org/main"]);
424 }),
425);
426
427// The Cloudflare OpenAPI spec is the biggest real spec we care about: 16MB,
428// 2700+ operations. Pushing it through the real HTTP + storage path is the
429// load-bearing check that a storage regression (per-row writes, N+1 reads)
430// shows up as a slow/failing scenario instead of a prod incident — and the
431// symmetric remove must land cleanly too.
432const CLOUDFLARE_SPEC_PATH = fileURLToPath(
433 new URL("../../packages/plugins/openapi/fixtures/cloudflare.json", import.meta.url),
434);
435
436scenario(
437 "Integrations · the full 16MB Cloudflare spec (2,700+ operations) registers and removes cleanly",
438 { needs: ["api"], timeout: 180_000 },
439 (ctx) =>
440 Effect.gen(function* () {
441 const identity = yield* ctx.target.newIdentity();
442 const client = yield* ctx.api.client(api, identity);
443 const slug = newSlug("srccf");
444
445 const added = yield* client.openapi.addSpec({
446 payload: {
447 spec: { kind: "blob", value: readFileSync(CLOUDFLARE_SPEC_PATH, "utf-8") },
448 slug,
449 description: "Cloudflare API",
450 baseUrl: "https://api.cloudflare.com/client/v4",
451 },
452 });
453 expect(added.slug, "the giant spec registered").toBe(slug);
454 expect(added.toolCount, "thousands of operations were extracted").toBeGreaterThan(1000);
455
456 const listed = yield* client.integrations.list();
457 expect(
458 listed.map((i) => String(i.slug)),
459 "the integration is in the catalog",
460 ).toContain(slug);
461
462 yield* client.integrations.remove({ params: { slug } });
463 const after = yield* client.integrations.list();
464 expect(
465 after.map((i) => String(i.slug)),
466 "the giant integration removes cleanly",
467 ).not.toContain(slug);
468 }),
469);
e2e/cloud/sources-refresh.test.ts+187−0
@@ -0,0 +1,187 @@
1// Cloud: connection refresh semantics over the wire. Tools are produced
2// per-connection by the owning plugin; `connections.refresh` re-runs that
3// discovery — for MCP it re-dials the LIVE server, so a server-side tool
4// change replaces the stamped tool rows. `canRefresh` on the integration says
5// whether the catalog row can be refreshed at all: a spec registered from a
6// URL can be re-fetched, a pasted blob cannot.
7//
8// Ported from apps/cloud/src/api/sources-refresh.node.test.ts. The upstream
9// MCP server is a real HTTP server started inside the scenario on 127.0.0.1.
10import { randomBytes } from "node:crypto";
11import { createServer } from "node:http";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15import { composePluginApi } from "@executor-js/api/server";
16import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
17import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
18import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20
21import { scenario } from "../src/scenario";
22
23const api = composePluginApi([openApiHttpPlugin(), mcpHttpPlugin()] as const);
24
25const newSlug = (prefix: string) =>
26 IntegrationSlug.make(`${prefix}_${randomBytes(4).toString("hex")}`);
27
28const MAIN = ConnectionName.make("main");
29const NONE = AuthTemplateSlug.make("none");
30
31/** Minimal OpenAPI 3 spec with a single GET /ping operation. */
32const pingSpec = JSON.stringify({
33 openapi: "3.0.3",
34 info: { title: "Refresh Fixture", version: "1.0.0" },
35 paths: {
36 "/ping": {
37 get: {
38 operationId: "ping",
39 summary: "Return a pong",
40 tags: ["default"],
41 responses: { "200": { description: "pong" } },
42 },
43 },
44 },
45});
46
47/** A real node:http server on 127.0.0.1 that serves `body` on every request,
48 * closed by the scope's finalizer. */
49const serveJson = (body: string) =>
50 Effect.acquireRelease(
51 Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
52 const server = createServer((_request, response) => {
53 response.writeHead(200, { "content-type": "application/json" });
54 response.end(body);
55 });
56 server.listen(0, "127.0.0.1", () => {
57 const address = server.address();
58 const port = typeof address === "object" && address ? address.port : 0;
59 resume(
60 Effect.succeed({
61 url: `http://127.0.0.1:${port}/spec.json`,
62 close: () => {
63 server.close();
64 server.closeAllConnections();
65 },
66 }),
67 );
68 });
69 }),
70 (server) => Effect.sync(server.close),
71 );
72
73scenario(
74 "Connections · refreshing an MCP connection re-dials the live server and replaces its tools",
75 { needs: ["api"] },
76 (ctx) =>
77 Effect.scoped(
78 Effect.gen(function* () {
79 // The factory is re-invoked per MCP session, so flipping `toolName`
80 // changes what the NEXT discovery dial sees.
81 let toolName = "before_refresh";
82 const server = yield* serveMcpServer(() =>
83 makeGreetingMcpServer({ name: "refresh-mcp", toolName, text: "ok" }),
84 );
85 const identity = yield* ctx.target.newIdentity();
86 const client = yield* ctx.api.client(api, identity);
87 const slug = newSlug("refmcp");
88
89 yield* client.mcp.addServer({
90 payload: {
91 transport: "remote",
92 name: "Refresh MCP",
93 endpoint: server.endpoint,
94 remoteTransport: "streamable-http",
95 slug,
96 },
97 });
98 yield* client.connections.create({
99 payload: {
100 owner: "org",
101 name: MAIN,
102 integration: slug,
103 template: NONE,
104 value: "unused",
105 },
106 });
107
108 const before = yield* client.tools.list({ query: { integration: slug } });
109 expect(
110 before.map((tool) => String(tool.address)),
111 "the connection stamped the server's current tool",
112 ).toContain(`tools.${slug}.org.main.before_refresh`);
113
114 // The server's catalog changes; only a refresh may pick that up.
115 toolName = "after_refresh";
116 const refreshed = yield* client.connections.refresh({
117 params: { owner: "org", integration: slug, name: MAIN },
118 });
119 expect(
120 refreshed.map((tool) => String(tool.address)),
121 "refresh returns the re-discovered tool set",
122 ).toContain(`tools.${slug}.org.main.after_refresh`);
123
124 const after = yield* client.tools.list({ query: { integration: slug } });
125 const addresses = after.map((tool) => String(tool.address));
126 expect(addresses, "the stale tool row is gone").not.toContain(
127 `tools.${slug}.org.main.before_refresh`,
128 );
129 expect(addresses, "the new tool row is persisted").toContain(
130 `tools.${slug}.org.main.after_refresh`,
131 );
132 }),
133 ),
134);
135
136scenario(
137 "Integrations · a spec registered from a URL is refreshable (canRefresh)",
138 { needs: ["api"] },
139 (ctx) =>
140 Effect.scoped(
141 Effect.gen(function* () {
142 const specServer = yield* serveJson(pingSpec);
143 const identity = yield* ctx.target.newIdentity();
144 const client = yield* ctx.api.client(api, identity);
145 const slug = newSlug("refurl");
146
147 yield* client.openapi.addSpec({
148 payload: {
149 spec: { kind: "url", url: specServer.url },
150 slug,
151 baseUrl: "http://127.0.0.1:59999",
152 },
153 });
154
155 const integration = yield* client.integrations.get({ params: { slug } });
156 expect(
157 integration?.canRefresh,
158 "a URL-sourced spec can be re-fetched, so the integration is refreshable",
159 ).toBe(true);
160 }),
161 ),
162);
163
164scenario(
165 "Integrations · a spec pasted as a blob is not refreshable (canRefresh)",
166 { needs: ["api"] },
167 (ctx) =>
168 Effect.gen(function* () {
169 const identity = yield* ctx.target.newIdentity();
170 const client = yield* ctx.api.client(api, identity);
171 const slug = newSlug("refblob");
172
173 yield* client.openapi.addSpec({
174 payload: {
175 spec: { kind: "blob", value: pingSpec },
176 slug,
177 baseUrl: "https://api.example.test",
178 },
179 });
180
181 const integration = yield* client.integrations.get({ params: { slug } });
182 expect(
183 integration?.canRefresh,
184 "a pasted blob has no upstream to re-poll, so the integration is not refreshable",
185 ).toBe(false);
186 }),
187);
e2e/cloud/surface-reachability.test.ts+111−0
@@ -0,0 +1,111 @@
1// Cloud-only: every mounted /api plane ANSWERS — nothing silently falls
2// through to the SPA's 200-HTML fallback (the billing-404 regression class).
3// Docs and the spec are served, the billing proxy and the protected API gate
4// reject anonymous callers with their own structured JSON, and unknown /api
5// paths are a real 404. All raw fetch: this is about raw wire dispatch.
6import { expect } from "@effect/vitest";
7import { Effect } from "effect";
8
9import { scenario } from "../src/scenario";
10
11scenario(
12 "Surfaces · Swagger UI and the OpenAPI spec document the mounted API",
13 { needs: ["api"] },
14 (ctx) =>
15 Effect.gen(function* () {
16 const docs = yield* Effect.promise(() => fetch(new URL("/api/docs", ctx.target.baseUrl)));
17 expect(docs.status, "Swagger UI is served").toBe(200);
18 expect(docs.headers.get("content-type"), "as an HTML page").toContain("text/html");
19 const docsHtml = yield* Effect.promise(() => docs.text());
20 expect(docsHtml.toLowerCase(), "it is the Swagger UI shell").toContain("swagger");
21
22 const spec = yield* Effect.promise(() =>
23 fetch(new URL("/api/openapi.json", ctx.target.baseUrl)),
24 );
25 expect(spec.status, "the OpenAPI spec is served").toBe(200);
26 expect(spec.headers.get("content-type"), "as JSON").toContain("application/json");
27 const parsed = (yield* Effect.promise(() => spec.json())) as {
28 paths?: Record<string, unknown>;
29 };
30 const paths = Object.keys(parsed.paths ?? {});
31 expect(paths, "the core protected API is documented").toContain("/api/integrations");
32 expect(paths, "the session-auth plane is documented").toContain("/api/auth/me");
33 expect(paths, "the org plane is documented").toContain("/api/org/domains");
34 expect(paths, "path-param routes are documented").toContain(
35 "/api/executions/{executionId}/resume",
36 );
37 }),
38);
39
40scenario(
41 "Surfaces · the billing proxy answers with its own 401 JSON, not the SPA fallback",
42 { needs: ["api", "billing"] },
43 (ctx) =>
44 Effect.gen(function* () {
45 const response = yield* Effect.promise(() =>
46 fetch(new URL("/api/billing/customer", ctx.target.baseUrl), {
47 method: "POST",
48 headers: { "content-type": "application/json" },
49 body: "{}",
50 }),
51 );
52 // The regression returned the SPA fallback (200 text/html). The real
53 // billing route is reached and rejects the unauthenticated call itself.
54 expect(response.status, "the billing route rejects the anonymous call").toBe(401);
55 expect(response.headers.get("content-type"), "with a JSON body").toContain(
56 "application/json",
57 );
58 const body = yield* Effect.promise(() => response.json());
59 expect(body, "the structured refusal a client can act on").toEqual({
60 error: "Unauthorized",
61 code: "unauthorized",
62 });
63 }),
64);
65
66scenario(
67 "Surfaces · every protected plane refuses anonymous callers with structured JSON",
68 { needs: ["api"] },
69 (ctx) =>
70 Effect.gen(function* () {
71 // The core protected API: anonymous callers hit the auth gate, which
72 // answers in JSON (never the SPA) with the documented refusal body.
73 const core = yield* Effect.promise(() =>
74 fetch(new URL("/api/integrations", ctx.target.baseUrl)),
75 );
76 expect(core.status, "no credentials → no organization to act in").toBe(403);
77 expect(core.headers.get("content-type"), "the gate answers in JSON").toContain(
78 "application/json",
79 );
80 expect(yield* Effect.promise(() => core.json()), "the documented refusal body").toEqual({
81 error: "No organization in session",
82 code: "no_organization",
83 });
84
85 // The session-auth plane is mounted and gated.
86 const sessionMe = yield* Effect.promise(() =>
87 fetch(new URL("/api/auth/me", ctx.target.baseUrl)),
88 );
89 expect(sessionMe.status, "the session API requires a session").toBe(401);
90
91 // The org plane is mounted and gated.
92 const orgDomains = yield* Effect.promise(() =>
93 fetch(new URL("/api/org/domains", ctx.target.baseUrl)),
94 );
95 expect(orgDomains.status, "the org API requires a session").toBe(401);
96 }),
97);
98
99scenario(
100 "Surfaces · unknown /api routes are a real 404, not the SPA fallback",
101 { needs: ["api"] },
102 (ctx) =>
103 Effect.gen(function* () {
104 const response = yield* Effect.promise(() =>
105 fetch(new URL("/api/this-route-does-not-exist", ctx.target.baseUrl)),
106 );
107 expect(response.status, "the API plane owns its 404s").toBe(404);
108 const body = yield* Effect.promise(() => response.text());
109 expect(body, "no SPA HTML leaks out of the API plane").not.toContain("<!DOCTYPE html");
110 }),
111);
e2e/cloud/tenant-isolation.test.ts+137−0
@@ -0,0 +1,137 @@
1// Cloud-only: the tenant partition itself. The per-request executor binds the
2// caller's organization from auth — a request can't even NAME a foreign org —
3// so the invariant is that everything written under one org (integrations,
4// tools, connections, secret values) is simply invisible from another org, and
5// that the same integration slug in two orgs is two independent records.
6import { randomBytes } from "node:crypto";
7
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10import type { HttpApiClient } from "effect/unstable/httpapi";
11import { composePluginApi } from "@executor-js/api/server";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
14
15import { scenario } from "../src/scenario";
16
17const api = composePluginApi([openApiHttpPlugin()] as const);
18type Client = HttpApiClient.ForApi<typeof api>;
19
20const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
21
22/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
23const pingSpec = JSON.stringify({
24 openapi: "3.0.3",
25 info: { title: "Ping API", version: "1.0.0" },
26 paths: {
27 "/ping": {
28 get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
29 },
30 },
31});
32
33const addPingSpec = (client: Client, slug: IntegrationSlug, description?: string) =>
34 client.openapi.addSpec({
35 payload: {
36 spec: { kind: "blob", value: pingSpec },
37 slug,
38 ...(description === undefined ? {} : { description }),
39 baseUrl: "http://127.0.0.1:59999", // never contacted during registration
40 authenticationTemplate: [
41 {
42 slug: "apiKey",
43 type: "apiKey",
44 headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
45 },
46 ],
47 },
48 });
49
50scenario(
51 "Tenant isolation · integrations, tools, and connections in one org are invisible to another",
52 { needs: ["api"] },
53 (ctx) =>
54 Effect.gen(function* () {
55 const orgA = yield* ctx.target.newIdentity();
56 const orgB = yield* ctx.target.newIdentity();
57 const clientA = yield* ctx.api.client(api, orgA);
58 const clientB = yield* ctx.api.client(api, orgB);
59
60 const slug = IntegrationSlug.make(`tenant-scn-${randomBytes(4).toString("hex")}`);
61 const connectionName = ConnectionName.make(`tenantconn${randomBytes(4).toString("hex")}`);
62 const secretValue = `tenant-secret-${randomBytes(8).toString("hex")}`;
63
64 // Org A registers an integration and stores a credential against it.
65 yield* addPingSpec(clientA, slug);
66 yield* clientA.connections.create({
67 payload: {
68 owner: "org",
69 name: connectionName,
70 integration: slug,
71 template: TEMPLATE_API_KEY,
72 value: secretValue,
73 },
74 });
75
76 // Sanity from org A's own side: the integration is really there.
77 const aIntegrations = yield* clientA.integrations.list();
78 expect(
79 aIntegrations.map((integration) => integration.slug),
80 "org A sees its own integration",
81 ).toContain(slug);
82
83 // Org B sees none of it — catalog, tools, connections, or secret bytes.
84 const bIntegrations = yield* clientB.integrations.list();
85 expect(
86 bIntegrations.map((integration) => integration.slug),
87 "org B's integration catalog has no trace of org A's integration",
88 ).not.toContain(slug);
89
90 const bTools = yield* clientB.tools.list();
91 const leakedTools = bTools
92 .filter((tool) => String(tool.address).includes(slug))
93 .map((tool) => tool.address);
94 expect(leakedTools, "no org-A tool leaks into org B's tool catalog").toEqual([]);
95
96 const bView = yield* clientB.openapi.getIntegration({ params: { slug } });
97 expect(bView, "fetching org A's integration by slug from org B yields nothing").toBeNull();
98
99 const bConnections = yield* clientB.connections.list({ query: {} });
100 expect(
101 bConnections.map((connection) => connection.name),
102 "org A's connection is not in org B's connection list",
103 ).not.toContain(connectionName);
104 expect(
105 JSON.stringify(bConnections),
106 "org A's secret value appears nowhere in org B's view",
107 ).not.toContain(secretValue);
108 }),
109);
110
111scenario(
112 "Tenant isolation · the same integration slug in two orgs is two independent records",
113 { needs: ["api"] },
114 (ctx) =>
115 Effect.gen(function* () {
116 const orgA = yield* ctx.target.newIdentity();
117 const orgB = yield* ctx.target.newIdentity();
118 const clientA = yield* ctx.api.client(api, orgA);
119 const clientB = yield* ctx.api.client(api, orgB);
120
121 // Both orgs register the SAME slug — neither blocks the other.
122 const slug = IntegrationSlug.make(`shared-scn-${randomBytes(4).toString("hex")}`);
123 yield* addPingSpec(clientA, slug, "Org A API");
124 yield* addPingSpec(clientB, slug, "Org B API");
125
126 // Updating org A's record must not mutate org B's same-slug record.
127 yield* clientA.integrations.update({
128 params: { slug },
129 payload: { description: "Org A Updated API" },
130 });
131
132 const aIntegration = yield* clientA.integrations.get({ params: { slug } });
133 const bIntegration = yield* clientB.integrations.get({ params: { slug } });
134 expect(aIntegration.description, "org A sees its own update").toBe("Org A Updated API");
135 expect(bIntegration.description, "org B's same-slug record is untouched").toBe("Org B API");
136 }),
137);
e2e/package.json+2−1
@@ -4,7 +4,7 @@ · 3 unmodified lines
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
7 "test": "vitest run",7 "test": "vitest run --project cloud && vitest run --project selfhost",
8 "test:cloud": "vitest run --project cloud",8 "test:cloud": "vitest run --project cloud",
9 "test:selfhost": "vitest run --project selfhost",9 "test:selfhost": "vitest run --project selfhost",
10 "test:watch": "vitest",10 "test:watch": "vitest",
@@ -20,6 +20,7 @@ · 9 unmodified lines
20 "@executor-js/plugin-openapi": "workspace:*",20 "@executor-js/plugin-openapi": "workspace:*",
21 "@executor-js/sdk": "workspace:*",21 "@executor-js/sdk": "workspace:*",
22 "@kitlangton/terminal-control": "^0.3.0",22 "@kitlangton/terminal-control": "^0.3.0",
23 "@modelcontextprotocol/sdk": "^1.29.0",
23 "effect": "catalog:",24 "effect": "catalog:",
24 "monaco-editor": "^0.55.1",25 "monaco-editor": "^0.55.1",
25 "playwright": "^1.60.0",26 "playwright": "^1.60.0",
e2e/src/surfaces/mcp.ts+72−0
@@ -2,6 +2,7 @@ · 1 unmodified line
2// headless OAuth via the target's consent strategy. Session methods are2// headless OAuth via the target's consent strategy. Session methods are
3// Effects; mcporter itself is promise-native underneath. Assertions are3// Effects; mcporter itself is promise-native underneath. Assertions are
4// vitest's job.4// vitest's job.
5import { createHash, randomBytes, randomUUID } from "node:crypto";
5import { mkdtempSync, writeFileSync } from "node:fs";6import { mkdtempSync, writeFileSync } from "node:fs";
6import { tmpdir } from "node:os";7import { tmpdir } from "node:os";
7import { join } from "node:path";8import { join } from "node:path";
@@ -30,6 +31,14 @@ export interface McpSession { · 22 unmodified lines
30 31
31export interface McpSurface {32export interface McpSurface {
32 readonly session: (identity: Identity) => McpSession;33 readonly session: (identity: Identity) => McpSession;
34 /**
35 * Mint a real MCP bearer exactly the way an MCP client does, headlessly:
36 * protected-resource discovery → authorization-server discovery → dynamic
37 * client registration → authorize with PKCE (consent via the target's
38 * strategy) → code exchange. For raw-wire scenarios that drive /mcp without
39 * an MCP client library.
40 */
41 readonly mintBearer: (email: string) => Effect.Effect<string>;
33}42}
34 43
35const textOf = (result: unknown): string => {44const textOf = (result: unknown): string => {
@@ -44,6 +53,69 @@ const textOf = (result: unknown): string => { · 8 unmodified lines
44};53};
45 54
46export const makeMcpSurface = (target: Target): McpSurface => ({55export const makeMcpSurface = (target: Target): McpSurface => ({
56 mintBearer: (email) =>
57 Effect.promise(async () => {
58 const consent = target.mcpConsent?.({ label: email, credentials: { email, password: "" } });
59 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
60
61 const mcpPath = new URL(target.mcpUrl).pathname;
62 const resource = (await (
63 await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
64 ).json()) as { readonly authorization_servers?: ReadonlyArray<string> };
65 const issuer = resource.authorization_servers?.[0];
66 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
67 const metadata = (await (
68 await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
69 ).json()) as {
70 readonly authorization_endpoint: string;
71 readonly token_endpoint: string;
72 readonly registration_endpoint: string;
73 };
74
75 const redirectUri = "http://127.0.0.1:9/callback";
76 const registered = (await (
77 await fetch(metadata.registration_endpoint, {
78 method: "POST",
79 headers: { "content-type": "application/json" },
80 body: JSON.stringify({
81 client_name: "executor-e2e",
82 redirect_uris: [redirectUri],
83 grant_types: ["authorization_code"],
84 response_types: ["code"],
85 token_endpoint_auth_method: "none",
86 }),
87 })
88 ).json()) as { readonly client_id: string };
89
90 const verifier = randomBytes(32).toString("base64url");
91 const authorizeUrl = new URL(metadata.authorization_endpoint);
92 authorizeUrl.searchParams.set("client_id", registered.client_id);
93 authorizeUrl.searchParams.set("redirect_uri", redirectUri);
94 authorizeUrl.searchParams.set("response_type", "code");
95 authorizeUrl.searchParams.set("state", randomUUID());
96 authorizeUrl.searchParams.set(
97 "code_challenge",
98 createHash("sha256").update(verifier).digest("base64url"),
99 );
100 authorizeUrl.searchParams.set("code_challenge_method", "S256");
101 const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
102
103 const token = (await (
104 await fetch(metadata.token_endpoint, {
105 method: "POST",
106 headers: { "content-type": "application/x-www-form-urlencoded" },
107 body: new URLSearchParams({
108 grant_type: "authorization_code",
109 code,
110 redirect_uri: redirectUri,
111 client_id: registered.client_id,
112 code_verifier: verifier,
113 }),
114 })
115 ).json()) as { readonly access_token?: string };
116 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
117 return token.access_token;
118 }),
47 session: (identity) => {119 session: (identity) => {
48 const serverName = target.name;120 const serverName = target.name;
49 let runtimePromise: Promise<Runtime> | undefined;121 let runtimePromise: Promise<Runtime> | undefined;
e2e/vitest.config.ts+4−1
@@ -19,7 +19,10 @@ const project = (name: string, overrides: Record<string, unknown> = {}) => ({ · 18 unmodified lines
19export default defineConfig({19export default defineConfig({
20 test: {20 test: {
21 projects: [21 projects: [
22 project("cloud"),22 // PGlite's socket server is effectively single-connection; parallel test
23 // files (each fanning out per-request postgres sockets) crash it. Run
24 // files serially — swap PGlite for real Postgres if wall-clock matters.
25 project("cloud", { fileParallelism: false }),
23 // selfhost identities are the shared bootstrap admin for now — run files26 // selfhost identities are the shared bootstrap admin for now — run files
24 // serially until per-test invite-signup isolation lands.27 // serially until per-test invite-signup isolation lands.
25 project("selfhost", { fileParallelism: false }),28 project("selfhost", { fileParallelism: false }),
packages/hosts/cloudflare/package.json+4−0
@@ -23,6 +23,10 @@ · 22 unmodified lines
23 "./mcp/durable-object": {23 "./mcp/durable-object": {
24 "types": "./src/mcp/session-durable-object.ts",24 "types": "./src/mcp/session-durable-object.ts",
25 "default": "./src/mcp/session-durable-object.ts"25 "default": "./src/mcp/session-durable-object.ts"
26 },
27 "./mcp/request-id-queue": {
28 "types": "./src/mcp/request-id-queue.ts",
29 "default": "./src/mcp/request-id-queue.ts"
26 }30 }
27 },31 },
28 "scripts": {32 "scripts": {
packages/hosts/cloudflare/src/mcp/request-id-queue.ts+125−0
@@ -0,0 +1,125 @@
1// The same-id JSON-RPC request queue used by the MCP worker transport.
2// Pure logic with no Cloudflare/agents dependency, in its own module so it
3// is unit-testable outside workerd.
4import { Effect, Exit, Match, Option, Predicate } from "effect";
5
6type JsonRpcLike = {
7 readonly id?: unknown;
8 readonly method?: unknown;
9 readonly params?: unknown;
10};
11
12type ToolCallParamsLike = {
13 readonly name?: unknown;
14 readonly arguments?: unknown;
15};
16
17type ResumeArgumentsLike = {
18 readonly executionId?: unknown;
19};
20
21const jsonRpcRequestIdKey = (id: unknown): string | null =>
22 Match.value(id).pipe(
23 Match.whenOr(
24 Match.string,
25 Match.number,
26 Match.boolean,
27 (value) => `${typeof value}:${String(value)}`,
28 ),
29 Match.option,
30 Option.getOrNull,
31 );
32
33const jsonRpcRequestQueueKey = (message: JsonRpcLike): string | null => {
34 const idKey = jsonRpcRequestIdKey(message.id);
35 if (!idKey) return null;
36
37 if (message.method !== "tools/call") return idKey;
38 if (!message.params || typeof message.params !== "object") return idKey;
39
40 const params = message.params as ToolCallParamsLike;
41 if (params.name !== "resume") return idKey;
42 if (!params.arguments || typeof params.arguments !== "object") return idKey;
43
44 const args = params.arguments as ResumeArgumentsLike;
45 if (typeof args.executionId !== "string" || args.executionId.length === 0) return idKey;
46
47 return `${idKey}:tools/call:resume:${args.executionId}`;
48};
49
50const extractJsonRpcRequestQueueKeys = async (request: Request): Promise<ReadonlyArray<string>> => {
51 if (request.method !== "POST") return [];
52 const contentType = request.headers.get("content-type") ?? "";
53 if (!contentType.includes("application/json")) return [];
54
55 const parsed = await Effect.runPromiseExit(Effect.tryPromise(() => request.clone().json()));
56 if (Exit.isFailure(parsed)) {
57 return [];
58 }
59 const messages = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
60 return messages.flatMap((message) => {
61 if (!message || typeof message !== "object") return [];
62 const rpc = message as JsonRpcLike;
63 if (typeof rpc.method !== "string") return [];
64 const key = jsonRpcRequestQueueKey(rpc);
65 return key ? [key] : [];
66 });
67};
68
69// Hard ceiling on how long a same-id JSON-RPC request will wait for an
70// earlier in-flight one to finish. Stays well under the 180s upstream
71// client timeout that Claude / Cowork enforce, so a poisoned queue slot
72// can't block the next request long enough for the client to give up.
73// If a previous request hasn't released within the budget, we proceed
74// anyway — at worst the MCP SDK rejects the second reply for a duplicate
75// id, which is recoverable; a perma-stuck queue is not.
76export const PREVIOUS_REQUEST_TIMEOUT_MS = 60_000;
77
78export class JsonRpcRequestIdQueue {
79 private readonly inFlight = new Map<string, Promise<void>>();
80 private readonly previousTimeoutMs: number;
81
82 constructor(options: { readonly previousTimeoutMs?: number } = {}) {
83 this.previousTimeoutMs = options.previousTimeoutMs ?? PREVIOUS_REQUEST_TIMEOUT_MS;
84 }
85
86 async run<A>(request: Request, run: () => Promise<A>): Promise<A> {
87 const ids = [...new Set(await extractJsonRpcRequestQueueKeys(request))];
88 if (ids.length === 0) return await run();
89
90 const previous = ids.map((id) => this.inFlight.get(id)).filter(Predicate.isNotUndefined);
91 let release!: () => void;
92 const current = new Promise<void>((resolve) => {
93 release = resolve;
94 });
95 for (const id of ids) {
96 this.inFlight.set(id, current);
97 }
98
99 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: promise queue must release in-flight ids after callback completion
100 try {
101 if (previous.length > 0) {
102 const settled = Promise.all(
103 previous.map((p) => Effect.runPromise(Effect.ignore(Effect.tryPromise(() => p)))),
104 );
105 const timeout = new Promise<"timeout">((resolve) =>
106 setTimeout(() => resolve("timeout"), this.previousTimeoutMs),
107 );
108 const outcome = await Promise.race([settled.then(() => "settled" as const), timeout]);
109 if (outcome === "timeout") {
110 console.warn(
111 `[mcp-worker-transport] previous in-flight request for ids=${JSON.stringify(ids)} did not release within ${this.previousTimeoutMs}ms; proceeding anyway`,
112 );
113 }
114 }
115 return await run();
116 } finally {
117 for (const id of ids) {
118 if (this.inFlight.get(id) === current) {
119 this.inFlight.delete(id);
120 }
121 }
122 release();
123 }
124 }
125}
packages/hosts/cloudflare/src/mcp/worker-transport.ts+5−122
@@ -1,6 +1,10 @@
1import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";1import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";
2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { Data, Effect, Exit, Match, Option, Predicate } from "effect";3import { Data, Effect } from "effect";
4
5import { JsonRpcRequestIdQueue } from "./request-id-queue.js";
6
7export { JsonRpcRequestIdQueue, PREVIOUS_REQUEST_TIMEOUT_MS } from "./request-id-queue.js";
4 8
5export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{9export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{
6 readonly cause: unknown;10 readonly cause: unknown;
@@ -13,21 +17,6 @@ export type McpWorkerTransport = Readonly<{ · 6 unmodified lines
13 close: () => Effect.Effect<void>;17 close: () => Effect.Effect<void>;
14}>;18}>;
15 19
16type JsonRpcLike = {
17 readonly id?: unknown;
18 readonly method?: unknown;
19 readonly params?: unknown;
20};
21
22type ToolCallParamsLike = {
23 readonly name?: unknown;
24 readonly arguments?: unknown;
25};
26
27type ResumeArgumentsLike = {
28 readonly executionId?: unknown;
29};
30
31type HandleRequestResult = {20type HandleRequestResult = {
32 readonly response: Response;21 readonly response: Response;
33 readonly replacedStandaloneSse: boolean;22 readonly replacedStandaloneSse: boolean;
@@ -58,112 +47,6 @@ const closeExistingStandaloneSse = (transport: WorkerTransport): boolean => { · 24 unmodified lines
58const isStandaloneSseGet = (request: Request): boolean =>47const isStandaloneSseGet = (request: Request): boolean =>
59 request.method === "GET" && (request.headers.get("accept") ?? "").includes("text/event-stream");48 request.method === "GET" && (request.headers.get("accept") ?? "").includes("text/event-stream");
60 49
61const jsonRpcRequestIdKey = (id: unknown): string | null =>
62 Match.value(id).pipe(
63 Match.whenOr(
64 Match.string,
65 Match.number,
66 Match.boolean,
67 (value) => `${typeof value}:${String(value)}`,
68 ),
69 Match.option,
70 Option.getOrNull,
71 );
72
73const jsonRpcRequestQueueKey = (message: JsonRpcLike): string | null => {
74 const idKey = jsonRpcRequestIdKey(message.id);
75 if (!idKey) return null;
76
77 if (message.method !== "tools/call") return idKey;
78 if (!message.params || typeof message.params !== "object") return idKey;
79
80 const params = message.params as ToolCallParamsLike;
81 if (params.name !== "resume") return idKey;
82 if (!params.arguments || typeof params.arguments !== "object") return idKey;
83
84 const args = params.arguments as ResumeArgumentsLike;
85 if (typeof args.executionId !== "string" || args.executionId.length === 0) return idKey;
86
87 return `${idKey}:tools/call:resume:${args.executionId}`;
88};
89
90const extractJsonRpcRequestQueueKeys = async (request: Request): Promise<ReadonlyArray<string>> => {
91 if (request.method !== "POST") return [];
92 const contentType = request.headers.get("content-type") ?? "";
93 if (!contentType.includes("application/json")) return [];
94
95 const parsed = await Effect.runPromiseExit(Effect.tryPromise(() => request.clone().json()));
96 if (Exit.isFailure(parsed)) {
97 return [];
98 }
99 const messages = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
100 return messages.flatMap((message) => {
101 if (!message || typeof message !== "object") return [];
102 const rpc = message as JsonRpcLike;
103 if (typeof rpc.method !== "string") return [];
104 const key = jsonRpcRequestQueueKey(rpc);
105 return key ? [key] : [];
106 });
107};
108
109// Hard ceiling on how long a same-id JSON-RPC request will wait for an
110// earlier in-flight one to finish. Stays well under the 180s upstream
111// client timeout that Claude / Cowork enforce, so a poisoned queue slot
112// can't block the next request long enough for the client to give up.
113// If a previous request hasn't released within the budget, we proceed
114// anyway — at worst the MCP SDK rejects the second reply for a duplicate
115// id, which is recoverable; a perma-stuck queue is not.
116export const PREVIOUS_REQUEST_TIMEOUT_MS = 60_000;
117
118export class JsonRpcRequestIdQueue {
119 private readonly inFlight = new Map<string, Promise<void>>();
120 private readonly previousTimeoutMs: number;
121
122 constructor(options: { readonly previousTimeoutMs?: number } = {}) {
123 this.previousTimeoutMs = options.previousTimeoutMs ?? PREVIOUS_REQUEST_TIMEOUT_MS;
124 }
125
126 async run<A>(request: Request, run: () => Promise<A>): Promise<A> {
127 const ids = [...new Set(await extractJsonRpcRequestQueueKeys(request))];
128 if (ids.length === 0) return await run();
129
130 const previous = ids.map((id) => this.inFlight.get(id)).filter(Predicate.isNotUndefined);
131 let release!: () => void;
132 const current = new Promise<void>((resolve) => {
133 release = resolve;
134 });
135 for (const id of ids) {
136 this.inFlight.set(id, current);
137 }
138
139 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: promise queue must release in-flight ids after callback completion
140 try {
141 if (previous.length > 0) {
142 const settled = Promise.all(
143 previous.map((p) => Effect.runPromise(Effect.ignore(Effect.tryPromise(() => p)))),
144 );
145 const timeout = new Promise<"timeout">((resolve) =>
146 setTimeout(() => resolve("timeout"), this.previousTimeoutMs),
147 );
148 const outcome = await Promise.race([settled.then(() => "settled" as const), timeout]);
149 if (outcome === "timeout") {
150 console.warn(
151 `[mcp-worker-transport] previous in-flight request for ids=${JSON.stringify(ids)} did not release within ${this.previousTimeoutMs}ms; proceeding anyway`,
152 );
153 }
154 }
155 return await run();
156 } finally {
157 for (const id of ids) {
158 if (this.inFlight.get(id) === current) {
159 this.inFlight.delete(id);
160 }
161 }
162 release();
163 }
164 }
165}
166
167export const makeMcpWorkerTransport = (50export const makeMcpWorkerTransport = (
168 options: WorkerTransportOptions,51 options: WorkerTransportOptions,
169): Effect.Effect<McpWorkerTransport> =>52): Effect.Effect<McpWorkerTransport> =>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t