- WORKOS_API_URL / AUTUMN_API_URL env seams point the REAL SDKs (incl. the vault plugin via credentials.apiUrl) at the vendored emulators. Production composition is unconditional again — the EXECUTOR_E2E_STUB layer swap and every *.test-layer.ts are deleted. - e2e cloud target: identities minted through the REAL login flow (login → emulator-hosted AuthKit, headless via login_hint → callback → genuine sealed-session cookie), orgs through the real create-organization route. - MCP_AUTHKIT_DOMAIN points at the emulator's OAuth authorization server — cloud MCP OAuth e2e now RUNS (was capability-skipped): 19/19 scenarios, zero skips across both targets. - Readiness probes wait for /api/auth/login to actually 302 (vite serves the SPA for /api/* during cold compile); submenu interactions wait for the loaded sub-content before clicking.
Unified · Split
| @@ -5,7 +5,6 @@ import { DbProvider, ExecutorApp } from "@executor-js/api/server"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { cloudPlugins } from "./plugins"; |
| 7 | 7 | import { CoreSharedServices } from "./auth/workos"; |
| 8 | −import { E2E_STUB, E2EStubWorkOSLayer } from "./testing/e2e-stub"; | |
| 9 | 8 | import { makeCloudExtensionRoutes } from "./extensions/routes"; |
| 10 | 9 | import { RequestScopedServicesLive } from "./api/layers"; |
| 11 | 10 | import { CloudMeteringEngineDecorator } from "./engine/execution-stack-metered"; |
| @@ -56,12 +55,10 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 44 unmodified lines | ||
| 56 | 55 | // the account provider + MCP seam, AND by the per-request identity layer below). |
| 57 | 56 | // Lives in `boot`, so `workosIdentityLayer`'s residual `WorkOSClient | |
| 58 | 57 | // ApiKeyService` (the long-lived control plane it reads) resolves from there. |
| 59 | −// EXECUTOR_E2E_STUB swaps the WorkOS control plane for an in-memory stub whose | |
| 60 | −// `authenticateSealedSession` resolves to user_1 — so the whole app (session + | |
| 61 | −// account + SSR) is logged in with no real WorkOS. Off in production. | |
| 62 | −const workOSBase = E2E_STUB ? E2EStubWorkOSLayer : CoreSharedServices; | |
| 63 | −const apiKeyService = ApiKeyService.WorkOS.pipe(Layer.provide(workOSBase)); | |
| 64 | −const controlPlane = Layer.mergeAll(workOSBase, apiKeyService); | |
| 58 | +// Tests point the REAL client at a WorkOS emulator via WORKOS_API_URL — no | |
| 59 | +// layer substitution. | |
| 60 | +const apiKeyService = ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)); | |
| 61 | +const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); | |
| 65 | 62 | |
| 66 | 63 | // `CloudDbProvider` only reads the per-request `DbService` at runtime; we widen |
| 67 | 64 | // its residual type to also carry the boot `AutumnService` the metering |
| @@ -1,11 +0,0 @@ | ||
| 1 | −import { Effect, Layer } from "effect"; | |
| 2 | − | |
| 3 | −import { ApiKeyService } from "./api-keys"; | |
| 4 | −import { ApiKeyManagementError } from "./errors"; | |
| 5 | − | |
| 6 | −export const ApiKeyServiceTestLayer = Layer.succeed(ApiKeyService)({ | |
| 7 | − validate: () => Effect.succeed(null), | |
| 8 | − listUserKeys: () => Effect.succeed([]), | |
| 9 | − createUserKey: () => Effect.fail(new ApiKeyManagementError({ cause: "unstubbed" })), | |
| 10 | − revokeUserKey: () => Effect.void, | |
| 11 | −}); | |
| @@ -1,90 +0,0 @@ | ||
| 1 | −import { requestHandler } from "@tanstack/react-start/server"; | |
| 2 | −import { Context, Effect, Layer } from "effect"; | |
| 3 | −import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 4 | −import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; | |
| 5 | − | |
| 6 | −import { | |
| 7 | − AutumnTestLayer, | |
| 8 | − makeAutumnTestState, | |
| 9 | − type AutumnTestState, | |
| 10 | −} from "../extensions/billing/service.test-layer"; | |
| 11 | −import { ApiKeyServiceTestLayer } from "./api-keys.test-layer"; | |
| 12 | −import { | |
| 13 | − makeUserStoreTestState, | |
| 14 | − UserStoreTestLayer, | |
| 15 | − type UserStoreTestState, | |
| 16 | −} from "./context.test-layer"; | |
| 17 | −import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi } from "./handlers"; | |
| 18 | −import { | |
| 19 | − makeSessionTestContext, | |
| 20 | − SessionAuthTestLayer, | |
| 21 | − type SessionTestContext, | |
| 22 | −} from "./middleware.test-layer"; | |
| 23 | −import { makeWorkOSTestState, WorkOSTestLayer, type WorkOSTestState } from "./workos.test-layer"; | |
| 24 | − | |
| 25 | −const TEST_BASE_URL = "http://test.local"; | |
| 26 | −const SESSION_COOKIE = "wos-session=test_session"; | |
| 27 | − | |
| 28 | −export type CloudAuthApiTestState = { | |
| 29 | − readonly workos: WorkOSTestState; | |
| 30 | − readonly autumn: AutumnTestState; | |
| 31 | − readonly userStore: UserStoreTestState; | |
| 32 | − readonly session: SessionTestContext; | |
| 33 | −}; | |
| 34 | − | |
| 35 | −export const makeCloudAuthApiTestState = ( | |
| 36 | − overrides: Partial<CloudAuthApiTestState> = {}, | |
| 37 | −): CloudAuthApiTestState => ({ | |
| 38 | − workos: makeWorkOSTestState(), | |
| 39 | − autumn: makeAutumnTestState(), | |
| 40 | − userStore: makeUserStoreTestState(), | |
| 41 | − session: makeSessionTestContext(), | |
| 42 | − ...overrides, | |
| 43 | −}); | |
| 44 | − | |
| 45 | −const makeCloudAuthApiTestClient = (state: CloudAuthApiTestState) => { | |
| 46 | − const ApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe( | |
| 47 | − Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), | |
| 48 | − Layer.provide(SessionAuthTestLayer(state.session)), | |
| 49 | − Layer.provideMerge(WorkOSTestLayer(state.workos)), | |
| 50 | − Layer.provideMerge(AutumnTestLayer(state.autumn)), | |
| 51 | − Layer.provideMerge(UserStoreTestLayer(state.userStore)), | |
| 52 | − Layer.provideMerge(ApiKeyServiceTestLayer), | |
| 53 | − Layer.provideMerge(HttpServer.layerServices), | |
| 54 | − Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 55 | − ); | |
| 56 | − | |
| 57 | − const handler = HttpRouter.toWebHandler(ApiLive, { disableLogger: true }).handler; | |
| 58 | − const startHandler = requestHandler((request) => handler(request, undefined)); | |
| 59 | − const fetchViaHandler: typeof globalThis.fetch = async (input, init) => { | |
| 60 | − const request = input instanceof Request ? input : new Request(input, init); | |
| 61 | − request.headers.set("cookie", SESSION_COOKIE); | |
| 62 | − return await startHandler(request, undefined); | |
| 63 | − }; | |
| 64 | − const clientLayer = FetchHttpClient.layer.pipe( | |
| 65 | − Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchViaHandler)), | |
| 66 | − ); | |
| 67 | − | |
| 68 | − return HttpApiClient.make(NonProtectedApi, { baseUrl: TEST_BASE_URL }).pipe( | |
| 69 | − Effect.provide(clientLayer), | |
| 70 | − ); | |
| 71 | −}; | |
| 72 | − | |
| 73 | −export type CloudAuthApiTestClient = Effect.Success<ReturnType<typeof makeCloudAuthApiTestClient>>; | |
| 74 | − | |
| 75 | −export class CloudAuthApiTestContext extends Context.Service< | |
| 76 | − CloudAuthApiTestContext, | |
| 77 | − { | |
| 78 | − readonly state: CloudAuthApiTestState; | |
| 79 | − readonly client: CloudAuthApiTestClient; | |
| 80 | − } | |
| 81 | −>()("CloudAuthApiTestContext") {} | |
| 82 | − | |
| 83 | −export const CloudAuthApiTestContextLayer = (state = makeCloudAuthApiTestState()) => | |
| 84 | − Layer.effect( | |
| 85 | − CloudAuthApiTestContext, | |
| 86 | − Effect.gen(function* () { | |
| 87 | − const client = yield* makeCloudAuthApiTestClient(state); | |
| 88 | − return { state, client }; | |
| 89 | − }), | |
| 90 | − ); | |
| @@ -1,41 +0,0 @@ | ||
| 1 | −import { Effect, Layer } from "effect"; | |
| 2 | − | |
| 3 | −import { UserStoreService } from "./context"; | |
| 4 | − | |
| 5 | −type UserStore = Parameters<Parameters<UserStoreService["Service"]["use"]>[0]>[0]; | |
| 6 | − | |
| 7 | −export type UserStoreTestState = { | |
| 8 | − readonly upsertedOrganizations: Array<{ readonly id: string; readonly name: string }>; | |
| 9 | −}; | |
| 10 | − | |
| 11 | −export const makeUserStoreTestState = ( | |
| 12 | − overrides: Partial<UserStoreTestState> = {}, | |
| 13 | −): UserStoreTestState => ({ | |
| 14 | − upsertedOrganizations: [], | |
| 15 | − ...overrides, | |
| 16 | −}); | |
| 17 | − | |
| 18 | −const testDate = () => new Date("2026-01-01T00:00:00.000Z"); | |
| 19 | − | |
| 20 | −const makeUserStoreTestService = (state: UserStoreTestState): UserStoreService["Service"] => { | |
| 21 | − const store = { | |
| 22 | − ensureAccount: async (id: string) => ({ id, createdAt: testDate() }), | |
| 23 | − getAccount: async (id: string) => ({ id, createdAt: testDate() }), | |
| 24 | − upsertOrganization: async (org: { readonly id: string; readonly name: string }) => { | |
| 25 | − state.upsertedOrganizations.push(org); | |
| 26 | − return { ...org, createdAt: testDate() }; | |
| 27 | − }, | |
| 28 | − getOrganization: async (id: string) => ({ | |
| 29 | − id, | |
| 30 | − name: id, | |
| 31 | − createdAt: testDate(), | |
| 32 | − }), | |
| 33 | − } satisfies UserStore; | |
| 34 | − | |
| 35 | − return { | |
| 36 | − use: (fn) => Effect.promise(() => fn(store)), | |
| 37 | − }; | |
| 38 | −}; | |
| 39 | − | |
| 40 | −export const UserStoreTestLayer = (state: UserStoreTestState) => | |
| 41 | − Layer.succeed(UserStoreService)(makeUserStoreTestService(state)); | |
| @@ -1,103 +0,0 @@ | ||
| 1 | −import { assert, describe, it } from "@effect/vitest"; | |
| 2 | −import { Effect, Exit } from "effect"; | |
| 3 | − | |
| 4 | −import { | |
| 5 | − CloudAuthApiTestContext, | |
| 6 | − CloudAuthApiTestContextLayer, | |
| 7 | − makeCloudAuthApiTestState, | |
| 8 | −} from "./cloud-auth-api.test-context"; | |
| 9 | −import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer"; | |
| 10 | −import { makeAutumnTestState } from "../extensions/billing/service.test-layer"; | |
| 11 | − | |
| 12 | −describe("create organization API", () => { | |
| 13 | − it.effect("lets a paid user create another organization through the HTTP API client", () => { | |
| 14 | − const state = makeCloudAuthApiTestState({ | |
| 15 | − workos: makeWorkOSTestState({ | |
| 16 | − memberships: [ | |
| 17 | − makeWorkOSTestMembership("org_free_1", "active"), | |
| 18 | − makeWorkOSTestMembership("org_free_2", "active"), | |
| 19 | − makeWorkOSTestMembership("org_paid", "active"), | |
| 20 | − ], | |
| 21 | − }), | |
| 22 | − autumn: makeAutumnTestState({ | |
| 23 | − subscriptionsByOrgId: { | |
| 24 | − org_paid: [{ planId: "team", status: "active" }], | |
| 25 | − }, | |
| 26 | − }), | |
| 27 | − }); | |
| 28 | − | |
| 29 | − return Effect.gen(function* () { | |
| 30 | − const { client } = yield* CloudAuthApiTestContext; | |
| 31 | − | |
| 32 | − const result = yield* client.cloudAuth.createOrganization({ | |
| 33 | − payload: { name: "Paid Extra Org" }, | |
| 34 | − }); | |
| 35 | − | |
| 36 | − assert.deepEqual(result, { id: "org_created", name: "Paid Extra Org" }); | |
| 37 | − assert.deepEqual(state.workos.createdOrganizations, [ | |
| 38 | − { id: "org_created", name: "Paid Extra Org" }, | |
| 39 | − ]); | |
| 40 | − assert.deepEqual(state.workos.createdMemberships, [ | |
| 41 | − { organizationId: "org_created", userId: "user_1", roleSlug: "admin" }, | |
| 42 | − ]); | |
| 43 | − assert.deepEqual(state.userStore.upsertedOrganizations, [ | |
| 44 | − { id: "org_created", name: "Paid Extra Org" }, | |
| 45 | − ]); | |
| 46 | − }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); | |
| 47 | − }); | |
| 48 | − | |
| 49 | − it.effect( | |
| 50 | − "rejects a free-only user at the free organization limit through the HTTP API client", | |
| 51 | − () => { | |
| 52 | − const state = makeCloudAuthApiTestState({ | |
| 53 | − workos: makeWorkOSTestState({ | |
| 54 | − memberships: [ | |
| 55 | − makeWorkOSTestMembership("org_free_1", "active"), | |
| 56 | − makeWorkOSTestMembership("org_free_2", "active"), | |
| 57 | − makeWorkOSTestMembership("org_free_3", "active"), | |
| 58 | − ], | |
| 59 | − }), | |
| 60 | − }); | |
| 61 | − | |
| 62 | − return Effect.gen(function* () { | |
| 63 | − const { client } = yield* CloudAuthApiTestContext; | |
| 64 | − | |
| 65 | − const exit = yield* Effect.exit( | |
| 66 | − client.cloudAuth.createOrganization({ | |
| 67 | − payload: { name: "Blocked Org" }, | |
| 68 | − }), | |
| 69 | − ); | |
| 70 | − | |
| 71 | − assert.isTrue(Exit.isFailure(exit)); | |
| 72 | − assert.deepEqual(state.workos.createdOrganizations, []); | |
| 73 | − assert.deepEqual(state.workos.createdMemberships, []); | |
| 74 | − assert.deepEqual(state.userStore.upsertedOrganizations, []); | |
| 75 | − }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); | |
| 76 | − }, | |
| 77 | − ); | |
| 78 | − | |
| 79 | − it.effect("does not count pending memberships toward the free organization limit", () => { | |
| 80 | − const state = makeCloudAuthApiTestState({ | |
| 81 | − workos: makeWorkOSTestState({ | |
| 82 | − memberships: [ | |
| 83 | − makeWorkOSTestMembership("org_free_1", "active"), | |
| 84 | − makeWorkOSTestMembership("org_free_2", "active"), | |
| 85 | − makeWorkOSTestMembership("org_invited", "pending"), | |
| 86 | − ], | |
| 87 | − }), | |
| 88 | − }); | |
| 89 | − | |
| 90 | − return Effect.gen(function* () { | |
| 91 | − const { client } = yield* CloudAuthApiTestContext; | |
| 92 | − | |
| 93 | − const result = yield* client.cloudAuth.createOrganization({ | |
| 94 | − payload: { name: "Below Active Limit" }, | |
| 95 | − }); | |
| 96 | − | |
| 97 | − assert.deepEqual(result, { id: "org_created", name: "Below Active Limit" }); | |
| 98 | − assert.deepEqual(state.workos.createdOrganizations, [ | |
| 99 | − { id: "org_created", name: "Below Active Limit" }, | |
| 100 | − ]); | |
| 101 | − }).pipe(Effect.provide(CloudAuthApiTestContextLayer(state))); | |
| 102 | − }); | |
| 103 | −}); | |
| @@ -1,29 +0,0 @@ | ||
| 1 | −import { Effect, Layer } from "effect"; | |
| 2 | − | |
| 3 | −import { SessionAuth, SessionContext, SessionCookies, type Session } from "./middleware"; | |
| 4 | − | |
| 5 | −export type SessionTestContext = Session; | |
| 6 | − | |
| 7 | −export const makeSessionTestContext = ( | |
| 8 | − overrides: Partial<SessionTestContext> = {}, | |
| 9 | −): SessionTestContext => ({ | |
| 10 | − accountId: "user_1", | |
| 11 | − email: "test@example.com", | |
| 12 | − name: "Test User", | |
| 13 | − avatarUrl: null, | |
| 14 | − organizationId: "org_existing_1", | |
| 15 | − sealedSession: "test_session", | |
| 16 | − refreshedSession: null, | |
| 17 | − ...overrides, | |
| 18 | −}); | |
| 19 | − | |
| 20 | −export const SessionAuthTestLayer = (session: Session = makeSessionTestContext()) => | |
| 21 | − Layer.succeed(SessionAuth)({ | |
| 22 | − cookie: (httpEffect) => | |
| 23 | − httpEffect.pipe( | |
| 24 | − Effect.provideService(SessionContext, session), | |
| 25 | − // The session handlers driven via this layer don't assert cookie output; | |
| 26 | − // a no-op setter satisfies the SessionCookies the middleware provides. | |
| 27 | − Effect.provideService(SessionCookies, { set: () => {} }), | |
| 28 | − ), | |
| 29 | − }); | |
| @@ -1,206 +0,0 @@ | ||
| 1 | −import { Data, Effect, Layer } from "effect"; | |
| 2 | −import type { Organization, OrganizationMembership, OrganizationRole } from "@workos-inc/node"; | |
| 3 | − | |
| 4 | −import { WorkOSClient, type WorkOSCollectedList } from "./workos"; | |
| 5 | − | |
| 6 | −export type WorkOSTestState = { | |
| 7 | − readonly memberships: readonly OrganizationMembership[]; | |
| 8 | − // When set, a successful create adds a unique org id + an active membership, | |
| 9 | − // so a running server's free-org limit trips live across requests. Off by | |
| 10 | − // default — existing tests keep the fixed `org_created` id and static list. | |
| 11 | − readonly growMembershipsOnCreate?: boolean; | |
| 12 | − // When set, the user is resolved from the `wos-session` cookie value and | |
| 13 | − // memberships are keyed per user — so each test/browser picks its own user id | |
| 14 | − // and is isolated on a shared instance (no reset needed), matching the | |
| 15 | − // in-process per-org harness. Implies grow-on-create. | |
| 16 | − readonly multiUser?: boolean; | |
| 17 | − readonly createdOrganizations: Array<{ readonly id: string; readonly name: string }>; | |
| 18 | − readonly createdMemberships: Array<{ | |
| 19 | − readonly organizationId: string; | |
| 20 | − readonly userId: string; | |
| 21 | − readonly roleSlug: string | undefined; | |
| 22 | − }>; | |
| 23 | −}; | |
| 24 | − | |
| 25 | −export class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ | |
| 26 | − readonly method: string; | |
| 27 | −}> {} | |
| 28 | − | |
| 29 | −export const makeWorkOSTestState = (overrides: Partial<WorkOSTestState> = {}): WorkOSTestState => ({ | |
| 30 | − memberships: [], | |
| 31 | − createdOrganizations: [], | |
| 32 | − createdMemberships: [], | |
| 33 | − ...overrides, | |
| 34 | −}); | |
| 35 | − | |
| 36 | −export const WorkOSTestRole: OrganizationRole = { | |
| 37 | − object: "role", | |
| 38 | − id: "role_admin", | |
| 39 | − name: "Admin", | |
| 40 | − slug: "admin", | |
| 41 | − description: null, | |
| 42 | − permissions: [], | |
| 43 | − resourceTypeSlug: "organization", | |
| 44 | − type: "OrganizationRole", | |
| 45 | − createdAt: "2026-01-01T00:00:00.000Z", | |
| 46 | − updatedAt: "2026-01-01T00:00:00.000Z", | |
| 47 | −}; | |
| 48 | − | |
| 49 | −export const makeWorkOSTestOrganization = (id: string, name = id): Organization => ({ | |
| 50 | − object: "organization", | |
| 51 | − id, | |
| 52 | − name, | |
| 53 | − allowProfilesOutsideOrganization: false, | |
| 54 | − domains: [], | |
| 55 | − createdAt: "2026-01-01T00:00:00.000Z", | |
| 56 | − updatedAt: "2026-01-01T00:00:00.000Z", | |
| 57 | − externalId: null, | |
| 58 | − metadata: {}, | |
| 59 | −}); | |
| 60 | − | |
| 61 | −export const makeWorkOSTestMembership = ( | |
| 62 | − organizationId: string, | |
| 63 | − status: OrganizationMembership["status"], | |
| 64 | −) => | |
| 65 | − ({ | |
| 66 | − object: "organization_membership", | |
| 67 | − id: `membership_${organizationId}`, | |
| 68 | − organizationId, | |
| 69 | − organizationName: organizationId, | |
| 70 | − status, | |
| 71 | − userId: "user_1", | |
| 72 | − role: WorkOSTestRole, | |
| 73 | − directoryManaged: false, | |
| 74 | − createdAt: "2026-01-01T00:00:00.000Z", | |
| 75 | − updatedAt: "2026-01-01T00:00:00.000Z", | |
| 76 | − customAttributes: {}, | |
| 77 | − }) satisfies OrganizationMembership; | |
| 78 | − | |
| 79 | −const decode = (value: string): string => { | |
| 80 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: tolerate non-URL-encoded cookie values from direct API callers | |
| 81 | − try { | |
| 82 | − return decodeURIComponent(value); | |
| 83 | − } catch { | |
| 84 | − return value; | |
| 85 | − } | |
| 86 | −}; | |
| 87 | − | |
| 88 | −const collected = <A>(data: readonly A[]): WorkOSCollectedList<A> => ({ | |
| 89 | − object: "list", | |
| 90 | − data: [...data], | |
| 91 | − listMetadata: { | |
| 92 | − before: null, | |
| 93 | − after: null, | |
| 94 | − }, | |
| 95 | −}); | |
| 96 | − | |
| 97 | −const makeWorkOSTestService = (state: WorkOSTestState): WorkOSClient["Service"] => { | |
| 98 | − const nextOrgId = "org_created"; | |
| 99 | − let orgCounter = 0; | |
| 100 | − // Per-user membership buckets (multi-user mode). Closure-scoped, so they live | |
| 101 | − // for the layer's lifetime (= the running server) and isolate users from each | |
| 102 | − // other without a reset. | |
| 103 | − const byUser = new Map<string, OrganizationMembership[]>(); | |
| 104 | − const memsFor = (userId: string) => { | |
| 105 | − let m = byUser.get(userId); | |
| 106 | − if (!m) byUser.set(userId, (m = [])); | |
| 107 | − return m; | |
| 108 | − }; | |
| 109 | − const grows = state.multiUser || state.growMembershipsOnCreate; | |
| 110 | − const service: Partial<WorkOSClient["Service"]> = { | |
| 111 | − listUserMemberships: (userId: string) => | |
| 112 | − Effect.succeed(collected(state.multiUser ? memsFor(userId) : state.memberships)), | |
| 113 | − createOrganization: (name) => | |
| 114 | − Effect.sync(() => { | |
| 115 | − const id = grows ? `${nextOrgId}_${++orgCounter}` : nextOrgId; | |
| 116 | − const org = makeWorkOSTestOrganization(id, name); | |
| 117 | − state.createdOrganizations.push({ id: org.id, name: org.name }); | |
| 118 | − return org; | |
| 119 | − }), | |
| 120 | − createMembership: (organizationId, userId, roleSlug) => | |
| 121 | − Effect.sync(() => { | |
| 122 | − state.createdMemberships.push({ organizationId, userId, roleSlug }); | |
| 123 | − const membership = makeWorkOSTestMembership(organizationId, "active"); | |
| 124 | − if (state.multiUser) memsFor(userId).push(membership); | |
| 125 | − else if (state.growMembershipsOnCreate) { | |
| 126 | − (state.memberships as OrganizationMembership[]).push(membership); | |
| 127 | − } | |
| 128 | − return membership; | |
| 129 | − }), | |
| 130 | − getOrganization: (organizationId: string) => | |
| 131 | − Effect.succeed(makeWorkOSTestOrganization(organizationId)), | |
| 132 | − // The create-org page polls pending invitations; no invitation flows in | |
| 133 | − // the stub world, so the list is always empty. | |
| 134 | − listInvitationsByEmail: (_email: string) => Effect.succeed(collected([])), | |
| 135 | − getUserOrgMembership: (organizationId: string, _userId: string) => | |
| 136 | − Effect.succeed(makeWorkOSTestMembership(organizationId, "active")), | |
| 137 | − // Multi-user refresh carries the user id forward + the switched-to org, so the | |
| 138 | − // create-org handler's `verified.organizationId === org.id` check passes and | |
| 139 | − // the user stays consistent across the refresh. The incoming sealed session | |
| 140 | − // may be URL-encoded (set-cookie round-trip through a real browser) and may | |
| 141 | − // already carry an `|org:` suffix from a previous switch — normalize both. | |
| 142 | − refreshSession: (sealedSession, organizationId) => | |
| 143 | − Effect.succeed( | |
| 144 | − state.multiUser | |
| 145 | − ? `${userOf(sealedSession)}|org:${organizationId}` | |
| 146 | − : `session_${organizationId}`, | |
| 147 | − ), | |
| 148 | − authenticateSealedSession: (sealedSession) => Effect.sync(() => sessionOf(sealedSession)), | |
| 149 | − // The protected-API identity path (`resolveSessionPrincipal`) authenticates | |
| 150 | − // from the Request; mirror the real client's cookie-parse + delegate. | |
| 151 | − authenticateRequest: (request: Request) => | |
| 152 | − Effect.sync(() => { | |
| 153 | − const match = /(?:^|;\s*)wos-session=([^;]+)/.exec(request.headers.get("cookie") ?? ""); | |
| 154 | − return match ? sessionOf(decodeURIComponent(match[1]!)) : null; | |
| 155 | − }), | |
| 156 | − }; | |
| 157 | − | |
| 158 | − function userOf(sealedSession: string): string { | |
| 159 | − return decode(sealedSession).split("|org:")[0] || "user_1"; | |
| 160 | − } | |
| 161 | − | |
| 162 | − function sessionOf(sealedSession: string) { | |
| 163 | − if (state.multiUser) { | |
| 164 | − // Cookie is `<userId>` (initial) or `<userId>|org:<orgId>` (after refresh); | |
| 165 | − // a browser round-trip URL-encodes it. The LAST org segment is current. | |
| 166 | − const parts = decode(sealedSession).split("|org:"); | |
| 167 | − const userPart = parts[0]; | |
| 168 | − const orgPart = parts.length > 1 ? parts[parts.length - 1] : undefined; | |
| 169 | − return { | |
| 170 | − userId: userPart || "user_1", | |
| 171 | − email: "test@example.com", | |
| 172 | − firstName: "Test", | |
| 173 | − lastName: "User", | |
| 174 | − avatarUrl: null, | |
| 175 | − organizationId: orgPart ?? "", | |
| 176 | − sessionId: "session_id", | |
| 177 | − refreshedSession: undefined, | |
| 178 | − }; | |
| 179 | − } | |
| 180 | − return { | |
| 181 | − userId: "user_1", | |
| 182 | − email: "test@example.com", | |
| 183 | − firstName: "Test", | |
| 184 | − lastName: "User", | |
| 185 | − avatarUrl: null, | |
| 186 | − organizationId: sealedSession.replace("session_", ""), | |
| 187 | − sessionId: "session_id", | |
| 188 | − refreshedSession: undefined, | |
| 189 | − }; | |
| 190 | − } | |
| 191 | − | |
| 192 | − return new Proxy(service as WorkOSClient["Service"], { | |
| 193 | − get: (target, prop) => { | |
| 194 | − if (prop in target) return target[prop as keyof typeof target]; | |
| 195 | − return () => | |
| 196 | − Effect.fail( | |
| 197 | − new UnstubbedWorkOSMethod({ | |
| 198 | − method: typeof prop === "string" ? prop : (prop.description ?? "symbol"), | |
| 199 | − }), | |
| 200 | − ); | |
| 201 | − }, | |
| 202 | − }); | |
| 203 | −}; | |
| 204 | − | |
| 205 | −export const WorkOSTestLayer = (state: WorkOSTestState) => | |
| 206 | − Layer.succeed(WorkOSClient)(makeWorkOSTestService(state)); | |
| @@ -110,6 +110,25 @@ class WorkOSConfigurationError extends Data.TaggedError("WorkOSConfigurationErro · 109 unmodified lines | ||
| 110 | 110 | readonly message: string; |
| 111 | 111 | }> {} |
| 112 | 112 | |
| 113 | +/** | |
| 114 | + * Optional base-URL override for the WorkOS API (`WORKOS_API_URL`) — points | |
| 115 | + * the REAL SDK at a WorkOS emulator in tests/dev. Unset in production, where | |
| 116 | + * the SDK uses api.workos.com. Sealed-session crypto, JWKS verification, and | |
| 117 | + * every endpoint follow this host, so the whole auth stack runs against the | |
| 118 | + * emulator with zero code substitution. | |
| 119 | + */ | |
| 120 | +export const workosApiUrlOptions = ( | |
| 121 | + url: string | undefined, | |
| 122 | +): { apiHostname?: string; port?: number; https?: boolean } => { | |
| 123 | + if (!url) return {}; | |
| 124 | + const parsed = new URL(url); | |
| 125 | + return { | |
| 126 | + apiHostname: parsed.hostname, | |
| 127 | + ...(parsed.port ? { port: Number(parsed.port) } : {}), | |
| 128 | + https: parsed.protocol === "https:", | |
| 129 | + }; | |
| 130 | +}; | |
| 131 | + | |
| 113 | 132 | // --------------------------------------------------------------------------- |
| 114 | 133 | // Service |
| 115 | 134 | // --------------------------------------------------------------------------- |
| @@ -125,7 +144,7 @@ const make = Effect.gen(function* () { · 9 unmodified lines | ||
| 125 | 144 | }); |
| 126 | 145 | } |
| 127 | 146 | |
| 128 | − const workos = new WorkOS({ apiKey, clientId }); | |
| 147 | + const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) }); | |
| 129 | 148 | |
| 130 | 149 | const use = <A>(fn: (wos: WorkOS) => Promise<A>) => |
| 131 | 150 | withServiceLogging( |
| @@ -62,6 +62,7 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 61 unmodified lines | ||
| 62 | 62 | workosCredentials: { |
| 63 | 63 | apiKey: env.WORKOS_API_KEY, |
| 64 | 64 | clientId: env.WORKOS_CLIENT_ID, |
| 65 | + apiUrl: env.WORKOS_API_URL, | |
| 65 | 66 | }, |
| 66 | 67 | }), |
| 67 | 68 | }); |
| @@ -26,6 +26,11 @@ declare global { · 25 unmodified lines | ||
| 26 | 26 | |
| 27 | 27 | // Billing |
| 28 | 28 | AUTUMN_SECRET_KEY?: string; |
| 29 | + /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */ | |
| 30 | + AUTUMN_API_URL?: string; | |
| 31 | + | |
| 32 | + /** Optional WorkOS base-URL override (WorkOS emulator in tests/dev). */ | |
| 33 | + WORKOS_API_URL?: string; | |
| 29 | 34 | |
| 30 | 35 | // MCP |
| 31 | 36 | EXECUTOR_MCP_DEBUG?: string; |
| @@ -57,6 +57,7 @@ const handler = Effect.gen(function* () { · 56 unmodified lines | ||
| 57 | 57 | }, |
| 58 | 58 | clientOptions: { |
| 59 | 59 | secretKey: env.AUTUMN_SECRET_KEY ?? "", |
| 60 | + ...(env.AUTUMN_API_URL ? { serverURL: env.AUTUMN_API_URL } : {}), | |
| 60 | 61 | }, |
| 61 | 62 | pathPrefix: "/api/billing", |
| 62 | 63 | }), |
| @@ -1,36 +0,0 @@ | ||
| 1 | −import { Effect, Layer } from "effect"; | |
| 2 | −import type { Autumn } from "autumn-js"; | |
| 3 | − | |
| 4 | −import { AutumnService, type IAutumnService } from "./service"; | |
| 5 | − | |
| 6 | −export type AutumnTestSubscriptionSummary = { | |
| 7 | − readonly planId?: string | null; | |
| 8 | − readonly status?: string | null; | |
| 9 | −}; | |
| 10 | − | |
| 11 | −export type AutumnTestState = { | |
| 12 | − readonly subscriptionsByOrgId: Readonly<Record<string, readonly AutumnTestSubscriptionSummary[]>>; | |
| 13 | −}; | |
| 14 | − | |
| 15 | −export const makeAutumnTestState = (overrides: Partial<AutumnTestState> = {}): AutumnTestState => ({ | |
| 16 | − subscriptionsByOrgId: {}, | |
| 17 | − ...overrides, | |
| 18 | −}); | |
| 19 | − | |
| 20 | −const makeAutumnTestService = (state: AutumnTestState): IAutumnService => { | |
| 21 | − const fakeClient = { | |
| 22 | − customers: { | |
| 23 | − getOrCreate: async ({ customerId }: { readonly customerId: string }) => ({ | |
| 24 | − subscriptions: state.subscriptionsByOrgId[customerId] ?? [], | |
| 25 | − }), | |
| 26 | − }, | |
| 27 | − } as Autumn; | |
| 28 | − | |
| 29 | − return { | |
| 30 | − use: (fn) => Effect.promise(() => fn(fakeClient)), | |
| 31 | − trackExecution: () => Effect.void, | |
| 32 | − }; | |
| 33 | −}; | |
| 34 | − | |
| 35 | −export const AutumnTestLayer = (state: AutumnTestState) => | |
| 36 | − Layer.succeed(AutumnService)(makeAutumnTestService(state)); | |
| @@ -48,7 +48,11 @@ const make = Effect.sync(() => { · 47 unmodified lines | ||
| 48 | 48 | } satisfies IAutumnService; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | − const client = new Autumn({ secretKey }); | |
| 51 | + // AUTUMN_API_URL points the real SDK at an Autumn emulator in tests/dev. | |
| 52 | + const client = new Autumn({ | |
| 53 | + secretKey, | |
| 54 | + ...(env.AUTUMN_API_URL ? { serverURL: env.AUTUMN_API_URL } : {}), | |
| 55 | + }); | |
| 52 | 56 | |
| 53 | 57 | const use = <A>(fn: (client: Autumn) => Promise<A>) => |
| 54 | 58 | Effect.tryPromise({ |
| @@ -34,7 +34,6 @@ import { · 33 unmodified lines | ||
| 34 | 34 | } from "../auth/handlers"; |
| 35 | 35 | import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; |
| 36 | 36 | import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; |
| 37 | −import { E2E_STUB, E2EStubAutumnLayer } from "../testing/e2e-stub"; | |
| 38 | 37 | import { OrgApi, OrgHttpApi } from "../org/api"; |
| 39 | 38 | import { OrgHandlers } from "../org/handlers"; |
| 40 | 39 | import { AutumnService } from "../extensions/billing/service"; |
| @@ -75,11 +74,8 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 34 unmodified lines | ||
| 75 | 74 | const SessionRoutes = HttpApiBuilder.layer(NonProtectedApi).pipe( |
| 76 | 75 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| 77 | 76 | Layer.provide(requestScopedMiddleware(rsLive).layer), |
| 78 | − // SessionAuthLive resolves the user from the wos-session cookie via WorkOSClient | |
| 79 | − // — which is the stub under EXECUTOR_E2E_STUB (app.ts), so the user is the cookie | |
| 80 | − // value. Only Autumn is swapped to the free-plan stub here. Off in production. | |
| 81 | 77 | Layer.provideMerge(SessionAuthLive), |
| 82 | − Layer.provideMerge(E2E_STUB ? E2EStubAutumnLayer : AutumnService.Default), | |
| 78 | + Layer.provideMerge(AutumnService.Default), | |
| 83 | 79 | Layer.provide(apiPrefixedRouter), |
| 84 | 80 | ); |
| 85 | 81 | |
| @@ -1,21 +0,0 @@ | ||
| 1 | −// Env-gated stub layers that turn `vite dev` into a fully-stubbed, logged-in | |
| 2 | −// instance — one target every surface (browser / API / MCP / CLI) can drive, | |
| 3 | −// replacing the bespoke in-process harnesses + e2e-server wiring. | |
| 4 | −// | |
| 5 | −// Enabled by `EXECUTOR_E2E_STUB=1`. NEVER set in production — when unset, the | |
| 6 | −// served route composition is byte-for-byte the real `*Live` layers. | |
| 7 | −import { WorkOSTestLayer, makeWorkOSTestState } from "../auth/workos.test-layer"; | |
| 8 | −import { AutumnTestLayer, makeAutumnTestState } from "../extensions/billing/service.test-layer"; | |
| 9 | − | |
| 10 | −export const E2E_STUB = process.env.EXECUTOR_E2E_STUB === "1"; | |
| 11 | − | |
| 12 | −// Multi-user stub WorkOS: the `wos-session` cookie value IS the user id, with | |
| 13 | −// per-user membership buckets — so each test/browser picks a fresh user and is | |
| 14 | −// isolated on the one shared instance (no reset). Swapped in for | |
| 15 | −// `CoreSharedServices`, it authenticates every surface (session routes, | |
| 16 | −// /account/me, SSR) with no real WorkOS. | |
| 17 | −const workos = makeWorkOSTestState({ memberships: [], multiUser: true }); | |
| 18 | −const autumn = makeAutumnTestState({}); // no paid subscription → free plan → 3-org limit applies | |
| 19 | − | |
| 20 | −export const E2EStubWorkOSLayer = WorkOSTestLayer(workos); | |
| 21 | −export const E2EStubAutumnLayer = AutumnTestLayer(autumn); | |
| @@ -41,10 +41,14 @@ scenario( · 40 unmodified lines | ||
| 41 | 41 | |
| 42 | 42 | const openCreateOrgModal = async (currentOrg: string) => { |
| 43 | 43 | await page.getByRole("button", { name: /Test User/ }).click(); |
| 44 | − // The org entry + "Create organization" are radix menu items; role | |
| 45 | − // locators keep us off the look-alike trigger button beneath. | |
| 44 | + // The org entry is a radix submenu trigger; its content loads the | |
| 45 | + // org list from the API, so wait for the sub-content to mount and | |
| 46 | + // click "Create organization" scoped INSIDE it — clicking during | |
| 47 | + // the loading re-render dismisses the whole menu. | |
| 46 | 48 | await page.getByRole("menuitem", { name: currentOrg }).click(); |
| 47 | − await page.getByRole("menuitem", { name: "Create organization" }).click(); | |
| 49 | + const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]'); | |
| 50 | + await subContent.waitFor({ state: "visible" }); | |
| 51 | + await subContent.getByText("Create organization", { exact: true }).click(); | |
| 48 | 52 | await page.getByText("Add another organization").waitFor(); |
| 49 | 53 | }; |
| 50 | 54 | |
| @@ -45,7 +45,9 @@ scenario( · 44 unmodified lines | ||
| 45 | 45 | await step('Open the org switcher and choose "Create organization"', async () => { |
| 46 | 46 | await page.getByRole("button", { name: /Test User/ }).click(); |
| 47 | 47 | await page.getByRole("menuitem", { name: ORG_1 }).click(); |
| 48 | − await page.getByRole("menuitem", { name: "Create organization" }).click(); | |
| 48 | + const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]'); | |
| 49 | + await subContent.waitFor({ state: "visible" }); | |
| 50 | + await subContent.getByText("Create organization", { exact: true }).click(); | |
| 49 | 51 | await page.getByText("Add another organization").waitFor(); |
| 50 | 52 | }); |
| 51 | 53 | |
| @@ -72,13 +72,18 @@ export const bootProcesses = ( · 71 unmodified lines | ||
| 72 | 72 | }; |
| 73 | 73 | }; |
| 74 | 74 | |
| 75 | −export const waitForHttp = async (url: string, timeoutMs = 90_000): Promise<void> => { | |
| 76 | − const deadline = Date.now() + timeoutMs; | |
| 75 | +export const waitForHttp = async ( | |
| 76 | + url: string, | |
| 77 | + options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {}, | |
| 78 | +): Promise<void> => { | |
| 79 | + const deadline = Date.now() + (options.timeoutMs ?? 90_000); | |
| 77 | 80 | let lastError: unknown; |
| 78 | 81 | while (Date.now() < deadline) { |
| 79 | 82 | try { |
| 80 | 83 | const response = await fetch(url, { redirect: "manual" }); |
| 81 | − if (response.status < 500) return; | |
| 84 | + // During a cold vite compile /api/* falls back to the SPA's 200 HTML — | |
| 85 | + // expectRedirect waits for the real handler (302) instead. | |
| 86 | + if (options.expectRedirect ? response.status === 302 : response.status < 500) return; | |
| 82 | 87 | lastError = new Error(`status ${response.status}`); |
| 83 | 88 | } catch (error) { |
| 84 | 89 | lastError = error; |
| @@ -1,14 +1,25 @@ | ||
| 1 | −// Boot the cloud target: the app's OWN dev stack (PGlite dev-db + vite dev) | |
| 2 | −// with EXECUTOR_E2E_STUB=1 — the one flag that makes `vite dev` a logged-in, | |
| 3 | −// fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no | |
| 4 | −// network). Set E2E_CLOUD_URL to attach to an already-running instance | |
| 5 | −// instead (e.g. while iterating on a scenario). | |
| 1 | +// Boot the cloud target: WorkOS + Autumn EMULATORS (in this process, from the | |
| 2 | +// vendored emulate fork) plus the app's own dev stack (PGlite dev-db + vite | |
| 3 | +// dev) pointed at them via WORKOS_API_URL / AUTUMN_API_URL. The app runs its | |
| 4 | +// REAL auth/billing code — real SDKs, real sealed-session crypto, real JWKS — | |
| 5 | +// against emulated services. Set E2E_CLOUD_URL to attach to a running stack. | |
| 6 | 6 | import { rmSync } from "node:fs"; |
| 7 | 7 | import { resolve } from "node:path"; |
| 8 | 8 | import { fileURLToPath } from "node:url"; |
| 9 | 9 | |
| 10 | +// Vendored fork import (same pattern as mcporter). | |
| 11 | +import { createEmulator } from "../../vendor/emulate/packages/emulate/dist/api.js"; | |
| 12 | + | |
| 10 | 13 | import { bootProcesses, waitForHttp } from "./boot"; |
| 11 | −import { CLOUD_BASE_URL, CLOUD_DB_PORT, CLOUD_PORT } from "../targets/cloud"; | |
| 14 | +import { | |
| 15 | + CLOUD_BASE_URL, | |
| 16 | + CLOUD_DB_PORT, | |
| 17 | + CLOUD_PORT, | |
| 18 | + WORKOS_EMULATOR_PORT, | |
| 19 | + AUTUMN_EMULATOR_PORT, | |
| 20 | + E2E_WORKOS_CLIENT_ID, | |
| 21 | + E2E_COOKIE_PASSWORD, | |
| 22 | +} from "../targets/cloud"; | |
| 12 | 23 | |
| 13 | 24 | const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); |
| 14 | 25 | |
| @@ -18,31 +29,36 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 3 unmodified lines | ||
| 18 | 29 | return; |
| 19 | 30 | } |
| 20 | 31 | |
| 21 | − // Fresh stub DB per suite run — hermetic, like the selfhost data dir. The | |
| 22 | − // WorkOS stub mints org ids from a per-process counter, so a persisted DB | |
| 23 | − // from a previous invocation collides with the new boot's ids (identities | |
| 32 | + // Fresh dev DB per suite run — hermetic, like the selfhost data dir. The | |
| 33 | + // WorkOS emulator mints org ids from a per-process counter, so a persisted | |
| 34 | + // DB from a previous invocation collides with the new boot's ids (identities | |
| 24 | 35 | // land in polluted orgs / org creation 500s). |
| 25 | 36 | const dbPath = resolve(cloudDir, ".e2e-stub-db"); |
| 26 | 37 | rmSync(dbPath, { recursive: true, force: true }); |
| 27 | 38 | |
| 39 | + // MCP access tokens minted by the emulator's OAuth server must carry the | |
| 40 | + // app's client id as audience (what the resource server verifies). | |
| 41 | + process.env.EMULATE_WORKOS_AUDIENCE = E2E_WORKOS_CLIENT_ID; | |
| 42 | + const workos = await createEmulator({ service: "workos", port: WORKOS_EMULATOR_PORT }); | |
| 43 | + const autumn = await createEmulator({ service: "autumn", port: AUTUMN_EMULATOR_PORT }); | |
| 44 | + | |
| 28 | 45 | const env = { |
| 29 | − EXECUTOR_E2E_STUB: "1", | |
| 30 | − // The harness boots loopback MCP/OAuth test servers and points the | |
| 31 | − // instance at them; the hosted SSRF guard would otherwise block outbound | |
| 32 | − // probes/dials to localhost. Hermetic stub instance only. | |
| 33 | − ALLOW_LOCAL_NETWORK: "true", | |
| 34 | − // Stub creds — never contacted; the stub layers replace the clients. | |
| 35 | − WORKOS_API_KEY: "sk_e2e_stub", | |
| 36 | − WORKOS_CLIENT_ID: "client_e2e_stub", | |
| 37 | − WORKOS_COOKIE_PASSWORD: "e2e_cookie_password_0123456789abcdef0123456789abcdef", | |
| 38 | − AUTUMN_SECRET_KEY: "am_e2e_stub", | |
| 46 | + // Real client, emulated service. | |
| 47 | + WORKOS_API_URL: workos.url, | |
| 48 | + AUTUMN_API_URL: autumn.url, | |
| 49 | + WORKOS_API_KEY: "sk_test_emulate", | |
| 50 | + WORKOS_CLIENT_ID: E2E_WORKOS_CLIENT_ID, | |
| 51 | + WORKOS_COOKIE_PASSWORD: E2E_COOKIE_PASSWORD, | |
| 52 | + AUTUMN_SECRET_KEY: "am_test_emulate", | |
| 39 | 53 | ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", |
| 40 | 54 | DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${CLOUD_DB_PORT}/postgres`, |
| 41 | 55 | EXECUTOR_DIRECT_DATABASE_URL: "true", |
| 42 | 56 | CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", |
| 43 | 57 | VITE_PUBLIC_SITE_URL: CLOUD_BASE_URL, |
| 44 | − MCP_AUTHKIT_DOMAIN: "https://example.com", | |
| 58 | + // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too. | |
| 59 | + MCP_AUTHKIT_DOMAIN: workos.url, | |
| 45 | 60 | MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL, |
| 61 | + ALLOW_LOCAL_NETWORK: "true", | |
| 46 | 62 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. |
| 47 | 63 | DEV_DB_PORT: String(CLOUD_DB_PORT), |
| 48 | 64 | DEV_DB_PATH: dbPath, |
| @@ -63,9 +79,17 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 14 unmodified lines | ||
| 63 | 79 | |
| 64 | 80 | try { |
| 65 | 81 | await waitForHttp(CLOUD_BASE_URL); |
| 82 | + // The API plane is ready when login actually redirects to AuthKit. | |
| 83 | + await waitForHttp(`${CLOUD_BASE_URL}/api/auth/login`, { expectRedirect: true }); | |
| 66 | 84 | } catch (error) { |
| 67 | 85 | await procs.teardown(); |
| 86 | + await workos.close(); | |
| 87 | + await autumn.close(); | |
| 68 | 88 | throw error; |
| 69 | 89 | } |
| 70 | − return procs.teardown; | |
| 90 | + return async () => { | |
| 91 | + await procs.teardown(); | |
| 92 | + await workos.close(); | |
| 93 | + await autumn.close(); | |
| 94 | + }; | |
| 71 | 95 | } |
| @@ -1,8 +1,9 @@ | ||
| 1 | −// The cloud app as a target: the stubbed dev server (`vite dev` + | |
| 2 | −// EXECUTOR_E2E_STUB=1) — real SSR, real routes, real PGlite-backed DB, WorkOS | |
| 3 | −// and Autumn stubbed in-memory. Isolation: the stub resolves the user FROM the | |
| 4 | −// wos-session cookie value, so every identity is a fresh user (and org) on the | |
| 5 | −// one shared instance — no resets. Boot lives in setup/cloud.globalsetup.ts. | |
| 1 | +// The cloud app as a target: its real dev server with the real WorkOS and | |
| 2 | +// Autumn SDKs pointed at emulators (WORKOS_API_URL / AUTUMN_API_URL — see | |
| 3 | +// setup/cloud.globalsetup.ts). Identities are minted through the REAL login | |
| 4 | +// flow: /api/auth/login → emulator hosted AuthKit (headless via login_hint) → | |
| 5 | +// /api/auth/callback → genuine sealed-session cookie. Isolation: every | |
| 6 | +// identity is a fresh user (and org) — no resets. | |
| 6 | 7 | import { randomUUID } from "node:crypto"; |
| 7 | 8 | |
| 8 | 9 | import { Effect } from "effect"; |
| @@ -12,46 +13,90 @@ import type { Identity, Target } from "../src/target"; · 3 unmodified lines | ||
| 12 | 13 | export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798); |
| 13 | 14 | export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436); |
| 14 | 15 | export const CLOUD_BASE_URL = process.env.E2E_CLOUD_URL ?? `http://127.0.0.1:${CLOUD_PORT}`; |
| 16 | +export const WORKOS_EMULATOR_PORT = Number(process.env.E2E_WORKOS_EMULATOR_PORT ?? 4914); | |
| 17 | +export const AUTUMN_EMULATOR_PORT = Number(process.env.E2E_AUTUMN_EMULATOR_PORT ?? 4915); | |
| 18 | +export const E2E_WORKOS_CLIENT_ID = "client_e2e_emulate"; | |
| 19 | +export const E2E_COOKIE_PASSWORD = "e2e_cookie_password_0123456789abcdef0123456789abcdef"; | |
| 15 | 20 | |
| 16 | −const freshId = () => randomUUID().slice(0, 8); | |
| 21 | +const cookiePair = (response: Response, name: string): string | undefined => { | |
| 22 | + for (const header of response.headers.getSetCookie?.() ?? []) { | |
| 23 | + if (header.startsWith(`${name}=`)) return header.split(";")[0]; | |
| 24 | + } | |
| 25 | + return undefined; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +/** The real product login, headless: login → hosted AuthKit → callback. */ | |
| 29 | +const signIn = async (email: string): Promise<string> => { | |
| 30 | + const login = await fetch(new URL("/api/auth/login", CLOUD_BASE_URL), { redirect: "manual" }); | |
| 31 | + const stateCookie = cookiePair(login, "wos-login-state"); | |
| 32 | + const authorizeUrl = new URL(login.headers.get("location") ?? ""); | |
| 33 | + if (!stateCookie || !authorizeUrl.searchParams.get("state")) { | |
| 34 | + throw new Error(`cloud signIn: login did not redirect to AuthKit (${login.status})`); | |
| 35 | + } | |
| 36 | + // The emulator's hosted login signs in headlessly via login_hint (creating | |
| 37 | + // the user if new) and redirects back with a code. | |
| 38 | + authorizeUrl.searchParams.set("login_hint", email); | |
| 39 | + const consent = await fetch(authorizeUrl, { redirect: "manual" }); | |
| 40 | + const callbackUrl = consent.headers.get("location"); | |
| 41 | + if (consent.status !== 302 || !callbackUrl) { | |
| 42 | + throw new Error(`cloud signIn: AuthKit emulator did not redirect (${consent.status})`); | |
| 43 | + } | |
| 44 | + const callback = await fetch(callbackUrl, { | |
| 45 | + redirect: "manual", | |
| 46 | + headers: { cookie: stateCookie }, | |
| 47 | + }); | |
| 48 | + const session = cookiePair(callback, "wos-session"); | |
| 49 | + if (!session) throw new Error(`cloud signIn: callback set no session (${callback.status})`); | |
| 50 | + return session; // "wos-session=<sealed>" | |
| 51 | +}; | |
| 17 | 52 | |
| 18 | 53 | export const cloudTarget = (): Target => ({ |
| 19 | 54 | name: "cloud", |
| 20 | 55 | baseUrl: CLOUD_BASE_URL, |
| 21 | 56 | mcpUrl: `${CLOUD_BASE_URL}/mcp`, |
| 22 | − capabilities: new Set(["api", "browser", "billing"]), | |
| 57 | + capabilities: new Set(["api", "browser", "billing", "mcp-oauth"]), | |
| 23 | 58 | newIdentity: ({ org = true } = {}) => |
| 24 | 59 | Effect.promise(async (): Promise<Identity> => { |
| 25 | − const user = `user_${freshId()}`; | |
| 26 | − // The stub WorkOS resolves the user FROM the cookie value; a bare value | |
| 27 | − // is a fresh signed-in user with no organization yet. | |
| 28 | − let value = user; | |
| 60 | + const label = `user-${randomUUID().slice(0, 8)}`; | |
| 61 | + const email = `${label}@e2e.test`; | |
| 62 | + let session = await signIn(email); | |
| 29 | 63 | if (org) { |
| 30 | − // Go through the REAL product flow: create the org via the session | |
| 31 | − // route and adopt the refreshed `<user>|org:<org>` cookie it sets — | |
| 32 | − // membership and session state come from the same path real users take. | |
| 64 | + // The real create-organization flow; the refreshed sealed session in | |
| 65 | + // the response carries the new org. | |
| 33 | 66 | const response = await fetch(new URL("/api/auth/create-organization", CLOUD_BASE_URL), { |
| 34 | 67 | method: "POST", |
| 35 | 68 | headers: { |
| 36 | 69 | "content-type": "application/json", |
| 37 | 70 | origin: new URL(CLOUD_BASE_URL).origin, |
| 38 | − cookie: `wos-session=${value}`, | |
| 71 | + cookie: session, | |
| 39 | 72 | }, |
| 40 | − body: JSON.stringify({ name: `Org ${user}` }), | |
| 73 | + body: JSON.stringify({ name: `Org ${label}` }), | |
| 41 | 74 | }); |
| 42 | 75 | if (!response.ok) { |
| 43 | − throw new Error(`cloud newIdentity: create-organization → ${response.status}`); | |
| 76 | + throw new Error(`cloud newIdentity: create-organization failed (${response.status})`); | |
| 44 | 77 | } |
| 45 | − const refreshed = (response.headers.getSetCookie?.() ?? []) | |
| 46 | − .map((cookie) => /^wos-session=([^;]+)/.exec(cookie)?.[1]) | |
| 47 | − .find(Boolean); | |
| 48 | − if (!refreshed) throw new Error("cloud newIdentity: no refreshed session cookie"); | |
| 49 | − value = decodeURIComponent(refreshed); | |
| 78 | + session = cookiePair(response, "wos-session") ?? session; | |
| 50 | 79 | } |
| 80 | + const [name, value] = session.split(/=(.*)/s); | |
| 51 | 81 | return { |
| 52 | − label: user, | |
| 53 | − headers: { cookie: `wos-session=${value}` }, | |
| 54 | − cookies: [{ name: "wos-session", value }], | |
| 82 | + label: email, | |
| 83 | + headers: { cookie: session }, | |
| 84 | + cookies: [{ name: name!, value: value! }], | |
| 85 | + credentials: { email, password: "emulated" }, | |
| 55 | 86 | }; |
| 56 | 87 | }), |
| 88 | + // MCP OAuth against the emulator's authorization server: complete the | |
| 89 | + // hosted flow headlessly as this identity. | |
| 90 | + mcpConsent: (identity) => async (request) => { | |
| 91 | + const authorizeUrl = new URL(request.authorizationUrl); | |
| 92 | + authorizeUrl.searchParams.set("login_hint", identity.credentials?.email ?? "mcp@e2e.test"); | |
| 93 | + const response = await fetch(authorizeUrl, { redirect: "manual" }); | |
| 94 | + const location = response.headers.get("location"); | |
| 95 | + if (response.status !== 302 || !location) { | |
| 96 | + throw new Error(`cloud mcpConsent: authorize did not redirect (${response.status})`); | |
| 97 | + } | |
| 98 | + const code = new URL(location).searchParams.get("code"); | |
| 99 | + if (!code) throw new Error("cloud mcpConsent: no code in redirect"); | |
| 100 | + return { code }; | |
| 101 | + }, | |
| 57 | 102 | }); |
| @@ -96,8 +96,22 @@ export interface WorkOSVaultPromiseApi { · 95 unmodified lines | ||
| 96 | 96 | export interface WorkOSVaultCredentials { |
| 97 | 97 | readonly apiKey: string; |
| 98 | 98 | readonly clientId: string; |
| 99 | + /** Optional WorkOS API base-URL override (e.g. a WorkOS emulator in tests). */ | |
| 100 | + readonly apiUrl?: string; | |
| 99 | 101 | } |
| 100 | 102 | |
| 103 | +const apiUrlOptions = ( | |
| 104 | + url: string | undefined, | |
| 105 | +): { apiHostname?: string; port?: number; https?: boolean } => { | |
| 106 | + if (!url) return {}; | |
| 107 | + const parsed = new URL(url); | |
| 108 | + return { | |
| 109 | + apiHostname: parsed.hostname, | |
| 110 | + ...(parsed.port ? { port: Number(parsed.port) } : {}), | |
| 111 | + https: parsed.protocol === "https:", | |
| 112 | + }; | |
| 113 | +}; | |
| 114 | + | |
| 101 | 115 | interface WorkOSVaultUseOptions { |
| 102 | 116 | readonly expectedErrorStatuses?: readonly number[]; |
| 103 | 117 | readonly expectedErrorOutcome?: string; |
| @@ -205,6 +219,7 @@ export const makeConfiguredWorkOSVaultClient = ( · 101 unmodified lines | ||
| 205 | 219 | new WorkOSClient({ |
| 206 | 220 | apiKey: credentials.apiKey, |
| 207 | 221 | clientId: credentials.clientId, |
| 222 | + ...apiUrlOptions(credentials.apiUrl), | |
| 208 | 223 | }), |
| 209 | 224 | ), |
| 210 | 225 | catch: (cause) => new WorkOSVaultClientInstantiationError({ cause }), |
| @@ -1 +1 @@ | ||
| 1 | −Subproject commit a42fe29889c8a11aa752fc848af5ed71a76fa26e | |
| 1 | +Subproject commit 38cd039bbf5de27af4b4d15010940379db07645d | |