diff --git a/.changeset/petite-clubs-grab.md b/.changeset/petite-clubs-grab.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/petite-clubs-grab.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json
index a0798d3e640..478c802b1ab 100644
--- a/packages/clerk-js/package.json
+++ b/packages/clerk-js/package.json
@@ -78,6 +78,7 @@
"dequal": "2.0.3"
},
"devDependencies": {
+ "@clerk/msw": "workspace:^",
"@clerk/testing": "workspace:^",
"@rsdoctor/rspack-plugin": "^0.4.13",
"@rspack/cli": "^1.6.0",
diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js
index 16029f103c5..55133e59808 100644
--- a/packages/clerk-js/rspack.config.js
+++ b/packages/clerk-js/rspack.config.js
@@ -501,6 +501,7 @@ const devConfig = ({ mode, env }) => {
...(isSandbox
? {
historyApiFallback: true,
+ static: ['sandbox/public'],
}
: {}),
},
diff --git a/packages/clerk-js/sandbox/README.md b/packages/clerk-js/sandbox/README.md
new file mode 100644
index 00000000000..86c01790d09
--- /dev/null
+++ b/packages/clerk-js/sandbox/README.md
@@ -0,0 +1,41 @@
+# `clerk-js` Sandbox
+
+This folder contains a sandbox environment for iterating on the Clerk UI components. Each main top-level component gets its own page.
+
+## Running the sandbox
+
+You can start the sandbox by running `pnpm dev:sandbox` **in the root of the `javascript` repo**. This will start the server on http://localhost:4000. It will also run the development server for `@clerk/ui`.
+
+## Setting component props
+
+You can pass specific props to a given component by running the following in the console:
+
+```
+components..setProps({ ... });
+```
+
+For example, to set props for the `SignIn` component:
+
+```js
+components.signIn.setProps({
+ /* ... */
+});
+```
+
+Doing so will change the URL of the page you're on to include the configured props as a URL query parameter. This allows you to share a link to the specific configuration of the props you've set.
+
+## Activating API mocking scenarios
+
+You can also activate specific API mocking scenarios to avoid making calls to the Clerk API. Activate a scenario with the following:
+
+```js
+scenario.setScenario('ScenarioName');
+```
+
+You can also use `scenario.availableScenarios` to see a list of valid scenarios. You can also pass this to `setScenario`:
+
+```js
+scenario.setScenario(scenario.UserButtonLoggedIn);
+```
+
+Like `setProps`, this command will persist the active scenario to the URL.
diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts
index 6465c8c0403..2474ce4480c 100644
--- a/packages/clerk-js/sandbox/app.ts
+++ b/packages/clerk-js/sandbox/app.ts
@@ -1,26 +1,22 @@
+import { PageMocking, type MockScenario } from '@clerk/msw';
import * as l from '../../localizations';
import type { Clerk as ClerkType } from '../';
-
-const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];
-
-function fillLocalizationSelect() {
- const select = document.getElementById('localizationSelect') as HTMLSelectElement;
-
- for (const locale of AVAILABLE_LOCALES) {
- if (locale === 'enUS') {
- select.add(new Option(locale, locale, true, true));
- continue;
- }
-
- select.add(new Option(locale, locale));
- }
-}
+import * as scenarios from './scenarios';
interface ComponentPropsControl {
setProps: (props: unknown) => void;
getProps: () => any | null;
}
+interface ScenarioControls {
+ setScenario: (scenario: AvailableScenario | null) => void;
+ availableScenarios: typeof AVAILABLE_SCENARIOS;
+}
+
+const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';
+
+const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];
+
const AVAILABLE_COMPONENTS = [
'clerk', // While not a component, we want to support passing options to the Clerk class.
'signIn',
@@ -39,17 +35,57 @@ const AVAILABLE_COMPONENTS = [
'taskChooseOrganization',
'taskResetPassword',
] as const;
+type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number];
-const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';
+const AVAILABLE_SCENARIOS = Object.keys(scenarios) as (keyof typeof scenarios)[];
+type AvailableScenario = (typeof AVAILABLE_SCENARIOS)[number];
-const urlParams = new URL(window.location.href).searchParams;
-for (const [component, encodedProps] of urlParams.entries()) {
- if (AVAILABLE_COMPONENTS.includes(component as (typeof AVAILABLE_COMPONENTS)[number])) {
- localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-${component}`, encodedProps);
+function fillLocalizationSelect() {
+ const select = document.getElementById('localizationSelect') as HTMLSelectElement;
+
+ for (const locale of AVAILABLE_LOCALES) {
+ if (locale === 'enUS') {
+ select.add(new Option(locale, locale, true, true));
+ continue;
+ }
+
+ select.add(new Option(locale, locale));
+ }
+}
+
+function getScenario(): (() => MockScenario) | null {
+ const scenarioName = localStorage.getItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`);
+ if (scenarioName && AVAILABLE_SCENARIOS.includes(scenarioName as AvailableScenario)) {
+ return scenarios[scenarioName as AvailableScenario];
+ }
+ return null;
+}
+
+function setScenario(scenario: AvailableScenario | null) {
+ if (!scenario) {
+ localStorage.removeItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`);
+ const url = new URL(window.location.href);
+ url.searchParams.delete('scenario');
+ window.location.href = url.toString();
+ return;
+ }
+
+ if (!AVAILABLE_SCENARIOS.includes(scenario)) {
+ throw new Error(`Invalid scenario: "${scenario}". Available scenarios: ${AVAILABLE_SCENARIOS.join(', ')}`);
}
+ localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`, scenario);
+
+ const url = new URL(window.location.href);
+ url.searchParams.set('scenario', scenario);
+ window.location.href = url.toString();
}
-function setComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number], props: unknown) {
+const scenarioControls: ScenarioControls = {
+ setScenario,
+ availableScenarios: AVAILABLE_SCENARIOS,
+};
+
+function setComponentProps(component: AvailableComponent, props: unknown) {
const encodedProps = JSON.stringify(props);
const url = new URL(window.location.href);
@@ -58,7 +94,7 @@ function setComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number], pro
window.location.href = url.toString();
}
-function getComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number]): unknown | null {
+function getComponentProps(component: AvailableComponent): unknown | null {
const url = new URL(window.location.href);
const encodedProps = url.searchParams.get(component);
if (encodedProps) {
@@ -73,7 +109,7 @@ function getComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number]): un
return null;
}
-function buildComponentControls(component: (typeof AVAILABLE_COMPONENTS)[number]): ComponentPropsControl {
+function buildComponentControls(component: AvailableComponent): ComponentPropsControl {
return {
setProps(props) {
setComponentProps(component, props);
@@ -84,7 +120,7 @@ function buildComponentControls(component: (typeof AVAILABLE_COMPONENTS)[number]
};
}
-const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], ComponentPropsControl> = {
+const componentControls: Record = {
clerk: buildComponentControls('clerk'),
signIn: buildComponentControls('signIn'),
signUp: buildComponentControls('signUp'),
@@ -105,11 +141,21 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
declare global {
interface Window {
- components: Record<(typeof AVAILABLE_COMPONENTS)[number], ComponentPropsControl>;
+ components: Record;
+ scenario: typeof scenarioControls;
+ AVAILABLE_SCENARIOS: Record;
}
}
window.components = componentControls;
+window.scenario = scenarioControls;
+window.AVAILABLE_SCENARIOS = AVAILABLE_SCENARIOS.reduce(
+ (acc, scenario) => {
+ acc[scenario] = scenario;
+ return acc;
+ },
+ {} as Record,
+);
const Clerk = window.Clerk;
function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType {
@@ -118,8 +164,6 @@ function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType {
}
}
-const app = document.getElementById('app') as HTMLDivElement;
-
function mountIndex(element: HTMLDivElement) {
assertClerkIsLoaded(Clerk);
const user = Clerk.user;
@@ -267,6 +311,17 @@ function otherOptions() {
return { updateOtherOptions };
}
+const urlParams = new URL(window.location.href).searchParams;
+for (const [component, encodedProps] of urlParams.entries()) {
+ if (AVAILABLE_COMPONENTS.includes(component as AvailableComponent)) {
+ localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-${component}`, encodedProps);
+ }
+
+ if (component === 'scenario' && AVAILABLE_SCENARIOS.includes(encodedProps as AvailableScenario)) {
+ localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`, encodedProps);
+ }
+}
+
void (async () => {
assertClerkIsLoaded(Clerk);
fillLocalizationSelect();
@@ -280,6 +335,8 @@ void (async () => {
}
});
+ const app = document.getElementById('app') as HTMLDivElement;
+
const routes = {
'/': () => {
mountIndex(app);
@@ -373,6 +430,17 @@ void (async () => {
if (route in routes) {
const renderCurrentRoute = routes[route];
addCurrentRouteIndicator(route);
+
+ const scenario = getScenario();
+ if (scenario) {
+ const mocking = new PageMocking({
+ onStateChange: state => {
+ console.log('Mocking state changed:', state);
+ },
+ });
+ await mocking.initialize(route, { scenario });
+ }
+
await Clerk.load({
...(componentControls.clerk.getProps() ?? {}),
signInUrl: '/sign-in',
diff --git a/packages/clerk-js/sandbox/public/mockServiceWorker.js b/packages/clerk-js/sandbox/public/mockServiceWorker.js
new file mode 100644
index 00000000000..d4008fb1272
--- /dev/null
+++ b/packages/clerk-js/sandbox/public/mockServiceWorker.js
@@ -0,0 +1,334 @@
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = '2.11.3';
+const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
+const activeClientIds = new Set();
+
+addEventListener('install', function () {
+ self.skipWaiting();
+});
+
+addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim());
+});
+
+addEventListener('message', async function (event) {
+ const clientId = Reflect.get(event.source || {}, 'id');
+
+ if (!clientId || !self.clients) {
+ return;
+ }
+
+ const client = await self.clients.get(clientId);
+
+ if (!client) {
+ return;
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ });
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ });
+ break;
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: {
+ packageVersion: PACKAGE_VERSION,
+ checksum: INTEGRITY_CHECKSUM,
+ },
+ });
+ break;
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId);
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: {
+ client: {
+ id: client.id,
+ frameType: client.frameType,
+ },
+ },
+ });
+ break;
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId);
+
+ const remainingClients = allClients.filter(client => {
+ return client.id !== clientId;
+ });
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister();
+ }
+
+ break;
+ }
+ }
+});
+
+addEventListener('fetch', function (event) {
+ const requestInterceptedAt = Date.now();
+
+ // Bypass navigation requests.
+ if (event.request.mode === 'navigate') {
+ return;
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
+ return;
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been terminated (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return;
+ }
+
+ const requestId = crypto.randomUUID();
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
+});
+
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ */
+async function handleRequest(event, requestId, requestInterceptedAt) {
+ const client = await resolveMainClient(event);
+ const requestCloneForEvents = event.request.clone();
+ const response = await getResponse(event, client, requestId, requestInterceptedAt);
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ const serializedRequest = await serializeRequest(requestCloneForEvents);
+
+ // Clone the response so both the client and the library could consume it.
+ const responseClone = response.clone();
+
+ sendToClient(
+ client,
+ {
+ type: 'RESPONSE',
+ payload: {
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ request: {
+ id: requestId,
+ ...serializedRequest,
+ },
+ response: {
+ type: responseClone.type,
+ status: responseClone.status,
+ statusText: responseClone.statusText,
+ headers: Object.fromEntries(responseClone.headers.entries()),
+ body: responseClone.body,
+ },
+ },
+ },
+ responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+ );
+ }
+
+ return response;
+}
+
+/**
+ * Resolve the main client for the given event.
+ * Client that issues a request doesn't necessarily equal the client
+ * that registered the worker. It's with the latter the worker should
+ * communicate with during the response resolving phase.
+ * @param {FetchEvent} event
+ * @returns {Promise}
+ */
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId);
+
+ if (activeClientIds.has(event.clientId)) {
+ return client;
+ }
+
+ if (client?.frameType === 'top-level') {
+ return client;
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ });
+
+ return allClients
+ .filter(client => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible';
+ })
+ .find(client => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id);
+ });
+}
+
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @returns {Promise}
+ */
+async function getResponse(event, client, requestId, requestInterceptedAt) {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const requestClone = event.request.clone();
+
+ function passthrough() {
+ // Cast the request headers to a new Headers instance
+ // so the headers can be manipulated with.
+ const headers = new Headers(requestClone.headers);
+
+ // Remove the "accept" header value that marked this request as passthrough.
+ // This prevents request alteration and also keeps it compliant with the
+ // user-defined CORS policies.
+ const acceptHeader = headers.get('accept');
+ if (acceptHeader) {
+ const values = acceptHeader.split(',').map(value => value.trim());
+ const filteredValues = values.filter(value => value !== 'msw/passthrough');
+
+ if (filteredValues.length > 0) {
+ headers.set('accept', filteredValues.join(', '));
+ } else {
+ headers.delete('accept');
+ }
+ }
+
+ return fetch(requestClone, { headers });
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough();
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough();
+ }
+
+ // Notify the client that a request has been intercepted.
+ const serializedRequest = await serializeRequest(event.request);
+ const clientMessage = await sendToClient(
+ client,
+ {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ interceptedAt: requestInterceptedAt,
+ ...serializedRequest,
+ },
+ },
+ [serializedRequest.body],
+ );
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data);
+ }
+
+ case 'PASSTHROUGH': {
+ return passthrough();
+ }
+ }
+
+ return passthrough();
+}
+
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array} transferrables
+ * @returns {Promise}
+ */
+function sendToClient(client, message, transferrables = []) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+
+ channel.port1.onmessage = event => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error);
+ }
+
+ resolve(event.data);
+ };
+
+ client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
+ });
+}
+
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
+ // Setting response status code to 0 is a no-op.
+ // However, when responding with a "Response.error()", the produced Response
+ // instance will have status code set to 0. Since it's not possible to create
+ // a Response instance with status code 0, handle that use-case separately.
+ if (response.status === 0) {
+ return Response.error();
+ }
+
+ const mockedResponse = new Response(response.body, response);
+
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+ value: true,
+ enumerable: true,
+ });
+
+ return mockedResponse;
+}
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+ return {
+ url: request.url,
+ mode: request.mode,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.arrayBuffer(),
+ keepalive: request.keepalive,
+ };
+}
diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts
new file mode 100644
index 00000000000..988c7ecf0f9
--- /dev/null
+++ b/packages/clerk-js/sandbox/scenarios/index.ts
@@ -0,0 +1 @@
+export { UserButtonSignedIn } from './user-button-signed-in';
diff --git a/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts b/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts
new file mode 100644
index 00000000000..20812fc3baa
--- /dev/null
+++ b/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts
@@ -0,0 +1,26 @@
+import {
+ clerkHandlers,
+ EnvironmentService,
+ SessionService,
+ setClerkState,
+ type MockScenario,
+ UserService,
+} from '@clerk/msw';
+
+export function UserButtonSignedIn(): MockScenario {
+ const user = UserService.create();
+ const session = SessionService.create(user);
+
+ setClerkState({
+ environment: EnvironmentService.MULTI_SESSION,
+ session,
+ user,
+ });
+
+ return {
+ description: 'UserButton component with signed-in user',
+ handlers: clerkHandlers,
+ initialState: { session, user },
+ name: 'user-button-signed-in',
+ };
+}
diff --git a/packages/msw/BillingService.ts b/packages/msw/BillingService.ts
new file mode 100644
index 00000000000..9f13225b7cc
--- /dev/null
+++ b/packages/msw/BillingService.ts
@@ -0,0 +1,377 @@
+import type {
+ BillingPaymentSourceJSON,
+ BillingPlanJSON,
+ BillingSubscriptionJSON,
+ SessionResource,
+ UserResource,
+} from '@clerk/shared/types';
+
+type AuthCheckResult = { authorized: true; data: T } | { authorized: false; error: string; status: number };
+
+export class BillingService {
+ private static createPaymentSources(): BillingPaymentSourceJSON[] {
+ return [
+ {
+ card_type: 'visa',
+ id: 'card_mock_4242',
+ is_default: true,
+ is_removable: true,
+ last4: '4242',
+ object: 'commerce_payment_method',
+ payment_method: 'card',
+ payment_type: 'card',
+ status: 'active',
+ wallet_type: null,
+ } as any,
+ ];
+ }
+
+ private static createPlans(): BillingPlanJSON[] {
+ return [
+ {
+ amount: 999,
+ amount_formatted: '9.99',
+ annual_amount: 9900,
+ annual_amount_formatted: '99.00',
+ annual_fee: { amount: 9900, amount_formatted: '99.00', currency: 'usd', currency_symbol: '$' },
+ annual_monthly_amount: 825,
+ annual_monthly_amount_formatted: '8.25',
+ annual_monthly_fee: { amount: 825, amount_formatted: '8.25', currency: 'usd', currency_symbol: '$' },
+ avatar_url: '',
+ currency: 'usd',
+ currency_symbol: '$',
+ description: 'Basic plan with essential features',
+ features: [
+ {
+ avatar_url: '',
+ description: 'Feature 1',
+ id: 'feat_1',
+ name: 'Feature 1',
+ object: 'feature',
+ slug: 'feature-1',
+ },
+ {
+ avatar_url: '',
+ description: 'Feature 2',
+ id: 'feat_2',
+ name: 'Feature 2',
+ object: 'feature',
+ slug: 'feature-2',
+ },
+ {
+ avatar_url: '',
+ description: 'Feature 3',
+ id: 'feat_3',
+ name: 'Feature 3',
+ object: 'feature',
+ slug: 'feature-3',
+ },
+ ],
+ fee: { amount: 999, amount_formatted: '9.99', currency: 'usd', currency_symbol: '$' },
+ for_payer_type: 'user',
+ free_trial_days: 14,
+ free_trial_enabled: true,
+ has_base_fee: true,
+ id: 'plan_basic_monthly',
+ is_default: false,
+ is_recurring: true,
+ name: 'Basic',
+ object: 'commerce_plan',
+ publicly_visible: true,
+ slug: 'basic',
+ },
+ ];
+ }
+
+ private static createSubscription(): BillingSubscriptionJSON {
+ const now = Date.now();
+ const thirtyDaysFromNow = now + 30 * 24 * 60 * 60 * 1000;
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
+
+ return {
+ active_at: thirtyDaysAgo,
+ created_at: thirtyDaysAgo,
+ eligible_for_free_trial: false,
+ id: 'sub_mock_active',
+ next_payment: {
+ amount: {
+ amount: 999,
+ amount_formatted: '9.99',
+ currency: 'usd',
+ currency_symbol: '$',
+ },
+ date: thirtyDaysFromNow,
+ } as any,
+ object: 'commerce_subscription',
+ past_due_at: null,
+ status: 'active',
+ subscription_items: [
+ {
+ amount: {
+ amount: 999,
+ amount_formatted: '9.99',
+ currency: 'usd',
+ currency_symbol: '$',
+ },
+ canceled_at: null,
+ created_at: thirtyDaysAgo,
+ id: 'subi_mock_basic',
+ is_free_trial: false,
+ object: 'commerce_subscription_item',
+ past_due_at: null,
+ payment_method_id: 'card_mock_4242',
+ period_end: thirtyDaysFromNow,
+ period_start: thirtyDaysAgo,
+ plan: this.createPlans()[0],
+ plan_period: 'month',
+ status: 'active',
+ upcoming_at: null,
+ updated_at: now,
+ },
+ ] as any,
+ updated_at: now,
+ };
+ }
+
+ private static createEligibleSubscription(): BillingSubscriptionJSON {
+ const now = Date.now();
+
+ return {
+ active_at: null,
+ created_at: now,
+ eligible_for_free_trial: true,
+ id: 'sub_mock_eligible',
+ next_payment: null,
+ object: 'commerce_subscription',
+ past_due_at: null,
+ status: 'inactive',
+ subscription_items: [],
+ updated_at: now,
+ } as unknown as BillingSubscriptionJSON;
+ }
+
+ private static createFreeTrialSubscription(): BillingSubscriptionJSON {
+ const now = Date.now();
+ const fourteenDaysFromNow = now + 14 * 24 * 60 * 60 * 1000;
+
+ return {
+ active_at: now,
+ created_at: now,
+ eligible_for_free_trial: false,
+ id: 'sub_mock_trial',
+ next_payment: {
+ amount: {
+ amount: 999,
+ amount_formatted: '9.99',
+ currency: 'usd',
+ currency_symbol: '$',
+ },
+ date: fourteenDaysFromNow,
+ },
+ object: 'commerce_subscription',
+ past_due_at: null,
+ status: 'trialing',
+ subscription_items: [
+ {
+ amount: {
+ amount: 0,
+ amount_formatted: '0.00',
+ currency: 'usd',
+ currency_symbol: '$',
+ },
+ canceled_at: null,
+ created_at: now,
+ id: 'subi_mock_trial_basic',
+ is_free_trial: true,
+ object: 'commerce_subscription_item',
+ past_due_at: null,
+ payment_method_id: null,
+ period_end: fourteenDaysFromNow,
+ period_start: now,
+ plan: this.createPlans()[0],
+ plan_period: 'trial',
+ status: 'trialing',
+ upcoming_at: fourteenDaysFromNow,
+ updated_at: now,
+ },
+ ],
+ updated_at: now,
+ } as unknown as BillingSubscriptionJSON;
+ }
+
+ static getPaymentSources(
+ session: SessionResource | null,
+ user: UserResource | null,
+ ): AuthCheckResult<{
+ data: BillingPaymentSourceJSON[];
+ response: { data: BillingPaymentSourceJSON[]; total_count: number };
+ total_count: number;
+ }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ const paymentSources = this.createPaymentSources();
+
+ return {
+ authorized: true,
+ data: {
+ data: paymentSources,
+ response: {
+ data: paymentSources,
+ total_count: paymentSources.length,
+ },
+ total_count: paymentSources.length,
+ },
+ };
+ }
+
+ static initializePaymentSource(
+ session: SessionResource | null,
+ user: UserResource | null,
+ ): AuthCheckResult<{ response: { client_secret: string; object: string; status: string } }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ return {
+ authorized: true,
+ data: {
+ response: {
+ client_secret: 'mock_client_secret_' + Math.random().toString(36).substring(2, 15),
+ object: 'payment_intent',
+ status: 'requires_payment_method',
+ },
+ },
+ };
+ }
+
+ static createPaymentSource(
+ session: SessionResource | null,
+ user: UserResource | null,
+ ): AuthCheckResult<{ response: BillingPaymentSourceJSON }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ return {
+ authorized: true,
+ data: {
+ response: {
+ card_type: 'visa',
+ id: 'card_mock_' + Math.random().toString(36).substring(2, 9),
+ is_default: false,
+ is_removable: true,
+ last4: '4242',
+ object: 'commerce_payment_source',
+ payment_method: 'card',
+ payment_type: 'card',
+ status: 'active',
+ wallet_type: null,
+ } as any,
+ },
+ };
+ }
+
+ static updatePaymentSource(
+ session: SessionResource | null,
+ user: UserResource | null,
+ ): AuthCheckResult<{ response: { success: boolean } }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ return {
+ authorized: true,
+ data: {
+ response: {
+ success: true,
+ },
+ },
+ };
+ }
+
+ static deletePaymentSource(
+ session: SessionResource | null,
+ user: UserResource | null,
+ ): AuthCheckResult<{ response: { deleted: boolean; id: string; object: string } }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ return {
+ authorized: true,
+ data: {
+ response: {
+ deleted: true,
+ id: 'card_mock_deleted',
+ object: 'commerce_payment_source',
+ },
+ },
+ };
+ }
+
+ static getPlans() {
+ const plans = this.createPlans();
+
+ return {
+ data: plans,
+ response: {
+ data: plans,
+ total_count: plans.length,
+ },
+ total_count: plans.length,
+ };
+ }
+
+ static getStatements() {
+ return {
+ data: [],
+ total_count: 0,
+ };
+ }
+
+ static getSubscription(
+ session: SessionResource | null,
+ user: UserResource | null,
+ subscriptionOverride?: BillingSubscriptionJSON | null,
+ ): AuthCheckResult<{ response: BillingSubscriptionJSON }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ const subscription = subscriptionOverride ?? this.createEligibleSubscription();
+
+ return {
+ authorized: true,
+ data: {
+ response: subscription,
+ },
+ };
+ }
+
+ static getSubscriptions() {
+ return {
+ data: [],
+ total_count: 0,
+ };
+ }
+
+ static startFreeTrial(
+ session: SessionResource | null,
+ user: UserResource | null,
+ ): AuthCheckResult<{ response: BillingSubscriptionJSON }> {
+ if (!session || !user) {
+ return { authorized: false, error: 'No active session', status: 401 };
+ }
+
+ const subscription = this.createFreeTrialSubscription();
+
+ return {
+ authorized: true,
+ data: {
+ response: subscription,
+ },
+ };
+ }
+}
diff --git a/packages/msw/EnvironmentService.ts b/packages/msw/EnvironmentService.ts
new file mode 100644
index 00000000000..108b752f76c
--- /dev/null
+++ b/packages/msw/EnvironmentService.ts
@@ -0,0 +1,527 @@
+import type { EnvironmentJSON } from '@clerk/shared/types';
+
+// For mocking, we allow flexibility while adhering to the general EnvironmentJSON structure
+export interface EnvironmentPreset {
+ config: Omit & {
+ user_settings: Partial> & {
+ attributes: EnvironmentJSON['user_settings']['attributes'];
+ social?: any; // Allow partial OAuth providers for mocking
+ };
+ meta?: any; // Allow extra metadata for mocking
+ };
+ description: string;
+ id: string;
+ name: string;
+}
+
+const singleSessionEnvironment: EnvironmentPreset = {
+ config: {
+ api_keys_settings: {
+ enabled: false,
+ id: 'api_keys_settings_1',
+ object: 'api_keys_settings',
+ },
+ auth_config: {
+ claimed_at: null,
+ id: 'aac_single',
+ object: 'auth_config',
+ preferred_channels: {},
+ reverification: false,
+ single_session_mode: true,
+ },
+ commerce_settings: {
+ billing: {
+ organization: {
+ enabled: false,
+ has_paid_plans: false,
+ },
+ stripe_publishable_key: '',
+ user: {
+ enabled: false,
+ has_paid_plans: false,
+ },
+ },
+ id: 'commerce_settings_1',
+ object: 'commerce_settings',
+ },
+ display_config: {
+ after_create_organization_url: '',
+ after_join_waitlist_url: '',
+ after_leave_organization_url: '',
+ after_sign_in_url: '',
+ after_sign_out_all_url: '',
+ after_sign_out_one_url: '',
+ after_sign_up_url: '',
+ after_switch_session_url: '',
+ application_name: 'Acme Co',
+ branded: true,
+ captcha_oauth_bypass: null,
+ captcha_provider: 'turnstile',
+ captcha_public_key: null,
+ captcha_public_key_invisible: null,
+ captcha_widget_type: 'invisible',
+ create_organization_url: '',
+ favicon_image_url: '',
+ home_url: 'https://example.com',
+ id: 'display_config_1',
+ instance_environment_type: 'production',
+ logo_image_url: '',
+ object: 'display_config',
+ organization_profile_url: '',
+ preferred_sign_in_strategy: 'password',
+ privacy_policy_url: '',
+ show_devmode_warning: false,
+ sign_in_url: '',
+ sign_up_url: '',
+ support_email: '',
+ terms_url: '',
+ theme: {
+ buttons: { font_color: '#000000', font_family: '', font_weight: '' },
+ general: {
+ background_color: '#ffffff',
+ border_radius: '',
+ box_shadow: '',
+ color: '#000000',
+ font_color: '#000000',
+ font_family: '',
+ label_font_weight: '',
+ padding: '',
+ },
+ accounts: { background_color: '#ffffff' },
+ },
+ user_profile_url: '',
+ waitlist_url: '',
+ },
+ id: 'env_single_session',
+ maintenance_mode: false,
+ meta: { responseHeaders: { country: 'us' } },
+ object: 'environment',
+ organization_settings: {
+ actions: {
+ admin_delete: true,
+ },
+ domains: {
+ default_role: null,
+ enabled: false,
+ enrollment_modes: [],
+ },
+ enabled: false,
+ force_organization_selection: false,
+ id: undefined as never,
+ max_allowed_memberships: 0,
+ object: undefined as never,
+ slug: {
+ disabled: false,
+ },
+ },
+ user_settings: {
+ attributes: {
+ email_address: {
+ enabled: true,
+ first_factors: ['email_code'],
+ required: true,
+ second_factors: ['totp', 'backup_code'],
+ used_for_first_factor: true,
+ used_for_second_factor: false,
+ verifications: ['email_code'],
+ verify_at_sign_up: true,
+ },
+ phone_number: {
+ enabled: true,
+ first_factors: ['phone_code'],
+ required: false,
+ second_factors: ['phone_code', 'totp', 'backup_code'],
+ used_for_first_factor: true,
+ used_for_second_factor: true,
+ verifications: ['phone_code'],
+ verify_at_sign_up: false,
+ },
+ web3_wallet: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ username: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ first_name: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ last_name: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ password: {
+ enabled: true,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ authenticator_app: {
+ enabled: true,
+ first_factors: [],
+ required: false,
+ second_factors: ['totp'],
+ used_for_first_factor: false,
+ used_for_second_factor: true,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ backup_code: {
+ enabled: true,
+ first_factors: [],
+ required: false,
+ second_factors: ['backup_code'],
+ used_for_first_factor: false,
+ used_for_second_factor: true,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ passkey: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ },
+ enterprise_sso: {
+ enabled: false,
+ },
+ passkey_settings: {
+ allow_autofill: false,
+ show_sign_in_button: false,
+ },
+ saml: {
+ enabled: false,
+ },
+ sign_in: {
+ second_factor: {
+ enabled: false,
+ required: false,
+ },
+ },
+ sign_up: {
+ allowlist_only: false,
+ captcha_enabled: false,
+ legal_consent_enabled: false,
+ mode: 'public',
+ progressive: false,
+ },
+ social: {
+ oauth_google: {
+ authenticatable: true,
+ enabled: true,
+ logo_url: 'https://img.clerk.com/static/google.png',
+ name: 'Google',
+ required: false,
+ strategy: 'oauth_google',
+ },
+ },
+ },
+ },
+ description: 'Single session mode environment',
+ id: 'single-session',
+ name: 'Single Session',
+};
+
+const multiSessionEnvironment: EnvironmentPreset = {
+ config: {
+ api_keys_settings: {
+ enabled: false,
+ id: 'api_keys_settings_1',
+ object: 'api_keys_settings',
+ },
+ auth_config: {
+ claimed_at: null,
+ id: 'aac_multi',
+ object: 'auth_config',
+ preferred_channels: {},
+ reverification: false,
+ single_session_mode: false,
+ },
+ commerce_settings: {
+ billing: {
+ organization: {
+ enabled: false,
+ has_paid_plans: false,
+ },
+ stripe_publishable_key: '',
+ user: {
+ enabled: true,
+ has_paid_plans: true,
+ },
+ },
+ id: 'commerce_settings_1',
+ object: 'commerce_settings',
+ },
+ display_config: {
+ after_create_organization_url: '',
+ after_join_waitlist_url: '',
+ after_leave_organization_url: '',
+ after_sign_in_url: '',
+ after_sign_out_all_url: '',
+ after_sign_out_one_url: '',
+ after_sign_up_url: '',
+ after_switch_session_url: '',
+ application_name: 'Acme Co',
+ branded: true,
+ captcha_oauth_bypass: null,
+ captcha_provider: 'turnstile',
+ captcha_public_key: null,
+ captcha_public_key_invisible: null,
+ captcha_widget_type: 'invisible',
+ create_organization_url: '',
+ favicon_image_url: '',
+ home_url: 'https://example.com',
+ id: 'display_config_1',
+ instance_environment_type: 'production',
+ logo_image_url: '',
+ object: 'display_config',
+ organization_profile_url: '',
+ preferred_sign_in_strategy: 'password',
+ privacy_policy_url: '',
+ show_devmode_warning: false,
+ sign_in_url: '',
+ sign_up_url: '',
+ support_email: '',
+ terms_url: '',
+ theme: {
+ buttons: { font_color: '#000000', font_family: '', font_weight: '' },
+ general: {
+ background_color: '#ffffff',
+ border_radius: '',
+ box_shadow: '',
+ color: '#000000',
+ font_color: '#000000',
+ font_family: '',
+ label_font_weight: '',
+ padding: '',
+ },
+ accounts: { background_color: '#ffffff' },
+ },
+ user_profile_url: '',
+ waitlist_url: '',
+ },
+ id: 'env_multi_session',
+ maintenance_mode: false,
+ meta: { responseHeaders: { country: 'us' } },
+ object: 'environment',
+ organization_settings: {
+ actions: {
+ admin_delete: false,
+ },
+ domains: {
+ default_role: 'org:member',
+ enabled: true,
+ enrollment_modes: ['manual_invitation', 'automatic_invitation', 'automatic_suggestion'],
+ },
+ enabled: true,
+ force_organization_selection: false,
+ id: undefined as never,
+ max_allowed_memberships: 3,
+ object: undefined as never,
+ slug: {
+ disabled: false,
+ },
+ },
+ user_settings: {
+ actions: {
+ create_organization: true,
+ delete_self: true,
+ },
+ attributes: {
+ email_address: {
+ enabled: true,
+ first_factors: ['email_code'],
+ required: true,
+ second_factors: ['totp', 'backup_code'],
+ used_for_first_factor: true,
+ used_for_second_factor: false,
+ verifications: ['email_code'],
+ verify_at_sign_up: true,
+ },
+ phone_number: {
+ enabled: true,
+ first_factors: ['phone_code'],
+ required: false,
+ second_factors: ['phone_code', 'totp', 'backup_code'],
+ used_for_first_factor: true,
+ used_for_second_factor: true,
+ verifications: ['phone_code'],
+ verify_at_sign_up: false,
+ },
+ web3_wallet: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ username: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ first_name: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ last_name: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ password: {
+ enabled: true,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ authenticator_app: {
+ enabled: true,
+ first_factors: [],
+ required: false,
+ second_factors: ['totp'],
+ used_for_first_factor: false,
+ used_for_second_factor: true,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ backup_code: {
+ enabled: true,
+ first_factors: [],
+ required: false,
+ second_factors: ['backup_code'],
+ used_for_first_factor: false,
+ used_for_second_factor: true,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ passkey: {
+ enabled: false,
+ first_factors: [],
+ required: false,
+ second_factors: [],
+ used_for_first_factor: false,
+ used_for_second_factor: false,
+ verifications: [],
+ verify_at_sign_up: false,
+ },
+ },
+ enterprise_sso: {
+ enabled: false,
+ },
+ passkey_settings: {
+ allow_autofill: false,
+ show_sign_in_button: false,
+ },
+ saml: {
+ enabled: false,
+ },
+ sign_in: {
+ second_factor: {
+ enabled: false,
+ required: false,
+ },
+ },
+ sign_up: {
+ allowlist_only: false,
+ captcha_enabled: false,
+ legal_consent_enabled: false,
+ mode: 'public',
+ progressive: false,
+ },
+ social: {
+ oauth_google: {
+ authenticatable: true,
+ enabled: true,
+ logo_url: 'https://img.clerk.com/static/google.png',
+ name: 'Google',
+ required: false,
+ strategy: 'oauth_google',
+ },
+ oauth_github: {
+ authenticatable: true,
+ enabled: true,
+ logo_url: 'https://img.clerk.com/static/github.png',
+ name: 'GitHub',
+ required: false,
+ strategy: 'oauth_github',
+ },
+ },
+ },
+ },
+ description: 'Multi-session mode environment with billing enabled',
+ id: 'multi-session',
+ name: 'Multi Session',
+};
+
+export class EnvironmentService {
+ static readonly MULTI_SESSION = multiSessionEnvironment;
+ static readonly SINGLE_SESSION = singleSessionEnvironment;
+
+ static getEnvironment(id: string): EnvironmentPreset | undefined {
+ const environments = [this.SINGLE_SESSION, this.MULTI_SESSION];
+ return environments.find(i => i.id === id);
+ }
+
+ static listEnvironments(): EnvironmentPreset[] {
+ return [this.SINGLE_SESSION, this.MULTI_SESSION];
+ }
+}
diff --git a/packages/msw/MockingController.ts b/packages/msw/MockingController.ts
new file mode 100644
index 00000000000..007bfe4097e
--- /dev/null
+++ b/packages/msw/MockingController.ts
@@ -0,0 +1,252 @@
+import { http, HttpResponse } from 'msw';
+import { setupWorker } from 'msw/browser';
+
+import type { MockScenario } from './types';
+
+/**
+ * Configuration options for the mock controller
+ */
+export interface MockConfig {
+ debug?: boolean;
+ delay?: number | { min: number; max: number };
+ persist?: boolean;
+}
+
+/**
+ * Controller for managing Clerk API mocking using MSW
+ * Browser-only implementation for sandbox and documentation sites
+ */
+export class MockingController {
+ private activeScenario: MockScenario | null = null;
+ private config: MockConfig;
+ private scenarios: Map = new Map();
+ private worker: ReturnType | null = null;
+
+ constructor(config: MockConfig = {}) {
+ this.config = {
+ debug: false,
+ delay: { min: 100, max: 500 },
+ persist: false,
+ ...config,
+ };
+ }
+
+ getActiveScenario(): MockScenario | null {
+ return this.activeScenario;
+ }
+
+ getScenarios(): MockScenario[] {
+ return Array.from(this.scenarios.values());
+ }
+
+ hasScenario(scenarioName: string): boolean {
+ return this.scenarios.has(scenarioName);
+ }
+
+ registerScenario(scenario: MockScenario): void {
+ this.scenarios.set(scenario.name, scenario);
+ }
+
+ async start(scenarioName?: string): Promise {
+ if (this.worker) {
+ this.worker.stop();
+ this.worker = null;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ const handlers = this.getHandlers(scenarioName);
+ console.log(
+ `[MSW] Loaded ${this.scenarios.size} scenarios, starting with scenario: ${scenarioName || 'default'} (${handlers.length} handlers)`,
+ );
+
+ const worker = setupWorker(...handlers);
+ this.worker = worker;
+
+ const isDeployed =
+ window.location.hostname !== 'localhost' &&
+ !window.location.hostname.includes('127.0.0.1') &&
+ !window.location.hostname.includes('192.168.');
+
+ const workerConfig = {
+ quiet: !this.config.debug,
+ onUnhandledRequest: (req: any) => {
+ if (
+ req.url.includes('/ingest/') ||
+ req.url.includes('/analytics/') ||
+ req.url.includes('/telemetry/') ||
+ req.url.includes('/metrics/') ||
+ req.url.includes('/tracking/') ||
+ req.url.includes('/tokens') ||
+ req.url.includes('clerk-telemetry') ||
+ req.url.includes('/__clerk')
+ ) {
+ return;
+ }
+ if (this.config.debug) {
+ console.warn(`[MSW] Unhandled request: ${req.method} ${req.url}`);
+ }
+ },
+ ...(isDeployed
+ ? {
+ serviceWorker: {
+ url: '/mockServiceWorker.js',
+ options: {
+ scope: '/',
+ },
+ },
+ }
+ : {
+ serviceWorker: {
+ url: '/mockServiceWorker.js',
+ },
+ }),
+ };
+
+ try {
+ await worker.start(workerConfig);
+ worker.events.on('request:start', ({ request }) => {
+ if (this.config.debug) {
+ console.log('[MSW] Request intercepted:', request.method, request.url);
+ }
+ });
+
+ worker.events.on('response:mocked', async ({ request, response }) => {
+ if (this.config.debug) {
+ console.log('[MSW] Response mocked:', request.method, request.url, response.status);
+ }
+ });
+
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
+ if (!navigator.serviceWorker.controller) {
+ const hasReloaded = sessionStorage.getItem('msw_reloaded');
+ if (!hasReloaded) {
+ sessionStorage.setItem('msw_reloaded', 'true');
+ window.location.reload();
+ return;
+ }
+ } else {
+ sessionStorage.removeItem('msw_reloaded');
+ }
+ }
+ } catch (error) {
+ try {
+ await this.worker.start({
+ quiet: !this.config.debug,
+ onUnhandledRequest: (req: any) => {
+ if (
+ req.url.includes('/ingest/') ||
+ req.url.includes('/analytics/') ||
+ req.url.includes('/telemetry/') ||
+ req.url.includes('/metrics/') ||
+ req.url.includes('/tracking/') ||
+ req.url.includes('/tokens') ||
+ req.url.includes('clerk-telemetry') ||
+ req.url.includes('/__clerk')
+ ) {
+ return;
+ }
+ if (this.config.debug) {
+ console.warn(`[MSW] Unhandled request: ${req.method} ${req.url}`);
+ }
+ },
+ });
+
+ this.worker.events.on('request:start', ({ request }) => {
+ if (this.config.debug) {
+ console.log('[MSW] Request intercepted:', request.method, request.url);
+ }
+ });
+
+ this.worker.events.on('response:mocked', async ({ request, response }) => {
+ if (this.config.debug) {
+ console.log('[MSW] Response mocked:', request.method, request.url, response.status);
+ }
+ });
+ } catch (fallbackError) {
+ console.error('[MSW] Failed to start worker in fallback mode:', fallbackError);
+ throw new Error(
+ `Failed to initialize mocking: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
+ );
+ }
+ }
+ }
+
+ stop(): void {
+ if (this.worker) {
+ this.worker.stop();
+ this.worker = null;
+ }
+ }
+
+ switchScenario(scenarioName: string): void {
+ const scenario = this.scenarios.get(scenarioName);
+ if (!scenario) {
+ throw new Error(`Scenario "${scenarioName}" not found`);
+ }
+
+ this.activeScenario = scenario;
+
+ if (this.worker) {
+ this.worker.use(...scenario.handlers);
+ }
+
+ if (this.config.debug) {
+ console.log(`[MSW] Switched to scenario: ${scenarioName}`);
+ }
+ }
+
+ private getHandlers(scenarioName?: string): any[] {
+ if (scenarioName) {
+ const scenario = this.scenarios.get(scenarioName);
+ if (!scenario) {
+ throw new Error(`[MSW] Scenario "${scenarioName}" not found`);
+ }
+ this.activeScenario = scenario;
+ return scenario.handlers;
+ }
+
+ return [
+ http.get('/v1/client', () => {
+ return HttpResponse.json({
+ response: {
+ lastActiveSessionId: null,
+ sessions: [],
+ signIn: null,
+ signUp: null,
+ },
+ });
+ }),
+
+ http.get('/v1/environment', () => {
+ return HttpResponse.json({
+ auth: {
+ authConfig: {
+ singleSessionMode: false,
+ urlBasedSessionSyncing: true,
+ },
+ displayConfig: {
+ afterSignInUrl: '',
+ afterSignUpUrl: '',
+ branded: false,
+ captchaPublicKey: null,
+ faviconImageUrl: '',
+ homeUrl: 'https://example.com',
+ instanceEnvironmentType: 'production',
+ logoImageUrl: '',
+ preferredSignInStrategy: 'password',
+ signInUrl: '',
+ signUpUrl: '',
+ userProfileUrl: '',
+ },
+ },
+ organization: null,
+ user: null,
+ });
+ }),
+
+ http.all('*', () => {
+ return HttpResponse.json({ error: 'Not found' }, { status: 404 });
+ }),
+ ];
+ }
+}
diff --git a/packages/msw/MockingProvider.tsx b/packages/msw/MockingProvider.tsx
new file mode 100644
index 00000000000..150dc9b40c2
--- /dev/null
+++ b/packages/msw/MockingProvider.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { createContext, ReactNode, useContext } from 'react';
+
+import type { MockScenario } from './types';
+import { usePageMocking } from './usePageMocking';
+
+interface MockingContextValue {
+ error: Error | null;
+ isEnabled: boolean;
+ isReady: boolean;
+ pathname: string;
+}
+
+const MockingContext = createContext(null);
+
+interface MockingProviderProps {
+ children: ReactNode;
+ debug?: boolean;
+ delay?: number | { min: number; max: number };
+ persist?: boolean;
+ scenario?: () => MockScenario;
+}
+
+export function MockingProvider({ children, debug, delay, persist, scenario }: MockingProviderProps) {
+ const mockingState = usePageMocking({ debug, delay, persist, scenario });
+
+ return {children};
+}
+
+export function useMockingContext() {
+ const context = useContext(MockingContext);
+ if (!context) {
+ throw new Error('useMockingContext must be used within a MockingProvider');
+ }
+ return context;
+}
diff --git a/packages/msw/MockingStatusIndicator.tsx b/packages/msw/MockingStatusIndicator.tsx
new file mode 100644
index 00000000000..2cf210450cc
--- /dev/null
+++ b/packages/msw/MockingStatusIndicator.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useMockingContext } from './MockingProvider';
+
+export function MockingStatusIndicator() {
+ const { error, isEnabled } = useMockingContext();
+
+ if (process.env.NODE_ENV !== 'development') {
+ return null;
+ }
+
+ const dotColor = error ? '#ef4444' : isEnabled ? '#22c55e' : '#9ca3af';
+
+ return (
+
+
+
+ {error ? 'Error' : isEnabled ? 'Mocked' : 'Live'}
+
+
+ );
+}
diff --git a/packages/msw/OrganizationService.ts b/packages/msw/OrganizationService.ts
new file mode 100644
index 00000000000..5b060fbbf5d
--- /dev/null
+++ b/packages/msw/OrganizationService.ts
@@ -0,0 +1,81 @@
+import type { OrganizationMembershipResource, OrganizationResource } from '@clerk/shared/types';
+
+type MembershipRole = 'org:admin' | 'org:member';
+
+export class OrganizationService {
+ static create(overrides: Partial = {}): OrganizationResource {
+ const orgId = overrides.id || 'org_mock_default';
+
+ return {
+ adminDeleteEnabled: true,
+ createdAt: new Date(),
+ hasImage: false,
+ id: orgId,
+ imageUrl: '',
+ maxAllowedMemberships: 100,
+ membersCount: 3,
+ name: 'Acme Inc',
+ object: 'organization',
+ pendingInvitationsCount: 0,
+ publicMetadata: {},
+ slug: 'acme-inc',
+ updatedAt: new Date(),
+ // Methods
+ addMember: async () => ({}) as any,
+ createDomain: async () => ({}) as any,
+ destroy: async () => {},
+ getDomains: async () => ({ data: [], totalCount: 0 }) as any,
+ getInvitations: async () => ({ data: [], totalCount: 0 }) as any,
+ getMembershipRequests: async () => ({ data: [], totalCount: 0 }) as any,
+ getMemberships: async () => ({ data: [], totalCount: 0 }) as any,
+ getRoles: async () => ({ data: [], totalCount: 0 }) as any,
+ inviteMember: async () => ({}) as any,
+ inviteMembers: async () => ({}) as any,
+ removeMember: async () => ({}) as any,
+ setLogo: async () => ({}) as any,
+ update: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ ...overrides,
+ } as unknown as OrganizationResource;
+ }
+
+ static createMembership(
+ organization: OrganizationResource,
+ userId: string,
+ role: MembershipRole = 'org:admin',
+ overrides: Partial = {},
+ ): OrganizationMembershipResource {
+ const adminPermissions = [
+ 'org:sys_profile:manage',
+ 'org:sys_profile:delete',
+ 'org:sys_memberships:read',
+ 'org:sys_memberships:manage',
+ 'org:sys_domains:read',
+ 'org:sys_domains:manage',
+ ];
+ const memberPermissions = ['org:sys_profile:read', 'org:sys_memberships:read'];
+
+ return {
+ createdAt: new Date(),
+ id: `orgmem_${organization.id}_${userId}`,
+ object: 'organization_membership',
+ organization,
+ permissions: role === 'org:admin' ? adminPermissions : memberPermissions,
+ publicMetadata: {},
+ publicUserData: {
+ firstName: 'Cameron',
+ hasImage: false,
+ identifier: 'example@personal.com',
+ imageUrl: '',
+ lastName: 'Walker',
+ userId,
+ },
+ role,
+ updatedAt: new Date(),
+ destroy: async () => ({}) as any,
+ update: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ ...overrides,
+ } as unknown as OrganizationMembershipResource;
+ }
+}
diff --git a/packages/msw/PageMocking.ts b/packages/msw/PageMocking.ts
new file mode 100644
index 00000000000..ebbb9fc0d71
--- /dev/null
+++ b/packages/msw/PageMocking.ts
@@ -0,0 +1,119 @@
+import type { MockConfig } from './MockingController';
+import { MockingController } from './MockingController';
+import type { MockScenario } from './types';
+
+export interface PageMockConfig extends MockConfig {
+ scenario?: () => MockScenario;
+}
+
+export interface PageMockingState {
+ controller: MockingController | null;
+ error: Error | null;
+ isEnabled: boolean;
+ isReady: boolean;
+}
+
+export interface PageMockingCallbacks {
+ onStateChange?: (state: PageMockingState) => void;
+}
+
+export class PageMocking {
+ private callbacks: PageMockingCallbacks;
+ private config: PageMockConfig | undefined;
+ private currentPathname: string | null = null;
+ private state: PageMockingState = {
+ controller: null,
+ error: null,
+ isEnabled: false,
+ isReady: false,
+ };
+
+ constructor(callbacks: PageMockingCallbacks = {}) {
+ this.callbacks = callbacks;
+ }
+
+ getState(): PageMockingState {
+ return { ...this.state };
+ }
+
+ async initialize(pathname: string, config?: PageMockConfig): Promise {
+ // If pathname changed and we have an active controller, clean up first
+ if (this.currentPathname !== null && this.currentPathname !== pathname) {
+ this.cleanup();
+ }
+
+ this.currentPathname = pathname;
+ this.config = config;
+
+ if (!config?.scenario) {
+ return this.getState();
+ }
+
+ try {
+ if (typeof window !== 'undefined') {
+ const clerkLocalStorageKeys = Object.keys(localStorage).filter(key => key.startsWith('__clerk'));
+ clerkLocalStorageKeys.forEach(key => localStorage.removeItem(key));
+ const clerkSessionStorageKeys = Object.keys(sessionStorage).filter(key => key.startsWith('__clerk'));
+ clerkSessionStorageKeys.forEach(key => sessionStorage.removeItem(key));
+
+ document.cookie = `__clerk_db_jwt=mock_dev_browser_jwt_${Date.now()}; path=/; max-age=31536000; Secure; SameSite=None`;
+ }
+
+ const scenario = config.scenario();
+
+ const mockController = new MockingController({
+ debug: config?.debug || scenario.debug || false,
+ delay: config?.delay,
+ persist: config?.persist,
+ });
+
+ mockController.registerScenario(scenario);
+ await mockController.start(scenario.name);
+
+ this.updateState({
+ controller: mockController,
+ error: null,
+ isEnabled: true,
+ isReady: true,
+ });
+ } catch (err) {
+ this.updateState({
+ controller: null,
+ error: err instanceof Error ? err : new Error('Failed to initialize page mocking'),
+ isEnabled: false,
+ isReady: false,
+ });
+ }
+
+ return this.getState();
+ }
+
+ /**
+ * Clean up the current mocking session
+ */
+ cleanup(): void {
+ if (this.state.controller) {
+ this.state.controller.stop();
+ }
+
+ this.updateState({
+ controller: null,
+ error: null,
+ isEnabled: false,
+ isReady: false,
+ });
+ }
+
+ /**
+ * Reinitialize mocking with the current configuration.
+ * Useful when the pathname changes.
+ */
+ async reinitialize(pathname: string): Promise {
+ return this.initialize(pathname, this.config);
+ }
+
+ private updateState(newState: Partial): void {
+ this.state = { ...this.state, ...newState };
+ this.callbacks.onStateChange?.(this.getState());
+ }
+}
diff --git a/packages/msw/README.md b/packages/msw/README.md
new file mode 100644
index 00000000000..f64ca1ee4a9
--- /dev/null
+++ b/packages/msw/README.md
@@ -0,0 +1,48 @@
+# @examples/msw
+
+Mock Service Worker (MSW) integration for Clerk component scenarios.
+
+## Features
+
+- 🎭 **Explicit Scenario Loading**: Pass scenario functions directly to components
+- 🔧 **Type-Safe**: Full TypeScript support with proper type checking
+- 🤖 **Automatic Session Management**: MSW automatically handles all standard Clerk API requests
+- 👥 **Preset Users**: Pre-configured user personas for consistent testing
+- 🏢 **Preset Environments**: Pre-configured Clerk environments (single-session, multi-session)
+
+## How It Works
+
+Instead of manually creating handlers for every Clerk API endpoint, this package provides:
+
+1. **Default handlers (`clerkHandlers`)** - Automatically respond to all standard Clerk session management requests
+2. **Preset users (`UserService`)** - Pre-configured user personas you can select from
+3. **Preset environments (`EnvironmentService`)** - Pre-configured Clerk environment types
+
+You just select the user and environment you want, set the state, and MSW handles the rest!
+
+## Installation
+
+This package is part of the monorepo and should be added as a workspace dependency:
+
+```json
+{
+ "dependencies": {
+ "@examples/msw": "workspace:*"
+ }
+}
+```
+
+### Setup Mock Service Worker
+
+Each consuming app needs to generate the `mockServiceWorker.js` file in its public directory:
+
+```bash
+# From your app directory (e.g., apps/previews)
+pnpx msw init public --save
+```
+
+This creates the service worker file that MSW uses to intercept network requests in the browser.
+
+## Development
+
+This package uses TypeScript source files directly (no build step required).
diff --git a/packages/msw/SessionService.ts b/packages/msw/SessionService.ts
new file mode 100644
index 00000000000..39acd4897c4
--- /dev/null
+++ b/packages/msw/SessionService.ts
@@ -0,0 +1,220 @@
+import type {
+ ClientJSON,
+ OrganizationResource,
+ SessionJSON,
+ SessionResource,
+ TokenJSON,
+ UserResource,
+} from '@clerk/shared/types';
+
+import { UserService } from './UserService';
+
+export type ClerkAPIResponse = {
+ response: T;
+};
+
+export type ClientResponse = {
+ client: ClientJSON;
+ response: SessionJSON;
+};
+
+// Keys to exclude from serialization (functions and internal methods)
+const EXCLUDED_KEYS = new Set([
+ 'checkAuthorization',
+ 'clearCache',
+ 'attemptFirstFactorVerification',
+ 'attemptSecondFactorVerification',
+ 'end',
+ 'getToken',
+ 'prepareFirstFactorVerification',
+ 'prepareSecondFactorVerification',
+ 'remove',
+ 'resolve',
+ 'startVerification',
+ 'touch',
+ 'verifyWithPasskey',
+ '__internal_toSnapshot',
+ 'create',
+ 'destroy',
+ 'update',
+ 'addMember',
+ 'createDomain',
+ 'getDomains',
+ 'getInvitations',
+ 'getMembershipRequests',
+ 'getMemberships',
+ 'getRoles',
+ 'inviteMember',
+ 'inviteMembers',
+ 'removeMember',
+ 'setLogo',
+]);
+
+function toSnakeCase(obj: any): any {
+ if (obj === null || obj === undefined) return obj;
+ if (obj instanceof Date) return obj.toISOString();
+ if (Array.isArray(obj)) return obj.map(toSnakeCase);
+ if (typeof obj === 'function') return undefined;
+ if (typeof obj !== 'object') return obj;
+
+ const result: any = {};
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ // Skip functions and excluded keys
+ if (typeof obj[key] === 'function' || EXCLUDED_KEYS.has(key)) {
+ continue;
+ }
+ const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
+ result[snakeKey] = toSnakeCase(obj[key]);
+ }
+ }
+ return result;
+}
+
+export class SessionService {
+ static createSession(options: { user: UserResource; sessionId?: string }): SessionResource {
+ return this.create(options.user, options.sessionId);
+ }
+
+ static create(user: UserResource, sessionId = 'sess_basic'): SessionResource {
+ const session = {
+ abandonAt: new Date(Date.now() + 86400000 * 30),
+ actor: null,
+ checkAuthorization: () => true,
+ clearCache: () => {},
+ createdAt: new Date(),
+ currentTask: undefined,
+ expireAt: new Date(Date.now() + 86400000 * 7),
+ factorVerificationAge: [0, 600000],
+ id: sessionId,
+ lastActiveAt: new Date(),
+ lastActiveOrganizationId: null,
+ lastActiveToken: null,
+ object: 'session',
+ publicUserData: {
+ firstName: user.firstName,
+ hasImage: user.hasImage,
+ identifier: user.primaryEmailAddress?.emailAddress || user.primaryPhoneNumber?.phoneNumber || '',
+ imageUrl: user.imageUrl,
+ lastName: user.lastName,
+ userId: user.id,
+ },
+ status: 'active',
+ tasks: null,
+ updatedAt: new Date(),
+ user,
+ attemptFirstFactorVerification: async () => ({}) as any,
+ attemptSecondFactorVerification: async () => ({}) as any,
+ end: async () => session,
+ getToken: async () => 'mock-token',
+ prepareFirstFactorVerification: async () => ({}) as any,
+ prepareSecondFactorVerification: async () => ({}) as any,
+ remove: async () => session,
+ startVerification: async () => ({}) as any,
+ touch: async () => session,
+ verifyWithPasskey: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ } as unknown as SessionResource;
+
+ return session;
+ }
+
+ static setOrganization(session: SessionResource, organization: OrganizationResource): void {
+ (session as any).lastActiveOrganizationId = organization.id;
+ }
+
+ static async generateToken(
+ user: UserResource,
+ session: SessionResource,
+ organizationId?: string | null,
+ ): Promise<{ jwt: string; object: string }> {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return {
+ jwt: UserService.generateJWT(user.id, session.id, organizationId),
+ object: 'token',
+ };
+ }
+
+ static serialize(data: any): any {
+ return toSnakeCase(data);
+ }
+
+ static getClientState(session: SessionResource | null): ClerkAPIResponse {
+ if (!session) {
+ return {
+ response: {
+ captcha_bypass: false,
+ cookie_expires_at: Date.now() + 86400000 * 365,
+ created_at: Date.now() - 86400000,
+ id: 'client_mock',
+ last_active_session_id: null,
+ last_authentication_strategy: null,
+ object: 'client',
+ sessions: [],
+ sign_in: null,
+ sign_up: null,
+ updated_at: Date.now(),
+ },
+ };
+ }
+
+ const serializedSession = this.serialize(session);
+
+ return {
+ response: {
+ captcha_bypass: false,
+ cookie_expires_at: Date.now() + 86400000 * 365,
+ created_at: Date.now() - 86400000,
+ id: 'client_mock',
+ last_active_session_id: session.id,
+ last_authentication_strategy: null,
+ object: 'client',
+ sessions: [serializedSession],
+ sign_in: null,
+ sign_up: null,
+ updated_at: Date.now(),
+ },
+ };
+ }
+
+ static handleTouch(session: SessionResource): ClientResponse {
+ const now = new Date();
+
+ session.abandonAt = new Date(now.getTime() + 86400000 * 30);
+ session.expireAt = new Date(now.getTime() + 86400000 * 7);
+ session.lastActiveAt = now;
+ session.updatedAt = now;
+
+ return {
+ client: {
+ captcha_bypass: false,
+ cookie_expires_at: now.getTime() + 86400000 * 365,
+ created_at: now.getTime() - 86400000,
+ id: 'client_mock',
+ last_active_session_id: session.id,
+ last_authentication_strategy: null,
+ object: 'client',
+ sessions: [this.serialize(session)],
+ sign_in: null,
+ sign_up: null,
+ updated_at: now.getTime(),
+ },
+ response: this.serialize(session),
+ };
+ }
+
+ static getEndResponse(session: SessionResource): ClerkAPIResponse {
+ return {
+ response: {
+ ...this.serialize(session),
+ status: 'ended',
+ },
+ };
+ }
+
+ static getSessionResponse(session: SessionResource): ClerkAPIResponse {
+ return {
+ response: this.serialize(session),
+ };
+ }
+}
diff --git a/packages/msw/SignInService.ts b/packages/msw/SignInService.ts
new file mode 100644
index 00000000000..4876475e2b8
--- /dev/null
+++ b/packages/msw/SignInService.ts
@@ -0,0 +1,123 @@
+import type { SessionResource } from '@clerk/shared/types';
+
+import { SessionService } from './SessionService';
+import { UserService } from './UserService';
+
+export class SignInService {
+ private static currentSignIn: any = null;
+ private static currentIdentifier: string = 'user@example.com';
+
+ static reset() {
+ this.currentSignIn = null;
+ this.currentIdentifier = 'user@example.com';
+ }
+
+ static setIdentifier(identifier: string) {
+ this.currentIdentifier = identifier;
+ }
+
+ static getIdentifier() {
+ return this.currentIdentifier;
+ }
+
+ static getCurrentSignIn() {
+ return this.currentSignIn;
+ }
+
+ static clearSignIn() {
+ this.currentSignIn = null;
+ }
+
+ static createSignInResponse(
+ options: {
+ createdSessionId?: string | null;
+ identifier?: string;
+ status?: 'needs_first_factor' | 'complete';
+ verificationAttempts?: number;
+ verificationStatus?: 'unverified' | 'verified';
+ } = {},
+ ) {
+ const {
+ createdSessionId = null,
+ identifier = this.currentIdentifier,
+ status = 'needs_first_factor',
+ verificationAttempts = 0,
+ verificationStatus = 'unverified',
+ } = options;
+
+ const signInResponse = {
+ abandon_at: null,
+ created_session_id: createdSessionId,
+ first_factor_verification: {
+ attempts: verificationAttempts,
+ error: null,
+ expire_at: Date.now() + 600000,
+ status: verificationStatus,
+ strategy: 'password',
+ supported_strategies: ['password'],
+ },
+ id: 'si_mock_signin_id',
+ identifier,
+ object: 'sign_in',
+ second_factor_verification: null,
+ status,
+ supported_first_factors: [
+ {
+ email_address_id: 'idn_mock_email',
+ primary: true,
+ safe_identifier: identifier,
+ strategy: 'password',
+ },
+ ],
+ supported_identifiers: ['email_address'],
+ supported_second_factors: [],
+ user_data: null,
+ };
+
+ this.currentSignIn = signInResponse;
+ return signInResponse;
+ }
+
+ static createUser(currentSession: SessionResource | null) {
+ const newUserId = 'user_mock_signed_in';
+ const newSessionId = 'sess_mock_signed_in';
+
+ const newUser = UserService.create();
+ newUser.id = newUserId;
+ newUser.primaryEmailAddress = UserService.createEmailAddress({
+ emailAddress: this.currentIdentifier,
+ id: 'email_signed_in_user',
+ verification: {
+ attempts: null,
+ expireAt: null,
+ status: 'verified',
+ strategy: 'ticket',
+ } as any,
+ });
+ newUser.emailAddresses = [newUser.primaryEmailAddress];
+ newUser.primaryEmailAddressId = newUser.primaryEmailAddress.id;
+
+ const newSession = UserService.createSession(newUserId, { id: newSessionId });
+
+ const signInResponse = this.createSignInResponse({
+ createdSessionId: newSessionId,
+ status: 'complete',
+ verificationAttempts: 1,
+ verificationStatus: 'verified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_in = signInResponse as any;
+ clientState.response.sessions.push(newSession as any);
+ clientState.response.last_active_session_id = newSessionId;
+
+ this.clearSignIn();
+
+ return {
+ clientState,
+ newSession,
+ newUser,
+ signInResponse,
+ };
+ }
+}
diff --git a/packages/msw/SignUpService.ts b/packages/msw/SignUpService.ts
new file mode 100644
index 00000000000..b22caa78256
--- /dev/null
+++ b/packages/msw/SignUpService.ts
@@ -0,0 +1,170 @@
+import type { SessionResource, UserResource } from '@clerk/shared/types';
+
+import { SessionService } from './SessionService';
+import { UserService } from './UserService';
+
+export class SignUpService {
+ private static currentSignUp: any = null;
+ private static currentEmail: string = 'user@example.com';
+ private static currentFirstName: string | null = null;
+ private static currentLastName: string | null = null;
+
+ static reset() {
+ this.currentSignUp = null;
+ this.currentEmail = 'user@example.com';
+ this.currentFirstName = null;
+ this.currentLastName = null;
+ }
+
+ static setEmail(email: string) {
+ this.currentEmail = email;
+ }
+
+ static setFirstName(firstName: string | null) {
+ this.currentFirstName = firstName;
+ }
+
+ static setLastName(lastName: string | null) {
+ this.currentLastName = lastName;
+ }
+
+ static getEmail() {
+ return this.currentEmail;
+ }
+
+ static getFirstName() {
+ return this.currentFirstName;
+ }
+
+ static getLastName() {
+ return this.currentLastName;
+ }
+
+ static getCurrentSignUp() {
+ return this.currentSignUp;
+ }
+
+ static clearSignUp() {
+ this.currentSignUp = null;
+ }
+
+ static createSignUpResponse(
+ options: {
+ createdSessionId?: string | null;
+ createdUserId?: string | null;
+ email?: string;
+ firstName?: string | null;
+ lastName?: string | null;
+ status?: 'missing_requirements' | 'complete';
+ unverifiedFields?: string[];
+ verificationAttempts?: number;
+ verificationStatus?: 'unverified' | 'verified' | 'failed';
+ } = {},
+ ) {
+ const {
+ createdSessionId = null,
+ createdUserId = null,
+ email = this.currentEmail,
+ firstName = this.currentFirstName,
+ lastName = this.currentLastName,
+ status = 'missing_requirements',
+ unverifiedFields = ['email_address'],
+ verificationAttempts = 0,
+ verificationStatus = 'unverified',
+ } = options;
+
+ const signUpResponse = {
+ abandoned: false,
+ attempt_id: null,
+ captcha_error: null,
+ captcha_token: null,
+ created_session_id: createdSessionId,
+ created_user_id: createdUserId,
+ email_address: email,
+ external_account: null,
+ external_account_strategy: null,
+ external_account_verification: null,
+ first_name: firstName,
+ has_password: true,
+ id: 'su_mock_signup_id',
+ last_name: lastName,
+ legal_accepted_at: null,
+ missing_fields: [],
+ object: 'sign_up',
+ optional_fields: ['first_name', 'last_name'],
+ passkey: null,
+ phone_number: null,
+ required_fields: [],
+ status,
+ supported_external_accounts: [],
+ supported_first_factors: [],
+ supported_second_factors: [],
+ unverified_fields: unverifiedFields,
+ unsafe_metadata: {},
+ username: null,
+ verifications: {
+ email_address: {
+ attempts: verificationAttempts,
+ error: null,
+ expire_at: Date.now() + 600000,
+ next_action: verificationAttempts === 0 ? 'needs_attempt' : '',
+ status: verificationStatus,
+ strategy: 'email_code',
+ supported_strategies: ['email_code'],
+ },
+ },
+ web3_wallet: null,
+ };
+
+ this.currentSignUp = signUpResponse;
+ return signUpResponse;
+ }
+
+ static createUser(currentSession: SessionResource | null) {
+ const newUserId = 'user_mock_new_user';
+ const newSessionId = 'sess_mock_new_session';
+
+ const newUser = UserService.create();
+ newUser.id = newUserId;
+ newUser.firstName = this.currentFirstName || 'User';
+ newUser.lastName = this.currentLastName || 'Mock';
+ newUser.fullName = `${newUser.firstName} ${newUser.lastName}`.trim();
+ newUser.primaryEmailAddress = UserService.createEmailAddress({
+ emailAddress: this.currentEmail,
+ id: 'email_new_user',
+ verification: {
+ attempts: null,
+ expireAt: null,
+ status: 'verified',
+ strategy: 'email_code',
+ } as any,
+ });
+ newUser.emailAddresses = [newUser.primaryEmailAddress];
+ newUser.primaryEmailAddressId = newUser.primaryEmailAddress.id;
+
+ const newSession = UserService.createSession(newUserId, { id: newSessionId });
+
+ const signUpResponse = this.createSignUpResponse({
+ createdSessionId: newSessionId,
+ createdUserId: newUserId,
+ status: 'complete',
+ unverifiedFields: [],
+ verificationAttempts: 1,
+ verificationStatus: 'verified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_up = signUpResponse as any;
+ clientState.response.sessions.push(newSession as any);
+ clientState.response.last_active_session_id = newSessionId;
+
+ this.clearSignUp();
+
+ return {
+ clientState,
+ newSession,
+ newUser,
+ signUpResponse,
+ };
+ }
+}
diff --git a/packages/msw/UserService.ts b/packages/msw/UserService.ts
new file mode 100644
index 00000000000..09e2bcc6191
--- /dev/null
+++ b/packages/msw/UserService.ts
@@ -0,0 +1,612 @@
+import type {
+ EmailAddressResource,
+ ExternalAccountResource,
+ PhoneNumberResource,
+ UserResource,
+ Web3WalletResource,
+} from '@clerk/shared/types';
+
+function generateJWT(userId: string, sessionId: string, organizationId?: string | null): string {
+ const header = { alg: 'RS256', typ: 'JWT' };
+ const payload: Record = {
+ azp: 'https://example.com',
+ exp: Math.floor(Date.now() / 1000) + 86400 * 7,
+ iat: Math.floor(Date.now() / 1000),
+ iss: 'https://clerk.example.com',
+ nbf: Math.floor(Date.now() / 1000),
+ sid: sessionId,
+ sub: userId,
+ };
+
+ if (organizationId) {
+ payload.org_id = organizationId;
+ payload.org_role = 'org:admin';
+ payload.org_slug = 'acme-inc';
+ }
+
+ const signature = 'mock-signature';
+
+ const base64Header = btoa(JSON.stringify(header));
+ const base64Payload = btoa(JSON.stringify(payload));
+ const base64Signature = btoa(signature);
+
+ return `${base64Header}.${base64Payload}.${base64Signature}`;
+}
+
+export class UserService {
+ static generateJWT = generateJWT;
+
+ static createEmailAddress(overrides: Partial = {}): EmailAddressResource {
+ return {
+ createdAt: new Date(),
+ emailAddress: 'example@personal.com',
+ id: 'email_default',
+ linkedTo: [],
+ matchesSsoConnection: false,
+ object: 'email_address',
+ reserved: false,
+ updatedAt: new Date(),
+ verification: {
+ attempts: null,
+ expireAt: null,
+ status: 'verified',
+ strategy: 'ticket',
+ },
+ create: async () => ({}) as any,
+ destroy: async () => ({}) as any,
+ prepareVerification: async () => ({}) as any,
+ attemptVerification: async () => ({}) as any,
+ toString: () => 'example@personal.com',
+ __internal_toSnapshot: () => ({}) as any,
+ ...overrides,
+ } as unknown as EmailAddressResource;
+ }
+
+ static createPhoneNumber(overrides: Partial = {}): PhoneNumberResource {
+ return {
+ backupCodes: null,
+ createdAt: new Date(),
+ defaultSecondFactor: true,
+ id: 'phone_default',
+ linkedTo: [],
+ object: 'phone_number',
+ phoneNumber: '+1 (555) 123-4567',
+ reserved: false,
+ reservedForSecondFactor: true,
+ updatedAt: new Date(),
+ verification: {
+ attempts: 1,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'verified',
+ strategy: 'phone_code',
+ },
+ destroy: async () => ({}) as any,
+ prepareVerification: async () => ({}) as any,
+ attemptVerification: async () => ({}) as any,
+ setReservedForSecondFactor: async () => ({}) as any,
+ toString: () => '+1 (555) 123-4567',
+ __internal_toSnapshot: () => ({}) as any,
+ ...overrides,
+ } as unknown as PhoneNumberResource;
+ }
+
+ static createWeb3Wallet(overrides: Partial = {}): Web3WalletResource {
+ const walletAddress = '0x1234567890abcdef1234567890abcdef12345678';
+ return {
+ createdAt: new Date(),
+ id: 'web3_default',
+ object: 'web3_wallet',
+ updatedAt: new Date(),
+ verification: {
+ attempts: 1,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'verified',
+ strategy: 'web3_base_signature',
+ },
+ web3Wallet: walletAddress,
+ destroy: async () => ({}) as any,
+ prepareVerification: async () => ({}) as any,
+ attemptVerification: async () => ({}) as any,
+ toString: () => walletAddress,
+ __internal_toSnapshot: () => ({}) as any,
+ ...overrides,
+ } as unknown as Web3WalletResource;
+ }
+
+ static createExternalAccount(overrides: Partial = {}): ExternalAccountResource {
+ return {
+ approvedScopes:
+ 'email https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid profile',
+ avatarUrl: 'https://lh3.googleusercontent.com/a/default-avatar',
+ createdAt: new Date(),
+ emailAddress: 'example@gmail.com',
+ firstName: 'Example',
+ id: 'eac_default',
+ identification: null,
+ imageUrl: 'https://lh3.googleusercontent.com/a/default-avatar',
+ label: null,
+ lastName: 'User',
+ object: 'external_account',
+ phoneNumber: '',
+ provider: 'google',
+ providerUserId: '104039773050285634152',
+ publicMetadata: {},
+ updatedAt: new Date(),
+ username: '',
+ verification: {
+ attempts: null,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'verified',
+ strategy: 'oauth_google',
+ },
+ destroy: async () => ({}) as any,
+ reauthorize: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ ...overrides,
+ } as unknown as ExternalAccountResource;
+ }
+
+ static createSession(
+ userId: string,
+ overrides: {
+ browserName?: string;
+ browserVersion?: string;
+ deviceType?: string;
+ id?: string;
+ ipAddress?: string;
+ city?: string;
+ country?: string;
+ lastActiveAt?: Date;
+ isMobile?: boolean;
+ } = {},
+ ) {
+ const browserName = overrides.browserName || 'Chrome';
+ const browserVersion = overrides.browserVersion || '138.0.0.0';
+ const deviceType = overrides.deviceType || 'Macintosh';
+ const city = overrides.city || 'San Francisco';
+ const country = overrides.country || 'US';
+ const ipAddress = overrides.ipAddress || '192.168.1.1';
+ const lastActiveAt = overrides.lastActiveAt || new Date();
+ const isMobile = overrides.isMobile ?? false;
+ const sessionId = overrides.id || 'sess_default';
+ const createdAt = new Date(Date.now() - 86400000 * 7);
+
+ return {
+ abandonAt: new Date(Date.now() + 86400000 * 30),
+ actor: null,
+ createdAt,
+ expireAt: new Date(Date.now() + 86400000 * 7),
+ factorVerificationAge: [0, 0],
+ id: sessionId,
+ lastActiveAt,
+ lastActiveOrganizationId: null,
+ lastActiveToken: null,
+ latestActivity: {
+ browserName,
+ browserVersion,
+ city,
+ country,
+ deviceType,
+ id: `sess_activity_${sessionId.replace('sess_', '')}`,
+ ipAddress,
+ isMobile,
+ object: 'session_activity',
+ },
+ object: 'session',
+ publicUserData: null,
+ status: 'active',
+ updatedAt: lastActiveAt,
+ user: null,
+ revoke: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ };
+ }
+
+ static create(): UserResource {
+ const emailAddress = this.createEmailAddress({
+ emailAddress: 'example@personal.com',
+ id: 'email_cameron_walker',
+ linkedTo: [
+ {
+ id: 'eac_gmail',
+ type: 'oauth_google',
+ pathRoot: '',
+ reload: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ },
+ ] as any,
+ });
+
+ const phoneNumber = this.createPhoneNumber({
+ id: 'phone_cameron_walker',
+ phoneNumber: '+1 (555) 123-4567',
+ });
+
+ const web3Wallet = this.createWeb3Wallet({
+ id: 'web3_cameron_walker',
+ });
+
+ const gmailAccount = this.createExternalAccount({
+ emailAddress: 'example@gmail.com',
+ firstName: 'Cameron',
+ id: 'eac_gmail',
+ lastName: 'Walker',
+ provider: 'google',
+ });
+
+ const emailAddresses = [emailAddress];
+ const phoneNumbers = [phoneNumber];
+ const web3Wallets = [web3Wallet];
+ const externalAccounts = [gmailAccount];
+
+ const user = {
+ backupCodeEnabled: true,
+ createOrganizationEnabled: true,
+ createOrganizationsLimit: null,
+ createdAt: new Date(),
+ deleteSelfEnabled: true,
+ emailAddresses,
+ enterpriseAccounts: [],
+ externalAccounts,
+ externalId: null,
+ firstName: 'Cameron',
+ fullName: 'Cameron Walker',
+ hasImage: true,
+ id: 'user_cameron_walker',
+ imageUrl: 'https://storage.googleapis.com/images.clerk.dev/examples/previews/cameron-walker.jpg',
+ lastSignInAt: new Date(),
+ lastName: 'Walker',
+ legalAcceptedAt: null,
+ organizationMemberships: [],
+ passkeys: [],
+ passwordEnabled: true,
+ phoneNumbers,
+ primaryEmailAddress: emailAddress,
+ primaryEmailAddressId: emailAddress.id,
+ primaryPhoneNumber: phoneNumber,
+ primaryPhoneNumberId: phoneNumber.id,
+ primaryWeb3Wallet: web3Wallet,
+ primaryWeb3WalletId: null,
+ publicMetadata: {},
+ samlAccounts: [],
+ totpEnabled: true,
+ twoFactorEnabled: true,
+ unsafeMetadata: {},
+ updatedAt: new Date(),
+ username: 'cameron.walker',
+ web3Wallets,
+ createBackupCode: async () => ({}) as any,
+ createEmailAddress: async () => emailAddress,
+ createExternalAccount: async () => ({}) as any,
+ createPasskey: async () => ({}) as any,
+ createPhoneNumber: async () => ({}) as any,
+ createTOTP: async () => ({}) as any,
+ createWeb3Wallet: async () => ({}) as any,
+ delete: async () => {},
+ disableTOTP: async () => ({}) as any,
+ get hasVerifiedEmailAddress() {
+ return true;
+ },
+ get hasVerifiedPhoneNumber() {
+ return true;
+ },
+ getOrganizationInvitations: async () => ({}) as any,
+ getOrganizationMemberships: async () => ({}) as any,
+ getOrganizationSuggestions: async () => ({}) as any,
+ getSessions: async () => {
+ const userId = 'user_cameron_walker';
+ return [
+ UserService.createSession(userId, {
+ browserName: 'Chrome',
+ browserVersion: '141.0.0.0',
+ city: 'San Francisco',
+ country: 'US',
+ deviceType: 'Macintosh',
+ id: 'sess_33YZ4JArIb5zfRsQDqbkeDZ2xqm',
+ ipAddress: '66.41.122.192',
+ isMobile: false,
+ lastActiveAt: new Date(),
+ }),
+ UserService.createSession(userId, {
+ browserName: 'Safari',
+ browserVersion: '18.6',
+ city: 'New York',
+ country: 'US',
+ deviceType: 'iPhone',
+ id: 'sess_313hqP3D2wEaq3GBzMfZkbUIYgG',
+ ipAddress: '2a09:bac5:91dd:2d2::48:9e',
+ isMobile: true,
+ lastActiveAt: new Date(Date.now() - 3600000 * 5),
+ }),
+ UserService.createSession(userId, {
+ browserName: 'Chrome',
+ browserVersion: '140.0.0.0',
+ city: 'Austin',
+ country: 'US',
+ deviceType: 'Macintosh',
+ id: 'sess_33TMapWAvoSd1LkK29QfGAgU1mh',
+ ipAddress: '2601:447:cd7e:3750:cca4:37a5:97ab:784f',
+ isMobile: false,
+ lastActiveAt: new Date(Date.now() - 86400000 * 2),
+ }),
+ ] as any;
+ },
+ isPrimaryIdentification: () => true,
+ leaveOrganization: async () => ({}) as any,
+ removePassword: async () => user,
+ setProfileImage: async () => ({}) as any,
+ get unverifiedExternalAccounts() {
+ return [];
+ },
+ update: async () => user,
+ updatePassword: async () => user,
+ get verifiedExternalAccounts() {
+ return externalAccounts.filter(
+ (account: ExternalAccountResource) => account.verification?.status === 'verified',
+ );
+ },
+ get verifiedWeb3Wallets() {
+ return web3Wallets.filter((wallet: Web3WalletResource) => wallet.verification?.status === 'verified');
+ },
+ verifyTOTP: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ } as unknown as UserResource;
+
+ return user;
+ }
+
+ static updateUser(user: UserResource, updates: Record): UserResource {
+ if (updates.first_name !== undefined) {
+ user.firstName = updates.first_name;
+ }
+ if (updates.last_name !== undefined) {
+ user.lastName = updates.last_name;
+ }
+ if (updates.first_name !== undefined || updates.last_name !== undefined) {
+ user.fullName = `${user.firstName || ''} ${user.lastName || ''}`.trim();
+ }
+ if (updates.username !== undefined) {
+ user.username = updates.username;
+ }
+ if (updates.profile_image_url !== undefined || updates.profileImageUrl !== undefined) {
+ user.imageUrl = updates.profile_image_url || updates.profileImageUrl;
+ }
+ if (updates.primary_email_address_id !== undefined) {
+ const email = user.emailAddresses.find(e => e.id === updates.primary_email_address_id);
+ if (email) {
+ user.primaryEmailAddress = email;
+ user.primaryEmailAddressId = email.id;
+ }
+ }
+ if (updates.primary_phone_number_id !== undefined) {
+ const phone = user.phoneNumbers.find(p => p.id === updates.primary_phone_number_id);
+ if (phone) {
+ user.primaryPhoneNumber = phone;
+ user.primaryPhoneNumberId = phone.id;
+ }
+ }
+ if (updates.primary_web3_wallet_id !== undefined) {
+ const wallet = user.web3Wallets.find(w => w.id === updates.primary_web3_wallet_id);
+ if (wallet) {
+ user.primaryWeb3Wallet = wallet;
+ user.primaryWeb3WalletId = wallet.id;
+ }
+ }
+ if (updates.public_metadata !== undefined) {
+ try {
+ user.publicMetadata =
+ typeof updates.public_metadata === 'string' ? JSON.parse(updates.public_metadata) : updates.public_metadata;
+ } catch (e) {
+ // Ignore parse errors
+ }
+ }
+ if (updates.unsafe_metadata !== undefined) {
+ try {
+ user.unsafeMetadata =
+ typeof updates.unsafe_metadata === 'string' ? JSON.parse(updates.unsafe_metadata) : updates.unsafe_metadata;
+ } catch (e) {
+ // Ignore parse errors
+ }
+ }
+
+ return user;
+ }
+
+ static addEmailAddress(user: UserResource, emailAddress: string): EmailAddressResource {
+ const newEmail = this.createEmailAddress({
+ emailAddress,
+ id: `email_${Math.random().toString(36).substring(2, 15)}`,
+ verification: {
+ attempts: 0,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'unverified',
+ strategy: 'email_code',
+ } as any,
+ });
+
+ user.emailAddresses.push(newEmail);
+
+ return newEmail;
+ }
+
+ static addPhoneNumber(user: UserResource, phoneNumber: string): PhoneNumberResource {
+ const newPhone = this.createPhoneNumber({
+ id: `phone_${Math.random().toString(36).substring(2, 15)}`,
+ phoneNumber,
+ verification: {
+ attempts: 0,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'unverified',
+ strategy: 'phone_code',
+ } as any,
+ });
+
+ user.phoneNumbers.push(newPhone);
+
+ return newPhone;
+ }
+
+ static prepareEmailVerification(user: UserResource, emailId: string): EmailAddressResource | null {
+ const email = user.emailAddresses.find(e => e.id === emailId);
+ if (!email) {
+ return null;
+ }
+
+ email.verification = {
+ attempts: (email.verification?.attempts || 0) + 1,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'unverified',
+ strategy: 'email_code',
+ } as any;
+
+ return email;
+ }
+
+ static preparePhoneVerification(user: UserResource, phoneId: string): PhoneNumberResource | null {
+ const phone = user.phoneNumbers.find(p => p.id === phoneId);
+ if (!phone) {
+ return null;
+ }
+
+ phone.verification = {
+ attempts: (phone.verification?.attempts || 0) + 1,
+ expireAt: new Date(Date.now() + 600000),
+ status: 'unverified',
+ strategy: 'phone_code',
+ } as any;
+
+ return phone;
+ }
+
+ static verifyEmailAddress(user: UserResource, emailId: string): EmailAddressResource | null {
+ const email = user.emailAddresses.find(e => e.id === emailId);
+ if (!email) {
+ return null;
+ }
+
+ email.verification = {
+ attempts: null,
+ expireAt: null,
+ status: 'verified',
+ strategy: 'email_code',
+ } as any;
+
+ return email;
+ }
+
+ static addExternalAccount(user: UserResource, strategy: string = 'oauth_google'): ExternalAccountResource {
+ const provider = strategy.replace('oauth_', '');
+ const externalAccountId = `eac_${Math.random().toString(36).substring(2, 15)}`;
+
+ const newExternalAccount = this.createExternalAccount({
+ emailAddress: `example@${provider === 'google' ? 'gmail' : provider}.com`,
+ firstName: user.firstName || 'Example',
+ id: externalAccountId,
+ lastName: user.lastName || 'User',
+ provider: provider as any,
+ });
+
+ user.externalAccounts.push(newExternalAccount);
+
+ const matchingEmail = user.emailAddresses.find(email => email.emailAddress === newExternalAccount.emailAddress);
+
+ if (matchingEmail && matchingEmail.linkedTo) {
+ matchingEmail.linkedTo.push({
+ id: externalAccountId,
+ pathRoot: '',
+ type: strategy,
+ reload: async () => ({}) as any,
+ __internal_toSnapshot: () => ({}) as any,
+ } as any);
+ }
+
+ return newExternalAccount;
+ }
+
+ static removeExternalAccount(user: UserResource, externalAccountId: string): boolean {
+ const index = user.externalAccounts.findIndex(ea => ea.id === externalAccountId);
+ if (index === -1) {
+ return false;
+ }
+
+ user.externalAccounts.splice(index, 1);
+
+ const linkedEmail = user.emailAddresses.find(
+ email => email.linkedTo && email.linkedTo.some((link: any) => link.id === externalAccountId),
+ );
+ if (linkedEmail) {
+ linkedEmail.linkedTo = linkedEmail.linkedTo.filter((link: any) => link.id !== externalAccountId);
+ }
+
+ return true;
+ }
+
+ static removePhoneNumber(user: UserResource, phoneId: string): boolean {
+ const index = user.phoneNumbers.findIndex(p => p.id === phoneId);
+ if (index === -1) {
+ return false;
+ }
+
+ const removedPhone = user.phoneNumbers[index];
+ user.phoneNumbers.splice(index, 1);
+
+ if (user.primaryPhoneNumberId === phoneId) {
+ user.primaryPhoneNumber = user.phoneNumbers[0] ?? null;
+ user.primaryPhoneNumberId = user.primaryPhoneNumber?.id ?? null;
+ }
+
+ return true;
+ }
+
+ static disableTOTP(user: UserResource): void {
+ user.totpEnabled = false;
+ user.twoFactorEnabled = !user.totpEnabled && user.backupCodeEnabled === false;
+ }
+
+ static generateBackupCodes(user: UserResource): string[] {
+ const codes: string[] = [];
+ for (let i = 0; i < 10; i++) {
+ const code = Math.random().toString(36).substring(2, 10).toUpperCase();
+ codes.push(code);
+ }
+ user.backupCodeEnabled = true;
+ return codes;
+ }
+
+ static updatePhoneNumber(
+ user: UserResource,
+ phoneId: string,
+ updates: Record,
+ ): PhoneNumberResource | null {
+ const phone = user.phoneNumbers.find(p => p.id === phoneId);
+ if (!phone) {
+ return null;
+ }
+
+ if (updates.reserved_for_second_factor !== undefined) {
+ phone.reservedForSecondFactor =
+ updates.reserved_for_second_factor === 'true' || updates.reserved_for_second_factor === true;
+ }
+ if (updates.default_second_factor !== undefined) {
+ phone.defaultSecondFactor = updates.default_second_factor === 'true' || updates.default_second_factor === true;
+ }
+
+ return phone;
+ }
+
+ static verifyPhoneNumber(user: UserResource, phoneId: string): PhoneNumberResource | null {
+ const phone = user.phoneNumbers.find(p => p.id === phoneId);
+ if (!phone) {
+ return null;
+ }
+
+ phone.verification = {
+ attempts: null,
+ expireAt: null,
+ status: 'verified',
+ strategy: 'phone_code',
+ } as any;
+
+ return phone;
+ }
+}
diff --git a/packages/msw/index.ts b/packages/msw/index.ts
new file mode 100644
index 00000000000..395b1de1292
--- /dev/null
+++ b/packages/msw/index.ts
@@ -0,0 +1,19 @@
+export { BillingService } from './BillingService';
+export { clerkHandlers, setClerkState } from './request-handlers';
+export { EnvironmentService, type EnvironmentPreset } from './EnvironmentService';
+export type { MockConfig } from './MockingController';
+export { MockingController } from './MockingController';
+export { MockingProvider, useMockingContext } from './MockingProvider';
+export { MockingStatusIndicator } from './MockingStatusIndicator';
+export { OrganizationService } from './OrganizationService';
+export type { PageMockConfig, PageMockingCallbacks, PageMockingState } from './PageMocking';
+export { PageMocking } from './PageMocking';
+export { SessionService } from './SessionService';
+export { SignInService } from './SignInService';
+export { SignUpService } from './SignUpService';
+export type { MockScenario } from './types';
+export { UserService } from './UserService';
+export { usePageMocking } from './usePageMocking';
+
+export { http, HttpResponse } from 'msw';
+export type { RequestHandler } from 'msw';
diff --git a/packages/msw/package.json b/packages/msw/package.json
new file mode 100644
index 00000000000..4c75a22e657
--- /dev/null
+++ b/packages/msw/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@clerk/msw",
+ "version": "0.0.0",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./index.ts",
+ "default": "./index.ts"
+ }
+ },
+ "dependencies": {
+ "@clerk/shared": "workspace:^",
+ "msw": "2.11.3"
+ },
+ "peerDependencies": {
+ "next": ">=15.0.0",
+ "react": "catalog:peer-react"
+ }
+}
diff --git a/packages/msw/request-handlers.ts b/packages/msw/request-handlers.ts
new file mode 100644
index 00000000000..60815c8f884
--- /dev/null
+++ b/packages/msw/request-handlers.ts
@@ -0,0 +1,2159 @@
+import { http, HttpResponse } from 'msw';
+
+import type {
+ BillingSubscriptionJSON,
+ OrganizationMembershipResource,
+ OrganizationResource,
+ SessionResource,
+ UserResource,
+} from '@clerk/shared/types';
+
+import { BillingService } from './BillingService';
+import { EnvironmentService, type EnvironmentPreset } from './EnvironmentService';
+import { OrganizationService } from './OrganizationService';
+import { SessionService } from './SessionService';
+import { SignInService } from './SignInService';
+import { SignUpService } from './SignUpService';
+import { UserService } from './UserService';
+
+type ErrorResponse = {
+ error: string;
+};
+
+type SuccessResponse = {
+ success: boolean;
+};
+
+function createNoStoreResponse(data: any, options?: { status?: number }) {
+ return HttpResponse.json(data, {
+ status: options?.status,
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ },
+ });
+}
+
+function createCacheableResponse(data: any, maxAge: number = 60) {
+ return HttpResponse.json(data, {
+ headers: {
+ 'Cache-Control': `public, max-age=${maxAge}`,
+ },
+ });
+}
+
+function createUserResourceResponse(session: SessionResource | null, user: UserResource, resource: any) {
+ if (session) {
+ session.user = user;
+ }
+ user.updatedAt = new Date();
+
+ const serializedUser = SessionService.serialize(user);
+ const clientState = SessionService.getClientState(session);
+
+ clientState.response.sessions = clientState.response.sessions?.map(sess => ({
+ ...sess,
+ user: serializedUser,
+ }));
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: SessionService.serialize(resource),
+ });
+}
+
+function parseUrlEncodedBody(text: string): Record {
+ const body: Record = {};
+ const params = new URLSearchParams(text);
+ params.forEach((value, key) => {
+ body[key] = value;
+ });
+ return body;
+}
+
+function createValidationError(paramName: string, message: string, longMessage: string) {
+ return createNoStoreResponse(
+ {
+ errors: [
+ {
+ code: 'form_param_nil',
+ long_message: longMessage,
+ message,
+ meta: { param_name: paramName },
+ },
+ ],
+ },
+ { status: 422 },
+ );
+}
+
+function normalizeOrganizationSlug(name?: string, providedSlug?: string) {
+ const base = (providedSlug || name || 'organization').toString().trim().toLowerCase();
+ const normalized = base.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+ return normalized || 'organization';
+}
+
+async function handleOrganizationCreate(request: Request) {
+ if (!currentSession || !currentUser) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ let body: any = {};
+ try {
+ const contentType = request.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ body = await request.json();
+ } else {
+ body = parseUrlEncodedBody(await request.text());
+ }
+ } catch {
+ body = {};
+ }
+
+ const name = body.name || body.organization_name;
+ const slugInput = body.slug || body.organization_slug;
+ const publicMetadataInput = body.public_metadata ?? body.publicMetadata;
+
+ if (!name || `${name}`.trim() === '') {
+ return createValidationError('name', 'is missing or empty', 'Name is required.');
+ }
+
+ const slug = normalizeOrganizationSlug(name, slugInput);
+
+ let publicMetadata = {};
+ if (publicMetadataInput !== undefined) {
+ try {
+ publicMetadata =
+ typeof publicMetadataInput === 'string' ? JSON.parse(publicMetadataInput) : publicMetadataInput || {};
+ } catch {
+ publicMetadata = {};
+ }
+ }
+
+ const organization = OrganizationService.create({
+ id: `org_${Math.random().toString(36).slice(2, 10)}`,
+ membersCount: 1,
+ name,
+ publicMetadata,
+ slug,
+ updatedAt: new Date(),
+ });
+
+ const membership = OrganizationService.createMembership(organization, currentUser.id, 'org:admin');
+ const memberships = (currentUser as any).organizationMemberships || [];
+ (currentUser as any).organizationMemberships = [...memberships, membership];
+
+ currentMembership = membership;
+ currentOrganization = organization;
+ SessionService.setOrganization(currentSession, organization);
+
+ const clientState = SessionService.getClientState(currentSession);
+ if (clientState.response.sessions) {
+ clientState.response.sessions = clientState.response.sessions.map(sess => ({
+ ...sess,
+ last_active_organization_id: organization.id,
+ }));
+ }
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: SessionService.serialize(organization),
+ });
+}
+
+let currentSession: SessionResource | null = null;
+let currentUser: UserResource | null = null;
+let currentOrganization: OrganizationResource | null = null;
+let currentMembership: OrganizationMembershipResource | null = null;
+let currentInvitations: any[] = [];
+let currentEnvironment: EnvironmentPreset = EnvironmentService.MULTI_SESSION;
+let currentSubscription: BillingSubscriptionJSON | null = null;
+
+export function setClerkState(state: {
+ environment?: EnvironmentPreset;
+ instance?: EnvironmentPreset;
+ membership?: OrganizationMembershipResource | null;
+ organization?: OrganizationResource | null;
+ session?: SessionResource | null;
+ user?: UserResource | null;
+}) {
+ currentSession = state.session ?? null;
+ currentUser = state.user ?? null;
+ currentOrganization = state.organization ?? null;
+ currentMembership = state.membership ?? null;
+ currentInvitations = [];
+ currentSubscription = null;
+ SignUpService.reset();
+ SignInService.reset();
+
+ // If organization is set, update the session's lastActiveOrganizationId
+ if (currentSession && currentOrganization) {
+ (currentSession as any).lastActiveOrganizationId = currentOrganization.id;
+ }
+
+ if (state.environment) {
+ currentEnvironment = state.environment;
+ }
+ // Support legacy 'instance' parameter for backwards compatibility
+ if (state.instance) {
+ currentEnvironment = state.instance;
+ }
+}
+
+export const clerkHandlers = [
+ // Environment endpoints
+ http.get('*/v1/environment', () => {
+ return HttpResponse.json(currentEnvironment.config, {
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ Pragma: 'no-cache',
+ Expires: '0',
+ },
+ });
+ }),
+
+ http.post('*/v1/environment', () => {
+ return HttpResponse.json(currentEnvironment.config, {
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ Pragma: 'no-cache',
+ Expires: '0',
+ },
+ });
+ }),
+
+ http.patch('*/v1/environment', () => {
+ return HttpResponse.json(currentEnvironment.config, {
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ Pragma: 'no-cache',
+ Expires: '0',
+ },
+ });
+ }),
+
+ // Client state endpoint
+ http.get('*/v1/client*', () => {
+ const currentSignUp = SignUpService.getCurrentSignUp();
+ const currentSignIn = SignInService.getCurrentSignIn();
+ const clientState = SessionService.getClientState(currentSession);
+ if (currentSignUp) {
+ clientState.response.sign_up = currentSignUp as any;
+ }
+ if (currentSignIn) {
+ clientState.response.sign_in = currentSignIn as any;
+ }
+
+ // Include organization and task data in sessions
+ if (clientState.response.sessions && currentSession) {
+ clientState.response.sessions = clientState.response.sessions.map((sess: any) => {
+ const updates: any = { ...sess };
+
+ // Include organization ID if active
+ if (currentOrganization) {
+ updates.last_active_organization_id = currentOrganization.id;
+ }
+
+ // Include task data
+ if ((currentSession as any).currentTask) {
+ updates.current_task = SessionService.serialize((currentSession as any).currentTask);
+ }
+ if ((currentSession as any).tasks) {
+ updates.tasks = SessionService.serialize((currentSession as any).tasks);
+ }
+
+ return updates;
+ });
+ }
+
+ return createNoStoreResponse(clientState);
+ }),
+
+ // POST client endpoint - used for setting active session/organization
+ http.post('*/v1/client', async ({ request }) => {
+ const body = parseUrlEncodedBody(await request.text());
+
+ // Handle setting active organization
+ if (body.active_organization_id && currentOrganization) {
+ if (currentSession) {
+ (currentSession as any).lastActiveOrganizationId = body.active_organization_id;
+ }
+ }
+
+ const clientState = SessionService.getClientState(currentSession);
+ const activeOrgId = currentOrganization?.id;
+ if (activeOrgId && currentMembership && clientState.response.sessions) {
+ clientState.response.sessions = clientState.response.sessions.map((sess: any) => ({
+ ...sess,
+ last_active_organization_id: activeOrgId,
+ }));
+ }
+ return createNoStoreResponse(clientState);
+ }),
+
+ // Session token endpoints
+ http.post('*/v1/client/sessions/tokens', async () => {
+ if (!currentSession || !currentUser) {
+ return HttpResponse.json({ error: 'No active session' }, { status: 401 });
+ }
+ const token = await SessionService.generateToken(currentUser, currentSession, currentOrganization?.id);
+ return createCacheableResponse(token, 60);
+ }),
+
+ http.post('*/v1/client/sessions/:sessionId/tokens', async () => {
+ if (!currentSession || !currentUser) {
+ return HttpResponse.json({ error: 'No active session' }, { status: 401 });
+ }
+ const token = await SessionService.generateToken(currentUser, currentSession, currentOrganization?.id);
+ return createCacheableResponse(token, 60);
+ }),
+
+ http.post('*/v1/client/sessions/:sessionId/tokens/:template', async () => {
+ if (!currentSession || !currentUser) {
+ return HttpResponse.json({ error: 'No active session' }, { status: 401 });
+ }
+ const token = await SessionService.generateToken(currentUser, currentSession, currentOrganization?.id);
+ return createCacheableResponse(token, 60);
+ }),
+
+ // Session management
+ http.post('*/v1/client/sessions/:sessionId/end', () => {
+ if (!currentSession) {
+ return HttpResponse.json({ error: 'No active session' }, { status: 401 });
+ }
+ return HttpResponse.json(SessionService.getEndResponse(currentSession));
+ }),
+
+ http.post('*/v1/client/sessions/:sessionId/touch', () => {
+ if (!currentSession || !currentUser) {
+ return HttpResponse.json({ error: 'No active session' }, { status: 401 });
+ }
+ return HttpResponse.json(SessionService.handleTouch(currentSession));
+ }),
+
+ // Set active organization endpoint
+ http.post('*/v1/client/sessions/:sessionId/organization/:orgId', ({ params }) => {
+ if (!currentSession || !currentUser) {
+ return HttpResponse.json({ error: 'No active session' }, { status: 401 });
+ }
+
+ const orgId = params.orgId as string;
+
+ // Find the organization from user's memberships
+ let org = currentOrganization;
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ const membership = (currentUser as any).organizationMemberships.find((m: any) => m.organization?.id === orgId);
+ if (membership?.organization) {
+ org = membership.organization;
+ currentOrganization = org;
+ currentMembership = membership;
+ }
+ }
+
+ if (org) {
+ (currentSession as any).lastActiveOrganizationId = org.id;
+ }
+
+ const touchResponse = SessionService.handleTouch(currentSession);
+ // Include organization ID in response
+ if (org) {
+ touchResponse.response.last_active_organization_id = org.id;
+ if (touchResponse.client.sessions) {
+ touchResponse.client.sessions = touchResponse.client.sessions.map((sess: any) => ({
+ ...sess,
+ last_active_organization_id: org!.id,
+ }));
+ }
+ }
+
+ return createNoStoreResponse(touchResponse);
+ }),
+
+ http.get('*/v1/client/sessions/:sessionId', () => {
+ if (!currentSession) {
+ return HttpResponse.json({ error: 'Session not found' }, { status: 404 });
+ }
+ return HttpResponse.json(SessionService.getSessionResponse(currentSession));
+ }),
+
+ // Session tasks endpoints
+ http.get('*/v1/client/sessions/:sessionId/tasks', () => {
+ if (!currentSession) {
+ return HttpResponse.json({ error: 'Session not found' }, { status: 404 });
+ }
+ const tasks = (currentSession as any).tasks || [];
+ return createNoStoreResponse({
+ response: {
+ data: Array.isArray(tasks) ? tasks : tasks.data || [],
+ total_count: Array.isArray(tasks) ? tasks.length : tasks.total_count || 0,
+ },
+ });
+ }),
+
+ http.post('*/v1/client/sessions/:sessionId/tasks/:taskId/resolve', async ({ params, request }) => {
+ if (!currentSession) {
+ return HttpResponse.json({ error: 'Session not found' }, { status: 404 });
+ }
+
+ const taskId = params.taskId as string;
+ const body = parseUrlEncodedBody(await request.text());
+
+ // If resolving with an organization, set it as active
+ if (body.destination_organization_id && currentUser) {
+ const orgId = body.destination_organization_id;
+ const memberships = (currentUser as any).organizationMemberships || [];
+ const membership = memberships.find((m: any) => m.organization?.id === orgId);
+
+ if (membership?.organization) {
+ currentOrganization = membership.organization;
+ currentMembership = membership;
+ (currentSession as any).lastActiveOrganizationId = orgId;
+ }
+ }
+
+ // Mark task as complete
+ if ((currentSession as any).tasks?.data) {
+ (currentSession as any).tasks.data = (currentSession as any).tasks.data.map((t: any) =>
+ t.id === taskId ? { ...t, status: 'complete' } : t,
+ );
+ }
+ if ((currentSession as any).currentTask?.id === taskId) {
+ (currentSession as any).currentTask = null;
+ }
+
+ const clientState = SessionService.getClientState(currentSession);
+ const resolvedOrgId = currentOrganization?.id;
+ if (resolvedOrgId && clientState.response.sessions) {
+ clientState.response.sessions = clientState.response.sessions.map((sess: any) => ({
+ ...sess,
+ last_active_organization_id: resolvedOrgId,
+ }));
+ }
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: { status: 'complete' },
+ });
+ }),
+
+ // User endpoints
+ http.get('/v1/client/users/:userId', () => {
+ if (!currentUser) {
+ return HttpResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ return HttpResponse.json({ response: SessionService.serialize(currentUser) });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me', async ({ request }) => {
+ if (!currentUser) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ UserService.updateUser(currentUser, body);
+
+ const clientState = SessionService.getClientState(currentSession);
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: SessionService.serialize(currentUser),
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/email_addresses', async ({ request }) => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ const emailAddress = body.email_address || body.emailAddress;
+
+ if (!emailAddress) {
+ return createValidationError('email_address', 'is missing or empty', 'Email address is required.');
+ }
+
+ const newEmail = UserService.addEmailAddress(currentUser, emailAddress);
+ return createUserResourceResponse(currentSession, currentUser, newEmail);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/email_addresses/:emailId/prepare_verification', ({ params }) => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const emailId = params.emailId as string;
+ const email = UserService.prepareEmailVerification(currentUser, emailId);
+
+ if (!email) {
+ return createNoStoreResponse({ error: 'Email address not found' }, { status: 404 });
+ }
+
+ return createUserResourceResponse(currentSession, currentUser, email);
+ }),
+
+ http.post(
+ 'https://*.clerk.accounts.dev/v1/me/email_addresses/:emailId/attempt_verification',
+ async ({ params, request }) => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ const code = body.code;
+
+ if (!code || code.trim() === '') {
+ return createValidationError('code', 'is missing or empty', 'Please enter your verification code.');
+ }
+
+ const emailId = params.emailId as string;
+ const email = UserService.verifyEmailAddress(currentUser, emailId);
+
+ if (!email) {
+ return createNoStoreResponse({ error: 'Email address not found' }, { status: 404 });
+ }
+
+ return createUserResourceResponse(currentSession, currentUser, email);
+ },
+ ),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/phone_numbers', async ({ request }) => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ const phoneNumber = body.phone_number || body.phoneNumber;
+
+ if (!phoneNumber) {
+ return createValidationError('phone_number', 'is missing or empty', 'Phone number is required.');
+ }
+
+ const newPhone = UserService.addPhoneNumber(currentUser, phoneNumber);
+ return createUserResourceResponse(currentSession, currentUser, newPhone);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/phone_numbers/:phoneId/prepare_verification', ({ params }) => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const phoneId = params.phoneId as string;
+ const phone = UserService.preparePhoneVerification(currentUser, phoneId);
+
+ if (!phone) {
+ return createNoStoreResponse({ error: 'Phone number not found' }, { status: 404 });
+ }
+
+ return createUserResourceResponse(currentSession, currentUser, phone);
+ }),
+
+ http.post(
+ 'https://*.clerk.accounts.dev/v1/me/phone_numbers/:phoneId/attempt_verification',
+ async ({ params, request }) => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ const code = body.code;
+
+ if (!code || code.trim() === '') {
+ return createValidationError('code', 'is missing or empty', 'Please enter your verification code.');
+ }
+
+ const phoneId = params.phoneId as string;
+ const phone = UserService.verifyPhoneNumber(currentUser, phoneId);
+
+ if (!phone) {
+ return createNoStoreResponse({ error: 'Phone number not found' }, { status: 404 });
+ }
+
+ return createUserResourceResponse(currentSession, currentUser, phone);
+ },
+ ),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/phone_numbers/:phoneId', async ({ params, request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+
+ if (method === 'DELETE') {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const phoneId = params.phoneId as string;
+ const removed = UserService.removePhoneNumber(currentUser, phoneId);
+
+ if (!removed) {
+ return createNoStoreResponse({ error: 'Phone number not found' }, { status: 404 });
+ }
+
+ const deletionResponse = { deleted: true, id: phoneId, object: 'phone_number' };
+ return createUserResourceResponse(currentSession, currentUser, deletionResponse);
+ }
+
+ if (method !== 'PATCH') {
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }
+
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ const phoneId = params.phoneId as string;
+ const phone = UserService.updatePhoneNumber(currentUser, phoneId, body);
+
+ if (!phone) {
+ return createNoStoreResponse({ error: 'Phone number not found' }, { status: 404 });
+ }
+
+ return createUserResourceResponse(currentSession, currentUser, phone);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/backup_codes', () => {
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const backupCodes = UserService.generateBackupCodes(currentUser);
+
+ return createUserResourceResponse(currentSession, currentUser, {
+ backup_codes: backupCodes,
+ object: 'backup_code',
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/totp', ({ request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+
+ if (method !== 'DELETE') {
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }
+
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ UserService.disableTOTP(currentUser);
+
+ const deletionResponse = { deleted: true, object: 'totp' };
+ return createUserResourceResponse(currentSession, currentUser, deletionResponse);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/external_accounts', async ({ request }) => {
+ const url = new URL(request.url);
+
+ if (url.pathname.includes('/external_accounts/')) {
+ return;
+ }
+
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const body = parseUrlEncodedBody(await request.text());
+ const strategy = body.strategy || 'oauth_google';
+
+ const newExternalAccount = UserService.addExternalAccount(currentUser, strategy);
+ return createUserResourceResponse(currentSession, currentUser, newExternalAccount);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/external_accounts/:externalAccountId', ({ params, request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+
+ if (method !== 'DELETE') {
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }
+
+ if (!currentUser || !currentSession) {
+ return createNoStoreResponse({ error: 'User not found' }, { status: 404 });
+ }
+
+ const externalAccountId = params.externalAccountId as string;
+ const removed = UserService.removeExternalAccount(currentUser, externalAccountId);
+
+ if (!removed) {
+ return createNoStoreResponse({ error: 'External account not found' }, { status: 404 });
+ }
+
+ const deletionResponse = { deleted: true, id: externalAccountId, object: 'external_account' };
+ return createUserResourceResponse(currentSession, currentUser, deletionResponse);
+ }),
+
+ // User sessions endpoint
+ http.get('*/v1/users/:userId/sessions', async () => {
+ if (!currentUser) {
+ return HttpResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ const sessions = await currentUser.getSessions();
+ const serializedSessions = sessions.map((session: any) => SessionService.serialize(session));
+ return createNoStoreResponse(serializedSessions);
+ }),
+
+ http.get('*/v1/me/sessions/active', async () => {
+ if (!currentUser) {
+ return HttpResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ const sessions = await currentUser.getSessions();
+ const serializedSessions = sessions.map((session: any) => SessionService.serialize(session));
+ return createNoStoreResponse(serializedSessions);
+ }),
+
+ http.get('*/v1/me/sessions', async () => {
+ if (!currentUser) {
+ return HttpResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ const sessions = await currentUser.getSessions();
+ const serializedSessions = sessions.map((session: any) => SessionService.serialize(session));
+ return createNoStoreResponse(serializedSessions);
+ }),
+
+ // Revoke session endpoint
+ http.post('*/v1/users/:userId/sessions/:sessionId/revoke', async () => {
+ return createNoStoreResponse({
+ object: 'session',
+ status: 'revoked',
+ });
+ }),
+
+ // Organization endpoints
+ http.post('https://*.clerk.accounts.dev/v1/organizations', async ({ request }) => {
+ return handleOrganizationCreate(request);
+ }),
+
+ http.post('*/v1/organizations', async ({ request }) => {
+ return handleOrganizationCreate(request);
+ }),
+
+ http.get('*/v1/me/organization_memberships*', () => {
+ // First check if user has organizationMemberships array (multiple orgs)
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ const memberships = (currentUser as any).organizationMemberships;
+ return createNoStoreResponse({
+ response: {
+ data: memberships.map((m: any) => SessionService.serialize(m)),
+ total_count: memberships.length,
+ },
+ });
+ }
+ // Fall back to single membership if set
+ if (currentMembership && currentOrganization) {
+ return createNoStoreResponse({
+ response: {
+ data: [SessionService.serialize(currentMembership)],
+ total_count: 1,
+ },
+ });
+ }
+ return createNoStoreResponse({
+ response: {
+ data: [],
+ total_count: 0,
+ },
+ });
+ }),
+
+ http.post('*/v1/me/organization_memberships/:orgId', ({ params, request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+ const orgId = params.orgId as string;
+
+ if (method !== 'DELETE') {
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }
+
+ if ((currentUser as any)?.organizationMemberships?.length > 0) {
+ (currentUser as any).organizationMemberships = (currentUser as any).organizationMemberships.filter(
+ (membership: any) => membership.organization?.id !== orgId,
+ );
+ }
+
+ if (currentOrganization?.id === orgId) {
+ currentOrganization = null;
+ currentMembership = null;
+ if (currentSession) {
+ (currentSession as any).lastActiveOrganizationId = null;
+ }
+ }
+
+ const deletionResponse = { deleted: true, id: orgId, object: 'organization_membership' };
+ return createNoStoreResponse({
+ response: deletionResponse,
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/organization_memberships/:orgId', ({ params, request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+ const orgId = params.orgId as string;
+
+ if (method !== 'DELETE') {
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }
+
+ if ((currentUser as any)?.organizationMemberships?.length > 0) {
+ (currentUser as any).organizationMemberships = (currentUser as any).organizationMemberships.filter(
+ (membership: any) => membership.organization?.id !== orgId,
+ );
+ }
+
+ if (currentOrganization?.id === orgId) {
+ currentOrganization = null;
+ currentMembership = null;
+ if (currentSession) {
+ (currentSession as any).lastActiveOrganizationId = null;
+ }
+ }
+
+ const deletionResponse = { deleted: true, id: orgId, object: 'organization_membership' };
+ return createNoStoreResponse({
+ response: deletionResponse,
+ });
+ }),
+
+ http.get('*/v1/me/organization_invitations*', () => {
+ return createNoStoreResponse({
+ response: {
+ data: [],
+ total_count: 0,
+ },
+ });
+ }),
+
+ http.get('*/v1/me/organization_suggestions*', () => {
+ return createNoStoreResponse({
+ response: {
+ data: [],
+ total_count: 0,
+ },
+ });
+ }),
+
+ // Organization profile endpoints
+ http.get('*/v1/organizations/:orgId', ({ params }) => {
+ const orgId = params.orgId as string;
+
+ // Check current active organization first
+ if (currentOrganization && orgId === currentOrganization.id) {
+ return createNoStoreResponse({
+ response: SessionService.serialize(currentOrganization),
+ });
+ }
+
+ // Check user's organization memberships
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ const membership = (currentUser as any).organizationMemberships.find((m: any) => m.organization?.id === orgId);
+ if (membership?.organization) {
+ return createNoStoreResponse({
+ response: SessionService.serialize(membership.organization),
+ });
+ }
+ }
+
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }),
+
+ http.get('https://*.clerk.accounts.dev/v1/organizations/:orgId', ({ params }) => {
+ const orgId = params.orgId as string;
+
+ if (currentOrganization && orgId === currentOrganization.id) {
+ return createNoStoreResponse({
+ response: SessionService.serialize(currentOrganization),
+ });
+ }
+
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ const membership = (currentUser as any).organizationMemberships.find((m: any) => m.organization?.id === orgId);
+ if (membership?.organization) {
+ return createNoStoreResponse({
+ response: SessionService.serialize(membership.organization),
+ });
+ }
+ }
+
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }),
+
+ http.get('*/v1/organizations/:orgId/memberships*', ({ params }) => {
+ const orgId = params.orgId as string;
+
+ // Check user's organization memberships for this org
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ const membership = (currentUser as any).organizationMemberships.find((m: any) => m.organization?.id === orgId);
+ if (membership) {
+ return createNoStoreResponse({
+ data: [SessionService.serialize(membership)],
+ total_count: 1,
+ });
+ }
+ }
+
+ // Fall back to current membership if it matches
+ if (currentMembership && currentOrganization?.id === orgId) {
+ return createNoStoreResponse({
+ data: [SessionService.serialize(currentMembership)],
+ total_count: 1,
+ });
+ }
+
+ return createNoStoreResponse({
+ data: [],
+ total_count: 0,
+ });
+ }),
+
+ http.get('*/v1/organizations/:orgId/invitations*', ({ params, request }) => {
+ const orgId = params.orgId as string;
+ const url = new URL(request.url);
+ const statusParam = url.searchParams.get('status');
+ const limit = Number(url.searchParams.get('limit')) || 10;
+ const offset = Number(url.searchParams.get('offset')) || 0;
+
+ const filtered = currentInvitations.filter(invite => invite.organization_id === orgId);
+ const statusFiltered = statusParam ? filtered.filter(invite => invite.status === statusParam) : filtered;
+ const data = statusFiltered.slice(offset, offset + limit);
+
+ return createNoStoreResponse({
+ response: {
+ data,
+ total_count: statusFiltered.length,
+ },
+ });
+ }),
+
+ http.get('https://*.clerk.accounts.dev/v1/organizations/:orgId/invitations*', ({ params, request }) => {
+ const orgId = params.orgId as string;
+ const url = new URL(request.url);
+ const statusParam = url.searchParams.get('status');
+ const limit = Number(url.searchParams.get('limit')) || 10;
+ const offset = Number(url.searchParams.get('offset')) || 0;
+
+ const filtered = currentInvitations.filter(invite => invite.organization_id === orgId);
+ const statusFiltered = statusParam ? filtered.filter(invite => invite.status === statusParam) : filtered;
+ const data = statusFiltered.slice(offset, offset + limit);
+
+ return createNoStoreResponse({
+ response: {
+ data,
+ total_count: statusFiltered.length,
+ },
+ });
+ }),
+
+ http.post('*/v1/organizations/:orgId/invitations', async ({ params, request }) => {
+ const orgId = params.orgId as string;
+ if (!currentOrganization || orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+
+ let body: any = {};
+ try {
+ const contentType = request.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ body = await request.json();
+ } else {
+ body = parseUrlEncodedBody(await request.text());
+ }
+ } catch {
+ body = {};
+ }
+
+ const email = body.email_address || body.emailAddress;
+ const role = body.role || 'org:member';
+ if (!email) {
+ return createNoStoreResponse(
+ {
+ errors: [
+ {
+ code: 'form_param_nil',
+ long_message: 'Email address is required.',
+ message: 'is missing or empty',
+ meta: { param_name: 'email_address' },
+ },
+ ],
+ },
+ { status: 422 },
+ );
+ }
+ const now = Date.now();
+ const invitation = {
+ created_at: now,
+ email_address: email,
+ id: `orginv_${orgId}_${now}_${Math.random().toString(36).slice(2, 6)}`,
+ organization_id: orgId,
+ public_metadata: {},
+ role,
+ role_name: role,
+ status: 'pending',
+ updated_at: now,
+ };
+ currentInvitations.push(invitation);
+ return createNoStoreResponse({
+ response: invitation,
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/organizations/:orgId/invitations', async ({ params, request }) => {
+ const orgId = params.orgId as string;
+ if (!currentOrganization || orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+
+ let body: any = {};
+ try {
+ const contentType = request.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ body = await request.json();
+ } else {
+ body = parseUrlEncodedBody(await request.text());
+ }
+ } catch {
+ body = {};
+ }
+
+ const email = body.email_address || body.emailAddress;
+ const role = body.role || 'org:member';
+ if (!email) {
+ return createNoStoreResponse(
+ {
+ errors: [
+ {
+ code: 'form_param_nil',
+ long_message: 'Email address is required.',
+ message: 'is missing or empty',
+ meta: { param_name: 'email_address' },
+ },
+ ],
+ },
+ { status: 422 },
+ );
+ }
+ const now = Date.now();
+ const invitation = {
+ created_at: now,
+ email_address: email,
+ id: `orginv_${orgId}_${now}_${Math.random().toString(36).slice(2, 6)}`,
+ organization_id: orgId,
+ public_metadata: {},
+ role,
+ role_name: role,
+ status: 'pending',
+ updated_at: now,
+ };
+ currentInvitations.push(invitation);
+ return createNoStoreResponse({
+ response: invitation,
+ });
+ }),
+
+ http.post('*/v1/organizations/:orgId/invitations/bulk', async ({ params, request }) => {
+ const orgId = params.orgId as string;
+ if (!currentOrganization || orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+ let body: any = {};
+ try {
+ const contentType = request.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ body = await request.json();
+ } else {
+ const text = await request.text();
+ body = parseUrlEncodedBody(text);
+ }
+ } catch {
+ body = {};
+ }
+
+ const role = body.role || 'org:member';
+ const now = Date.now();
+
+ const fromArrayObjects = (arr: any[]) =>
+ arr
+ .map((item, idx) => {
+ const email = item?.email_address || item?.emailAddress;
+ const itemRole = item?.role || role;
+ if (!email) {
+ return null;
+ }
+ return {
+ created_at: now,
+ email_address: email,
+ id: `orginv_${orgId}_${now}_${idx}`,
+ organization_id: orgId,
+ public_metadata: item?.public_metadata || item?.publicMetadata || {},
+ role: itemRole,
+ role_name: itemRole,
+ status: 'pending',
+ updated_at: now,
+ };
+ })
+ .filter(Boolean) as any[];
+
+ let invitations: any[] = [];
+
+ if (Array.isArray(body)) {
+ invitations = fromArrayObjects(body);
+ } else if (Array.isArray(body.invitations)) {
+ invitations = fromArrayObjects(body.invitations);
+ } else if (Array.isArray(body.params)) {
+ invitations = fromArrayObjects(body.params);
+ } else {
+ const emails =
+ body.email_address || body.email_addresses || body.emailAddresses || body.emailAddress || body.emails || [];
+ const emailList = Array.isArray(emails)
+ ? emails
+ : typeof emails === 'string'
+ ? emails
+ .split(',')
+ .map(e => e.trim())
+ .filter(Boolean)
+ : [];
+ invitations = emailList.map((email: string, idx: number) => ({
+ created_at: now,
+ email_address: email,
+ id: `orginv_${orgId}_${now}_${idx}`,
+ organization_id: orgId,
+ public_metadata: {},
+ role,
+ role_name: role,
+ status: 'pending',
+ updated_at: now,
+ }));
+ }
+
+ currentInvitations = currentInvitations.concat(invitations);
+ return createNoStoreResponse({
+ response: invitations,
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/organizations/:orgId/invitations/bulk', async ({ params, request }) => {
+ const orgId = params.orgId as string;
+ if (!currentOrganization || orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+ let body: any = {};
+ try {
+ const contentType = request.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ body = await request.json();
+ } else {
+ const text = await request.text();
+ body = parseUrlEncodedBody(text);
+ }
+ } catch {
+ body = {};
+ }
+
+ const role = body.role || 'org:member';
+ const now = Date.now();
+
+ const fromArrayObjects = (arr: any[]) =>
+ arr
+ .map((item, idx) => {
+ const email = item?.email_address || item?.emailAddress;
+ const itemRole = item?.role || role;
+ if (!email) {
+ return null;
+ }
+ return {
+ created_at: now,
+ email_address: email,
+ id: `orginv_${orgId}_${now}_${idx}`,
+ organization_id: orgId,
+ public_metadata: item?.public_metadata || item?.publicMetadata || {},
+ role: itemRole,
+ role_name: itemRole,
+ status: 'pending',
+ updated_at: now,
+ };
+ })
+ .filter(Boolean) as any[];
+
+ let invitations: any[] = [];
+
+ if (Array.isArray(body)) {
+ invitations = fromArrayObjects(body);
+ } else if (Array.isArray(body.invitations)) {
+ invitations = fromArrayObjects(body.invitations);
+ } else if (Array.isArray(body.params)) {
+ invitations = fromArrayObjects(body.params);
+ } else {
+ const emails =
+ body.email_address || body.email_addresses || body.emailAddresses || body.emailAddress || body.emails || [];
+ const emailList = Array.isArray(emails)
+ ? emails
+ : typeof emails === 'string'
+ ? emails
+ .split(',')
+ .map(e => e.trim())
+ .filter(Boolean)
+ : [];
+ invitations = emailList.map((email: string, idx: number) => ({
+ created_at: now,
+ email_address: email,
+ id: `orginv_${orgId}_${now}_${idx}`,
+ organization_id: orgId,
+ public_metadata: {},
+ role,
+ role_name: role,
+ status: 'pending',
+ updated_at: now,
+ }));
+ }
+
+ currentInvitations = currentInvitations.concat(invitations);
+ return createNoStoreResponse({
+ response: invitations,
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/organizations/:orgId/invitations/:invitationId/revoke', ({ params }) => {
+ const orgId = params.orgId as string;
+ const invitationId = params.invitationId as string;
+
+ const idx = currentInvitations.findIndex(inv => inv.organization_id === orgId && inv.id === invitationId);
+
+ if (idx === -1) {
+ return createNoStoreResponse({ error: 'Invitation not found' }, { status: 404 });
+ }
+
+ const now = Date.now();
+ currentInvitations[idx] = {
+ ...currentInvitations[idx],
+ status: 'revoked',
+ updated_at: now,
+ revoked_at: now,
+ };
+
+ return createNoStoreResponse({
+ response: currentInvitations[idx],
+ });
+ }),
+
+ http.post('*/v1/organizations/:orgId/invitations/:invitationId/revoke', ({ params }) => {
+ const orgId = params.orgId as string;
+ const invitationId = params.invitationId as string;
+
+ const idx = currentInvitations.findIndex(inv => inv.organization_id === orgId && inv.id === invitationId);
+
+ if (idx === -1) {
+ return createNoStoreResponse({ error: 'Invitation not found' }, { status: 404 });
+ }
+
+ const now = Date.now();
+ currentInvitations[idx] = {
+ ...currentInvitations[idx],
+ status: 'revoked',
+ updated_at: now,
+ revoked_at: now,
+ };
+
+ return createNoStoreResponse({
+ response: currentInvitations[idx],
+ });
+ }),
+
+ http.get('https://*.clerk.accounts.dev/v1/organizations/:orgId/domains', () => {
+ return createNoStoreResponse({
+ data: [],
+ total_count: 0,
+ });
+ }),
+
+ http.get('*/v1/organizations/:orgId/domains*', () => {
+ return createNoStoreResponse({
+ data: [],
+ total_count: 0,
+ });
+ }),
+
+ http.get('*/v1/organizations/:orgId/membership_requests*', () => {
+ return createNoStoreResponse({
+ data: [],
+ total_count: 0,
+ });
+ }),
+
+ http.get('*/v1/organizations/:orgId/roles*', () => {
+ return createNoStoreResponse({
+ data: [
+ {
+ id: 'role_admin',
+ key: 'org:admin',
+ name: 'Admin',
+ description: 'Full access to all organization resources',
+ permissions: [
+ 'org:sys_profile:manage',
+ 'org:sys_profile:delete',
+ 'org:sys_memberships:read',
+ 'org:sys_memberships:manage',
+ 'org:sys_domains:read',
+ 'org:sys_domains:manage',
+ ],
+ is_creator_eligible: true,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ },
+ {
+ id: 'role_member',
+ key: 'org:member',
+ name: 'Member',
+ description: 'Basic member access',
+ permissions: ['org:sys_profile:read', 'org:sys_memberships:read'],
+ is_creator_eligible: false,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ },
+ ],
+ total_count: 2,
+ });
+ }),
+
+ // Update organization
+ http.patch('*/v1/organizations/:orgId', async ({ params, request }) => {
+ if (!currentOrganization || params.orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+ const body = parseUrlEncodedBody(await request.text());
+ if (body.name) {
+ (currentOrganization as any).name = body.name;
+ }
+ if (body.slug) {
+ (currentOrganization as any).slug = body.slug;
+ }
+ return createNoStoreResponse({
+ response: SessionService.serialize(currentOrganization),
+ });
+ }),
+
+ http.post('*/v1/organizations/:orgId', async ({ params, request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+ const orgId = params.orgId as string;
+
+ if (!currentOrganization || orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+
+ if (method === 'PATCH') {
+ const body = parseUrlEncodedBody(await request.text());
+ if (body.name) {
+ (currentOrganization as any).name = body.name;
+ }
+ if (body.slug) {
+ (currentOrganization as any).slug = body.slug;
+ }
+
+ if (currentMembership) {
+ (currentMembership as any).organization = currentOrganization;
+ }
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ (currentUser as any).organizationMemberships = (currentUser as any).organizationMemberships.map(
+ (membership: any) =>
+ membership.organization?.id === orgId ? { ...membership, organization: currentOrganization } : membership,
+ );
+ }
+
+ return createNoStoreResponse({
+ response: SessionService.serialize(currentOrganization),
+ });
+ }
+
+ if (method === 'DELETE') {
+ return createNoStoreResponse(
+ {
+ errors: [
+ {
+ code: 'organization_delete_mocked',
+ message: 'Organization deletion is not available in this preview.',
+ long_message: 'Organization deletion is mocked in this preview environment and cannot be performed.',
+ meta: { reason: 'mocked_delete' },
+ },
+ ],
+ },
+ { status: 422 },
+ );
+ }
+
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }),
+
+ http.post('*/v1/organizations/:orgId/logo', async ({ params, request }) => {
+ const url = new URL(request.url);
+ const method = url.searchParams.get('_method');
+ const orgId = params.orgId as string;
+
+ if (!currentOrganization || orgId !== currentOrganization.id) {
+ return createNoStoreResponse({ error: 'Organization not found' }, { status: 404 });
+ }
+
+ if (method === 'PUT') {
+ (currentOrganization as any).hasImage = true;
+ (currentOrganization as any).imageUrl =
+ (currentOrganization as any).imageUrl || 'https://img.clerk.com/static/default-organization-logo.png';
+ (currentOrganization as any).updatedAt = new Date();
+ } else if (method === 'DELETE') {
+ (currentOrganization as any).hasImage = false;
+ (currentOrganization as any).imageUrl = '';
+ (currentOrganization as any).updatedAt = new Date();
+ } else {
+ return createNoStoreResponse({ error: 'Method not allowed' }, { status: 405 });
+ }
+
+ if (currentMembership) {
+ (currentMembership as any).organization = currentOrganization;
+ }
+ if (currentUser && (currentUser as any).organizationMemberships?.length > 0) {
+ (currentUser as any).organizationMemberships = (currentUser as any).organizationMemberships.map(
+ (membership: any) =>
+ membership.organization?.id === orgId ? { ...membership, organization: currentOrganization } : membership,
+ );
+ }
+
+ return createNoStoreResponse({
+ response: SessionService.serialize(currentOrganization),
+ });
+ }),
+
+ // Commerce endpoints - Payment methods
+ http.get('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods', () => {
+ const result = BillingService.getPaymentSources(currentSession, currentUser);
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+ const data = result.data.data ?? result.data.response.data ?? [];
+ const total = result.data.total_count ?? result.data.response.total_count ?? data.length;
+ return createNoStoreResponse({
+ data,
+ response: {
+ data,
+ total_count: total,
+ },
+ total_count: total,
+ });
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/initialize', () => {
+ const result = BillingService.initializePaymentSource(currentSession, currentUser);
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+ return createNoStoreResponse(result.data);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods', () => {
+ const result = BillingService.createPaymentSource(currentSession, currentUser);
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+ return createNoStoreResponse(result.data);
+ }),
+
+ http.patch('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/:id', () => {
+ const result = BillingService.updatePaymentSource(currentSession, currentUser);
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+ return createNoStoreResponse(result.data);
+ }),
+
+ http.delete('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/:id', () => {
+ const result = BillingService.deletePaymentSource(currentSession, currentUser);
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+ return createNoStoreResponse(result.data);
+ }),
+
+ http.post('https://*.clerk.accounts.dev/v1/me/commerce/checkouts', async ({ request }) => {
+ if (!currentSession || !currentUser) {
+ return createNoStoreResponse({ error: 'No active session' }, { status: 401 });
+ }
+
+ let body: Record = {};
+ let formBody: URLSearchParams | null = null;
+
+ // Read as text first (always works), then parse
+ const text = await request.text();
+ if (text) {
+ try {
+ body = JSON.parse(text);
+ } catch {
+ formBody = new URLSearchParams(text);
+ formBody.forEach((value, key) => {
+ body[key] = value;
+ });
+ }
+ }
+
+ const checkoutId = `chk_mock_${Math.random().toString(36).slice(2, 10)}`;
+ const paymentIntentId = `pi_mock_${Math.random().toString(36).slice(2, 10)}`;
+ const plansResponse = BillingService.getPlans();
+ const plans = plansResponse.response?.data ?? plansResponse.data ?? [];
+ const paymentSourcesResult = BillingService.getPaymentSources(currentSession, currentUser);
+ const paymentMethods = paymentSourcesResult.authorized
+ ? (paymentSourcesResult.data.data ?? paymentSourcesResult.data.response.data ?? [])
+ : [];
+ const normalizePlan = (input: any) => {
+ const safeArray = (value: any) => (Array.isArray(value) ? value : []);
+ const plan =
+ input ??
+ ({
+ annual_fee: null,
+ annual_monthly_fee: null,
+ avatar_url: '',
+ description: 'Mock plan',
+ fee: { amount: 999, amount_formatted: '9.99', currency: 'usd', currency_symbol: '$' },
+ for_payer_type: 'user',
+ free_trial_days: 14,
+ free_trial_enabled: true,
+ has_base_fee: true,
+ id: 'plan_mock_default',
+ is_default: true,
+ is_recurring: true,
+ name: 'Mock Plan',
+ object: 'commerce_plan',
+ publicly_visible: true,
+ slug: 'mock-plan',
+ } as any);
+
+ return {
+ ...plan,
+ annual_fee: plan.annual_fee ?? null,
+ annual_monthly_fee: plan.annual_monthly_fee ?? null,
+ avatar_url: plan.avatar_url ?? '',
+ description: plan.description ?? null,
+ features: safeArray((plan as any).features),
+ free_trial_days: plan.free_trial_days ?? null,
+ free_trial_enabled: plan.free_trial_enabled ?? false,
+ has_base_fee: plan.has_base_fee ?? false,
+ for_payer_type: plan.for_payer_type ?? 'user',
+ publicly_visible: plan.publicly_visible ?? true,
+ };
+ };
+ if (plans.length === 0) {
+ plans.push(normalizePlan(null));
+ }
+ const urlParams = new URL(request.url).searchParams;
+ const getParam = (key: string) => {
+ const lower = key.toLowerCase();
+ return (
+ body[key] ??
+ body?.params?.[key] ??
+ body[lower] ??
+ body?.params?.[lower] ??
+ formBody?.get(key) ??
+ formBody?.get(lower) ??
+ urlParams.get(key) ??
+ urlParams.get(lower)
+ );
+ };
+
+ const preferredPlanId = getParam('plan_id') || getParam('planId') || getParam('plan') || getParam('planId');
+ const rawPeriod =
+ getParam('plan_period') ||
+ getParam('planPeriod') ||
+ getParam('interval') ||
+ getParam('billing_interval') ||
+ getParam('billingInterval') ||
+ getParam('billing_period') ||
+ getParam('billingPeriod') ||
+ getParam('cycle') ||
+ getParam('billing_cycle') ||
+ getParam('billingCycle') ||
+ getParam('period');
+ const normalizePeriod = (value: any): 'month' | 'annual' => {
+ const v = typeof value === 'string' ? value.toLowerCase() : '';
+ if (v === 'month' || v === 'monthly') {
+ return 'month';
+ }
+ if (['annual', 'year', 'yearly', 'annually'].includes(v)) {
+ return 'annual';
+ }
+ // honor explicit values only; fall back to month when absent/unknown
+ return 'month';
+ };
+ const planPeriod: 'month' | 'annual' = rawPeriod ? normalizePeriod(rawPeriod) : 'month';
+ const plan = plans.find(item => item.id === preferredPlanId) ?? plans[0];
+ const normalizedPlan = normalizePlan(plan);
+ const now = Date.now();
+ const needsPaymentMethod = paymentMethods.length === 0;
+ const payer = {
+ created_at: now,
+ email: (currentUser as any).emailAddresses?.[0]?.emailAddress ?? `${currentUser?.id}@example.com`,
+ first_name: (currentUser as any).firstName ?? null,
+ id: `payer_${currentUser?.id ?? 'mock'}`,
+ last_name: (currentUser as any).lastName ?? null,
+ object: 'commerce_payer',
+ organization_id: null,
+ organization_name: null,
+ updated_at: now,
+ user_id: currentUser?.id ?? null,
+ };
+ const selectedFee =
+ planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee;
+ const totals = {
+ grand_total: selectedFee,
+ subtotal: selectedFee,
+ tax_total: { amount: 0, amount_formatted: '0.00', currency: 'usd', currency_symbol: '$' },
+ total_due_after_free_trial: selectedFee,
+ total_due_now: selectedFee,
+ };
+ const paymentMethod = paymentMethods[0] ?? null;
+ const intervalLabel = planPeriod === 'annual' ? 'year' : 'month';
+
+ return createNoStoreResponse({
+ response: {
+ billing_interval: intervalLabel,
+ external_client_secret: `mock_checkout_secret_${checkoutId}`,
+ external_gateway_id: 'mock_gateway',
+ free_trial_ends_at: normalizedPlan.free_trial_enabled ? now + 14 * 24 * 60 * 60 * 1000 : null,
+ id: checkoutId,
+ interval: intervalLabel,
+ is_immediate_plan_change: true,
+ needs_payment_method: needsPaymentMethod,
+ object: 'commerce_checkout',
+ payer,
+ payment_method: paymentMethod,
+ plan: {
+ ...normalizedPlan,
+ fee: planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee,
+ },
+ plan_period: planPeriod,
+ plan_period_start: now,
+ status: 'needs_confirmation',
+ totals,
+ },
+ });
+ }),
+
+ http.post(
+ 'https://*.clerk.accounts.dev/v1/me/commerce/checkouts/:checkoutId/confirm',
+ async ({ params, request }) => {
+ if (!currentSession || !currentUser) {
+ return createNoStoreResponse({ error: 'No active session' }, { status: 401 });
+ }
+
+ let body: Record = {};
+ let formBody: URLSearchParams | null = null;
+
+ const text = await request.text();
+ if (text) {
+ try {
+ body = JSON.parse(text);
+ } catch {
+ formBody = new URLSearchParams(text);
+ formBody.forEach((value, key) => {
+ body[key] = value;
+ });
+ }
+ }
+
+ const urlParams = new URL(request.url).searchParams;
+ const getParam = (key: string) => {
+ const lower = key.toLowerCase();
+ return (
+ body[key] ??
+ body?.params?.[key] ??
+ body[lower] ??
+ body?.params?.[lower] ??
+ formBody?.get(key) ??
+ formBody?.get(lower) ??
+ urlParams.get(key) ??
+ urlParams.get(lower)
+ );
+ };
+
+ const plansResponse = BillingService.getPlans();
+ const plans = plansResponse.response?.data ?? plansResponse.data ?? [];
+ const preferredPlanId = getParam('plan_id') || getParam('planId') || getParam('plan');
+ const rawPeriod =
+ getParam('plan_period') ||
+ getParam('planPeriod') ||
+ getParam('interval') ||
+ getParam('billing_interval') ||
+ getParam('billingInterval') ||
+ getParam('billing_period') ||
+ getParam('billingPeriod') ||
+ getParam('cycle') ||
+ getParam('billing_cycle') ||
+ getParam('billingCycle') ||
+ getParam('period');
+ const normalizePeriod = (value: any): 'month' | 'annual' => {
+ const v = typeof value === 'string' ? value.toLowerCase() : '';
+ if (v === 'month' || v === 'monthly') {
+ return 'month';
+ }
+ if (['annual', 'year', 'yearly', 'annually'].includes(v)) {
+ return 'annual';
+ }
+ // honor explicit values only; fall back to month when absent/unknown
+ return 'month';
+ };
+ const planPeriod: 'month' | 'annual' = rawPeriod ? normalizePeriod(rawPeriod) : 'month';
+ const plan = plans.find(item => item.id === preferredPlanId) ?? plans[0];
+
+ const normalizePlan = (input: any) => {
+ const safeArray = (value: any) => (Array.isArray(value) ? value : []);
+ const planData =
+ input ??
+ ({
+ annual_fee: null,
+ annual_monthly_fee: null,
+ avatar_url: '',
+ description: 'Mock plan',
+ fee: { amount: 999, amount_formatted: '9.99', currency: 'usd', currency_symbol: '$' },
+ for_payer_type: 'user',
+ free_trial_days: 14,
+ free_trial_enabled: true,
+ has_base_fee: true,
+ id: 'plan_mock_default',
+ is_default: true,
+ is_recurring: true,
+ name: 'Mock Plan',
+ object: 'commerce_plan',
+ publicly_visible: true,
+ slug: 'mock-plan',
+ } as any);
+
+ return {
+ ...planData,
+ annual_fee: planData.annual_fee ?? null,
+ annual_monthly_fee: planData.annual_monthly_fee ?? null,
+ avatar_url: planData.avatar_url ?? '',
+ description: planData.description ?? null,
+ features: safeArray((planData as any).features),
+ free_trial_days: planData.free_trial_days ?? null,
+ free_trial_enabled: planData.free_trial_enabled ?? false,
+ has_base_fee: planData.has_base_fee ?? false,
+ for_payer_type: planData.for_payer_type ?? 'user',
+ publicly_visible: planData.publicly_visible ?? true,
+ };
+ };
+
+ const normalizedPlan = normalizePlan(plan);
+ const paymentSourcesResult = BillingService.getPaymentSources(currentSession, currentUser);
+ const paymentMethods = paymentSourcesResult.authorized
+ ? (paymentSourcesResult.data.data ?? paymentSourcesResult.data.response.data ?? [])
+ : [];
+ const needsPaymentMethod = paymentMethods.length === 0;
+ const selectedFee =
+ planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee;
+ const totals = {
+ grand_total: selectedFee,
+ subtotal: selectedFee,
+ tax_total: { amount: 0, amount_formatted: '0.00', currency: 'usd', currency_symbol: '$' },
+ total_due_after_free_trial: selectedFee,
+ total_due_now: selectedFee,
+ };
+ const now = Date.now();
+ const checkoutId = params.checkoutId as string;
+
+ const payer = {
+ created_at: now,
+ email: (currentUser as any).emailAddresses?.[0]?.emailAddress ?? `${currentUser?.id}@example.com`,
+ first_name: (currentUser as any).firstName ?? null,
+ id: `payer_${currentUser?.id ?? 'mock'}`,
+ last_name: (currentUser as any).lastName ?? null,
+ object: 'commerce_payer',
+ organization_id: null,
+ organization_name: null,
+ updated_at: now,
+ user_id: currentUser?.id ?? null,
+ };
+
+ return createNoStoreResponse({
+ response: {
+ external_client_secret: `mock_checkout_secret_${checkoutId}`,
+ external_gateway_id: 'mock_gateway',
+ free_trial_ends_at: normalizedPlan.free_trial_enabled ? now + 14 * 24 * 60 * 60 * 1000 : null,
+ id: checkoutId,
+ is_immediate_plan_change: true,
+ needs_payment_method: needsPaymentMethod,
+ object: 'commerce_checkout',
+ payer,
+ payment_method: paymentMethods[0] ?? null,
+ plan: {
+ ...normalizedPlan,
+ fee: planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee,
+ },
+ plan_period: planPeriod,
+ plan_period_start: now,
+ status: 'completed',
+ totals,
+ },
+ });
+ },
+ ),
+
+ // Commerce endpoints - Plans
+ http.get('*/v1/commerce/plans', () => {
+ return createNoStoreResponse(BillingService.getPlans());
+ }),
+
+ // Commerce endpoints - User subscription creation (trial/start)
+ http.post('*/v1/me/commerce/subscription', async ({ request }) => {
+ let body: any = {};
+ try {
+ body = await request.json();
+ } catch {
+ body = {};
+ }
+
+ const result = BillingService.startFreeTrial(currentSession, currentUser);
+
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+
+ currentSubscription = result.data.response;
+
+ return createNoStoreResponse(result.data);
+ }),
+
+ // Commerce endpoints - User subscription (singular)
+ http.get('*/v1/me/commerce/subscription', () => {
+ const result = BillingService.getSubscription(currentSession, currentUser, currentSubscription);
+
+ if (!result.authorized) {
+ return createNoStoreResponse({ error: result.error }, { status: result.status });
+ }
+ return createNoStoreResponse(result.data);
+ }),
+
+ // Commerce endpoints - User subscriptions (plural)
+ http.get('*/v1/me/commerce/subscriptions', () => {
+ return createNoStoreResponse(BillingService.getSubscriptions());
+ }),
+
+ // Commerce endpoints - Statements
+ http.get('*/v1/me/commerce/statements', () => {
+ return createNoStoreResponse(BillingService.getStatements());
+ }),
+
+ // Image endpoints
+ http.get('https://storage.googleapis.com/images.clerk.dev/examples/previews/*', async ({ request }) => {
+ const url = new URL(request.url);
+ const filename = url.pathname.split('/').pop();
+
+ if (filename === 'cameron-walker.jpg') {
+ const response = await fetch('/cameron-walker.jpg');
+ const blob = await response.blob();
+ return new HttpResponse(blob, {
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Cache-Control': 'public, max-age=31536000',
+ },
+ });
+ }
+
+ return new HttpResponse(null, { status: 404 });
+ }),
+
+ // Telemetry endpoints
+ http.post('https://clerk-telemetry.com/v1/event', () => {
+ return HttpResponse.json({ success: true });
+ }),
+
+ http.post('*/clerk-telemetry.com/v1/*', () => {
+ return HttpResponse.json({ success: true });
+ }),
+
+ // Sign up endpoints
+ http.post('*/v1/client/sign_ups', async ({ request }) => {
+ let body: any = {};
+ try {
+ const text = await request.text();
+ const params = new URLSearchParams(text);
+ params.forEach((value, key) => {
+ body[key] = value;
+ });
+ } catch (e) {
+ // Ignore parse errors
+ }
+
+ const email = (body?.email_address as string) || (body?.emailAddress as string) || 'user@example.com';
+ const firstName = (body?.first_name as string) || (body?.firstName as string) || null;
+ const lastName = (body?.last_name as string) || (body?.lastName as string) || null;
+
+ SignUpService.setEmail(email);
+ SignUpService.setFirstName(firstName);
+ SignUpService.setLastName(lastName);
+
+ const signUpResponse = SignUpService.createSignUpResponse({
+ email,
+ firstName,
+ lastName,
+ status: 'missing_requirements',
+ unverifiedFields: ['email_address'],
+ verificationAttempts: 0,
+ verificationStatus: 'unverified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_up = signUpResponse as any;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signUpResponse,
+ });
+ }),
+
+ http.patch('*/v1/client/sign_ups/:signUpId', async ({ request }) => {
+ let body: any = {};
+ try {
+ const text = await request.text();
+ const params = new URLSearchParams(text);
+ params.forEach((value, key) => {
+ body[key] = value;
+ });
+ } catch (e) {
+ // Ignore
+ }
+
+ if (body?.email_address || body?.emailAddress) {
+ const email = (body?.email_address as string) || (body?.emailAddress as string);
+ SignUpService.setEmail(email);
+ }
+ if (body?.first_name || body?.firstName) {
+ SignUpService.setFirstName((body?.first_name as string) || (body?.firstName as string));
+ }
+ if (body?.last_name || body?.lastName) {
+ SignUpService.setLastName((body?.last_name as string) || (body?.lastName as string));
+ }
+
+ const signUpResponse = SignUpService.createSignUpResponse({
+ status: 'missing_requirements',
+ unverifiedFields: ['email_address'],
+ verificationAttempts: 0,
+ verificationStatus: 'unverified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_up = signUpResponse as any;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signUpResponse,
+ });
+ }),
+
+ http.post('*/v1/client/sign_ups/:signUpId/prepare_verification', () => {
+ const signUpResponse = SignUpService.createSignUpResponse({
+ status: 'missing_requirements',
+ unverifiedFields: ['email_address'],
+ verificationAttempts: 1,
+ verificationStatus: 'unverified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_up = signUpResponse as any;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signUpResponse,
+ });
+ }),
+
+ http.post('*/v1/client/sign_ups/:signUpId/attempt_verification', async ({ request }) => {
+ let body: any = {};
+ try {
+ const text = await request.text();
+ const params = new URLSearchParams(text);
+ params.forEach((value, key) => {
+ body[key] = value;
+ });
+ } catch (e) {
+ // Ignore
+ }
+
+ const code = body?.code;
+
+ // Validate code is provided
+ if (!code || code.trim() === '') {
+ return HttpResponse.json(
+ {
+ errors: [
+ {
+ code: 'form_param_nil',
+ long_message: 'Please enter your verification code.',
+ message: 'is missing or empty',
+ meta: {
+ param_name: 'code',
+ },
+ },
+ ],
+ },
+ {
+ status: 422,
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ },
+ },
+ );
+ }
+
+ // Create a new user and session using the service
+ const { clientState, newSession, newUser, signUpResponse } = SignUpService.createUser(currentSession);
+
+ // Update current session and user
+ currentSession = newSession as any;
+ currentUser = newUser;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signUpResponse,
+ });
+ }),
+
+ http.get('*/v1/client/sign_ups/:signUpId', () => {
+ const currentSignUp = SignUpService.getCurrentSignUp();
+ const clientState = SessionService.getClientState(currentSession);
+ if (currentSignUp) {
+ clientState.response.sign_up = currentSignUp as any;
+ }
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: currentSignUp,
+ });
+ }),
+
+ http.get('*/v1/client/sign_ups', () => {
+ const currentSignUp = SignUpService.getCurrentSignUp();
+ const clientState = SessionService.getClientState(currentSession);
+ if (currentSignUp) {
+ clientState.response.sign_up = currentSignUp as any;
+ }
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: currentSignUp,
+ });
+ }),
+
+ // Sign in endpoints
+ http.post('*/v1/client/sign_ins', async ({ request }) => {
+ let body: any = {};
+ try {
+ const text = await request.text();
+ const params = new URLSearchParams(text);
+ params.forEach((value, key) => {
+ body[key] = value;
+ });
+ } catch (e) {
+ // Ignore
+ }
+
+ const identifier = (body?.identifier as string) || 'user@example.com';
+ SignInService.setIdentifier(identifier);
+
+ const signInResponse = SignInService.createSignInResponse({
+ identifier,
+ status: 'needs_first_factor',
+ verificationAttempts: 0,
+ verificationStatus: 'unverified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_in = signInResponse as any;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signInResponse,
+ });
+ }),
+
+ http.post('*/v1/client/sign_ins/:signInId/prepare_first_factor', () => {
+ const signInResponse = SignInService.createSignInResponse({
+ status: 'needs_first_factor',
+ verificationAttempts: 0,
+ verificationStatus: 'unverified',
+ });
+
+ const clientState = SessionService.getClientState(currentSession);
+ clientState.response.sign_in = signInResponse as any;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signInResponse,
+ });
+ }),
+
+ http.post('*/v1/client/sign_ins/:signInId/attempt_first_factor', async ({ request }) => {
+ let body: any = {};
+ try {
+ const text = await request.text();
+ const params = new URLSearchParams(text);
+ params.forEach((value, key) => {
+ body[key] = value;
+ });
+ } catch (e) {
+ // Ignore
+ }
+
+ const password = body?.password;
+
+ // Validate password is provided
+ if (!password || password.trim() === '') {
+ return HttpResponse.json(
+ {
+ errors: [
+ {
+ code: 'form_password_incorrect',
+ long_message: 'Password is incorrect. Try again, or use another method.',
+ message: 'is incorrect',
+ meta: {
+ param_name: 'password',
+ },
+ },
+ ],
+ },
+ {
+ status: 422,
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ },
+ },
+ );
+ }
+
+ // Create a new user and session using the service
+ const { clientState, newSession, newUser, signInResponse } = SignInService.createUser(currentSession);
+
+ // Update current session and user
+ currentSession = newSession as any;
+ currentUser = newUser;
+
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: signInResponse,
+ });
+ }),
+
+ http.get('*/v1/client/sign_ins*', () => {
+ const currentSignIn = SignInService.getCurrentSignIn();
+ const clientState = SessionService.getClientState(currentSession);
+ if (currentSignIn) {
+ clientState.response.sign_in = currentSignIn as any;
+ }
+ return createNoStoreResponse({
+ client: clientState.response,
+ response: currentSignIn,
+ });
+ }),
+
+ // Catch-all endpoints
+ http.post('*/__clerk/client*', () => {
+ return HttpResponse.json({ client: {}, response: {} });
+ }),
+
+ http.all('*/clerk.accounts.dev/*', () => {
+ return HttpResponse.json({ response: {} });
+ }),
+
+ http.all('https://*.clerk.com/v1/*', () => {
+ return HttpResponse.json({ response: {} });
+ }),
+];
diff --git a/packages/msw/tsconfig.json b/packages/msw/tsconfig.json
new file mode 100644
index 00000000000..641f6c3120e
--- /dev/null
+++ b/packages/msw/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "incremental": false,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "moduleResolution": "bundler",
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022"
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/msw/types.ts b/packages/msw/types.ts
new file mode 100644
index 00000000000..d9899be739b
--- /dev/null
+++ b/packages/msw/types.ts
@@ -0,0 +1,21 @@
+import type {
+ OrganizationResource,
+ SessionResource,
+ SignInResource,
+ SignUpResource,
+ UserResource,
+} from '@clerk/shared/types';
+
+export interface MockScenario {
+ debug?: boolean;
+ description: string;
+ handlers: any[];
+ initialState?: {
+ organization?: OrganizationResource;
+ session?: SessionResource;
+ signIn?: SignInResource;
+ signUp?: SignUpResource;
+ user?: UserResource;
+ };
+ name: string;
+}
diff --git a/packages/msw/usePageMocking.ts b/packages/msw/usePageMocking.ts
new file mode 100644
index 00000000000..1b21c2e9629
--- /dev/null
+++ b/packages/msw/usePageMocking.ts
@@ -0,0 +1,51 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { useEffect, useRef, useState } from 'react';
+
+import type { PageMockConfig, PageMockingState } from './PageMocking';
+import { PageMocking } from './PageMocking';
+
+export type { PageMockConfig } from './PageMocking';
+
+export function usePageMocking(config?: PageMockConfig) {
+ const pathname = usePathname();
+ const pageMockingRef = useRef(null);
+ const [state, setState] = useState({
+ controller: null,
+ error: null,
+ isEnabled: false,
+ isReady: false,
+ });
+
+ useEffect(() => {
+ let mounted = true;
+
+ // Create the PageMocking instance if it doesn't exist
+ if (!pageMockingRef.current) {
+ pageMockingRef.current = new PageMocking({
+ onStateChange: newState => {
+ if (mounted) {
+ setState(newState);
+ }
+ },
+ });
+ }
+
+ const pageMocking = pageMockingRef.current;
+
+ pageMocking.initialize(pathname, config);
+
+ return () => {
+ mounted = false;
+ pageMocking.cleanup();
+ };
+ }, [pathname]);
+
+ return {
+ error: state.error,
+ isEnabled: state.isEnabled,
+ isReady: state.isReady,
+ pathname,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d57b8881d0a..57d54b6b39e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -508,6 +508,9 @@ importers:
specifier: 2.0.3
version: 2.0.3
devDependencies:
+ '@clerk/msw':
+ specifier: workspace:^
+ version: link:../msw
'@clerk/testing':
specifier: workspace:^
version: link:../testing
@@ -693,6 +696,21 @@ importers:
specifier: workspace:^
version: link:../shared
+ packages/msw:
+ dependencies:
+ '@clerk/shared':
+ specifier: workspace:^
+ version: link:../shared
+ msw:
+ specifier: 2.11.3
+ version: 2.11.3(@types/node@22.19.0)(typescript@5.8.3)
+ next:
+ specifier: '>=15.0.0'
+ version: 15.2.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react:
+ specifier: 18.3.1
+ version: 18.3.1
+
packages/nextjs:
dependencies:
'@clerk/backend':
@@ -1962,6 +1980,12 @@ packages:
'@braidai/lang@1.1.2':
resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==}
+ '@bundled-es-modules/cookie@2.0.1':
+ resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==}
+
+ '@bundled-es-modules/statuses@1.0.1':
+ resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==}
+
'@capsizecss/unpack@3.0.0':
resolution: {integrity: sha512-+ntATQe1AlL7nTOYjwjj6w3299CgRot48wL761TUGYpYgAou3AaONZazp0PKZyCyWhudWsjhq1nvRHOvbMzhTA==}
engines: {node: '>=18'}
@@ -2436,7 +2460,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {node: '>=0.10.0'}
+ engines: {'0': node >=0.10.0}
'@expo/cli@0.22.26':
resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==}
@@ -3295,6 +3319,10 @@ packages:
'@module-federation/webpack-bundler-runtime@0.21.2':
resolution: {integrity: sha512-06R/NDY6Uh5RBIaBOFwYWzJCf1dIiQd/DFHToBVhejUT3ZFG7GzHEPIIsAGqMzne/JSmVsvjlXiJu7UthQ6rFA==}
+ '@mswjs/interceptors@0.39.8':
+ resolution: {integrity: sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==}
+ engines: {node: '>=18'}
+
'@mswjs/interceptors@0.40.0':
resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==}
engines: {node: '>=18'}
@@ -11149,6 +11177,16 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ msw@2.11.3:
+ resolution: {integrity: sha512-878imp8jxIpfzuzxYfX0qqTq1IFQz/1/RBHs/PyirSjzi+xKM/RRfIpIqHSCWjH0GxidrjhgiiXC+DWXNDvT9w==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ typescript: '>= 4.8.x'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
msw@2.11.6:
resolution: {integrity: sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==}
engines: {node: '>=18'}
@@ -16350,6 +16388,14 @@ snapshots:
'@braidai/lang@1.1.2': {}
+ '@bundled-es-modules/cookie@2.0.1':
+ dependencies:
+ cookie: 0.7.2
+
+ '@bundled-es-modules/statuses@1.0.1':
+ dependencies:
+ statuses: 2.0.2
+
'@capsizecss/unpack@3.0.0':
dependencies:
fontkit: 2.0.4
@@ -18297,6 +18343,15 @@ snapshots:
'@module-federation/runtime': 0.21.2
'@module-federation/sdk': 0.21.2
+ '@mswjs/interceptors@0.39.8':
+ dependencies:
+ '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/logger': 0.3.0
+ '@open-draft/until': 2.1.0
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ strict-event-emitter: 0.5.1
+
'@mswjs/interceptors@0.40.0':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@@ -28358,6 +28413,32 @@ snapshots:
ms@2.1.3: {}
+ msw@2.11.3(@types/node@22.19.0)(typescript@5.8.3):
+ dependencies:
+ '@bundled-es-modules/cookie': 2.0.1
+ '@bundled-es-modules/statuses': 1.0.1
+ '@inquirer/confirm': 5.1.20(@types/node@22.19.0)
+ '@mswjs/interceptors': 0.39.8
+ '@open-draft/deferred-promise': 2.2.0
+ '@types/cookie': 0.6.0
+ '@types/statuses': 2.0.6
+ graphql: 16.12.0
+ headers-polyfill: 4.0.3
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ path-to-regexp: 6.3.0
+ picocolors: 1.1.1
+ rettime: 0.7.0
+ strict-event-emitter: 0.5.1
+ tough-cookie: 6.0.0
+ type-fest: 4.41.0
+ until-async: 3.0.2
+ yargs: 17.7.2
+ optionalDependencies:
+ typescript: 5.8.3
+ transitivePeerDependencies:
+ - '@types/node'
+
msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3):
dependencies:
'@inquirer/confirm': 5.1.20(@types/node@22.19.0)