* 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.
Unified · Split
| @@ -72,13 +72,7 @@ · 71 unmodified lines | ||
| 72 | 72 | // drivers (Playwright, PTY, mcporter, raw HTTP) plus build tooling (the |
| 73 | 73 | // run viewer); they normalize untyped failures into the recorded |
| 74 | 74 | // 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}"], | |
| 82 | 76 | "rules": { |
| 83 | 77 | // The viewer is standalone tooling — it deliberately doesn't pull the |
| 84 | 78 | // product design system or Effect in. |
| @@ -23,9 +23,8 @@ · 22 unmodified lines | ||
| 23 | 23 | "deploy": "op run --env-file=.env.production -- bash -lc 'bun run build && wrangler deploy -c dist/server/wrangler.json'", |
| 24 | 24 | "cf-typegen": "wrangler types", |
| 25 | 25 | "typecheck": "tsgo --noEmit", |
| 26 | − "test": "node ../../node_modules/vitest/vitest.mjs run && node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts", | |
| 26 | + "test": "node ../../node_modules/vitest/vitest.mjs run", | |
| 27 | 27 | "test:watch": "node ../../node_modules/vitest/vitest.mjs", |
| 28 | − "test:node": "node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts", | |
| 29 | 28 | "typecheck:slow": "tsc --noEmit" |
| 30 | 29 | }, |
| 31 | 30 | "dependencies": { |
| @@ -72,7 +71,6 @@ · 40 unmodified lines | ||
| 72 | 71 | "sonner": "^2.0.7" |
| 73 | 72 | }, |
| 74 | 73 | "devDependencies": { |
| 75 | − "@cloudflare/vitest-pool-workers": "^0.15.0", | |
| 76 | 74 | "@cloudflare/workers-types": "^4.20250620.0", |
| 77 | 75 | "@effect/platform-node": "catalog:", |
| 78 | 76 | "@effect/vitest": "catalog:", |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | 2 | // Vitest globalSetup — starts an in-process PGlite socket server so tests |
| 3 | 3 | // running in the Cloudflare Workers runtime can connect to a real Postgres |
| 4 | −// via postgres.js. Port must match DATABASE_URL in wrangler.test.jsonc. | |
| 4 | +// via postgres.js. Port must match DATABASE_URL in vitest.config.ts. | |
| 5 | 5 | // --------------------------------------------------------------------------- |
| 6 | 6 | |
| 7 | 7 | import { PGlite } from "@electric-sql/pglite"; |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -79,8 +79,8 @@ export const CLOUD_MOUNT_PREFIX = "/api" as const; · 78 unmodified lines | ||
| 79 | 79 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 80 | 80 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 81 | 81 | // 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`. | |
| 84 | 84 | allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", |
| 85 | 85 | webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", |
| 86 | 86 | // `oauthCallbackPath` is NOT set here — `ExecutorApp.make` derives it from the |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -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 | −}); | |
| @@ -3,7 +3,6 @@ · 2 unmodified lines | ||
| 3 | 3 | // |
| 4 | 4 | // Kept as its own `cloudflare:workers`-free leaf: the node-pool test |
| 5 | 5 | // (mcp-auth.node.test.ts) imports these verifiers at runtime, and |
| 6 | −// `test-bearer.ts` (shared with node tests) imports `VerifiedToken` from here. | |
| 7 | 6 | // `mcp/auth.ts` (which DOES read `cloudflare:workers` env) imports this leaf; |
| 8 | 7 | // the dependency points one way only. |
| 9 | 8 | // --------------------------------------------------------------------------- |
| @@ -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 | −}); | |
| @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | JsonRpcRequestIdQueue, |
| 5 | 5 | PREVIOUS_REQUEST_TIMEOUT_MS, |
| 6 | −} from "@executor-js/cloudflare/mcp/worker-transport"; | |
| 6 | +} from "@executor-js/cloudflare/mcp/request-id-queue"; | |
| 7 | 7 | |
| 8 | 8 | const jsonRpcRequest = (body: unknown): Request => |
| 9 | 9 | new Request("https://example.invalid/mcp", { |
| @@ -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 | −}); | |
| @@ -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 }; | |
| @@ -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 | −}; | |
| @@ -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 | −}; | |
| @@ -1 +0,0 @@ | ||
| 1 | −/// <reference types="@cloudflare/vitest-pool-workers/types" /> | |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | "strict": true, |
| 7 | 7 | "esModuleInterop": true, |
| 8 | 8 | "skipLibCheck": true, |
| 9 | − "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"], | |
| 9 | + "types": ["@cloudflare/workers-types"], | |
| 10 | 10 | "outDir": "dist", |
| 11 | 11 | "rootDir": ".", |
| 12 | 12 | "declaration": false, |
| @@ -22,5 +22,5 @@ · 9 unmodified lines | ||
| 22 | 22 | } |
| 23 | 23 | ] |
| 24 | 24 | }, |
| 25 | − "include": ["src", "vite.config.ts", "worker-configuration.d.ts", "test-types.d.ts"] | |
| 25 | + "include": ["src", "vite.config.ts", "worker-configuration.d.ts"] | |
| 26 | 26 | } |
| @@ -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). | |
| 1 | 4 | import { resolve } from "node:path"; |
| 2 | − | |
| 3 | −import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; | |
| 4 | 5 | import { defineConfig } from "vitest/config"; |
| 5 | 6 | |
| 6 | −// `auth/handlers.ts` imports `setCookie`/`deleteCookie` from | |
| 7 | −// `@tanstack/react-start/server`. That barrel transitively pulls in | |
| 8 | −// `@tanstack/start-server-core`, which does `import("#tanstack-start-entry")` | |
| 9 | −// (+ `#tanstack-router-entry` / `#tanstack-start-plugin-adapters`). Those | |
| 10 | −// `imports`-field specifiers are declared on `@tanstack/start-client-core`, | |
| 11 | −// not on `start-server-core`, so Vite's resolver (used by the workerd pool) | |
| 12 | −// can't resolve them relative to the importing package and the module graph | |
| 13 | −// fails to load. Alias the three specifiers to a no-op stub so the workerd | |
| 14 | −// pool can load any module that transitively imports react-start. Cloud only | |
| 15 | −// uses the cookie helpers, never the SSR handler path the stub shims out. | |
| 16 | 7 | const tanstackStartEntryStub = resolve(__dirname, "./test-stubs/tanstack-start-entry.ts"); |
| 17 | 8 | |
| 18 | 9 | export default defineConfig({ |
| 19 | − plugins: [ | |
| 20 | − cloudflareTest({ | |
| 21 | − wrangler: { configPath: "./wrangler.test.jsonc" }, | |
| 22 | − }), | |
| 23 | − ], | |
| 24 | 10 | resolve: { |
| 25 | 11 | alias: { |
| 12 | + "cloudflare:workers": resolve(__dirname, "./test-stubs/cloudflare-workers.ts"), | |
| 26 | 13 | "#tanstack-start-entry": tanstackStartEntryStub, |
| 27 | 14 | "#tanstack-router-entry": tanstackStartEntryStub, |
| 28 | 15 | "#tanstack-start-plugin-adapters": tanstackStartEntryStub, |
| @@ -30,18 +17,15 @@ export default defineConfig({ · 1 unmodified line | ||
| 30 | 17 | }, |
| 31 | 18 | test: { |
| 32 | 19 | include: ["src/**/*.test.ts"], |
| 33 | − exclude: ["src/**/*.node.test.ts", "**/node_modules/**"], | |
| 20 | + exclude: ["**/node_modules/**"], | |
| 34 | 21 | 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", | |
| 45 | 29 | }, |
| 46 | 30 | }, |
| 47 | 31 | }); |
| @@ -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 | −}); | |
| @@ -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 | −} | |
| @@ -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 | −} | |
| @@ -99,7 +99,6 @@ · 98 unmodified lines | ||
| 99 | 99 | "sonner": "^2.0.7", |
| 100 | 100 | }, |
| 101 | 101 | "devDependencies": { |
| 102 | − "@cloudflare/vitest-pool-workers": "^0.15.0", | |
| 103 | 102 | "@cloudflare/workers-types": "^4.20250620.0", |
| 104 | 103 | "@effect/platform-node": "catalog:", |
| 105 | 104 | "@effect/vitest": "catalog:", |
| @@ -328,6 +327,7 @@ · 222 unmodified lines | ||
| 328 | 327 | "@executor-js/plugin-openapi": "workspace:*", |
| 329 | 328 | "@executor-js/sdk": "workspace:*", |
| 330 | 329 | "@kitlangton/terminal-control": "^0.3.0", |
| 330 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 331 | 331 | "effect": "catalog:", |
| 332 | 332 | "monaco-editor": "^0.55.1", |
| 333 | 333 | "playwright": "^1.60.0", |
| @@ -4037,7 +4037,7 @@ · 3703 unmodified lines | ||
| 4037 | 4037 | |
| 4038 | 4038 | "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], |
| 4039 | 4039 | |
| 4040 | − "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], | |
| 4040 | + "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], | |
| 4041 | 4041 | |
| 4042 | 4042 | "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], |
| 4043 | 4043 | |
| @@ -5085,6 +5085,8 @@ · 1041 unmodified lines | ||
| 5085 | 5085 | |
| 5086 | 5086 | "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], |
| 5087 | 5087 | |
| 5088 | + "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], | |
| 5089 | + | |
| 5088 | 5090 | "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], |
| 5089 | 5091 | |
| 5090 | 5092 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| @@ -5523,7 +5525,7 @@ · 432 unmodified lines | ||
| 5523 | 5525 | |
| 5524 | 5526 | "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], |
| 5525 | 5527 | |
| 5526 | − "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], | |
| 5528 | + "miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], | |
| 5527 | 5529 | |
| 5528 | 5530 | "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], |
| 5529 | 5531 | |
| @@ -5639,8 +5641,6 @@ · 109 unmodified lines | ||
| 5639 | 5641 | |
| 5640 | 5642 | "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], |
| 5641 | 5643 | |
| 5642 | − "wrangler/miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], | |
| 5643 | − | |
| 5644 | 5644 | "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], |
| 5645 | 5645 | |
| 5646 | 5646 | "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
| @@ -5719,6 +5719,10 @@ · 72 unmodified lines | ||
| 5719 | 5719 | |
| 5720 | 5720 | "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], |
| 5721 | 5721 | |
| 5722 | + "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], | |
| 5723 | + | |
| 5724 | + "@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], | |
| 5725 | + | |
| 5722 | 5726 | "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], |
| 5723 | 5727 | |
| 5724 | 5728 | "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], |
| @@ -6097,16 +6101,6 @@ · 372 unmodified lines | ||
| 6097 | 6101 | |
| 6098 | 6102 | "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], |
| 6099 | 6103 | |
| 6100 | − "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], | |
| 6101 | − | |
| 6102 | − "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], | |
| 6103 | − | |
| 6104 | − "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], | |
| 6105 | − | |
| 6106 | − "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], | |
| 6107 | − | |
| 6108 | − "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], | |
| 6109 | − | |
| 6110 | 6104 | "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], |
| 6111 | 6105 | |
| 6112 | 6106 | "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], |
| @@ -6203,10 +6197,6 @@ · 90 unmodified lines | ||
| 6203 | 6197 | |
| 6204 | 6198 | "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], |
| 6205 | 6199 | |
| 6206 | − "wrangler/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], | |
| 6207 | − | |
| 6208 | − "wrangler/miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], | |
| 6209 | − | |
| 6210 | 6200 | "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], |
| 6211 | 6201 | |
| 6212 | 6202 | "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| @@ -6287,6 +6277,16 @@ · 74 unmodified lines | ||
| 6287 | 6277 | |
| 6288 | 6278 | "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="], |
| 6289 | 6279 | |
| 6280 | + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], | |
| 6281 | + | |
| 6282 | + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], | |
| 6283 | + | |
| 6284 | + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], | |
| 6285 | + | |
| 6286 | + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], | |
| 6287 | + | |
| 6288 | + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], | |
| 6289 | + | |
| 6290 | 6290 | "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], |
| 6291 | 6291 | |
| 6292 | 6292 | "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -40,16 +40,25 @@ scenario( · 39 unmodified lines | ||
| 40 | 40 | }); |
| 41 | 41 | |
| 42 | 42 | 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 | + } | |
| 53 | 62 | }; |
| 54 | 63 | |
| 55 | 64 | for (let i = 2; i <= FREE_LIMIT; i++) { |
| @@ -43,12 +43,24 @@ scenario( · 42 unmodified lines | ||
| 43 | 43 | |
| 44 | 44 | // ── Step 2: create the second org via the account-menu switcher ── |
| 45 | 45 | 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 | + } | |
| 52 | 64 | }); |
| 53 | 65 | |
| 54 | 66 | await step(`Create "${ORG_2}" via the org switcher modal`, async () => { |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -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 | +); | |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | − "test": "vitest run", | |
| 7 | + "test": "vitest run --project cloud && vitest run --project selfhost", | |
| 8 | 8 | "test:cloud": "vitest run --project cloud", |
| 9 | 9 | "test:selfhost": "vitest run --project selfhost", |
| 10 | 10 | "test:watch": "vitest", |
| @@ -20,6 +20,7 @@ · 9 unmodified lines | ||
| 20 | 20 | "@executor-js/plugin-openapi": "workspace:*", |
| 21 | 21 | "@executor-js/sdk": "workspace:*", |
| 22 | 22 | "@kitlangton/terminal-control": "^0.3.0", |
| 23 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 23 | 24 | "effect": "catalog:", |
| 24 | 25 | "monaco-editor": "^0.55.1", |
| 25 | 26 | "playwright": "^1.60.0", |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | // headless OAuth via the target's consent strategy. Session methods are |
| 3 | 3 | // Effects; mcporter itself is promise-native underneath. Assertions are |
| 4 | 4 | // vitest's job. |
| 5 | +import { createHash, randomBytes, randomUUID } from "node:crypto"; | |
| 5 | 6 | import { mkdtempSync, writeFileSync } from "node:fs"; |
| 6 | 7 | import { tmpdir } from "node:os"; |
| 7 | 8 | import { join } from "node:path"; |
| @@ -30,6 +31,14 @@ export interface McpSession { · 22 unmodified lines | ||
| 30 | 31 | |
| 31 | 32 | export interface McpSurface { |
| 32 | 33 | readonly session: (identity: Identity) => McpSession; |
| 34 | + /** | |
| 35 | + * Mint a real MCP bearer exactly the way an MCP client does, headlessly: | |
| 36 | + * protected-resource discovery → authorization-server discovery → dynamic | |
| 37 | + * client registration → authorize with PKCE (consent via the target's | |
| 38 | + * strategy) → code exchange. For raw-wire scenarios that drive /mcp without | |
| 39 | + * an MCP client library. | |
| 40 | + */ | |
| 41 | + readonly mintBearer: (email: string) => Effect.Effect<string>; | |
| 33 | 42 | } |
| 34 | 43 | |
| 35 | 44 | const textOf = (result: unknown): string => { |
| @@ -44,6 +53,69 @@ const textOf = (result: unknown): string => { · 8 unmodified lines | ||
| 44 | 53 | }; |
| 45 | 54 | |
| 46 | 55 | 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 | + }), | |
| 47 | 119 | session: (identity) => { |
| 48 | 120 | const serverName = target.name; |
| 49 | 121 | let runtimePromise: Promise<Runtime> | undefined; |
| @@ -19,7 +19,10 @@ const project = (name: string, overrides: Record<string, unknown> = {}) => ({ · 18 unmodified lines | ||
| 19 | 19 | export default defineConfig({ |
| 20 | 20 | test: { |
| 21 | 21 | projects: [ |
| 22 | − project("cloud"), | |
| 22 | + // PGlite's socket server is effectively single-connection; parallel test | |
| 23 | + // files (each fanning out per-request postgres sockets) crash it. Run | |
| 24 | + // files serially — swap PGlite for real Postgres if wall-clock matters. | |
| 25 | + project("cloud", { fileParallelism: false }), | |
| 23 | 26 | // selfhost identities are the shared bootstrap admin for now — run files |
| 24 | 27 | // serially until per-test invite-signup isolation lands. |
| 25 | 28 | project("selfhost", { fileParallelism: false }), |
| @@ -23,6 +23,10 @@ · 22 unmodified lines | ||
| 23 | 23 | "./mcp/durable-object": { |
| 24 | 24 | "types": "./src/mcp/session-durable-object.ts", |
| 25 | 25 | "default": "./src/mcp/session-durable-object.ts" |
| 26 | + }, | |
| 27 | + "./mcp/request-id-queue": { | |
| 28 | + "types": "./src/mcp/request-id-queue.ts", | |
| 29 | + "default": "./src/mcp/request-id-queue.ts" | |
| 26 | 30 | } |
| 27 | 31 | }, |
| 28 | 32 | "scripts": { |
| @@ -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 | +} | |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp"; |
| 2 | 2 | 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"; | |
| 4 | 8 | |
| 5 | 9 | export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{ |
| 6 | 10 | readonly cause: unknown; |
| @@ -13,21 +17,6 @@ export type McpWorkerTransport = Readonly<{ · 6 unmodified lines | ||
| 13 | 17 | close: () => Effect.Effect<void>; |
| 14 | 18 | }>; |
| 15 | 19 | |
| 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 | − | |
| 31 | 20 | type HandleRequestResult = { |
| 32 | 21 | readonly response: Response; |
| 33 | 22 | readonly replacedStandaloneSse: boolean; |
| @@ -58,112 +47,6 @@ const closeExistingStandaloneSse = (transport: WorkerTransport): boolean => { · 24 unmodified lines | ||
| 58 | 47 | const isStandaloneSseGet = (request: Request): boolean => |
| 59 | 48 | request.method === "GET" && (request.headers.get("accept") ?? "").includes("text/event-stream"); |
| 60 | 49 | |
| 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 | − | |
| 167 | 50 | export const makeMcpWorkerTransport = ( |
| 168 | 51 | options: WorkerTransportOptions, |
| 169 | 52 | ): Effect.Effect<McpWorkerTransport> => |