Skip to content

Commit 7be8f45

Browse files
feat(clerk-js): Add initial protect integration (#7227)
Co-authored-by: wobsoriano <sorianorobertc@gmail.com>
1 parent fca04f0 commit 7be8f45

File tree

13 files changed

+259
-2
lines changed

13 files changed

+259
-2
lines changed

.changeset/beige-sloths-provide.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
"@clerk/shared": minor
4+
---
5+
6+
Introduce ProtectConfig resource and Protect loader integration.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { ProtectConfigJSON } from '@clerk/shared/types';
2+
import type { Page } from '@playwright/test';
3+
import { expect, test } from '@playwright/test';
4+
5+
import { appConfigs } from '../presets';
6+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
7+
8+
const mockProtectSettings = async (page: Page, config?: ProtectConfigJSON) => {
9+
await page.route('*/**/v1/environment*', async route => {
10+
const response = await route.fetch();
11+
const json = await response.json();
12+
const newJson = {
13+
...json,
14+
...(config ? { protect_config: config } : {}),
15+
};
16+
await route.fulfill({ response, json: newJson });
17+
});
18+
};
19+
20+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Protect checks @generic', ({ app }) => {
21+
test.describe.configure({ mode: 'parallel' });
22+
23+
test.afterAll(async () => {
24+
await app.teardown();
25+
});
26+
27+
test('should add loader script when protect_config.loader is set', async ({ page, context }) => {
28+
const u = createTestUtils({ app, page, context });
29+
await mockProtectSettings(page, {
30+
object: 'protect_config',
31+
id: 'n',
32+
loaders: [
33+
{
34+
rollout: 1.0,
35+
type: 'script',
36+
target: 'body',
37+
attributes: { id: 'test-protect-loader-1', type: 'module', src: 'data:application/json;base64,Cgo=' },
38+
},
39+
],
40+
});
41+
await u.page.goToAppHome();
42+
await u.page.waitForClerkJsLoaded();
43+
44+
await expect(page.locator('#test-protect-loader-1')).toHaveAttribute('type', 'module');
45+
});
46+
47+
test('should not add loader script when protect_config.loader is set and rollout 0.00', async ({ page, context }) => {
48+
const u = createTestUtils({ app, page, context });
49+
await mockProtectSettings(page, {
50+
object: 'protect_config',
51+
id: 'n',
52+
loaders: [
53+
{
54+
rollout: 0, // force 0% rollout, should not materialize
55+
type: 'script',
56+
target: 'body',
57+
attributes: { id: 'test-protect-loader-2', type: 'module', src: 'data:application/json;base64,Cgo=' },
58+
},
59+
],
60+
});
61+
await u.page.goToAppHome();
62+
await u.page.waitForClerkJsLoaded();
63+
64+
await expect(page.locator('#test-protect-loader-2')).toHaveCount(0);
65+
});
66+
67+
test('should not create loader element when protect_config.loader is not set', async ({ page, context }) => {
68+
const u = createTestUtils({ app, page, context });
69+
await mockProtectSettings(page);
70+
await u.page.goToAppHome();
71+
await u.page.waitForClerkJsLoaded();
72+
73+
// Playwright locators are always objects, never undefined
74+
await expect(page.locator('#test-protect-loader')).toHaveCount(0);
75+
});
76+
});

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{ "path": "./dist/clerk.browser.js", "maxSize": "81KB" },
55
{ "path": "./dist/clerk.channel.browser.js", "maxSize": "81KB" },
66
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" },
7-
{ "path": "./dist/clerk.headless*.js", "maxSize": "63.2KB" },
7+
{ "path": "./dist/clerk.headless*.js", "maxSize": "65KB" },
88
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
99
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120.1KB" },
1010
{ "path": "./dist/vendors*.js", "maxSize": "47KB" },

packages/clerk-js/src/core/clerk.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ import { createClientFromJwt } from './jwt-client';
158158
import { APIKeys } from './modules/apiKeys';
159159
import { Billing } from './modules/billing';
160160
import { createCheckoutInstance } from './modules/checkout/instance';
161+
import { Protect } from './protect';
161162
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
162163
import { getTaskEndpoint, navigateIfTaskExists, warnMissingPendingTaskHandlers } from './sessionTasks';
163164
import { State } from './state';
@@ -227,6 +228,7 @@ export class Clerk implements ClerkInterface {
227228
#domain: DomainOrProxyUrl['domain'];
228229
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
229230
#authService?: AuthCookieService;
231+
#protect?: Protect;
230232
#captchaHeartbeat?: CaptchaHeartbeat;
231233
#broadcastChannel: BroadcastChannel | null = null;
232234
#componentControls?: ReturnType<MountComponentRenderer> | null;
@@ -429,6 +431,7 @@ export class Clerk implements ClerkInterface {
429431

430432
// This line is used for the piggy-backing mechanism
431433
BaseResource.clerk = this;
434+
this.#protect = new Protect();
432435
}
433436

434437
public getFapiClient = (): FapiClient => this.#fapiClient;
@@ -516,6 +519,7 @@ export class Clerk implements ClerkInterface {
516519
...(telemetryEnabled && this.telemetry ? { telemetryCollector: this.telemetry } : {}),
517520
});
518521
}
522+
this.#protect?.load(this.environment as Environment);
519523
debugLogger.info('load() complete', {}, 'clerk');
520524
} catch (error) {
521525
this.#publicEventBus.emit(clerkEvents.Status, 'error');
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { inBrowser } from '@clerk/shared/browser';
2+
import { logger } from '@clerk/shared/logger';
3+
import type { ProtectLoader } from '@clerk/shared/types';
4+
5+
import type { Environment } from './resources';
6+
export class Protect {
7+
#initialized: boolean = false;
8+
9+
load(env: Environment): void {
10+
const config = env?.protectConfig;
11+
12+
if (!config?.loaders || !Array.isArray(config.loaders) || config.loaders.length === 0) {
13+
// not enabled or no protect config available
14+
return;
15+
} else if (this.#initialized) {
16+
// already initialized - do nothing
17+
return;
18+
} else if (!inBrowser()) {
19+
// no document: not running browser?
20+
return;
21+
}
22+
23+
// here rather than at end to mark as initialized even if load fails.
24+
this.#initialized = true;
25+
26+
for (const loader of config.loaders) {
27+
try {
28+
this.applyLoader(loader);
29+
} catch (error) {
30+
logger.warnOnce(`[protect] failed to apply loader: ${error}`);
31+
}
32+
}
33+
}
34+
35+
// apply individual loader
36+
applyLoader(loader: ProtectLoader) {
37+
// we use rollout for percentage based rollouts (as the environment file is cached)
38+
if (loader.rollout !== undefined) {
39+
const rollout = loader.rollout;
40+
if (typeof rollout !== 'number' || rollout < 0) {
41+
// invalid rollout percentage - do nothing
42+
logger.warnOnce(`[protect] loader rollout value is invalid: ${rollout}`);
43+
return;
44+
}
45+
if (rollout === 0 || Math.random() > rollout) {
46+
// not in rollout percentage - do nothing
47+
return;
48+
}
49+
}
50+
51+
const type = loader.type || 'script';
52+
const target = loader.target || 'body';
53+
54+
const element = document.createElement(type);
55+
56+
if (loader.attributes) {
57+
for (const [key, value] of Object.entries(loader.attributes)) {
58+
switch (typeof value) {
59+
case 'string':
60+
case 'number':
61+
case 'boolean':
62+
element.setAttribute(key, String(value));
63+
break;
64+
default:
65+
// illegal to set.
66+
logger.warnOnce(`[protect] loader attribute is invalid type: ${key}=${value}`);
67+
break;
68+
}
69+
}
70+
}
71+
72+
if (loader.textContent && typeof loader.textContent === 'string') {
73+
element.textContent = loader.textContent;
74+
}
75+
76+
switch (target) {
77+
case 'head':
78+
document.head.appendChild(element);
79+
break;
80+
case 'body':
81+
document.body.appendChild(element);
82+
break;
83+
default:
84+
if (target?.startsWith('#')) {
85+
const targetElement = document.getElementById(target.substring(1));
86+
if (!targetElement) {
87+
logger.warnOnce(`[protect] loader target element not found: ${target}`);
88+
return;
89+
}
90+
targetElement.appendChild(element);
91+
return;
92+
}
93+
logger.warnOnce(`[protect] loader target is invalid: ${target}`);
94+
break;
95+
}
96+
}
97+
}

packages/clerk-js/src/core/resources/Environment.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import type {
66
EnvironmentJSONSnapshot,
77
EnvironmentResource,
88
OrganizationSettingsResource,
9+
ProtectConfigResource,
910
UserSettingsResource,
1011
} from '@clerk/shared/types';
1112

1213
import { eventBus, events } from '../../core/events';
1314
import { APIKeySettings } from './APIKeySettings';
14-
import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, UserSettings } from './internal';
15+
import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, ProtectConfig, UserSettings } from './internal';
1516
import { OrganizationSettings } from './OrganizationSettings';
1617

1718
export class Environment extends BaseResource implements EnvironmentResource {
@@ -26,6 +27,7 @@ export class Environment extends BaseResource implements EnvironmentResource {
2627
organizationSettings: OrganizationSettingsResource = new OrganizationSettings();
2728
commerceSettings: CommerceSettingsResource = new CommerceSettings();
2829
apiKeysSettings: APIKeySettings = new APIKeySettings();
30+
protectConfig: ProtectConfigResource = new ProtectConfig();
2931

3032
public static getInstance(): Environment {
3133
if (!Environment.instance) {
@@ -54,6 +56,7 @@ export class Environment extends BaseResource implements EnvironmentResource {
5456
this.userSettings = new UserSettings(data.user_settings);
5557
this.commerceSettings = new CommerceSettings(data.commerce_settings);
5658
this.apiKeysSettings = new APIKeySettings(data.api_keys_settings);
59+
this.protectConfig = new ProtectConfig(data.protect_config);
5760

5861
return this;
5962
}
@@ -95,6 +98,7 @@ export class Environment extends BaseResource implements EnvironmentResource {
9598
user_settings: this.userSettings.__internal_toSnapshot(),
9699
commerce_settings: this.commerceSettings.__internal_toSnapshot(),
97100
api_keys_settings: this.apiKeysSettings.__internal_toSnapshot(),
101+
protect_config: this.protectConfig.__internal_toSnapshot(),
98102
};
99103
}
100104
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type {
2+
ProtectConfigJSON,
3+
ProtectConfigJSONSnapshot,
4+
ProtectConfigResource,
5+
ProtectLoader,
6+
} from '@clerk/shared/types';
7+
8+
import { BaseResource } from './internal';
9+
10+
export class ProtectConfig extends BaseResource implements ProtectConfigResource {
11+
id: string = '';
12+
loaders?: ProtectLoader[];
13+
rollout?: number;
14+
15+
public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) {
16+
super();
17+
18+
this.fromJSON(data);
19+
}
20+
21+
protected fromJSON(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null): this {
22+
if (!data) {
23+
return this;
24+
}
25+
26+
this.id = this.withDefault(data.id, this.id);
27+
this.loaders = this.withDefault(data.loaders, this.loaders);
28+
29+
return this;
30+
}
31+
32+
public __internal_toSnapshot(): ProtectConfigJSONSnapshot {
33+
return {
34+
object: 'protect_config',
35+
id: this.id,
36+
loaders: this.loaders,
37+
};
38+
}
39+
}

packages/clerk-js/src/core/resources/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export * from './OrganizationSuggestion';
2929
export * from './SamlAccount';
3030
export * from './Session';
3131
export * from './Passkey';
32+
export * from './ProtectConfig';
3233
export * from './PublicUserData';
3334
export * from './SessionWithActivities';
3435
export * from './SignIn';

packages/shared/src/types/environment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AuthConfigResource } from './authConfig';
33
import type { CommerceSettingsResource } from './commerceSettings';
44
import type { DisplayConfigResource } from './displayConfig';
55
import type { OrganizationSettingsResource } from './organizationSettings';
6+
import type { ProtectConfigResource } from './protectConfig';
67
import type { ClerkResource } from './resource';
78
import type { EnvironmentJSONSnapshot } from './snapshots';
89
import type { UserSettingsResource } from './userSettings';
@@ -14,6 +15,7 @@ export interface EnvironmentResource extends ClerkResource {
1415
displayConfig: DisplayConfigResource;
1516
commerceSettings: CommerceSettingsResource;
1617
apiKeysSettings: APIKeysSettingsResource;
18+
protectConfig: ProtectConfigResource;
1719
isSingleSession: () => boolean;
1820
isProduction: () => boolean;
1921
isDevelopmentOrStaging: () => boolean;

packages/shared/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type * from './permission';
4646
export type * from './phoneCodeChannel';
4747
export type * from './phoneNumber';
4848
export type * from './protect';
49+
export type * from './protectConfig';
4950
export type * from './redirects';
5051
export type * from './resource';
5152
export type * from './role';

0 commit comments

Comments
 (0)