From e48e31387e0a52b1589a239b112635f83ee3d24b Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:54:34 -0600 Subject: [PATCH 1/6] feat(msw): Add msw package --- packages/msw/BillingService.ts | 377 ++++ packages/msw/EnvironmentService.ts | 527 ++++++ packages/msw/MockingController.ts | 252 +++ packages/msw/MockingProvider.tsx | 37 + packages/msw/MockingStatusIndicator.tsx | 48 + packages/msw/OrganizationService.ts | 81 + packages/msw/README.md | 48 + packages/msw/SessionService.ts | 220 +++ packages/msw/SignInService.ts | 123 ++ packages/msw/SignUpService.ts | 170 ++ packages/msw/UserService.ts | 612 +++++++ packages/msw/index.ts | 18 + packages/msw/package.json | 25 + packages/msw/request-handlers.ts | 2159 +++++++++++++++++++++++ packages/msw/tsconfig.json | 22 + packages/msw/types.ts | 21 + packages/msw/usePageMocking.ts | 82 + 17 files changed, 4822 insertions(+) create mode 100644 packages/msw/BillingService.ts create mode 100644 packages/msw/EnvironmentService.ts create mode 100644 packages/msw/MockingController.ts create mode 100644 packages/msw/MockingProvider.tsx create mode 100644 packages/msw/MockingStatusIndicator.tsx create mode 100644 packages/msw/OrganizationService.ts create mode 100644 packages/msw/README.md create mode 100644 packages/msw/SessionService.ts create mode 100644 packages/msw/SignInService.ts create mode 100644 packages/msw/SignUpService.ts create mode 100644 packages/msw/UserService.ts create mode 100644 packages/msw/index.ts create mode 100644 packages/msw/package.json create mode 100644 packages/msw/request-handlers.ts create mode 100644 packages/msw/tsconfig.json create mode 100644 packages/msw/types.ts create mode 100644 packages/msw/usePageMocking.ts 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/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..b4eb2c13dac --- /dev/null +++ b/packages/msw/index.ts @@ -0,0 +1,18 @@ +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 { SessionService } from './SessionService'; +export { SignInService } from './SignInService'; +export { SignUpService } from './SignUpService'; +export type { MockScenario } from './types'; +export { UserService } from './UserService'; +export type { PageMockConfig } from './usePageMocking'; +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..36076d439d8 --- /dev/null +++ b/packages/msw/package.json @@ -0,0 +1,25 @@ +{ + "name": "@clerk/msw", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./index.ts", + "default": "./index.ts" + } + }, + "dependencies": { + "@clerk/shared": "workspace:^", + "msw": "2.11.3" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:" + }, + "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..9cc58d14fae --- /dev/null +++ b/packages/msw/usePageMocking.ts @@ -0,0 +1,82 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import type { MockConfig } from './MockingController'; +import { MockingController } from './MockingController'; +import type { MockScenario } from './types'; + +export interface PageMockConfig extends MockConfig { + scenario?: () => MockScenario; +} + +export function usePageMocking(config?: PageMockConfig) { + const pathname = usePathname(); + const [controller, setController] = useState(null); + const [error, setError] = useState(null); + const [isEnabled, setIsEnabled] = useState(false); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + let mounted = true; + + const initializeMocking = async () => { + try { + if (!config?.scenario) { + return; + } + + // Clear Clerk's cached data to prevent stale environment/session data + if (typeof window !== 'undefined') { + const clerkKeys = Object.keys(localStorage).filter(key => key.startsWith('__clerk')); + clerkKeys.forEach(key => localStorage.removeItem(key)); + const sessionKeys = Object.keys(sessionStorage).filter(key => key.startsWith('__clerk')); + sessionKeys.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); + + if (mounted) { + setController(mockController); + setIsEnabled(true); + setIsReady(true); + setError(null); + } + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err : new Error('Failed to initialize page mocking')); + setIsReady(false); + setIsEnabled(false); + } + } + }; + + initializeMocking(); + + return () => { + mounted = false; + if (controller) { + controller.stop(); + } + }; + }, [pathname]); + + return { + error, + isEnabled, + isReady, + pathname, + }; +} From 43a54bf55c6c51b9a191f5a7acf5969103578150 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:21:46 -0600 Subject: [PATCH 2/6] feat(clerk-js,msw): Setup clerk-js sandbox to support scenarios --- packages/clerk-js/package.json | 1 + packages/clerk-js/rspack.config.js | 1 + packages/clerk-js/sandbox/app.ts | 72 +++- .../sandbox/public/mockServiceWorker.js | 334 ++++++++++++++++++ packages/clerk-js/sandbox/scenarios/index.ts | 1 + .../scenarios/user-button-signed-in.ts | 26 ++ packages/msw/PageMocking.ts | 119 +++++++ packages/msw/index.ts | 3 +- packages/msw/package.json | 6 +- packages/msw/usePageMocking.ts | 85 ++--- pnpm-lock.yaml | 83 ++++- 11 files changed, 660 insertions(+), 71 deletions(-) create mode 100644 packages/clerk-js/sandbox/public/mockServiceWorker.js create mode 100644 packages/clerk-js/sandbox/scenarios/index.ts create mode 100644 packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts create mode 100644 packages/msw/PageMocking.ts 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/app.ts b/packages/clerk-js/sandbox/app.ts index 6465c8c0403..22c71ad27fc 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,5 +1,7 @@ +import { PageMocking, type MockScenario } from '@clerk/msw'; import * as l from '../../localizations'; import type { Clerk as ClerkType } from '../'; +import * as scenarios from './scenarios'; const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[]; @@ -21,6 +23,11 @@ interface ComponentPropsControl { getProps: () => any | null; } +interface ScenarioControls { + setScenario: (scenario: AvailableScenario | null) => void; + availableScenarios: typeof AVAILABLE_SCENARIOS; +} + const AVAILABLE_COMPONENTS = [ 'clerk', // While not a component, we want to support passing options to the Clerk class. 'signIn', @@ -39,17 +46,57 @@ const AVAILABLE_COMPONENTS = [ 'taskChooseOrganization', 'taskResetPassword', ] as const; +type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number]; + +const AVAILABLE_SCENARIOS = Object.keys(scenarios) as (keyof typeof scenarios)[]; +type AvailableScenario = (typeof AVAILABLE_SCENARIOS)[number]; const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox'; 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])) { + 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); + } } -function setComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number], props: unknown) { +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(); +} + +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 +105,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 +120,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 +131,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 +152,13 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component declare global { interface Window { - components: Record<(typeof AVAILABLE_COMPONENTS)[number], ComponentPropsControl>; + components: Record; + scenario: typeof scenarioControls; } } window.components = componentControls; +window.scenario = scenarioControls; const Clerk = window.Clerk; function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType { @@ -373,6 +422,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/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/index.ts b/packages/msw/index.ts index b4eb2c13dac..395b1de1292 100644 --- a/packages/msw/index.ts +++ b/packages/msw/index.ts @@ -6,12 +6,13 @@ 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 type { PageMockConfig } from './usePageMocking'; export { usePageMocking } from './usePageMocking'; export { http, HttpResponse } from 'msw'; diff --git a/packages/msw/package.json b/packages/msw/package.json index 36076d439d8..4c75a22e657 100644 --- a/packages/msw/package.json +++ b/packages/msw/package.json @@ -2,6 +2,7 @@ "name": "@clerk/msw", "version": "0.0.0", "private": true, + "sideEffects": false, "type": "module", "exports": { ".": { @@ -13,11 +14,6 @@ "@clerk/shared": "workspace:^", "msw": "2.11.3" }, - "devDependencies": { - "@types/node": "catalog:", - "@types/react": "catalog:", - "typescript": "catalog:" - }, "peerDependencies": { "next": ">=15.0.0", "react": "catalog:peer-react" diff --git a/packages/msw/usePageMocking.ts b/packages/msw/usePageMocking.ts index 9cc58d14fae..1b21c2e9629 100644 --- a/packages/msw/usePageMocking.ts +++ b/packages/msw/usePageMocking.ts @@ -1,82 +1,51 @@ 'use client'; import { usePathname } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import type { MockConfig } from './MockingController'; -import { MockingController } from './MockingController'; -import type { MockScenario } from './types'; +import type { PageMockConfig, PageMockingState } from './PageMocking'; +import { PageMocking } from './PageMocking'; -export interface PageMockConfig extends MockConfig { - scenario?: () => MockScenario; -} +export type { PageMockConfig } from './PageMocking'; export function usePageMocking(config?: PageMockConfig) { const pathname = usePathname(); - const [controller, setController] = useState(null); - const [error, setError] = useState(null); - const [isEnabled, setIsEnabled] = useState(false); - const [isReady, setIsReady] = useState(false); + const pageMockingRef = useRef(null); + const [state, setState] = useState({ + controller: null, + error: null, + isEnabled: false, + isReady: false, + }); useEffect(() => { let mounted = true; - const initializeMocking = async () => { - try { - if (!config?.scenario) { - return; - } - - // Clear Clerk's cached data to prevent stale environment/session data - if (typeof window !== 'undefined') { - const clerkKeys = Object.keys(localStorage).filter(key => key.startsWith('__clerk')); - clerkKeys.forEach(key => localStorage.removeItem(key)); - const sessionKeys = Object.keys(sessionStorage).filter(key => key.startsWith('__clerk')); - sessionKeys.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(); + // Create the PageMocking instance if it doesn't exist + if (!pageMockingRef.current) { + pageMockingRef.current = new PageMocking({ + onStateChange: newState => { + if (mounted) { + setState(newState); + } + }, + }); + } - const mockController = new MockingController({ - debug: config?.debug || scenario.debug || false, - delay: config?.delay, - persist: config?.persist, - }); - - mockController.registerScenario(scenario); - await mockController.start(scenario.name); - - if (mounted) { - setController(mockController); - setIsEnabled(true); - setIsReady(true); - setError(null); - } - } catch (err) { - if (mounted) { - setError(err instanceof Error ? err : new Error('Failed to initialize page mocking')); - setIsReady(false); - setIsEnabled(false); - } - } - }; + const pageMocking = pageMockingRef.current; - initializeMocking(); + pageMocking.initialize(pathname, config); return () => { mounted = false; - if (controller) { - controller.stop(); - } + pageMocking.cleanup(); }; }, [pathname]); return { - error, - isEnabled, - isReady, + 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) From 4f5c929da0dc12fd8b469e3b11c40c103c559d55 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:56:26 -0600 Subject: [PATCH 3/6] fix(clerk-js): Reorder code --- packages/clerk-js/sandbox/app.ts | 58 ++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 22c71ad27fc..2474ce4480c 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -3,21 +3,6 @@ import * as l from '../../localizations'; import type { Clerk as ClerkType } from '../'; import * as scenarios from './scenarios'; -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)); - } -} - interface ComponentPropsControl { setProps: (props: unknown) => void; getProps: () => any | null; @@ -28,6 +13,10 @@ interface ScenarioControls { 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', @@ -51,16 +40,16 @@ type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number]; const AVAILABLE_SCENARIOS = Object.keys(scenarios) as (keyof typeof scenarios)[]; type AvailableScenario = (typeof AVAILABLE_SCENARIOS)[number]; -const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox'; +function fillLocalizationSelect() { + const select = document.getElementById('localizationSelect') as HTMLSelectElement; -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); - } + for (const locale of AVAILABLE_LOCALES) { + if (locale === 'enUS') { + select.add(new Option(locale, locale, true, true)); + continue; + } - if (component === 'scenario' && AVAILABLE_SCENARIOS.includes(encodedProps as AvailableScenario)) { - localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`, encodedProps); + select.add(new Option(locale, locale)); } } @@ -154,11 +143,19 @@ declare global { interface Window { 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 { @@ -167,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; @@ -316,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(); @@ -329,6 +335,8 @@ void (async () => { } }); + const app = document.getElementById('app') as HTMLDivElement; + const routes = { '/': () => { mountIndex(app); From 0ff91cac21293fc7441cb968e966c9ef02a5c466 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:56:04 -0600 Subject: [PATCH 4/6] chore(repo): Add empty changeset --- .changeset/petite-clubs-grab.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/petite-clubs-grab.md 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 @@ +--- +--- From 7af7215a19831b8fcc2922898528637de1c5f109 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:04:49 -0600 Subject: [PATCH 5/6] chore(clerk-js): Add README for sandbox --- packages/clerk-js/sandbox/README.md | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/clerk-js/sandbox/README.md diff --git a/packages/clerk-js/sandbox/README.md b/packages/clerk-js/sandbox/README.md new file mode 100644 index 00000000000..ab4deb068d3 --- /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`. +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