From aed649d9c2d20834893d839d9b010c4543147eff Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 18:51:05 -0800 Subject: [PATCH 01/16] Add ProtectConfig resource and integration Introduces the ProtectConfig resource and related types to support protect configuration in the environment. Adds a Protect class that loads a protect script based on environment config, and integrates it into Clerk initialization and environment loading. --- packages/clerk-js/src/core/clerk.ts | 4 ++ packages/clerk-js/src/core/protect.ts | 40 ++++++++++++++++++ .../src/core/resources/Environment.ts | 6 ++- .../src/core/resources/ProtectConfig.ts | 41 +++++++++++++++++++ .../clerk-js/src/core/resources/internal.ts | 1 + packages/shared/src/types/environment.ts | 2 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/json.ts | 2 + packages/shared/src/types/protectConfig.ts | 22 ++++++++++ packages/shared/src/types/snapshots.ts | 3 ++ 10 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 packages/clerk-js/src/core/protect.ts create mode 100644 packages/clerk-js/src/core/resources/ProtectConfig.ts create mode 100644 packages/shared/src/types/protectConfig.ts diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b59a646623a..5247a7abec5 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -158,6 +158,7 @@ import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; import { Billing } from './modules/billing'; import { createCheckoutInstance } from './modules/checkout/instance'; +import { Protect } from './protect'; import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; import { getTaskEndpoint, navigateIfTaskExists, warnMissingPendingTaskHandlers } from './sessionTasks'; import { State } from './state'; @@ -227,6 +228,7 @@ export class Clerk implements ClerkInterface { #domain: DomainOrProxyUrl['domain']; #proxyUrl: DomainOrProxyUrl['proxyUrl']; #authService?: AuthCookieService; + #protect?: Protect; #captchaHeartbeat?: CaptchaHeartbeat; #broadcastChannel: BroadcastChannel | null = null; #componentControls?: ReturnType | null; @@ -429,6 +431,7 @@ export class Clerk implements ClerkInterface { // This line is used for the piggy-backing mechanism BaseResource.clerk = this; + this.#protect = new Protect(); } public getFapiClient = (): FapiClient => this.#fapiClient; @@ -516,6 +519,7 @@ export class Clerk implements ClerkInterface { ...(telemetryEnabled && this.telemetry ? { telemetryCollector: this.telemetry } : {}), }); } + this.#protect?.load(this.environment as Environment); debugLogger.info('load() complete', {}, 'clerk'); } catch (error) { this.#publicEventBus.emit(clerkEvents.Status, 'error'); diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts new file mode 100644 index 00000000000..78e3472e787 --- /dev/null +++ b/packages/clerk-js/src/core/protect.ts @@ -0,0 +1,40 @@ +import type { Environment } from './resources'; + +export class Protect { + #initialized: boolean = false; + + load(env: Environment): void { + const config = env?.protectConfig; + + if (!config?.loader) { + // no protect config available + return; + } else if (this.#initialized) { + // already initialized - do nothing + return; + } else if (typeof document === 'undefined') { + // no document: not running browser? + return; + } + + // here rather than at end to mark as initialized even if load fails. + this.#initialized = true; + + if (config.loader.type) { + const element = document.createElement(config.loader.type); + if (config.loader.attributes) { + Object.entries(config.loader.attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); + } + switch (config.loader.target) { + case 'head': + document.head.appendChild(element); + break; + case 'body': + document.body.appendChild(element); + break; + default: + break; + } + } + } +} diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index bb8322de994..f057787a747 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -6,12 +6,13 @@ import type { EnvironmentJSONSnapshot, EnvironmentResource, OrganizationSettingsResource, + ProtectConfigResource, UserSettingsResource, } from '@clerk/shared/types'; import { eventBus, events } from '../../core/events'; import { APIKeySettings } from './APIKeySettings'; -import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, UserSettings } from './internal'; +import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, ProtectConfig, UserSettings } from './internal'; import { OrganizationSettings } from './OrganizationSettings'; export class Environment extends BaseResource implements EnvironmentResource { @@ -26,6 +27,7 @@ export class Environment extends BaseResource implements EnvironmentResource { organizationSettings: OrganizationSettingsResource = new OrganizationSettings(); commerceSettings: CommerceSettingsResource = new CommerceSettings(); apiKeysSettings: APIKeySettings = new APIKeySettings(); + protectConfig: ProtectConfigResource = new ProtectConfig(); public static getInstance(): Environment { if (!Environment.instance) { @@ -54,6 +56,7 @@ export class Environment extends BaseResource implements EnvironmentResource { this.userSettings = new UserSettings(data.user_settings); this.commerceSettings = new CommerceSettings(data.commerce_settings); this.apiKeysSettings = new APIKeySettings(data.api_keys_settings); + this.protectConfig = new ProtectConfig(data.protect_config); return this; } @@ -95,6 +98,7 @@ export class Environment extends BaseResource implements EnvironmentResource { user_settings: this.userSettings.__internal_toSnapshot(), commerce_settings: this.commerceSettings.__internal_toSnapshot(), api_keys_settings: this.apiKeysSettings.__internal_toSnapshot(), + protect_config: this.protectConfig.__internal_toSnapshot(), }; } } diff --git a/packages/clerk-js/src/core/resources/ProtectConfig.ts b/packages/clerk-js/src/core/resources/ProtectConfig.ts new file mode 100644 index 00000000000..d51bb87d94a --- /dev/null +++ b/packages/clerk-js/src/core/resources/ProtectConfig.ts @@ -0,0 +1,41 @@ +import type { + ProtectConfigJSON, + ProtectConfigJSONSnapshot, + ProtectConfigResource, + ProtectLoader, +} from '@clerk/shared/types'; + +import { BaseResource } from './internal'; + +export class ProtectConfig extends BaseResource implements ProtectConfigResource { + id: string = ''; + enabled: boolean = false; + loader?: ProtectLoader; + + public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) { + super(); + + this.fromJSON(data); + } + + protected fromJSON(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null): this { + if (!data) { + return this; + } + + this.id = this.withDefault(data.id, this.id); + this.enabled = this.withDefault(data.enabled, this.enabled); + this.loader = this.withDefault(data.loader, this.loader); + + return this; + } + + public __internal_toSnapshot(): ProtectConfigJSONSnapshot { + return { + object: 'protect_config', + id: this.id, + enabled: this.enabled, + loader: this.loader, + }; + } +} diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 2c27131bbae..e9e1e1f5fa8 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -29,6 +29,7 @@ export * from './OrganizationSuggestion'; export * from './SamlAccount'; export * from './Session'; export * from './Passkey'; +export * from './ProtectConfig'; export * from './PublicUserData'; export * from './SessionWithActivities'; export * from './SignIn'; diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index fbe1725b412..5a81ea75d8f 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -3,6 +3,7 @@ import type { AuthConfigResource } from './authConfig'; import type { CommerceSettingsResource } from './commerceSettings'; import type { DisplayConfigResource } from './displayConfig'; import type { OrganizationSettingsResource } from './organizationSettings'; +import type { ProtectConfigResource } from './protectConfig'; import type { ClerkResource } from './resource'; import type { EnvironmentJSONSnapshot } from './snapshots'; import type { UserSettingsResource } from './userSettings'; @@ -14,6 +15,7 @@ export interface EnvironmentResource extends ClerkResource { displayConfig: DisplayConfigResource; commerceSettings: CommerceSettingsResource; apiKeysSettings: APIKeysSettingsResource; + protectConfig: ProtectConfigResource; isSingleSession: () => boolean; isProduction: () => boolean; isDevelopmentOrStaging: () => boolean; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 40f96733b9f..833538f382b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -46,6 +46,7 @@ export type * from './permission'; export type * from './phoneCodeChannel'; export type * from './phoneNumber'; export type * from './protect'; +export type * from './protectConfig'; export type * from './redirects'; export type * from './resource'; export type * from './role'; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 5bcaed20a7f..6b2070b8283 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -25,6 +25,7 @@ import type { OrganizationCustomRoleKey, OrganizationPermissionKey } from './org import type { OrganizationSettingsJSON } from './organizationSettings'; import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { PhoneCodeChannel } from './phoneCodeChannel'; +import type { ProtectConfigJSON } from './protectConfig'; import type { SamlIdpSlug } from './saml'; import type { SessionStatus, SessionTask } from './session'; import type { SessionVerificationLevel, SessionVerificationStatus } from './sessionVerification'; @@ -90,6 +91,7 @@ export interface EnvironmentJSON extends ClerkResourceJSON { maintenance_mode: boolean; organization_settings: OrganizationSettingsJSON; user_settings: UserSettingsJSON; + protect_config: ProtectConfigJSON; } export type LastAuthenticationStrategy = diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts new file mode 100644 index 00000000000..4cc78b563ca --- /dev/null +++ b/packages/shared/src/types/protectConfig.ts @@ -0,0 +1,22 @@ +import type { ClerkResource } from './resource'; +import type { ProtectConfigJSONSnapshot } from './snapshots'; + +export interface ProtectLoader { + target: 'head' | 'body'; + type: string; + attributes: Record; +} + +export interface ProtectConfigJSON { + object: 'protect_config'; + id: string; + enabled: boolean; + loader?: ProtectLoader; +} + +export interface ProtectConfigResource extends ClerkResource { + id: string; + enabled: boolean; + loader?: ProtectLoader; + __internal_toSnapshot: () => ProtectConfigJSONSnapshot; +} diff --git a/packages/shared/src/types/snapshots.ts b/packages/shared/src/types/snapshots.ts index 36524a5cf28..5f36eab401c 100644 --- a/packages/shared/src/types/snapshots.ts +++ b/packages/shared/src/types/snapshots.ts @@ -31,6 +31,7 @@ import type { Web3WalletJSON, } from './json'; import type { OrganizationSettingsJSON } from './organizationSettings'; +import type { ProtectConfigJSON } from './protectConfig'; import type { SignInJSON } from './signIn'; import type { UserSettingsJSON } from './userSettings'; import type { Nullable, Override } from './utils'; @@ -119,6 +120,8 @@ export type EnvironmentJSONSnapshot = EnvironmentJSON; export type DisplayConfigJSONSnapshot = DisplayConfigJSON; +export type ProtectConfigJSONSnapshot = ProtectConfigJSON; + export type EmailAddressJSONSnapshot = Override< EmailAddressJSON, { From f51eee9102510fcbf13e471f264f62cda955c37a Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 18:54:40 -0800 Subject: [PATCH 02/16] Remove 'enabled' property from ProtectConfig The 'enabled' property was removed from ProtectConfig interfaces and class. This simplifies the configuration and avoids redundant checks, relying on the presence of the loader to determine enablement. --- packages/clerk-js/src/core/protect.ts | 2 +- packages/clerk-js/src/core/resources/ProtectConfig.ts | 3 --- packages/shared/src/types/protectConfig.ts | 2 -- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 78e3472e787..c60b1e5ae02 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -7,7 +7,7 @@ export class Protect { const config = env?.protectConfig; if (!config?.loader) { - // no protect config available + // not enabled or no protect config available return; } else if (this.#initialized) { // already initialized - do nothing diff --git a/packages/clerk-js/src/core/resources/ProtectConfig.ts b/packages/clerk-js/src/core/resources/ProtectConfig.ts index d51bb87d94a..0b577094c4e 100644 --- a/packages/clerk-js/src/core/resources/ProtectConfig.ts +++ b/packages/clerk-js/src/core/resources/ProtectConfig.ts @@ -9,7 +9,6 @@ import { BaseResource } from './internal'; export class ProtectConfig extends BaseResource implements ProtectConfigResource { id: string = ''; - enabled: boolean = false; loader?: ProtectLoader; public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) { @@ -24,7 +23,6 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource } this.id = this.withDefault(data.id, this.id); - this.enabled = this.withDefault(data.enabled, this.enabled); this.loader = this.withDefault(data.loader, this.loader); return this; @@ -34,7 +32,6 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource return { object: 'protect_config', id: this.id, - enabled: this.enabled, loader: this.loader, }; } diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts index 4cc78b563ca..339dfceaf36 100644 --- a/packages/shared/src/types/protectConfig.ts +++ b/packages/shared/src/types/protectConfig.ts @@ -10,13 +10,11 @@ export interface ProtectLoader { export interface ProtectConfigJSON { object: 'protect_config'; id: string; - enabled: boolean; loader?: ProtectLoader; } export interface ProtectConfigResource extends ClerkResource { id: string; - enabled: boolean; loader?: ProtectLoader; __internal_toSnapshot: () => ProtectConfigJSONSnapshot; } From 7c28e7ff5242f98554f9bc1251f666c8a14d8e5a Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 19:08:25 -0800 Subject: [PATCH 03/16] Add integration tests for protect service loader Introduces tests to verify script creation based on protect_config.loader settings. Ensures the loader script is added when configured and absent otherwise. --- integration/tests/protect-service.test.ts | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 integration/tests/protect-service.test.ts diff --git a/integration/tests/protect-service.test.ts b/integration/tests/protect-service.test.ts new file mode 100644 index 00000000000..b674afc8505 --- /dev/null +++ b/integration/tests/protect-service.test.ts @@ -0,0 +1,50 @@ +import type { ProtectConfigJSON } from '@clerk/shared/types'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +const mockProtectSettings = async (page: Page, config: ProtectConfigJSON) => { + await page.route('*/**/v1/environment*', async route => { + const response = await route.fetch(); + const json = await response.json(); + const newJson = { + ...json, + protect_config: config, + }; + await route.fulfill({ response, json: newJson }); + }); +}; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Hello World Div @xgeneric', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('should create script when protect_config.loader is set', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await mockProtectSettings(page, { + object: 'protect_config', + id: 'n', + loader: { + type: 'script', + target: 'body', + attributes: { id: 'test-protect-loader', type: 'module', src: 'data:application/json;base64,Cgo=' }, + }, + }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await expect(page.locator('#test-protect-loader')).toHaveAttribute('type', 'module'); + }); + + test('should not create Hello World div when protect_config.loader is not set', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await mockProtectSettings(page, undefined); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + expect(page.locator('#test-protect-loader')).toBeUndefined(); + }); +}); From b31d373e56fc62f5b825f211cf5a23288f381bf8 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 19:11:41 -0800 Subject: [PATCH 04/16] Validate loader attribute types before setting Adds type checking for loader attributes in Protect class to ensure only string, number, or boolean values are set as element attributes. Prevents illegal attribute values from being assigned. --- packages/clerk-js/src/core/protect.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index c60b1e5ae02..ccde5743877 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -23,7 +23,18 @@ export class Protect { if (config.loader.type) { const element = document.createElement(config.loader.type); if (config.loader.attributes) { - Object.entries(config.loader.attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); + Object.entries(config.loader.attributes).forEach(([key, value]) => { + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + element.setAttribute(key, String(value)); + break; + default: + // illegal to set. + break; + } + }); } switch (config.loader.target) { case 'head': From 7c319e8208579eb363122780d6cc637a3b5f7fef Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 19:14:48 -0800 Subject: [PATCH 05/16] Update test name and mockProtectSettings signature Renamed the test from 'Hello World Div @xgeneric' to 'Clerk Protect checks' for clarity. Updated mockProtectSettings to accept an optional config parameter. --- integration/tests/protect-service.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/tests/protect-service.test.ts b/integration/tests/protect-service.test.ts index b674afc8505..a9eea5588e2 100644 --- a/integration/tests/protect-service.test.ts +++ b/integration/tests/protect-service.test.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test'; import { appConfigs } from '../presets'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; -const mockProtectSettings = async (page: Page, config: ProtectConfigJSON) => { +const mockProtectSettings = async (page: Page, config?: ProtectConfigJSON) => { await page.route('*/**/v1/environment*', async route => { const response = await route.fetch(); const json = await response.json(); @@ -17,14 +17,14 @@ const mockProtectSettings = async (page: Page, config: ProtectConfigJSON) => { }); }; -testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Hello World Div @xgeneric', ({ app }) => { +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Protect checks', ({ app }) => { test.describe.configure({ mode: 'parallel' }); test.afterAll(async () => { await app.teardown(); }); - test('should create script when protect_config.loader is set', async ({ page, context }) => { + test('should add loader script when protect_config.loader is set', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await mockProtectSettings(page, { object: 'protect_config', @@ -40,7 +40,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Hello Wor await expect(page.locator('#test-protect-loader')).toHaveAttribute('type', 'module'); }); - test('should not create Hello World div when protect_config.loader is not set', async ({ page, context }) => { + test('should not create loader element when protect_config.loader is not set', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await mockProtectSettings(page, undefined); await u.page.goToAppHome(); From 9a2c1c6d80e0b40f4d30a7f2a3eda82f43ecf8fb Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 14 Nov 2025 19:25:08 -0800 Subject: [PATCH 06/16] chore: clean up test --- integration/tests/protect-service.test.ts | 11 +++++++---- packages/clerk-js/bundlewatch.config.json | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/integration/tests/protect-service.test.ts b/integration/tests/protect-service.test.ts index a9eea5588e2..7570f6ebc7e 100644 --- a/integration/tests/protect-service.test.ts +++ b/integration/tests/protect-service.test.ts @@ -11,13 +11,13 @@ const mockProtectSettings = async (page: Page, config?: ProtectConfigJSON) => { const json = await response.json(); const newJson = { ...json, - protect_config: config, + ...(config ? { protect_config: config } : {}), }; await route.fulfill({ response, json: newJson }); }); }; -testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Protect checks', ({ app }) => { +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Protect checks @generic', ({ app }) => { test.describe.configure({ mode: 'parallel' }); test.afterAll(async () => { @@ -37,14 +37,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Pro }); await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); + await expect(page.locator('#test-protect-loader')).toHaveAttribute('type', 'module'); }); test('should not create loader element when protect_config.loader is not set', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await mockProtectSettings(page, undefined); + await mockProtectSettings(page); await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); - expect(page.locator('#test-protect-loader')).toBeUndefined(); + + // Playwright locators are always objects, never undefined + await expect(page.locator('#test-protect-loader')).toHaveCount(0); }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c61b05e0018..ced45dc5008 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.channel.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "63.2KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "65KB" }, { "path": "./dist/ui-common*.js", "maxSize": "117.1KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120KB" }, { "path": "./dist/vendors*.js", "maxSize": "47KB" }, From 1cc396377419a2bf7ee8f5d3778741c82b208628 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Fri, 14 Nov 2025 19:29:15 -0800 Subject: [PATCH 07/16] chore: add changeset --- .changeset/beige-sloths-provide.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/beige-sloths-provide.md diff --git a/.changeset/beige-sloths-provide.md b/.changeset/beige-sloths-provide.md new file mode 100644 index 00000000000..77f6bbb79a2 --- /dev/null +++ b/.changeset/beige-sloths-provide.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": minor +"@clerk/shared": minor +--- + +Introduce "feature name" make it more descriptive From aee6e573c75ba4d2e344e2e231417c8ee1eeb835 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 19:39:28 -0800 Subject: [PATCH 08/16] Update changeset with ProtectConfig and loader details Revised the changeset description to specify the introduction of the ProtectConfig resource and Protect loader integration for improved clarity. --- .changeset/beige-sloths-provide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/beige-sloths-provide.md b/.changeset/beige-sloths-provide.md index 77f6bbb79a2..e36f9a48bf5 100644 --- a/.changeset/beige-sloths-provide.md +++ b/.changeset/beige-sloths-provide.md @@ -3,4 +3,4 @@ "@clerk/shared": minor --- -Introduce "feature name" make it more descriptive +Introduce ProtectConfig resource and Protect loader integration. From a4579397d8bfcb645b43cfe8f7070b9a631108d9 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Fri, 14 Nov 2025 19:52:31 -0800 Subject: [PATCH 09/16] Use inBrowser utility for environment check Replaces direct document type check with the inBrowser utility to determine browser environment in Protect class initialization. --- packages/clerk-js/src/core/protect.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index ccde5743877..8f3cf39019d 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -1,5 +1,6 @@ -import type { Environment } from './resources'; +import { inBrowser } from '@clerk/shared/browser'; +import type { Environment } from './resources'; export class Protect { #initialized: boolean = false; @@ -12,7 +13,7 @@ export class Protect { } else if (this.#initialized) { // already initialized - do nothing return; - } else if (typeof document === 'undefined') { + } else if (!inBrowser()) { // no document: not running browser? return; } From f1599cbf27e2247167830b7e72116a9a25f2effe Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Sat, 15 Nov 2025 14:26:44 -0800 Subject: [PATCH 10/16] Add rollout percentage to Protect loader config Introduces a 'rollout' property to ProtectConfig, enabling percentage-based rollouts for loader initialization. Adds validation and debug logging for rollout values, loader types, attributes, and targets to improve error handling and observability. --- packages/clerk-js/src/core/protect.ts | 69 ++++++++++++------- .../src/core/resources/ProtectConfig.ts | 2 + packages/shared/src/types/protectConfig.ts | 2 + 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 8f3cf39019d..1d8eb758572 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -1,5 +1,7 @@ import { inBrowser } from '@clerk/shared/browser'; +import { debugLogger } from '@/utils/debug'; + import type { Environment } from './resources'; export class Protect { #initialized: boolean = false; @@ -21,32 +23,51 @@ export class Protect { // here rather than at end to mark as initialized even if load fails. this.#initialized = true; - if (config.loader.type) { - const element = document.createElement(config.loader.type); - if (config.loader.attributes) { - Object.entries(config.loader.attributes).forEach(([key, value]) => { - switch (typeof value) { - case 'string': - case 'number': - case 'boolean': - element.setAttribute(key, String(value)); - break; - default: - // illegal to set. - break; - } - }); + // we use rollout for percentage based rollouts (as the environment file is cached) + if (config.rollout) { + if (typeof config.rollout !== 'number' || config.rollout < 0 || config.rollout > 1) { + // invalid rollout percentage - do nothing + debugLogger.warn(`[protect] loader rollout value is invalid`, { rollout: config.rollout }); + return; } - switch (config.loader.target) { - case 'head': - document.head.appendChild(element); - break; - case 'body': - document.body.appendChild(element); - break; - default: - break; + if (Math.random() > config.rollout) { + // not in rollout percentage - do nothing + return; } } + + if (!config.loader.type) { + debugLogger.warn(`[protect] loader type is missing`); + return; + } + + const element = document.createElement(config.loader.type); + + if (config.loader.attributes) { + Object.entries(config.loader.attributes).forEach(([key, value]) => { + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + element.setAttribute(key, String(value)); + break; + default: + // illegal to set. + debugLogger.warn(`[protect] loader attribute is invalid type`, { key, value }); + break; + } + }); + } + switch (config.loader.target) { + case 'head': + document.head.appendChild(element); + break; + case 'body': + document.body.appendChild(element); + break; + default: + debugLogger.warn(`[protect] loader target is invalid`, { target: config.loader.target }); + break; + } } } diff --git a/packages/clerk-js/src/core/resources/ProtectConfig.ts b/packages/clerk-js/src/core/resources/ProtectConfig.ts index 0b577094c4e..5d7061594bd 100644 --- a/packages/clerk-js/src/core/resources/ProtectConfig.ts +++ b/packages/clerk-js/src/core/resources/ProtectConfig.ts @@ -10,6 +10,7 @@ import { BaseResource } from './internal'; export class ProtectConfig extends BaseResource implements ProtectConfigResource { id: string = ''; loader?: ProtectLoader; + rollout?: number; public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) { super(); @@ -23,6 +24,7 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource } this.id = this.withDefault(data.id, this.id); + this.rollout = this.withDefault(data.rollout, this.rollout); this.loader = this.withDefault(data.loader, this.loader); return this; diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts index 339dfceaf36..548e2e02040 100644 --- a/packages/shared/src/types/protectConfig.ts +++ b/packages/shared/src/types/protectConfig.ts @@ -10,11 +10,13 @@ export interface ProtectLoader { export interface ProtectConfigJSON { object: 'protect_config'; id: string; + rollout?: number; loader?: ProtectLoader; } export interface ProtectConfigResource extends ClerkResource { id: string; loader?: ProtectLoader; + rollout?: number; __internal_toSnapshot: () => ProtectConfigJSONSnapshot; } From 57668395b0d0ede84957c508d2323adc90722123 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Sat, 15 Nov 2025 14:46:19 -0800 Subject: [PATCH 11/16] Replace debugLogger with logger in protect.ts Swapped out usage of debugLogger for logger from @clerk/shared/logger in protect.ts. Updated warning calls to use logger.warnOnce for improved logging consistency. --- packages/clerk-js/src/core/protect.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 1d8eb758572..a49f19d8a56 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -1,6 +1,5 @@ import { inBrowser } from '@clerk/shared/browser'; - -import { debugLogger } from '@/utils/debug'; +import { logger } from '@clerk/shared/logger'; import type { Environment } from './resources'; export class Protect { @@ -27,7 +26,7 @@ export class Protect { if (config.rollout) { if (typeof config.rollout !== 'number' || config.rollout < 0 || config.rollout > 1) { // invalid rollout percentage - do nothing - debugLogger.warn(`[protect] loader rollout value is invalid`, { rollout: config.rollout }); + logger.warnOnce(`[protect] loader rollout value is invalid: ${config.rollout}`); return; } if (Math.random() > config.rollout) { @@ -37,7 +36,7 @@ export class Protect { } if (!config.loader.type) { - debugLogger.warn(`[protect] loader type is missing`); + logger.warnOnce(`[protect] loader type is missing`); return; } @@ -53,7 +52,7 @@ export class Protect { break; default: // illegal to set. - debugLogger.warn(`[protect] loader attribute is invalid type`, { key, value }); + logger.warnOnce(`[protect] loader attribute is invalid type: ${key}=${value}`); break; } }); @@ -66,7 +65,7 @@ export class Protect { document.body.appendChild(element); break; default: - debugLogger.warn(`[protect] loader target is invalid`, { target: config.loader.target }); + logger.warnOnce(`[protect] loader target is invalid: ${config.loader.target}`); break; } } From 07704a1984e30ecdb412ca8fac46e6c2b4c1b78b Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Sat, 15 Nov 2025 15:41:29 -0800 Subject: [PATCH 12/16] Refactor Protect loader to support multiple loaders Updated ProtectConfig and related types to use an array of loaders instead of a single loader. Refactored Protect class logic to apply each loader individually, supporting rollout percentages, custom targets (including element IDs), and text content. This change enables more flexible and extensible protect configuration. --- packages/clerk-js/src/core/protect.ts | 52 ++++++++++++++----- .../src/core/resources/ProtectConfig.ts | 7 ++- packages/shared/src/types/protectConfig.ts | 12 ++--- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index a49f19d8a56..0cb48348fa4 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -1,5 +1,6 @@ import { inBrowser } from '@clerk/shared/browser'; import { logger } from '@clerk/shared/logger'; +import type { ProtectLoader } from '@clerk/shared/types'; import type { Environment } from './resources'; export class Protect { @@ -8,7 +9,7 @@ export class Protect { load(env: Environment): void { const config = env?.protectConfig; - if (!config?.loader) { + if (!config?.loaders || !Array.isArray(config.loaders) || config.loaders.length === 0) { // not enabled or no protect config available return; } else if (this.#initialized) { @@ -22,28 +23,37 @@ export class Protect { // here rather than at end to mark as initialized even if load fails. this.#initialized = true; + for (const loader of config.loaders) { + this.applyLoader(loader); + } + } + + // apply individual loader + applyLoader(loader: ProtectLoader) { // we use rollout for percentage based rollouts (as the environment file is cached) - if (config.rollout) { - if (typeof config.rollout !== 'number' || config.rollout < 0 || config.rollout > 1) { + if (loader.rollout) { + if (typeof loader.rollout !== 'number' || loader.rollout < 0 || loader.rollout > 1) { // invalid rollout percentage - do nothing - logger.warnOnce(`[protect] loader rollout value is invalid: ${config.rollout}`); + logger.warnOnce(`[protect] loader rollout value is invalid: ${loader.rollout}`); return; } - if (Math.random() > config.rollout) { + if (Math.random() > loader.rollout) { // not in rollout percentage - do nothing return; } } - if (!config.loader.type) { - logger.warnOnce(`[protect] loader type is missing`); - return; + if (!loader.type) { + loader.type = 'script'; + } + if (!loader.target) { + loader.target = 'body'; } - const element = document.createElement(config.loader.type); + const element = document.createElement(loader.type); - if (config.loader.attributes) { - Object.entries(config.loader.attributes).forEach(([key, value]) => { + if (loader.attributes) { + for (const [key, value] of Object.entries(loader.attributes)) { switch (typeof value) { case 'string': case 'number': @@ -55,9 +65,14 @@ export class Protect { logger.warnOnce(`[protect] loader attribute is invalid type: ${key}=${value}`); break; } - }); + } + } + + if (loader.textContent && typeof loader.textContent === 'string') { + element.textContent = loader.textContent; } - switch (config.loader.target) { + + switch (loader.target) { case 'head': document.head.appendChild(element); break; @@ -65,7 +80,16 @@ export class Protect { document.body.appendChild(element); break; default: - logger.warnOnce(`[protect] loader target is invalid: ${config.loader.target}`); + if (loader.target?.startsWith('#')) { + const targetElement = document.getElementById(loader.target.substring(1)); + if (!targetElement) { + logger.warnOnce(`[protect] loader target element not found: ${loader.target}`); + return; + } + targetElement.appendChild(element); + return; + } + logger.warnOnce(`[protect] loader target is invalid: ${loader.target}`); break; } } diff --git a/packages/clerk-js/src/core/resources/ProtectConfig.ts b/packages/clerk-js/src/core/resources/ProtectConfig.ts index 5d7061594bd..93e46083c9e 100644 --- a/packages/clerk-js/src/core/resources/ProtectConfig.ts +++ b/packages/clerk-js/src/core/resources/ProtectConfig.ts @@ -9,7 +9,7 @@ import { BaseResource } from './internal'; export class ProtectConfig extends BaseResource implements ProtectConfigResource { id: string = ''; - loader?: ProtectLoader; + loaders?: ProtectLoader[]; rollout?: number; public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) { @@ -24,8 +24,7 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource } this.id = this.withDefault(data.id, this.id); - this.rollout = this.withDefault(data.rollout, this.rollout); - this.loader = this.withDefault(data.loader, this.loader); + this.loaders = this.withDefault(data.loaders, this.loaders); return this; } @@ -34,7 +33,7 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource return { object: 'protect_config', id: this.id, - loader: this.loader, + loaders: this.loaders, }; } } diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts index 548e2e02040..515546aa64d 100644 --- a/packages/shared/src/types/protectConfig.ts +++ b/packages/shared/src/types/protectConfig.ts @@ -2,21 +2,21 @@ import type { ClerkResource } from './resource'; import type { ProtectConfigJSONSnapshot } from './snapshots'; export interface ProtectLoader { - target: 'head' | 'body'; + rollout?: number; + target: 'head' | 'body' | `#${string}`; type: string; - attributes: Record; + attributes?: Record; + textContent?: string; } export interface ProtectConfigJSON { object: 'protect_config'; id: string; - rollout?: number; - loader?: ProtectLoader; + loaders?: ProtectLoader[]; } export interface ProtectConfigResource extends ClerkResource { id: string; - loader?: ProtectLoader; - rollout?: number; + loaders?: ProtectLoader[]; __internal_toSnapshot: () => ProtectConfigJSONSnapshot; } From 52c7174479fb438e51688b4a684fbe4d014fa53c Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Sat, 15 Nov 2025 15:42:55 -0800 Subject: [PATCH 13/16] Add test for loader script with rollout 0 in protect config Introduces a test to verify that the loader script is not added when the protect_config loader's rollout is set to 0. Ensures correct behavior for conditional loader injection based on rollout value. --- integration/tests/protect-service.test.ts | 33 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/integration/tests/protect-service.test.ts b/integration/tests/protect-service.test.ts index 7570f6ebc7e..d70084e9cb3 100644 --- a/integration/tests/protect-service.test.ts +++ b/integration/tests/protect-service.test.ts @@ -29,11 +29,14 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Pro await mockProtectSettings(page, { object: 'protect_config', id: 'n', - loader: { - type: 'script', - target: 'body', - attributes: { id: 'test-protect-loader', type: 'module', src: 'data:application/json;base64,Cgo=' }, - }, + loaders: [ + { + rollout: 1.0, + type: 'script', + target: 'body', + attributes: { id: 'test-protect-loader', type: 'module', src: 'data:application/json;base64,Cgo=' }, + }, + ], }); await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); @@ -41,6 +44,26 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Pro await expect(page.locator('#test-protect-loader')).toHaveAttribute('type', 'module'); }); + test('should not add loader script when protect_config.loader is set and rollout 0.00', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await mockProtectSettings(page, { + object: 'protect_config', + id: 'n', + loaders: [ + { + rollout: 0, + type: 'script', + target: 'body', + attributes: { id: 'test-protect-loader', type: 'module', src: 'data:application/json;base64,Cgo=' }, + }, + ], + }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await expect(page.locator('#test-protect-loader')).toHaveCount(0); + }); + test('should not create loader element when protect_config.loader is not set', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await mockProtectSettings(page); From 9ca60cbb3defd6e93aab9491210751aa2ed73453 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Sun, 16 Nov 2025 19:35:32 -0800 Subject: [PATCH 14/16] Handle errors when applying loaders in Protect Wraps loader application in a try-catch block and logs a warning if a loader fails to apply, improving robustness during initialization by continuing to load other loaders if present. --- packages/clerk-js/src/core/protect.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 0cb48348fa4..4a7ad2a6110 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -24,7 +24,11 @@ export class Protect { this.#initialized = true; for (const loader of config.loaders) { - this.applyLoader(loader); + try { + this.applyLoader(loader); + } catch (error) { + logger.warnOnce(`[protect] failed to apply loader: ${error}`); + } } } From 1d5e411ba1d7a20f8f5acbcf25bbb71c9bf5c1d0 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Sun, 16 Nov 2025 20:13:25 -0800 Subject: [PATCH 15/16] Refactor loader type and target handling in Protect Replaces direct mutation of loader.type and loader.target with local variables, improving code clarity and preventing side effects. Updates element creation and target selection logic to use these local variables. --- packages/clerk-js/src/core/protect.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 4a7ad2a6110..280511f1eac 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -47,14 +47,10 @@ export class Protect { } } - if (!loader.type) { - loader.type = 'script'; - } - if (!loader.target) { - loader.target = 'body'; - } + const type = loader.type || 'script'; + const target = loader.target || 'body'; - const element = document.createElement(loader.type); + const element = document.createElement(type); if (loader.attributes) { for (const [key, value] of Object.entries(loader.attributes)) { @@ -76,7 +72,7 @@ export class Protect { element.textContent = loader.textContent; } - switch (loader.target) { + switch (target) { case 'head': document.head.appendChild(element); break; @@ -84,16 +80,16 @@ export class Protect { document.body.appendChild(element); break; default: - if (loader.target?.startsWith('#')) { - const targetElement = document.getElementById(loader.target.substring(1)); + if (target?.startsWith('#')) { + const targetElement = document.getElementById(target.substring(1)); if (!targetElement) { - logger.warnOnce(`[protect] loader target element not found: ${loader.target}`); + logger.warnOnce(`[protect] loader target element not found: ${target}`); return; } targetElement.appendChild(element); return; } - logger.warnOnce(`[protect] loader target is invalid: ${loader.target}`); + logger.warnOnce(`[protect] loader target is invalid: ${target}`); break; } } From 4dce93206a3efdc0c44995623f05723467012aa8 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Mon, 17 Nov 2025 00:31:52 -0800 Subject: [PATCH 16/16] Refine loader rollout logic and update test IDs Updated loader script element IDs in integration tests for clarity and uniqueness. Improved rollout logic in Protect class to handle 0% rollout and invalid values more robustly, ensuring loader is not applied when rollout is zero or invalid. --- integration/tests/protect-service.test.ts | 10 +++++----- packages/clerk-js/src/core/protect.ts | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/integration/tests/protect-service.test.ts b/integration/tests/protect-service.test.ts index d70084e9cb3..f42530bb679 100644 --- a/integration/tests/protect-service.test.ts +++ b/integration/tests/protect-service.test.ts @@ -34,14 +34,14 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Pro rollout: 1.0, type: 'script', target: 'body', - attributes: { id: 'test-protect-loader', type: 'module', src: 'data:application/json;base64,Cgo=' }, + attributes: { id: 'test-protect-loader-1', type: 'module', src: 'data:application/json;base64,Cgo=' }, }, ], }); await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); - await expect(page.locator('#test-protect-loader')).toHaveAttribute('type', 'module'); + await expect(page.locator('#test-protect-loader-1')).toHaveAttribute('type', 'module'); }); test('should not add loader script when protect_config.loader is set and rollout 0.00', async ({ page, context }) => { @@ -51,17 +51,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Pro id: 'n', loaders: [ { - rollout: 0, + rollout: 0, // force 0% rollout, should not materialize type: 'script', target: 'body', - attributes: { id: 'test-protect-loader', type: 'module', src: 'data:application/json;base64,Cgo=' }, + attributes: { id: 'test-protect-loader-2', type: 'module', src: 'data:application/json;base64,Cgo=' }, }, ], }); await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); - await expect(page.locator('#test-protect-loader')).toHaveCount(0); + await expect(page.locator('#test-protect-loader-2')).toHaveCount(0); }); test('should not create loader element when protect_config.loader is not set', async ({ page, context }) => { diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts index 280511f1eac..569ec4e7969 100644 --- a/packages/clerk-js/src/core/protect.ts +++ b/packages/clerk-js/src/core/protect.ts @@ -35,13 +35,14 @@ export class Protect { // apply individual loader applyLoader(loader: ProtectLoader) { // we use rollout for percentage based rollouts (as the environment file is cached) - if (loader.rollout) { - if (typeof loader.rollout !== 'number' || loader.rollout < 0 || loader.rollout > 1) { + if (loader.rollout !== undefined) { + const rollout = loader.rollout; + if (typeof rollout !== 'number' || rollout < 0) { // invalid rollout percentage - do nothing - logger.warnOnce(`[protect] loader rollout value is invalid: ${loader.rollout}`); + logger.warnOnce(`[protect] loader rollout value is invalid: ${rollout}`); return; } - if (Math.random() > loader.rollout) { + if (rollout === 0 || Math.random() > rollout) { // not in rollout percentage - do nothing return; }