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