History

Run cloud's real auth/billing against WorkOS + Autumn emulators; delete all hand-rolled test layers

- 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.

Rhys Sullivan committed 5 months ago

Commit feacf2a1 parent 17174c422 files · +186 −605Browse files

Unified · Split

22 files
  1. apps/cloud/src/app.ts +4 −7
  2. apps/cloud/src/auth/api-keys.test-layer.ts +0 −11
  3. apps/cloud/src/auth/cloud-auth-api.test-context.ts +0 −90
  4. apps/cloud/src/auth/context.test-layer.ts +0 −41
  5. apps/cloud/src/auth/create-organization.e2e.node.test.ts +0 −103
  6. apps/cloud/src/auth/middleware.test-layer.ts +0 −29
  7. apps/cloud/src/auth/workos.test-layer.ts +0 −206
  8. apps/cloud/src/auth/workos.ts +20 −1
  9. apps/cloud/src/engine/execution-stack.ts +1 −0
  10. apps/cloud/src/env-augment.d.ts +5 −0
  11. apps/cloud/src/extensions/billing/route.ts +1 −0
  12. apps/cloud/src/extensions/billing/service.test-layer.ts +0 −36
  13. apps/cloud/src/extensions/billing/service.ts +5 −1
  14. apps/cloud/src/extensions/routes.ts +1 −5
  15. apps/cloud/src/testing/e2e-stub.ts +0 −21
  16. e2e/cloud/org-limit.test.ts +7 −3
  17. e2e/cloud/org-switcher.test.ts +3 −1
  18. e2e/setup/boot.ts +8 −3
  19. e2e/setup/cloud.globalsetup.ts +45 −21
  20. e2e/targets/cloud.ts +70 −25
  21. packages/plugins/workos-vault/src/sdk/client.ts +15 −0
  22. vendor/emulate +1 −1
apps/cloud/src/app.ts+4−7
@@ -5,7 +5,6 @@ import { DbProvider, ExecutorApp } from "@executor-js/api/server"; · 4 unmodified lines
55
66 import { cloudPlugins } from "./plugins";
77 import { CoreSharedServices } from "./auth/workos";
8−import { E2E_STUB, E2EStubWorkOSLayer } from "./testing/e2e-stub";
98 import { makeCloudExtensionRoutes } from "./extensions/routes";
109 import { RequestScopedServicesLive } from "./api/layers";
1110 import { CloudMeteringEngineDecorator } from "./engine/execution-stack-metered";
@@ -56,12 +55,10 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 44 unmodified lines
5655 // the account provider + MCP seam, AND by the per-request identity layer below).
5756 // Lives in `boot`, so `workosIdentityLayer`'s residual `WorkOSClient |
5857 // 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);
6562
6663 // `CloudDbProvider` only reads the per-request `DbService` at runtime; we widen
6764 // its residual type to also carry the boot `AutumnService` the metering
apps/cloud/src/auth/api-keys.test-layer.ts+0−11
@@ -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−});
apps/cloud/src/auth/cloud-auth-api.test-context.ts+0−90
@@ -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− );
apps/cloud/src/auth/context.test-layer.ts+0−41
@@ -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));
apps/cloud/src/auth/create-organization.e2e.node.test.ts+0−103
@@ -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−});
apps/cloud/src/auth/middleware.test-layer.ts+0−29
@@ -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− });
apps/cloud/src/auth/workos.test-layer.ts+0−206
@@ -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));
apps/cloud/src/auth/workos.ts+20−1
@@ -110,6 +110,25 @@ class WorkOSConfigurationError extends Data.TaggedError("WorkOSConfigurationErro · 109 unmodified lines
110110 readonly message: string;
111111 }> {}
112112
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+
113132 // ---------------------------------------------------------------------------
114133 // Service
115134 // ---------------------------------------------------------------------------
@@ -125,7 +144,7 @@ const make = Effect.gen(function* () { · 9 unmodified lines
125144 });
126145 }
127146
128− const workos = new WorkOS({ apiKey, clientId });
147+ const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) });
129148
130149 const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
131150 withServiceLogging(
apps/cloud/src/engine/execution-stack.ts+1−0
@@ -62,6 +62,7 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 61 unmodified lines
6262 workosCredentials: {
6363 apiKey: env.WORKOS_API_KEY,
6464 clientId: env.WORKOS_CLIENT_ID,
65+ apiUrl: env.WORKOS_API_URL,
6566 },
6667 }),
6768 });
apps/cloud/src/env-augment.d.ts+5−0
@@ -26,6 +26,11 @@ declare global { · 25 unmodified lines
2626
2727 // Billing
2828 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;
2934
3035 // MCP
3136 EXECUTOR_MCP_DEBUG?: string;
apps/cloud/src/extensions/billing/route.ts+1−0
@@ -57,6 +57,7 @@ const handler = Effect.gen(function* () { · 56 unmodified lines
5757 },
5858 clientOptions: {
5959 secretKey: env.AUTUMN_SECRET_KEY ?? "",
60+ ...(env.AUTUMN_API_URL ? { serverURL: env.AUTUMN_API_URL } : {}),
6061 },
6162 pathPrefix: "/api/billing",
6263 }),
apps/cloud/src/extensions/billing/service.test-layer.ts+0−36
@@ -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));
apps/cloud/src/extensions/billing/service.ts+5−1
@@ -48,7 +48,11 @@ const make = Effect.sync(() => { · 47 unmodified lines
4848 } satisfies IAutumnService;
4949 }
5050
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+ });
5256
5357 const use = <A>(fn: (client: Autumn) => Promise<A>) =>
5458 Effect.tryPromise({
apps/cloud/src/extensions/routes.ts+1−5
@@ -34,7 +34,6 @@ import { · 33 unmodified lines
3434 } from "../auth/handlers";
3535 import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
3636 import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
37−import { E2E_STUB, E2EStubAutumnLayer } from "../testing/e2e-stub";
3837 import { OrgApi, OrgHttpApi } from "../org/api";
3938 import { OrgHandlers } from "../org/handlers";
4039 import { AutumnService } from "../extensions/billing/service";
@@ -75,11 +74,8 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 34 unmodified lines
7574 const SessionRoutes = HttpApiBuilder.layer(NonProtectedApi).pipe(
7675 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
7776 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.
8177 Layer.provideMerge(SessionAuthLive),
82− Layer.provideMerge(E2E_STUB ? E2EStubAutumnLayer : AutumnService.Default),
78+ Layer.provideMerge(AutumnService.Default),
8379 Layer.provide(apiPrefixedRouter),
8480 );
8581
apps/cloud/src/testing/e2e-stub.ts+0−21
@@ -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);
e2e/cloud/org-limit.test.ts+7−3
@@ -41,10 +41,14 @@ scenario( · 40 unmodified lines
4141
4242 const openCreateOrgModal = async (currentOrg: string) => {
4343 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.
4648 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();
4852 await page.getByText("Add another organization").waitFor();
4953 };
5054
e2e/cloud/org-switcher.test.ts+3−1
@@ -45,7 +45,9 @@ scenario( · 44 unmodified lines
4545 await step('Open the org switcher and choose "Create organization"', async () => {
4646 await page.getByRole("button", { name: /Test User/ }).click();
4747 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();
4951 await page.getByText("Add another organization").waitFor();
5052 });
5153
e2e/setup/boot.ts+8−3
@@ -72,13 +72,18 @@ export const bootProcesses = ( · 71 unmodified lines
7272 };
7373 };
7474
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);
7780 let lastError: unknown;
7881 while (Date.now() < deadline) {
7982 try {
8083 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;
8287 lastError = new Error(`status ${response.status}`);
8388 } catch (error) {
8489 lastError = error;
e2e/setup/cloud.globalsetup.ts+45−21
@@ -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.
66 import { rmSync } from "node:fs";
77 import { resolve } from "node:path";
88 import { fileURLToPath } from "node:url";
99
10+// Vendored fork import (same pattern as mcporter).
11+import { createEmulator } from "../../vendor/emulate/packages/emulate/dist/api.js";
12+
1013 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";
1223
1324 const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
1425
@@ -18,31 +29,36 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 3 unmodified lines
1829 return;
1930 }
2031
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
2435 // land in polluted orgs / org creation 500s).
2536 const dbPath = resolve(cloudDir, ".e2e-stub-db");
2637 rmSync(dbPath, { recursive: true, force: true });
2738
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+
2845 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",
3953 ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
4054 DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${CLOUD_DB_PORT}/postgres`,
4155 EXECUTOR_DIRECT_DATABASE_URL: "true",
4256 CLOUDFLARE_INCLUDE_PROCESS_ENV: "true",
4357 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,
4560 MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL,
61+ ALLOW_LOCAL_NETWORK: "true",
4662 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.
4763 DEV_DB_PORT: String(CLOUD_DB_PORT),
4864 DEV_DB_PATH: dbPath,
@@ -63,9 +79,17 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 14 unmodified lines
6379
6480 try {
6581 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 });
6684 } catch (error) {
6785 await procs.teardown();
86+ await workos.close();
87+ await autumn.close();
6888 throw error;
6989 }
70− return procs.teardown;
90+ return async () => {
91+ await procs.teardown();
92+ await workos.close();
93+ await autumn.close();
94+ };
7195 }
e2e/targets/cloud.ts+70−25
@@ -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.
67 import { randomUUID } from "node:crypto";
78
89 import { Effect } from "effect";
@@ -12,46 +13,90 @@ import type { Identity, Target } from "../src/target"; · 3 unmodified lines
1213 export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798);
1314 export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436);
1415 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";
1520
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+};
1752
1853 export const cloudTarget = (): Target => ({
1954 name: "cloud",
2055 baseUrl: CLOUD_BASE_URL,
2156 mcpUrl: `${CLOUD_BASE_URL}/mcp`,
22− capabilities: new Set(["api", "browser", "billing"]),
57+ capabilities: new Set(["api", "browser", "billing", "mcp-oauth"]),
2358 newIdentity: ({ org = true } = {}) =>
2459 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);
2963 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.
3366 const response = await fetch(new URL("/api/auth/create-organization", CLOUD_BASE_URL), {
3467 method: "POST",
3568 headers: {
3669 "content-type": "application/json",
3770 origin: new URL(CLOUD_BASE_URL).origin,
38− cookie: `wos-session=${value}`,
71+ cookie: session,
3972 },
40− body: JSON.stringify({ name: `Org ${user}` }),
73+ body: JSON.stringify({ name: `Org ${label}` }),
4174 });
4275 if (!response.ok) {
43− throw new Error(`cloud newIdentity: create-organization → ${response.status}`);
76+ throw new Error(`cloud newIdentity: create-organization failed (${response.status})`);
4477 }
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;
5079 }
80+ const [name, value] = session.split(/=(.*)/s);
5181 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" },
5586 };
5687 }),
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+ },
57102 });
packages/plugins/workos-vault/src/sdk/client.ts+15−0
@@ -96,8 +96,22 @@ export interface WorkOSVaultPromiseApi { · 95 unmodified lines
9696 export interface WorkOSVaultCredentials {
9797 readonly apiKey: string;
9898 readonly clientId: string;
99+ /** Optional WorkOS API base-URL override (e.g. a WorkOS emulator in tests). */
100+ readonly apiUrl?: string;
99101 }
100102
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+
101115 interface WorkOSVaultUseOptions {
102116 readonly expectedErrorStatuses?: readonly number[];
103117 readonly expectedErrorOutcome?: string;
@@ -205,6 +219,7 @@ export const makeConfiguredWorkOSVaultClient = ( · 101 unmodified lines
205219 new WorkOSClient({
206220 apiKey: credentials.apiKey,
207221 clientId: credentials.clientId,
222+ ...apiUrlOptions(credentials.apiUrl),
208223 }),
209224 ),
210225 catch: (cause) => new WorkOSVaultClientInstantiationError({ cause }),
vendor/emulate+1−1
@@ -1 +1 @@
1−Subproject commit a42fe29889c8a11aa752fc848af5ed71a76fa26e
1+Subproject commit 38cd039bbf5de27af4b4d15010940379db07645d