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
7272 // drivers (Playwright, PTY, mcporter, raw HTTP) plus build tooling (the
7373 // run viewer); they normalize untyped failures into the recorded
7474 // transcript. Scenario code (e2e/scenarios, e2e/cloud, …) stays strict.
75− "files": [
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− ],
75+ "files": ["e2e/**/*.{ts,tsx}"],
8276 "rules": {
8377 // The viewer is standalone tooling — it deliberately doesn't pull the
8478 // product design system or Effect in.
apps/cloud/package.json+1−3
@@ -23,9 +23,8 @@ · 22 unmodified lines
2323 "deploy": "op run --env-file=.env.production -- bash -lc 'bun run build && wrangler deploy -c dist/server/wrangler.json'",
2424 "cf-typegen": "wrangler types",
2525 "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",
2727 "test:watch": "node ../../node_modules/vitest/vitest.mjs",
28− "test:node": "node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts",
2928 "typecheck:slow": "tsc --noEmit"
3029 },
3130 "dependencies": {
@@ -72,7 +71,6 @@ · 40 unmodified lines
7271 "sonner": "^2.0.7"
7372 },
7473 "devDependencies": {
75− "@cloudflare/vitest-pool-workers": "^0.15.0",
7674 "@cloudflare/workers-types": "^4.20250620.0",
7775 "@effect/platform-node": "catalog:",
7876 "@effect/vitest": "catalog:",
apps/cloud/scripts/test-globalsetup.ts+1−1
@@ -1,7 +1,7 @@
11 // ---------------------------------------------------------------------------
22 // Vitest globalSetup — starts an in-process PGlite socket server so tests
33 // 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.
55 // ---------------------------------------------------------------------------
66
77 import { PGlite } from "@electric-sql/pglite";
apps/cloud/src/account/workos-account-service.test.ts+0−192
@@ -1,192 +0,0 @@
1−import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { HttpRouter, HttpServer } from "effect/unstable/http";
3−import { describe, expect, it } from "@effect/vitest";
4−import { Effect, Layer } from "effect";
5−
6−import { AccountHttpApi } from "@executor-js/api";
7−import { AccountHandlers } from "@executor-js/api/server";
8−
9−import { ApiKeyService } from "../auth/api-keys";
10−import { UserStoreService } from "../auth/context";
11−import type { Session } from "../auth/middleware";
12−import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
13−import { AutumnService } from "../extensions/billing/service";
14−import { 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−
26−const 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−
36−const orgLessSession: Session = { ...authedSession, organizationId: null };
37−
38−const 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.
53−const 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−
63−const 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−
89−const stubAutumn = Layer.succeed(AutumnService)({
90− use: (() => Effect.succeed({ subscriptions: [] })) as AutumnService["Service"]["use"],
91− trackExecution: () => Effect.void,
92−} satisfies AutumnService["Service"]);
93−
94−const 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`.
115−const 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−
121−describe("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 @@
1−import {
2− HttpApi,
3− HttpApiBuilder,
4− HttpApiClient,
5− HttpApiEndpoint,
6− HttpApiGroup,
7− HttpApiSwagger,
8− OpenApi,
9−} from "effect/unstable/httpapi";
10−import {
11− FetchHttpClient,
12− HttpClient,
13− HttpRouter,
14− HttpServer,
15− HttpServerResponse,
16−} from "effect/unstable/http";
17−import { expect, layer } from "@effect/vitest";
18−import { Cause, Effect, Layer, Schema } from "effect";
19−import { RouterConfigLive } from "@executor-js/api/server";
20−import { toErrorServerResponse } from "./api/error-response";
21−
22−const SourceResponse = Schema.Struct({ source: Schema.String });
23−
24−// ---------------------------------------------------------------------------
25−// Test APIs — mirror the prod paths but with stub handlers.
26−// ---------------------------------------------------------------------------
27−
28−const OrgGroup = HttpApiGroup.make("org").add(
29− HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }),
30−);
31−const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup);
32−
33−const AuthGroup = HttpApiGroup.make("auth").add(
34− HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }),
35−);
36−const AuthTestApi = HttpApi.make("authApi").add(AuthGroup);
37−
38−const 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− );
52−const ProtectedTestApi = HttpApi.make("protectedApi").add(ProtectedGroup);
53−const DocumentationTestApi = ProtectedTestApi.add(AuthGroup).add(OrgGroup);
54−
55−// ---------------------------------------------------------------------------
56−// Stub handlers
57−// ---------------------------------------------------------------------------
58−
59−const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) =>
60− handlers.handle("ping", () => Effect.succeed({ source: "org" })),
61−);
62−
63−const AuthHandlers = HttpApiBuilder.group(AuthTestApi, "auth", (handlers) =>
64− handlers.handle("me", () => Effect.succeed({ source: "auth" })),
65−);
66−
67−const 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−
80−type ProtectedMode = "ok" | "none" | "error" | "bad-status" | "defect";
81−const testState: { mode: ProtectedMode } = { mode: "ok" };
82−const 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.
90−const 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−
119−const OrgTestLive = HttpApiBuilder.layer(OrgTestApi).pipe(Layer.provide(OrgTestHandlers));
120−const AuthTestLive = HttpApiBuilder.layer(AuthTestApi).pipe(Layer.provide(AuthHandlers));
121−const ProtectedTestLive = HttpApiBuilder.layer(ProtectedTestApi).pipe(
122− Layer.provide(ProtectedHandlers),
123− Layer.provide(TestProtectedGate),
124−);
125−
126−const AutumnTestRoutesLive = HttpRouter.add(
127− "*",
128− "/autumn/*",
129− Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })),
130−);
131−
132−const 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−
141−const TestApiLive = Layer.mergeAll(
142− OrgTestLive,
143− AuthTestLive,
144− TestDocsLive,
145− ProtectedTestLive,
146− AutumnTestRoutesLive,
147−).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(HttpServer.layerServices));
148−
149−const 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−
157−const 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−
167−const TEST_BASE_URL = "http://test.local";
168−const fetchViaHandler: typeof globalThis.fetch = (input, init) =>
169− requestHandler(input instanceof Request ? input : new Request(input, init));
170−
171−const TestClientLayer = FetchHttpClient.layer.pipe(
172− Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchViaHandler)),
173−);
174−
175−const getClient = () => HttpApiClient.make(TestApi, { baseUrl: TEST_BASE_URL });
176−
177−layer(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−
9−import { describe, expect, it } from "@effect/vitest";
10−import { Effect, Result } from "effect";
11−import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
12−import { Schema } from "effect";
13−
14−import {
15− AuthTemplateSlug,
16− ConnectionName,
17− IntegrationSlug,
18− connectionIdentifier,
19−} from "@executor-js/sdk";
20−import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
21−
22−import { asOrg } from "../testing/api-harness";
23−
24−const 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−
32−const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
33−const 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.
38−const 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−
52−describe("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−
11−import { describe, expect, it } from "@effect/vitest";
12−import { Effect, Schema } from "effect";
13−import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
14−import { readFileSync } from "node:fs";
15−import { resolve } from "node:path";
16−
17−import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
18−import {
19− serveGraphqlTestServer,
20− makeGreetingGraphqlSchema,
21−} from "@executor-js/plugin-graphql/testing";
22−import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
23−import {
24− makeOpenApiHttpApiTestAddSpecPayload,
25− makeOpenApiHttpApiTestSpecPayload,
26− serveOpenApiEchoTestServer,
27−} from "@executor-js/plugin-openapi/testing";
28−
29−import { asOrg, asUser } from "../testing/api-harness";
30−
31−const isJsonObject = (value: unknown): value is Record<string, unknown> =>
32− typeof value === "object" && value !== null && !Array.isArray(value);
33−
34−const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add(
35− HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
36−);
37−
38−const MinimalSourceApi = HttpApi.make("sourcesApiTest")
39− .add(PingGroup)
40− .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" }));
41−
42−const makeMinimalOpenApiSpecPayload = (
43− slug: string,
44− options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "slug"> = {},
45−) =>
46− makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, {
47− slug,
48− ...options,
49− });
50−
51−const makeMinimalOpenApiPreviewPayload = () => makeOpenApiHttpApiTestSpecPayload(MinimalSourceApi);
52−
53−const randomSlug = (prefix: string) =>
54− IntegrationSlug.make(`${prefix}_${crypto.randomUUID().replace(/-/g, "_")}`);
55−
56−const NAME_MAIN = ConnectionName.make("main");
57−const NAME_PERSONAL = ConnectionName.make("personal");
58−const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
59−const 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.
67−const CLOUDFLARE_SPEC_PATH = resolve(
68− __dirname,
69− "../../../../packages/plugins/openapi/fixtures/cloudflare.json",
70−);
71−const CLOUDFLARE_SPEC = readFileSync(CLOUDFLARE_SPEC_PATH, "utf-8");
72−
73−describe("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−
9−import { describe, expect, it } from "@effect/vitest";
10−import { Effect, Schema } from "effect";
11−import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
12−
13−import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
14−import {
15− makeOpenApiHttpApiTestSpecPayload,
16− serveMutableOpenApiSpecTestServer,
17−} from "@executor-js/plugin-openapi/testing";
18−import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
19−
20−import { asOrg } from "../testing/api-harness";
21−
22−const PingEndpoint = HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown });
23−
24−const 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−
28−const makeRefreshSpecText = () => makeOpenApiHttpApiTestSpecPayload(RefreshApi).spec;
29−
30−const NAME_MAIN = ConnectionName.make("main");
31−const TEMPLATE_NONE = AuthTemplateSlug.make("none");
32−
33−describe("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−
11−import { describe, expect, it } from "@effect/vitest";
12−import { Effect, Schema } from "effect";
13−import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
14−
15−import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk";
16−import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
17−
18−import { asOrg } from "../testing/api-harness";
19−
20−const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add(
21− HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
22−);
23−
24−const TenantIsolationApi = HttpApi.make("tenantIsolationTest")
25− .add(PingGroup)
26− .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" }));
27−
28−const 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−
38−const randomSlug = () => IntegrationSlug.make(`a_${crypto.randomUUID().replace(/-/g, "_")}`);
39−const NAME_MAIN = ConnectionName.make("main");
40−const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
41−
42−describe("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−
17−import { describe, expect, it } from "@effect/vitest";
18−import { Effect, Schema } from "effect";
19−import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
20−
21−import {
22− AuthTemplateSlug,
23− ConnectionName,
24− IntegrationSlug,
25− ProviderItemId,
26− ProviderKey,
27−} from "@executor-js/sdk";
28−import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
29−
30−import { ProtectedCloudApi, asOrg } from "../testing/api-harness";
31−
32−const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add(
33− HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }),
34−);
35−
36−const MissingAuthSourceApi = HttpApi.make("cloudAuthFailureSource").add(PingGroup);
37−
38−const 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`.
41−const VAULT_PROVIDER = ProviderKey.make("workos-vault");
42−
43−type CloudApiShape = HttpApiClient.ForApi<typeof ProtectedCloudApi>;
44−type EffectSuccess<T> = T extends Effect.Effect<infer A, unknown, unknown> ? A : never;
45−type ExecuteResult = EffectSuccess<ReturnType<CloudApiShape["executions"]["execute"]>>;
46−
47−const 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−
67−describe("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 @@
1−import { HttpApiBuilder, HttpApi } from "effect/unstable/httpapi";
2−import { HttpRouter, HttpServer } from "effect/unstable/http";
3−import { describe, expect, it } from "@effect/vitest";
4−import { Data, Effect, Layer } from "effect";
5−import type { Effect as EffectType } from "effect/Effect";
6−
7−import { CloudAuthPublicApi } from "./api";
8−import { CloudAuthPublicHandlers } from "./handlers";
9−import { UserStoreService } from "./context";
10−import { WorkOSError } from "./errors";
11−import { WorkOSClient } from "./workos";
12−
13−const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi);
14−type EffectSuccess<T> = T extends EffectType<infer A, infer _E, infer _R> ? A : never;
15−type AuthenticateWithCodeResult = EffectSuccess<
16− ReturnType<WorkOSClient["Service"]["authenticateWithCode"]>
17−>;
18−const 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−
34−class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{
35− method: string;
36−}> {}
37−
38−const 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−
67−describe("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
7979 export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
8080 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
8181 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
82− // the test workers (`wrangler.test.jsonc` / `wrangler.miniflare.jsonc`) opt in
83− // with `"true"` so fixtures can reach localhost. See `hosted-http-client.ts`.
82+ // the e2e dev-server env opts in with `"true"` so in-scenario fixture
83+ // servers on localhost are reachable. See `hosted-http-client.ts`.
8484 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
8585 webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
8686 // `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−
23−import { describe, expect, it } from "@effect/vitest";
24−
25−import { cloudApiHandler } from "./app";
26−
27−const handler = cloudApiHandler().handler;
28−
29−const call = (method: string, path: string, init: RequestInit = {}) =>
30− handler(new Request(`http://test.local${path}`, { method, ...init }));
31−
32−describe("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−
27−import { env, runDurableObjectAlarm, runInDurableObject, SELF } from "cloudflare:test";
28−import { Effect } from "effect";
29−import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
30−
31−import { makeTestBearer } from "./testing/test-bearer";
32−
33−// ---------------------------------------------------------------------------
34−// Constants
35−// ---------------------------------------------------------------------------
36−
37−const BASE = "https://test-resource.example.com";
38−const MCP_URL = `${BASE}/mcp`;
39−const OAUTH_RESOURCE_URL = `${BASE}/.well-known/oauth-protected-resource/mcp`;
40−
41−const JSON_AND_SSE = "application/json, text/event-stream";
42−const CONTENT_TYPE_JSON = "application/json";
43−const HEARTBEAT_MS = 30 * 1000;
44−const SESSION_TIMEOUT_MS = 5 * 60 * 1000;
45−const SESSION_META_KEY = "session-meta";
46−const LAST_ACTIVITY_KEY = "last-activity-ms";
47−
48−const doRuntimeControls = (
49− instance: unknown,
50−): {
51− closeRuntime: () => Effect.Effect<void>;
52−} => instance as { closeRuntime: () => Effect.Effect<void> };
53−
54−const doActivityState = (instance: unknown): { lastActivityMs: number } =>
55− instance as { lastActivityMs: number };
56−
57−const 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−
68−const TOOLS_LIST_REQUEST = {
69− jsonrpc: "2.0" as const,
70− id: 2,
71− method: "tools/list",
72− params: {},
73−};
74−
75−const INITIALIZED_NOTIFICATION = {
76− jsonrpc: "2.0" as const,
77− method: "notifications/initialized",
78−};
79−
80−const nextOrgId = (() => {
81− let seq = 0;
82− return () => `org_mcp_flow_${++seq}_${crypto.randomUUID().slice(0, 8)}`;
83−})();
84−
85−const nextAccountId = (() => {
86− let seq = 0;
87− return () => `user_mcp_flow_${++seq}_${crypto.randomUUID().slice(0, 8)}`;
88−})();
89−
90−// ---------------------------------------------------------------------------
91−// Request helpers
92−// ---------------------------------------------------------------------------
93−
94−type McpPostInit = {
95− readonly bearer?: string;
96− readonly sessionId?: string | null;
97− readonly body: unknown;
98− readonly accept?: string;
99−};
100−
101−const 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−
115−const mcpPost = (init: McpPostInit): Promise<Response> => mcpPostTo(MCP_URL, init);
116−
117−const orgScopedMcpUrl = (organizationId: string): string => `${BASE}/${organizationId}/mcp`;
118−
119−const orgScopedResourceUrl = (organizationId: string): string =>
120− `${BASE}/.well-known/oauth-protected-resource/${organizationId}/mcp`;
121−
122−const 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−
132−const 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−
145−beforeAll(() => {
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−
151−afterAll(() => undefined);
152−
153−// ---------------------------------------------------------------------------
154−// 1. OPTIONS preflight on /mcp
155−// ---------------------------------------------------------------------------
156−
157−describe("/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−
182−describe("/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−
211−describe("/.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−
256−describe("/: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−
316−describe("/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−
342−describe("/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−
359−describe("/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−
377−describe("/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−
404−describe("/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−
428−describe("/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−
456−describe("/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−
665−describe("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−
22−import { expect, layer } from "@effect/vitest";
23−import { resolve } from "node:path";
24−import { createServer } from "node:http";
25−import type { AddressInfo } from "node:net";
26−
27−import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
28−import { Context, Data, Effect, Exit, Layer, Option, Schema, Scope } from "effect";
29−
30−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
31−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
32−import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
33−import { unstable_dev, type Unstable_DevWorker } from "wrangler";
34−import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing";
35−
36−import { 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−
43−const ApprovedResponse = Schema.Struct({
44− approved: Schema.Boolean,
45−});
46−type ApprovedResponse = typeof ApprovedResponse.Type;
47−
48−const ApproveGroup = HttpApiGroup.make("approve").add(
49− HttpApiEndpoint.post("approveThing", "/approve", {
50− success: ApprovedResponse,
51− }),
52−);
53−
54−const UpstreamApi = HttpApi.make("approveApi").add(ApproveGroup);
55−
56−const ApproveHandlers = HttpApiBuilder.group(UpstreamApi, "approve", (h) =>
57− h.handle("approveThing", () => Effect.succeed(ApprovedResponse.make({ approved: true }))),
58−);
59−
60−// ---------------------------------------------------------------------------
61−// Services
62−// ---------------------------------------------------------------------------
63−
64−class Upstream extends Context.Service<
65− Upstream,
66− { readonly baseUrl: string; readonly specJson: string }
67−>()("MiniflareE2E/Upstream") {}
68−
69−class 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−
77−type 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−
85−class 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−
97−class MiniflareE2ETestError extends Data.TaggedError("MiniflareE2ETestError")<{
98− readonly message: string;
99− readonly cause?: unknown;
100−}> {}
101−
102−const 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−
127−const 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−});
133−type OtlpAttributeValue = typeof OtlpAttributeValue.Type;
134−
135−const 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−
170−const decodeOtlpPayload = Schema.decodeUnknownOption(OtlpPayloadFromJson);
171−
172−const 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−
181−const 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−
271−const 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−
315−const TestEnv = Layer.mergeAll(UpstreamLive, WorkerLive).pipe(
316− Layer.provideMerge(TelemetryReceiverLive),
317−);
318−
319−// ---------------------------------------------------------------------------
320−// Client helpers
321−// ---------------------------------------------------------------------------
322−
323−let accountCounter = 0;
324−let orgCounter = 0;
325−const nextAccountId = () => `acct_miniflare_${++accountCounter}`;
326−const nextOrgId = () => `org_miniflare_${++orgCounter}`;
327−
328−const 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−
352−const 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−
363−const 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−
376−const 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−
387−const 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−
434−layer(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−
851−layer(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
33 //
44 // Kept as its own `cloudflare:workers`-free leaf: the node-pool test
55 // (mcp-auth.node.test.ts) imports these verifiers at runtime, and
6−// `test-bearer.ts` (shared with node tests) imports `VerifiedToken` from here.
76 // `mcp/auth.ts` (which DOES read `cloudflare:workers` env) imports this leaf;
87 // the dependency points one way only.
98 // ---------------------------------------------------------------------------
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−
23−import { describe, expect, it } from "@effect/vitest";
24−
25−import { Effect } from "effect";
26−import {
27− AuthTemplateSlug,
28− ConnectionName,
29− IntegrationSlug,
30− OAuthClientSlug,
31− OAuthState,
32−} from "@executor-js/sdk";
33−import { serveOAuthTestServer } from "@executor-js/sdk/testing";
34−
35−import { asOrg } from "../testing/api-harness";
36−
37−describe("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
33 import {
44 JsonRpcRequestIdQueue,
55 PREVIOUS_REQUEST_TIMEOUT_MS,
6−} from "@executor-js/cloudflare/mcp/worker-transport";
6+} from "@executor-js/cloudflare/mcp/request-id-queue";
77
88 const jsonRpcRequest = (body: unknown): Request =>
99 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−
21−import { describe, expect, it } from "@effect/vitest";
22−import { Effect } from "effect";
23−import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
24−import { Schema } from "effect";
25−
26−import {
27− AuthTemplateSlug,
28− ConnectionName,
29− IntegrationSlug,
30− connectionIdentifier,
31−} from "@executor-js/sdk";
32−import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing";
33−
34−import { asUser } from "./testing/api-harness";
35−
36−const uniq = () => crypto.randomUUID().slice(0, 8);
37−const nextOrgId = () => `org_iso_${uniq()}`;
38−const nextUserId = () => `user_iso_${uniq()}`;
39−
40−const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
41−const canonicalConnectionName = (name: ConnectionName): ConnectionName =>
42− connectionIdentifier(String(name));
43−
44−const 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.
54−const 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−
68−describe("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−
21−import { Effect, Layer } from "effect";
22−import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";
23−import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";
24−
25−import {
26− ExecutionEngineService,
27− ExecutorService,
28− collectTables,
29− providePluginExtensions,
30− type PluginExtensionServices,
31−} from "@executor-js/api/server";
32−import { createExecutionEngine } from "@executor-js/execution";
33−import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
34−import { createExecutor, Subject, Tenant } from "@executor-js/sdk";
35−import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
36−
37−import executorConfig from "../../executor.config";
38−import { AuthContext, RouterConfigLive } from "@executor-js/api/server";
39−
40−import { ProtectedCloudApi, ProtectedCloudApiHandlers } from "../api/layers";
41−import { DbService } from "../db/db";
42−import { createDrizzleFumaDb } from "../db/fuma";
43−
44−export const TEST_BASE_URL = "http://test.local";
45−export const TEST_ORG_HEADER = "x-test-org-id";
46−export 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.
52−const 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−
60−const fakeVault = makeTestWorkOSVaultClient();
61−const testPlugins = executorConfig.plugins({
62− workosVaultClient: fakeVault,
63−});
64−const testHttpClientLayer = FetchHttpClient.layer;
65−
66−const 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.
102−const 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−
153−const 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−
162−const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;
163−
164−export 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−
173−export 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−
186−export const clientLayerForOrg = (organizationId: string) =>
187− FetchHttpClient.layer.pipe(
188− Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForOrg(organizationId))),
189− );
190−
191−export 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.
199−type ApiShape = HttpApiClient.ForApi<typeof ProtectedCloudApi>;
200−
201−export 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.
213−export 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.
224−export { 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−
6−import type { VerifiedToken } from "../mcp/jwt";
7−
8−export const TEST_BEARER_PREFIX = "test-accept::";
9−export const NO_ORG_SENTINEL = "none";
10−
11−export const makeTestBearer = (accountId: string, organizationId: string | null): string =>
12− `${TEST_BEARER_PREFIX}${accountId}::${organizationId ?? NO_ORG_SENTINEL}`;
13−
14−export 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−
16−import { Effect, Layer } from "effect";
17−import { drizzle } from "drizzle-orm/postgres-js";
18−import postgres, { type Sql } from "postgres";
19−
20−import {
21− McpAuth,
22− McpAuthLive,
23− McpJwtVerificationError,
24− McpOrganizationAuth,
25− McpOrganizationAuthLive,
26− mcpAuthorized,
27− mcpUnauthorized,
28−} from "../mcp/auth";
29−import { classifyMcpPath, makeMcpWebHandler, prepareMcpOrgScope } from "../mcp/mount";
30−import { cloudMcpAuthProviderLayer } from "../mcp/auth-provider";
31−import { ApiKeyService } from "../auth/api-keys";
32−import { organizations } from "../db/schema";
33−import { parseTestBearer } from "./test-bearer";
34−import { DoTelemetryLive } from "../observability/telemetry";
35−import { CoreSharedServices } from "../auth/workos";
36−
37−export { McpSessionDO } from "../mcp/session-durable-object";
38−
39−const 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−
56−const 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−
74−const 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.
83−const 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.
115−const testMcpFetch = makeMcpWebHandler({
116− authProvider: cloudMcpAuthProviderLayer,
117− seamsRequirements: Layer.mergeAll(TestMcpAuthLive, TestMcpOrganizationAuthLive),
118− runtime: DoTelemetryLive,
119−});
120−
121−const 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−
130−export 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
66 "strict": true,
77 "esModuleInterop": true,
88 "skipLibCheck": true,
9− "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"],
9+ "types": ["@cloudflare/workers-types"],
1010 "outDir": "dist",
1111 "rootDir": ".",
1212 "declaration": false,
@@ -22,5 +22,5 @@ · 9 unmodified lines
2222 }
2323 ]
2424 },
25− "include": ["src", "vite.config.ts", "worker-configuration.d.ts", "test-types.d.ts"]
25+ "include": ["src", "vite.config.ts", "worker-configuration.d.ts"]
2626 }
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).
14 import { resolve } from "node:path";
2−
3−import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
45 import { defineConfig } from "vitest/config";
56
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.
167 const tanstackStartEntryStub = resolve(__dirname, "./test-stubs/tanstack-start-entry.ts");
178
189 export default defineConfig({
19− plugins: [
20− cloudflareTest({
21− wrangler: { configPath: "./wrangler.test.jsonc" },
22− }),
23− ],
2410 resolve: {
2511 alias: {
12+ "cloudflare:workers": resolve(__dirname, "./test-stubs/cloudflare-workers.ts"),
2613 "#tanstack-start-entry": tanstackStartEntryStub,
2714 "#tanstack-router-entry": tanstackStartEntryStub,
2815 "#tanstack-start-plugin-adapters": tanstackStartEntryStub,
@@ -30,18 +17,15 @@ export default defineConfig({ · 1 unmodified line
3017 },
3118 test: {
3219 include: ["src/**/*.test.ts"],
33− exclude: ["src/**/*.node.test.ts", "**/node_modules/**"],
20+ exclude: ["**/node_modules/**"],
3421 globalSetup: ["./scripts/test-globalsetup.ts"],
35− // postgres.js's Cloudflare polyfill leaves a couple of `.then()` chains
36− // on `writer.ready` uncaught when the socket tears down before the
37− // writer settles (DbService scope close). The rejection is benign —
38− // the socket is closing anyway — so filter it out rather than fail
39− // the run with noise.
40− onUnhandledError(error) {
41− // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: Vitest unhandled-error hook receives unknown host errors
42− if (error && (error as Error).message === "Stream was cancelled.") {
43− return false;
44− }
22+ fileParallelism: false,
23+ env: {
24+ DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5434/postgres",
25+ WORKOS_API_KEY: "test_api_key",
26+ WORKOS_CLIENT_ID: "test_client_id",
27+ WORKOS_COOKIE_PASSWORD: "test_cookie_password_at_least_32_chars!",
28+ NODE_ENV: "test",
4529 },
4630 },
4731 });
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−
7−import { resolve } from "node:path";
8−import { defineConfig } from "vitest/config";
9−
10−export 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
9999 "sonner": "^2.0.7",
100100 },
101101 "devDependencies": {
102− "@cloudflare/vitest-pool-workers": "^0.15.0",
103102 "@cloudflare/workers-types": "^4.20250620.0",
104103 "@effect/platform-node": "catalog:",
105104 "@effect/vitest": "catalog:",
@@ -328,6 +327,7 @@ · 222 unmodified lines
328327 "@executor-js/plugin-openapi": "workspace:*",
329328 "@executor-js/sdk": "workspace:*",
330329 "@kitlangton/terminal-control": "^0.3.0",
330+ "@modelcontextprotocol/sdk": "^1.29.0",
331331 "effect": "catalog:",
332332 "monaco-editor": "^0.55.1",
333333 "playwright": "^1.60.0",
@@ -4037,7 +4037,7 @@ · 3703 unmodified lines
40374037
40384038 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
40394039
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=="],
40414041
40424042 "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
40434043
@@ -5085,6 +5085,8 @@ · 1041 unmodified lines
50855085
50865086 "@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=="],
50875087
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+
50885090 "@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=="],
50895091
50905092 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -5523,7 +5525,7 @@ · 432 unmodified lines
55235525
55245526 "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
55255527
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=="],
55275529
55285530 "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
55295531
@@ -5639,8 +5641,6 @@ · 109 unmodified lines
56395641
56405642 "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=="],
56415643
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−
56445644 "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=="],
56455645
56465646 "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
57195719
57205720 "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
57215721
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+
57225726 "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
57235727
57245728 "@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
60976101
60986102 "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
60996103
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−
61106104 "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
61116105
61126106 "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
62036197
62046198 "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
62056199
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−
62106200 "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
62116201
62126202 "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -6287,6 +6277,16 @@ · 74 unmodified lines
62876277
62886278 "@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=="],
62896279
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+
62906290 "@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=="],
62916291
62926292 "@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).
6+import { expect } from "@effect/vitest";
7+import { Effect } from "effect";
8+import { AccountHttpApi } from "@executor-js/api";
9+
10+import { scenario } from "../src/scenario";
11+
12+scenario(
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+
29+scenario(
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+
79+scenario(
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.
7+import { expect } from "@effect/vitest";
8+import { Effect } from "effect";
9+
10+import { scenario } from "../src/scenario";
11+
12+/** First `Set-Cookie` header for `name`, as the raw header string. */
13+const 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+
20+scenario(
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+
47+scenario(
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+
85+scenario(
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+
107+scenario(
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.
7+import { randomBytes } from "node:crypto";
8+
9+import { expect } from "@effect/vitest";
10+import { Effect } from "effect";
11+import { composePluginApi } from "@executor-js/api/server";
12+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+import {
14+ AuthTemplateSlug,
15+ ConnectionName,
16+ IntegrationSlug,
17+ ProviderItemId,
18+} from "@executor-js/sdk/shared";
19+
20+import { scenario } from "../src/scenario";
21+
22+const 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. */
26+const 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+
36+scenario(
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.
12+import { randomBytes } from "node:crypto";
13+
14+import { expect } from "@effect/vitest";
15+import { Effect } from "effect";
16+import type { HttpApiClient } from "effect/unstable/httpapi";
17+import { composePluginApi } from "@executor-js/api/server";
18+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20+
21+import { scenario } from "../src/scenario";
22+import type { Identity, Target } from "../src/target";
23+
24+const api = composePluginApi([openApiHttpPlugin()] as const);
25+type Client = HttpApiClient.ForApi<typeof api>;
26+
27+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
28+
29+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
30+const 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. */
41+const 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+
61+const 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+
67+const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
68+
69+const 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. */
87+const 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. */
97+const 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. */
110+const 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. */
117+const 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. */
126+const 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+
137+scenario(
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+
194+scenario(
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+
237+scenario(
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.
8+import { randomBytes } from "node:crypto";
9+
10+import { expect } from "@effect/vitest";
11+import { Effect } from "effect";
12+import type { HttpApiClient } from "effect/unstable/httpapi";
13+import { composePluginApi } from "@executor-js/api/server";
14+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
15+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16+
17+import { scenario } from "../src/scenario";
18+
19+const api = composePluginApi([openApiHttpPlugin()] as const);
20+type Client = HttpApiClient.ForApi<typeof api>;
21+
22+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
23+
24+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
25+const 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. */
36+const 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.
57+const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
58+
59+scenario(
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+
102+scenario(
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+
146+scenario(
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+
181+scenario(
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+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { composePluginApi } from "@executor-js/api/server";
15+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17+
18+import { scenario } from "../src/scenario";
19+import type { Identity } from "../src/target";
20+
21+const coreApi = composePluginApi([] as const);
22+
23+const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
24+
25+interface Connected {
26+ readonly client: Client;
27+ readonly transport: StreamableHTTPClientTransport;
28+}
29+
30+/** A real SDK client over StreamableHTTP; `sessionId` resumes an existing session. */
31+const 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+
48+const 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+
54+const closeQuietly = (connected: Connected): Effect.Effect<void> =>
55+ Effect.promise(() => connected.client.close().catch(() => undefined));
56+
57+scenario(
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+
93+scenario(
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+
135+scenario(
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+
165+scenario(
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+
213+const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list";
214+
215+const GATED_CODE = `
216+const result = await tools.executor.coreTools.policies.list({});
217+return JSON.stringify(result);
218+`;
219+
220+scenario(
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.
13+import { randomBytes, randomUUID } from "node:crypto";
14+
15+import { expect } from "@effect/vitest";
16+import { Effect } from "effect";
17+
18+import { scenario } from "../src/scenario";
19+import type { Identity } from "../src/target";
20+
21+const JSON_AND_SSE = "application/json, text/event-stream";
22+const PROTOCOL_VERSION = "2025-03-26";
23+const 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+
34+const TOOLS_LIST_REQUEST = {
35+ jsonrpc: "2.0" as const,
36+ id: 2,
37+ method: "tools/list",
38+ params: {},
39+};
40+
41+const INITIALIZED_NOTIFICATION = {
42+ jsonrpc: "2.0" as const,
43+ method: "notifications/initialized",
44+};
45+
46+type JsonRpcError = {
47+ readonly jsonrpc: string;
48+ readonly error: { readonly code: number; readonly message: string };
49+};
50+
51+const 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. */
54+const 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+
62+const 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. */
78+const 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+
97+scenario(
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+
126+scenario(
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+
175+scenario(
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+
201+scenario(
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+
232+scenario("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+
253+scenario(
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+
273+scenario(
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+
311+scenario(
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+
335+scenario(
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+
374+scenario(
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+
406+scenario(
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+
434+scenario(
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+
470+scenario(
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+
511+scenario(
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).
10+import { randomBytes } from "node:crypto";
11+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { composePluginApi } from "@executor-js/api/server";
15+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
16+import {
17+ AuthTemplateSlug,
18+ ConnectionName,
19+ IntegrationSlug,
20+ OAuthClientSlug,
21+ OAuthState,
22+} from "@executor-js/sdk/shared";
23+import { serveOAuthTestServer } from "@executor-js/sdk/testing";
24+
25+import { scenario } from "../src/scenario";
26+
27+const api = composePluginApi([openApiHttpPlugin()] as const);
28+
29+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
30+
31+/** Narrow a `start` result to the redirect arm, failing with what came back. */
32+const 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+
41+scenario(
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+
60+scenario(
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+
100+scenario(
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+
221+scenario("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
4040 });
4141
4242 const openCreateOrgModal = async (currentOrg: string) => {
43− await page.getByRole("button", { name: /Test User/ }).click();
44− // The org entry is a radix submenu trigger; its content loads the
45− // org list from the API, so wait for the sub-content to mount and
46− // click "Create organization" scoped INSIDE it — clicking during
47− // the loading re-render dismisses the whole menu.
48− await page.getByRole("menuitem", { name: currentOrg }).click();
49− const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');
50− await subContent.waitFor({ state: "visible" });
51− await subContent.getByText("Create organization", { exact: true }).click();
52− await page.getByText("Add another organization").waitFor();
43+ // Under parallel-suite load the radix menu re-renders while the org
44+ // list loads; a click can land on a closing menu. Bounded retry with
45+ // a clean reopen keeps the journey honest without 30s hangs.
46+ for (let attempt = 1; ; attempt++) {
47+ try {
48+ await page.keyboard.press("Escape");
49+ await page.getByRole("button", { name: /Test User/ }).click();
50+ await page.getByRole("menuitem", { name: currentOrg }).click({ timeout: 5_000 });
51+ const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');
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+ }
5362 };
5463
5564 for (let i = 2; i <= FREE_LIMIT; i++) {
e2e/cloud/org-switcher.test.ts+18−6
@@ -43,12 +43,24 @@ scenario( · 42 unmodified lines
4343
4444 // ── Step 2: create the second org via the account-menu switcher ──
4545 await step('Open the org switcher and choose "Create organization"', async () => {
46− await page.getByRole("button", { name: /Test User/ }).click();
47− await page.getByRole("menuitem", { name: ORG_1 }).click();
48− const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]');
49− await subContent.waitFor({ state: "visible" });
50− await subContent.getByText("Create organization", { exact: true }).click();
51− await page.getByText("Add another organization").waitFor();
46+ // Bounded retry: under parallel-suite load the radix menu re-renders
47+ // while the org list loads and a click can land on a closing menu.
48+ for (let attempt = 1; ; attempt++) {
49+ try {
50+ await page.keyboard.press("Escape");
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+ }
5264 });
5365
5466 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.
12+import { randomBytes } from "node:crypto";
13+import { readFileSync } from "node:fs";
14+import { fileURLToPath } from "node:url";
15+
16+import { expect } from "@effect/vitest";
17+import { Effect } from "effect";
18+import { composePluginApi } from "@executor-js/api/server";
19+import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
20+import {
21+ makeGreetingGraphqlSchema,
22+ serveGraphqlTestServer,
23+} from "@executor-js/plugin-graphql/testing";
24+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
25+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
26+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
27+import { serveOpenApiEchoTestServer } from "@executor-js/plugin-openapi/testing";
28+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
29+
30+import { scenario } from "../src/scenario";
31+
32+const 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). */
36+const newSlug = (prefix: string) =>
37+ IntegrationSlug.make(`${prefix}_${randomBytes(4).toString("hex")}`);
38+
39+const MAIN = ConnectionName.make("main");
40+const PERSONAL = ConnectionName.make("personal");
41+const API_KEY = AuthTemplateSlug.make("apiKey");
42+const NONE = AuthTemplateSlug.make("none");
43+
44+/** Minimal OpenAPI 3 spec with a single GET /ping operation. */
45+const 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. */
61+const 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+
70+scenario(
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+
116+scenario(
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+
132+scenario(
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+
201+scenario(
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+
235+scenario(
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+
300+scenario(
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+
362+scenario(
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.
432+const CLOUDFLARE_SPEC_PATH = fileURLToPath(
433+ new URL("../../packages/plugins/openapi/fixtures/cloudflare.json", import.meta.url),
434+);
435+
436+scenario(
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.
10+import { randomBytes } from "node:crypto";
11+import { createServer } from "node:http";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+import { composePluginApi } from "@executor-js/api/server";
16+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
17+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
18+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20+
21+import { scenario } from "../src/scenario";
22+
23+const api = composePluginApi([openApiHttpPlugin(), mcpHttpPlugin()] as const);
24+
25+const newSlug = (prefix: string) =>
26+ IntegrationSlug.make(`${prefix}_${randomBytes(4).toString("hex")}`);
27+
28+const MAIN = ConnectionName.make("main");
29+const NONE = AuthTemplateSlug.make("none");
30+
31+/** Minimal OpenAPI 3 spec with a single GET /ping operation. */
32+const 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. */
49+const 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+
73+scenario(
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+
136+scenario(
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+
164+scenario(
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.
6+import { expect } from "@effect/vitest";
7+import { Effect } from "effect";
8+
9+import { scenario } from "../src/scenario";
10+
11+scenario(
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+
40+scenario(
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+
66+scenario(
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+
99+scenario(
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.
6+import { randomBytes } from "node:crypto";
7+
8+import { expect } from "@effect/vitest";
9+import { Effect } from "effect";
10+import type { HttpApiClient } from "effect/unstable/httpapi";
11+import { composePluginApi } from "@executor-js/api/server";
12+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
14+
15+import { scenario } from "../src/scenario";
16+
17+const api = composePluginApi([openApiHttpPlugin()] as const);
18+type Client = HttpApiClient.ForApi<typeof api>;
19+
20+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
21+
22+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
23+const 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+
33+const 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+
50+scenario(
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+
111+scenario(
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
44 "private": true,
55 "type": "module",
66 "scripts": {
7− "test": "vitest run",
7+ "test": "vitest run --project cloud && vitest run --project selfhost",
88 "test:cloud": "vitest run --project cloud",
99 "test:selfhost": "vitest run --project selfhost",
1010 "test:watch": "vitest",
@@ -20,6 +20,7 @@ · 9 unmodified lines
2020 "@executor-js/plugin-openapi": "workspace:*",
2121 "@executor-js/sdk": "workspace:*",
2222 "@kitlangton/terminal-control": "^0.3.0",
23+ "@modelcontextprotocol/sdk": "^1.29.0",
2324 "effect": "catalog:",
2425 "monaco-editor": "^0.55.1",
2526 "playwright": "^1.60.0",
e2e/src/surfaces/mcp.ts+72−0
@@ -2,6 +2,7 @@ · 1 unmodified line
22 // headless OAuth via the target's consent strategy. Session methods are
33 // Effects; mcporter itself is promise-native underneath. Assertions are
44 // vitest's job.
5+import { createHash, randomBytes, randomUUID } from "node:crypto";
56 import { mkdtempSync, writeFileSync } from "node:fs";
67 import { tmpdir } from "node:os";
78 import { join } from "node:path";
@@ -30,6 +31,14 @@ export interface McpSession { · 22 unmodified lines
3031
3132 export interface McpSurface {
3233 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>;
3342 }
3443
3544 const textOf = (result: unknown): string => {
@@ -44,6 +53,69 @@ const textOf = (result: unknown): string => { · 8 unmodified lines
4453 };
4554
4655 export 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+ }),
47119 session: (identity) => {
48120 const serverName = target.name;
49121 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
1919 export default defineConfig({
2020 test: {
2121 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 }),
2326 // selfhost identities are the shared bootstrap admin for now — run files
2427 // serially until per-test invite-signup isolation lands.
2528 project("selfhost", { fileParallelism: false }),
packages/hosts/cloudflare/package.json+4−0
@@ -23,6 +23,10 @@ · 22 unmodified lines
2323 "./mcp/durable-object": {
2424 "types": "./src/mcp/session-durable-object.ts",
2525 "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"
2630 }
2731 },
2832 "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.
4+import { Effect, Exit, Match, Option, Predicate } from "effect";
5+
6+type JsonRpcLike = {
7+ readonly id?: unknown;
8+ readonly method?: unknown;
9+ readonly params?: unknown;
10+};
11+
12+type ToolCallParamsLike = {
13+ readonly name?: unknown;
14+ readonly arguments?: unknown;
15+};
16+
17+type ResumeArgumentsLike = {
18+ readonly executionId?: unknown;
19+};
20+
21+const 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+
33+const 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+
50+const 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.
76+export const PREVIOUS_REQUEST_TIMEOUT_MS = 60_000;
77+
78+export 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 @@
11 import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";
22 import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3−import { Data, Effect, Exit, Match, Option, Predicate } from "effect";
3+import { Data, Effect } from "effect";
4+
5+import { JsonRpcRequestIdQueue } from "./request-id-queue.js";
6+
7+export { JsonRpcRequestIdQueue, PREVIOUS_REQUEST_TIMEOUT_MS } from "./request-id-queue.js";
48
59 export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{
610 readonly cause: unknown;
@@ -13,21 +17,6 @@ export type McpWorkerTransport = Readonly<{ · 6 unmodified lines
1317 close: () => Effect.Effect<void>;
1418 }>;
1519
16−type JsonRpcLike = {
17− readonly id?: unknown;
18− readonly method?: unknown;
19− readonly params?: unknown;
20−};
21−
22−type ToolCallParamsLike = {
23− readonly name?: unknown;
24− readonly arguments?: unknown;
25−};
26−
27−type ResumeArgumentsLike = {
28− readonly executionId?: unknown;
29−};
30−
3120 type HandleRequestResult = {
3221 readonly response: Response;
3322 readonly replacedStandaloneSse: boolean;
@@ -58,112 +47,6 @@ const closeExistingStandaloneSse = (transport: WorkerTransport): boolean => { · 24 unmodified lines
5847 const isStandaloneSseGet = (request: Request): boolean =>
5948 request.method === "GET" && (request.headers.get("accept") ?? "").includes("text/event-stream");
6049
61−const 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−
73−const 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−
90−const 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.
116−export const PREVIOUS_REQUEST_TIMEOUT_MS = 60_000;
117−
118−export 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−
16750 export const makeMcpWorkerTransport = (
16851 options: WorkerTransportOptions,
16952 ): 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