diff --git a/.changeset/managed-protect-check-gate.md b/.changeset/managed-protect-check-gate.md new file mode 100644 index 00000000000..10a09c3f63e --- /dev/null +++ b/.changeset/managed-protect-check-gate.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Internal groundwork for managed Protect challenge handling. No behavioral changes: the machinery is inactive until the companion UI ships and server-side enablement occurs. diff --git a/.changeset/protect-check-marker-constant.md b/.changeset/protect-check-marker-constant.md new file mode 100644 index 00000000000..9e0d914c6c1 --- /dev/null +++ b/.changeset/protect-check-marker-constant.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Add the internal `PROTECT_CHECK_ELEMENT_ID` constant for the Protect challenge placement marker. Internal change; no public API changes. diff --git a/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts b/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts new file mode 100644 index 00000000000..61312a59387 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts @@ -0,0 +1,78 @@ +import { PROTECT_CHECK_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import type { ProtectCheckJSON } from '@clerk/shared/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { FapiResponseJSON } from '../fapiClient'; +import { FraudProtection } from '../fraudProtection'; +import type { ProtectRequestContext } from '../protectCheckGate'; +import type { Clerk } from '../resources/internal'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), +})); + +import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle'; + +const mockExecute = vi.mocked(executeProtectCheckWithTimeout); + +const gatedPayload = (): FapiResponseJSON => + ({ + response: { + object: 'sign_in', + id: 'si_wired', + status: 'needs_protect_check', + protect_check: { + status: 'pending', + token: 'challenge-token', + sdk_url: 'https://protect.example.com/sdk.js', + } satisfies ProtectCheckJSON, + }, + }) as FapiResponseJSON; + +const clearedPayload = (): FapiResponseJSON => + ({ + response: { object: 'sign_in', id: 'si_wired', status: 'complete', protect_check: null }, + }) as FapiResponseJSON; + +afterEach(() => { + document.body.innerHTML = ''; + mockExecute.mockReset(); +}); + +describe('FraudProtection × ProtectCheckGate wiring', () => { + it('resolves a gated payload through the gate and returns the replayed operation result', async () => { + // Inline marker host: keeps the wiring test free of modal plumbing. + const marker = document.createElement('div'); + marker.id = PROTECT_CHECK_ELEMENT_ID; + document.body.appendChild(marker); + + mockExecute.mockResolvedValue('proof-wired'); + const rawFetch = vi.fn(() => Promise.resolve(clearedPayload())); + // First call is the gated original request; the second is the gate's replay of it. + const operationResult = clearedPayload(); + const cb = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(gatedPayload()) + .mockResolvedValueOnce(operationResult); + const ctx = { rawFetch, publish: vi.fn() } as unknown as ProtectRequestContext; + + const result = await FraudProtection.getInstance().execute({} as unknown as Clerk, cb, ctx); + + expect(result).toBe(operationResult); + expect(cb).toHaveBeenCalledTimes(2); + expect(rawFetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/client/sign_ins/si_wired/protect_check', + body: { proof_token: 'proof-wired' }, + }); + }); + + it('returns payloads untouched when no protect context is provided (non-resource callers)', async () => { + const payload = gatedPayload(); + await expect( + FraudProtection.getInstance().execute({} as unknown as Clerk, () => Promise.resolve(payload)), + ).resolves.toBe(payload); + expect(mockExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts b/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts new file mode 100644 index 00000000000..0af1bef5160 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts @@ -0,0 +1,565 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { ProtectCheckJSON } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FapiResponseJSON } from '../fapiClient'; +import type { ProtectRequestContext } from '../protectCheckGate'; +import { + findPendingProtectCheck, + PROTECT_CHECK_MODAL_CONTAINER_ID, + PROTECT_CHECK_MODAL_WRAPPER_ID, + ProtectCheckGate, +} from '../protectCheckGate'; +import type { Clerk } from '../resources/internal'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), +})); + +import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle'; + +const mockExecute = vi.mocked(executeProtectCheckWithTimeout); + +const checkJSON = (overrides: Partial = {}): ProtectCheckJSON => ({ + status: 'pending', + token: 'challenge-token', + sdk_url: 'https://protect.example.com/sdk.js', + ...overrides, +}); + +const signInPayload = (protect_check: ProtectCheckJSON | null, id = 'si_1'): FapiResponseJSON => + ({ + response: { + object: 'sign_in', + id, + status: protect_check ? 'needs_protect_check' : 'needs_first_factor', + protect_check, + }, + }) as FapiResponseJSON; + +const signUpPayload = (protect_check: ProtectCheckJSON | null, id = 'su_1'): FapiResponseJSON => + ({ + response: { object: 'sign_up', id, status: 'missing_requirements', protect_check }, + }) as FapiResponseJSON; + +const alreadyResolvedError = () => + new ClerkAPIResponseError('Already resolved', { + data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }], + status: 400, + clerkTraceId: 'trace_123', + }); + +/** + * Fake modal host: `open` mounts the wrapper + container ids the gate queries, `close` removes + * them — the contract the ui package's ProtectCheckModal will fulfil. + */ +const makeClerk = () => { + const open = vi.fn(() => { + const wrapper = document.createElement('div'); + wrapper.id = PROTECT_CHECK_MODAL_WRAPPER_ID; + wrapper.style.visibility = 'hidden'; + const container = document.createElement('div'); + container.id = PROTECT_CHECK_MODAL_CONTAINER_ID; + wrapper.appendChild(container); + document.body.appendChild(wrapper); + return Promise.resolve(); + }); + const close = vi.fn(() => { + document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.remove(); + return Promise.resolve(); + }); + return { + clerk: { + __internal_openProtectCheckModal: open, + __internal_closeProtectCheckModal: close, + } as unknown as Clerk, + open, + close, + }; +}; + +const makeCtx = (handlers: { + onPatch?: ( + path: string, + body: unknown, + ) => FapiResponseJSON | Promise | null> | null; + onGet?: (path: string) => FapiResponseJSON | Promise | null> | null; + signal?: AbortSignal; + waitForCaptchaIdle?: () => Promise; +}) => { + const rawFetch = vi.fn((init: { method: 'GET' | 'PATCH'; path: string; body?: unknown }) => { + if (init.method === 'PATCH') { + if (!handlers.onPatch) { + throw new Error(`unexpected PATCH ${init.path}`); + } + return Promise.resolve(handlers.onPatch(init.path, init.body)); + } + if (!handlers.onGet) { + throw new Error(`unexpected GET ${init.path}`); + } + return Promise.resolve(handlers.onGet(init.path)); + }); + const publish = vi.fn(); + const ctx = { + rawFetch, + publish, + signal: handlers.signal, + waitForCaptchaIdle: handlers.waitForCaptchaIdle, + } as unknown as ProtectRequestContext; + return { ctx, rawFetch, publish }; +}; + +beforeEach(() => { + mockExecute.mockReset(); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('findPendingProtectCheck', () => { + it('detects a pending check on a direct sign-in response', () => { + expect(findPendingProtectCheck(signInPayload(checkJSON()))).toEqual({ + flow: 'signIn', + id: 'si_1', + check: { + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + expiresAt: undefined, + uiHints: undefined, + }, + }); + }); + + it('detects a pending check on a direct sign-up response', () => { + expect(findPendingProtectCheck(signUpPayload(checkJSON()))?.flow).toBe('signUp'); + }); + + it.each([ + ['null payload', null], + ['non-auth response', { response: { object: 'client', id: 'c_1' } } as FapiResponseJSON], + ['no protect_check', signInPayload(null)], + ['completed protect_check', signInPayload(checkJSON({ status: 'completed' as ProtectCheckJSON['status'] }))], + [ + 'client-nested check only (belongs to another call)', + { + response: { + object: 'client', + id: 'c_1', + sign_in: { object: 'sign_in', id: 'si_1', protect_check: checkJSON() }, + }, + } as unknown as FapiResponseJSON, + ], + ])('ignores %s', (_label, payload) => { + expect(findPendingProtectCheck(payload)).toBeNull(); + }); +}); + +describe('ProtectCheckGate.process', () => { + it('passes non-gated payloads through untouched', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const payload = signInPayload(null); + const { ctx, rawFetch, publish } = makeCtx({}); + + await expect(gate.process(clerk, payload, () => Promise.resolve(payload), ctx)).resolves.toBe(payload); + expect(open).not.toHaveBeenCalled(); + expect(rawFetch).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('passes gated payloads through — and publishes them — while a host is registered for the flow', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const payload = signInPayload(checkJSON()); + const { ctx, publish } = makeCtx({}); + const dispose = gate.registerHost('signIn'); + + await expect(gate.process(clerk, payload, () => Promise.resolve(payload), ctx)).resolves.toBe(payload); + expect(open).not.toHaveBeenCalled(); + // The owning surface (prebuilt card) needs the pending state that _baseFetch deferred. + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith(payload); + + dispose(); + dispose(); // double-dispose must not underflow + expect(gate.hasRegisteredHost('signIn')).toBe(false); + }); + + it('resolves a gated sign-in then REPLAYS the original operation and returns the replay result', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open, close } = makeClerk(); + mockExecute.mockResolvedValue('proof-1'); + const cleared = signInPayload(null); + const operationResult = signInPayload(null); // e.g. the prepare that finally ran + const { ctx, rawFetch, publish } = makeCtx({ onPatch: () => cleared }); + const replay = vi.fn(() => Promise.resolve(operationResult)); + + const result = await gate.process(clerk, signInPayload(checkJSON()), replay, ctx); + + // The PATCH only clears the gate; the caller's operation must be re-run for its side effect. + expect(result).toBe(operationResult); + expect(replay).toHaveBeenCalledTimes(1); + const patchOrder = rawFetch.mock.invocationCallOrder[0]; + const replayOrder = replay.mock.invocationCallOrder[0]; + expect(patchOrder).toBeLessThan(replayOrder); + expect(open).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ token: 'challenge-token', sdkUrl: 'https://protect.example.com/sdk.js' }), + expect.any(HTMLElement), + expect.objectContaining({ setWidgetVisible: expect.any(Function) }), + ); + expect(mockExecute.mock.calls[0][1].id).toBe(PROTECT_CHECK_MODAL_CONTAINER_ID); + expect(rawFetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/client/sign_ins/si_1/protect_check', + body: { proof_token: 'proof-1' }, + }); + // Managed resolutions never publish the intermediate gated state. + expect(publish).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('uses the sign-up endpoints for gated sign-ups', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockResolvedValue('proof-su'); + const { ctx, rawFetch } = makeCtx({ onPatch: () => signUpPayload(null) }); + + await gate.process(clerk, signUpPayload(checkJSON()), () => Promise.resolve(signUpPayload(null)), ctx); + + expect(rawFetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/client/sign_ups/su_1/protect_check', + body: { proof_token: 'proof-su' }, + }); + }); + + it('runs inline into the clerk-protect-check placement marker and clears it on release', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const marker = document.createElement('div'); + marker.id = 'clerk-protect-check'; + document.body.appendChild(marker); + mockExecute.mockImplementation((_check, container) => { + container.appendChild(document.createElement('iframe')); // the widget + return Promise.resolve('proof-1'); + }); + const { ctx } = makeCtx({ onPatch: () => signInPayload(null) }); + + await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + + expect(open).not.toHaveBeenCalled(); + expect(mockExecute.mock.calls[0][1]).toBe(marker); + // Marker stays (customer's node); the run's widget leftovers do not. + expect(document.getElementById('clerk-protect-check')).toBe(marker); + expect(marker.childNodes.length).toBe(0); + }); + + it('falls back to the modal when the placement marker is not a
', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const marker = document.createElement('span'); + marker.id = 'clerk-protect-check'; + document.body.appendChild(marker); + mockExecute.mockResolvedValue('proof-1'); + const { ctx } = makeCtx({ onPatch: () => signInPayload(null) }); + + await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + + expect(open).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be a
')); + warn.mockRestore(); + }); + + it('loops chained challenges inside one host session, then replays once', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open, close } = makeClerk(); + mockExecute.mockResolvedValueOnce('proof-1').mockResolvedValueOnce('proof-2'); + const chained = signInPayload(checkJSON({ token: 'challenge-token-2' })); + let patchCount = 0; + const { ctx } = makeCtx({ onPatch: () => (++patchCount === 1 ? chained : signInPayload(null)) }); + const operationResult = signInPayload(null); + const replay = vi.fn(() => Promise.resolve(operationResult)); + + const result = await gate.process(clerk, signInPayload(checkJSON()), replay, ctx); + + expect(result).toBe(operationResult); + expect(mockExecute).toHaveBeenCalledTimes(2); + expect(mockExecute.mock.calls[1][0]).toEqual(expect.objectContaining({ token: 'challenge-token-2' })); + expect(replay).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('gives up on a never-ending challenge chain and closes the host', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + let n = 0; + mockExecute.mockImplementation(() => Promise.resolve(`proof-${n}`)); + const { ctx } = makeCtx({ onPatch: () => signInPayload(checkJSON({ token: `challenge-token-${++n}` })) }); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx), + ).rejects.toMatchObject({ code: 'protect_check_execution_failed' }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('throws instead of returning a still-pending payload when replays keep coming back gated', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + mockExecute.mockResolvedValue('proof-x'); + const { ctx } = makeCtx({ onPatch: () => signInPayload(null) }); + // Every replay of the operation comes back gated again — pathological server. + const replay = vi.fn(() => Promise.resolve(signInPayload(checkJSON()))); + + await expect(gate.process(clerk, signInPayload(checkJSON()), replay, ctx)).rejects.toMatchObject({ + code: 'protect_check_execution_failed', + }); + expect(replay).toHaveBeenCalledTimes(3); + expect(open).toHaveBeenCalledTimes(3); + }); + + it('treats protect_check_already_resolved as soft success: reloads, then replays', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockResolvedValue('proof-1'); + const { ctx, rawFetch } = makeCtx({ + onPatch: () => { + throw alreadyResolvedError(); + }, + onGet: () => signInPayload(null), + }); + const operationResult = signInPayload(null); + const replay = vi.fn(() => Promise.resolve(operationResult)); + + const result = await gate.process(clerk, signInPayload(checkJSON()), replay, ctx); + + expect(result).toBe(operationResult); + expect(replay).toHaveBeenCalledTimes(1); + expect(rawFetch).toHaveBeenCalledWith( + { method: 'GET', path: '/client/sign_ins/si_1' }, + { forceUpdateClient: true }, + ); + }); + + it('reloads an expired challenge before running and uses the re-minted check', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockResolvedValue('proof-fresh'); + const reMinted = signInPayload(checkJSON({ token: 'challenge-token-fresh', expires_at: Date.now() + 60_000 })); + const { ctx } = makeCtx({ onGet: () => reMinted, onPatch: () => signInPayload(null) }); + + await gate.process( + clerk, + signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })), + () => Promise.resolve(signInPayload(null)), + ctx, + ); + + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute.mock.calls[0][0]).toEqual(expect.objectContaining({ token: 'challenge-token-fresh' })); + }); + + it('fails with protect_check_timed_out when the server keeps returning an expired challenge', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + const { ctx } = makeCtx({ onGet: () => signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })) }); + + await expect( + gate.process( + clerk, + signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })), + () => Promise.resolve(signInPayload(null)), + ctx, + ), + ).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + expect(mockExecute).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['a null reload (offline)', null], + ['a wrong-id response', signInPayload(null, 'si_other')], + ['a wrong-flow response', signUpPayload(null)], + ])('treats %s during a session as a protocol failure, not gate clearance', async (_label, badPayload) => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + mockExecute.mockResolvedValue('proof-1'); + const { ctx } = makeCtx({ onPatch: () => badPayload }); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx), + ).rejects.toMatchObject({ code: 'protect_check_execution_failed' }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('propagates challenge failures and closes the host', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + mockExecute.mockRejectedValue( + Object.assign(new Error('load failed'), { code: 'protect_check_script_load_failed' }), + ); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), makeCtx({}).ctx), + ).rejects.toMatchObject({ code: 'protect_check_script_load_failed' }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('rejects with protect_check_aborted and never opens UI when the caller signal is already aborted', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + const controller = new AbortController(); + controller.abort(); + const { ctx } = makeCtx({ signal: controller.signal }); + + await expect( + gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx), + ).rejects.toMatchObject({ code: 'protect_check_aborted' }); + expect(open).not.toHaveBeenCalled(); + }); + + it('aborting mid-challenge rejects, releases the host, and never submits', async () => { + const gate = new ProtectCheckGate(); + const { clerk, close } = makeClerk(); + const controller = new AbortController(); + mockExecute.mockImplementation( + (_check, _container, opts) => + new Promise((_resolve, reject) => { + opts?.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { code: 'protect_check_aborted' })), + ); + }), + ); + const { ctx, rawFetch } = makeCtx({ signal: controller.signal }); + + const run = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + await vi.waitFor(() => expect(mockExecute).toHaveBeenCalled()); + controller.abort(); + + await expect(run).rejects.toMatchObject({ code: 'protect_check_aborted' }); + expect(rawFetch).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('fails bounded (not forever) when the modal opens but the container never appears', async () => { + vi.useFakeTimers(); + try { + const gate = new ProtectCheckGate(); + // Open resolves but mounts nothing — an older hot-loaded ui accepting the unknown modal name. + const close = vi.fn(() => Promise.resolve()); + const clerk = { + __internal_openProtectCheckModal: vi.fn(() => Promise.resolve()), + __internal_closeProtectCheckModal: close, + } as unknown as Clerk; + const { ctx } = makeCtx({}); + + const run = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + const assertion = expect(run).rejects.toMatchObject({ code: 'protect_check_execution_failed' }); + await vi.advanceTimersByTimeAsync(5_000); + await assertion; + expect(close).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('waits for the captcha coordinator before opening its own UI', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + mockExecute.mockResolvedValue('proof-1'); + let releaseCaptcha: () => void = () => undefined; + const captchaIdle = new Promise(resolve => { + releaseCaptcha = resolve; + }); + const { ctx } = makeCtx({ onPatch: () => signInPayload(null), waitForCaptchaIdle: () => captchaIdle }); + + const run = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(open).not.toHaveBeenCalled(); + + releaseCaptcha(); + await run; + expect(open).toHaveBeenCalledTimes(1); + }); + + it('single-flights concurrent gated calls: second waits, then replays instead of opening a second host', async () => { + const gate = new ProtectCheckGate(); + const { clerk, open } = makeClerk(); + let resolveProof: (token: string) => void = () => undefined; + mockExecute.mockImplementationOnce( + () => + new Promise(resolve => { + resolveProof = resolve; + }), + ); + const { ctx } = makeCtx({ onPatch: () => signInPayload(null) }); + + const firstResult = signInPayload(null); + const firstReplay = vi.fn(() => Promise.resolve(firstResult)); + const first = gate.process(clerk, signInPayload(checkJSON()), firstReplay, ctx); + await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + + const secondResult = signInPayload(null, 'si_2'); + const secondReplay = vi.fn(() => Promise.resolve(secondResult)); + const second = gate.process(clerk, signInPayload(checkJSON(), 'si_2'), secondReplay, makeCtx({}).ctx); + + resolveProof('proof-1'); + await expect(first).resolves.toBe(firstResult); + await expect(second).resolves.toBe(secondResult); + expect(firstReplay).toHaveBeenCalledTimes(1); + expect(secondReplay).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledTimes(1); + }); + + it('flips the modal wrapper visible when the script announces its widget', async () => { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + mockExecute.mockImplementation(async (_check, _container, opts) => { + await opts?.setWidgetVisible?.(true); + return 'proof-1'; + }); + const { ctx } = makeCtx({ + onPatch: () => { + // Wrapper must already be visible by the time the proof is submitted. + expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('visible'); + return signInPayload(null); + }, + }); + + await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + }); + + it('reveals a still-running modal after the delay so long solves are not an invisible frozen page', async () => { + vi.useFakeTimers(); + try { + const gate = new ProtectCheckGate(); + const { clerk } = makeClerk(); + let resolveProof: (token: string) => void = () => undefined; + mockExecute.mockImplementationOnce( + () => + new Promise(resolve => { + resolveProof = resolve; + }), + ); + const { ctx } = makeCtx({ onPatch: () => signInPayload(null) }); + + const run = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), ctx); + await vi.waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('hidden'); + + await vi.advanceTimersByTimeAsync(500); + expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('visible'); + + resolveProof('proof-1'); + await run; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9b41d90341f..1c6ad51cae0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -192,6 +192,7 @@ import { createCheckoutInstance } from './modules/checkout/instance'; import { OAuthApplication } from './modules/oauthApplication'; import { Protect } from './protect'; import { protectAssertionParams } from './protectAssertion'; +import { ProtectCheckGate } from './protectCheckGate'; import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; import { State } from './state'; @@ -982,6 +983,25 @@ export class Clerk implements ClerkInterface { return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('blankCaptcha')); }; + public __internal_openProtectCheckModal = (): Promise => { + this.assertComponentsReady(this.#clerkUI); + return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.openModal('protectCheck', {})); + }; + + public __internal_closeProtectCheckModal = (): Promise => { + this.assertComponentsReady(this.#clerkUI); + return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('protectCheck')); + }; + + /** + * Lets a mounted surface that renders Protect challenges itself (the prebuilt SignIn/SignUp + * components) suspend managed challenge handling for its flow. The inline placement marker is + * NOT a registrant — it only relocates where the managed gate renders. Returns a disposer. + */ + public __internal_registerProtectCheckHost = (flow: 'signIn' | 'signUp'): (() => void) => { + return ProtectCheckGate.getInstance().registerHost(flow); + }; + public __internal_loadStripeJs = async () => { if (__BUILD_DISABLE_RHC__) { clerkUnsupportedEnvironmentWarning('Stripe'); diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts index 8dcac4aebed..f21a5a08318 100644 --- a/packages/clerk-js/src/core/fraudProtection.ts +++ b/packages/clerk-js/src/core/fraudProtection.ts @@ -1,6 +1,8 @@ import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge'; +import type { ProtectRequestContext } from './protectCheckGate'; +import { ProtectCheckGate } from './protectCheckGate'; import type { Clerk } from './resources/internal'; import { Client } from './resources/internal'; @@ -25,7 +27,26 @@ export class FraudProtection { ) {} // TODO @userland-errors: - public async execute Promise, R = Awaited>>(clerk: Clerk, cb: T): Promise { + public async execute Promise, R = Awaited>>( + clerk: Clerk, + cb: T, + protect?: ProtectRequestContext, + ): Promise { + // Managed Protect challenges ride successful payloads (HTTP 200 + pending `protect_check`), + // unlike the legacy captcha's error path, so every path that returns a result — including + // the post-captcha replays below — funnels through this gate check. The gate is handed a + // waiter on the captcha single-flight so the two Clerk-owned modals never stack. + const run = async (): Promise => { + const result = await cb(); + if (!protect) { + return result; + } + return (await ProtectCheckGate.getInstance().process(clerk, result, cb, { + ...protect, + waitForCaptchaIdle: () => this.inflightException ?? Promise.resolve(), + })) as R; + }; + // TODO @userland-errors: if (this.captchaAttemptsExceeded()) { throw new ClerkRuntimeError( @@ -39,7 +60,7 @@ export class FraudProtection { await this.inflightException; } - return await cb(); + return await run(); } catch (e) { if (!isClerkAPIResponseError(e)) { throw e; @@ -60,7 +81,7 @@ export class FraudProtection { await this.inflightException; // If this is resolved, it means the request finally resolved with 200 // so we can replay the original request - return await cb(); + return await run(); } // Otherwise, create a new placeholder promise to prevent other exceptions from being handled @@ -68,6 +89,11 @@ export class FraudProtection { this.inflightException = new Promise(r => (resolve = r)); try { + // Mirror of the gate's waitForCaptchaIdle: never open the captcha modal over an + // in-flight Protect challenge modal. + await ProtectCheckGate.getInstance() + .waitForIdle() + .catch(() => undefined); const captchaParams: any = await this.managedChallenge(clerk); if (captchaParams?.captchaError !== 'modal_component_not_ready') { await this.client.getOrCreateInstance().__internal_sendCaptchaToken(captchaParams); @@ -82,7 +108,7 @@ export class FraudProtection { this.inflightException = null; } - return await cb(); + return await run(); } } diff --git a/packages/clerk-js/src/core/protectCheckGate.ts b/packages/clerk-js/src/core/protectCheckGate.ts new file mode 100644 index 00000000000..d52e68c4e89 --- /dev/null +++ b/packages/clerk-js/src/core/protectCheckGate.ts @@ -0,0 +1,447 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { ERROR_CODES, PROTECT_CHECK_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import type { ProtectCheckJSON, ProtectCheckResource } from '@clerk/shared/types'; + +import type { FapiResponseJSON } from './fapiClient'; +import type { Clerk } from './resources/internal'; + +export const PROTECT_CHECK_MODAL_WRAPPER_ID = 'cl-modal-protect-check-wrapper'; +export const PROTECT_CHECK_MODAL_CONTAINER_ID = 'cl-modal-protect-check-container'; + +/** + * The managed modal opens invisible so a challenge that resolves without interaction never + * flashes UI (same posture as the captcha modal). Unlike captcha, a challenge can legitimately + * run for a while (proof-of-transfer), so a still-running check reveals the modal after this + * delay instead of leaving the page frozen with nothing visible. + */ +const MODAL_REVEAL_DELAY_MS = 500; + +/** + * Upper bound on waiting for the modal container to appear after `openModal` resolves. An older + * hot-loaded `@clerk/ui` without the protect-check modal accepts the unknown modal name and + * renders nothing — without this bound the caller's promise would hang forever. + */ +const MODAL_MOUNT_TIMEOUT_MS = 5_000; + +/** + * Chained challenges are an SDK-side loop (the PATCH response may carry a fresh check). A + * server bug that chains forever must not trap the user in the modal. + */ +const MAX_CHAINED_CHALLENGES = 5; + +/** + * Rounds of clear-the-gate-then-replay per gated call. The managed path promises the caller a + * post-challenge result, so perpetual re-gating is a protocol failure and throws — returning a + * still-pending payload would silently reintroduce the stall this gate exists to remove. + */ +const MAX_GATED_ROUNDS = 3; + +type ProtectFlow = 'signIn' | 'signUp'; + +/** + * Raw resource fetch, provided by `BaseResource._fetch` so the gate's own PATCH/GET calls get + * the exact semantics of any resource call (deferred-hydration rules, ClerkAPIResponseError on + * 4xx) without re-entering FraudProtection. + */ +export type RawResourceFetch = ( + requestInit: { method: 'GET' | 'PATCH'; path: string; body?: unknown }, + opts?: { forceUpdateClient?: boolean }, +) => Promise | null>; + +/** + * Per-request context handed down from `BaseResource._fetch` through `FraudProtection.execute`. + * `publish` performs the client piggyback update that `_baseFetch` defers for gated payloads — + * the gate publishes only when a registered host owns the pending state; managed resolutions + * never publish the intermediate gate. + */ +export interface ProtectRequestContext { + rawFetch: RawResourceFetch; + publish: (payload: FapiResponseJSON | null) => void; + signal?: AbortSignal; + /** Lets the gate wait out an in-flight legacy captcha modal before opening its own UI. */ + waitForCaptchaIdle?: () => Promise; +} + +interface GatedInfo { + flow: ProtectFlow; + id: string; + check: ProtectCheckResource; +} + +interface ChallengeHost { + container: HTMLDivElement; + setWidgetVisible?: (visible: boolean) => Promise; + release: () => Promise; +} + +type MaybeGatedResponse = { + object?: string; + id?: string; + protect_check?: ProtectCheckJSON | null; +}; + +function toProtectCheckResource(json: ProtectCheckJSON): ProtectCheckResource { + return { + status: json.status, + token: json.token, + sdkUrl: json.sdk_url, + expiresAt: json.expires_at, + uiHints: json.ui_hints, + }; +} + +function abortedError(): ClerkRuntimeError { + return new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' }); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortedError(); + } +} + +/** + * A payload gates the calling request when its direct response is a sign-in/sign-up carrying a + * pending `protect_check`. Only the direct response is inspected: the gated call's own response + * is the authoritative signal, and reacting to the piggybacked `client` mirror would double-handle + * gates that belong to a different in-flight call. (OAuth redirect completion, which only ever + * sees the nested mirror, gets its own intent-scoped resolver — PROT-968.) + */ +export function findPendingProtectCheck(payload: FapiResponseJSON | null): GatedInfo | null { + const response = payload?.response as MaybeGatedResponse | null | undefined; + if (!response || typeof response !== 'object') { + return null; + } + if (response.object !== 'sign_in' && response.object !== 'sign_up') { + return null; + } + if (!response.id || response.protect_check?.status !== 'pending') { + return null; + } + return { + flow: response.object === 'sign_in' ? 'signIn' : 'signUp', + id: response.id, + check: toProtectCheckResource(response.protect_check), + }; +} + +/** + * Bounded, abortable element wait. `@clerk/shared`'s `waitForElement` deliberately never + * rejects; here an absent element is an answer (older UI, broken mount), not something to wait + * on forever. + */ +function waitForElementBounded(selector: string, timeoutMs: number, signal?: AbortSignal): Promise { + return new Promise(resolve => { + const immediate = document.querySelector(selector); + if (immediate) { + return resolve(immediate); + } + let settled = false; + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + settle(el); + } + }); + const timeoutId = setTimeout(() => settle(null), timeoutMs); + const onAbort = () => settle(null); + const settle = (el: HTMLElement | null) => { + if (settled) { + return; + } + settled = true; + observer.disconnect(); + clearTimeout(timeoutId); + signal?.removeEventListener('abort', onAbort); + resolve(el); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +/** + * Resolves Protect challenges (`protect_check`) automatically so custom-flow apps never see the + * gate: when a resource call comes back gated, the challenge runs in a Clerk-owned host — the + * `clerk-protect-check` placement marker when the page provides one, a managed modal otherwise — + * the proof is submitted, and then the **original operation is replayed** (the stored proof on + * the attempt lets the replay pass), so the caller receives the true result of the operation it + * requested. Clearing the gate alone is not enough: for pre-op gates (e.g. a gated + * `prepareFirstFactor`) the gated side effect only happens on the replay. + * + * Prebuilt components (and any other surface that renders challenges itself) opt out by + * registering a host for their flow, in which case gated payloads pass through untouched. + * + * Mirrors `FraudProtection`'s posture for the legacy captcha: one challenge session at a time + * (concurrent gated calls wait, then replay), and the caller's promise is held for the duration. + */ +export class ProtectCheckGate { + private static instance: ProtectCheckGate; + + private hostCounts: Record = { signIn: 0, signUp: 0 }; + private inflightSession: Promise | null = null; + + public static getInstance(): ProtectCheckGate { + if (!ProtectCheckGate.instance) { + ProtectCheckGate.instance = new ProtectCheckGate(); + } + return ProtectCheckGate.instance; + } + + /** + * Declares that a mounted surface which renders challenges itself (the prebuilt SignIn/SignUp + * components) owns gated payloads for the given flow; managed handling stands down while any + * registration is live. The inline placement marker is NOT a registrant — it only relocates + * where the managed gate renders. Returns a disposer. + */ + public registerHost(flow: ProtectFlow): () => void { + this.hostCounts[flow] += 1; + let disposed = false; + return () => { + if (!disposed) { + disposed = true; + this.hostCounts[flow] -= 1; + } + }; + } + + public hasRegisteredHost(flow: ProtectFlow): boolean { + return this.hostCounts[flow] > 0; + } + + /** Lets the legacy captcha coordinator wait out an in-flight challenge session. */ + public waitForIdle(): Promise { + return this.inflightSession ?? Promise.resolve(); + } + + public async process(clerk: Clerk, payload: T, replay: () => Promise, ctx: ProtectRequestContext): Promise { + let current = payload; + let rounds = 0; + + for (;;) { + const gated = findPendingProtectCheck(current as FapiResponseJSON | null); + if (!gated) { + return current; + } + if (this.hasRegisteredHost(gated.flow)) { + // A mounted surface owns this gate: deliberately publish the pending state (deferred by + // `_baseFetch`) so that surface — and anything else observing client state — sees it. + ctx.publish(current as FapiResponseJSON | null); + return current; + } + if (rounds >= MAX_GATED_ROUNDS) { + throw new ClerkRuntimeError('Protect check could not be cleared after multiple attempts', { + code: 'protect_check_execution_failed', + }); + } + rounds += 1; + throwIfAborted(ctx.signal); + + if (this.inflightSession) { + // Another gated call owns the challenge UI. Wait it out (its failure is its caller's to + // surface), then replay below — the stored proof on the attempt lets the replay pass. + await this.inflightSession.catch(() => undefined); + } else { + const session = this.resolveGated(clerk, gated, ctx); + this.inflightSession = session.catch(() => undefined); + try { + await session; + } finally { + this.inflightSession = null; + } + } + + throwIfAborted(ctx.signal); + current = await replay(); + } + } + + /** + * Runs challenges until the gate is clear (chained checks included). Resolves the gate ONLY — + * the caller replays the original operation afterwards. + */ + private async resolveGated(clerk: Clerk, gated: GatedInfo, ctx: ProtectRequestContext): Promise { + // Fail closed where the challenge cannot run: the gate requires a remote `import(sdk_url)` + // that no-RHC builds must not perform, and a DOM to host the widget. The guard lives here + // (not in the shared lifecycle module) because @clerk/shared compiles with the flag + // hard-coded `false`. + if (__BUILD_DISABLE_RHC__ || typeof document === 'undefined') { + throw new ClerkRuntimeError('Protect verification is not supported in this environment', { + code: ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT, + }); + } + + // Pragmatic mutual exclusion with the legacy captcha modal (which has its own single-flight): + // never stack the two Clerk-owned modals. A full shared coordinator can replace both later. + await ctx.waitForCaptchaIdle?.().catch(() => undefined); + throwIfAborted(ctx.signal); + + const lifecycle = await import('@clerk/shared/internal/clerk-js/protectCheckLifecycle'); + const host = await this.acquireHost(clerk, ctx.signal); + + const basePath = gated.flow === 'signIn' ? '/client/sign_ins' : '/client/sign_ups'; + const reload = () => ctx.rawFetch({ method: 'GET', path: `${basePath}/${gated.id}` }, { forceUpdateClient: true }); + const submit = (proofToken: string) => + ctx.rawFetch({ + method: 'PATCH', + path: `${basePath}/${gated.id}/protect_check`, + body: { proof_token: proofToken }, + }); + + // Every payload consumed inside the session must belong to the attempt being resolved; a + // null (offline), wrong-flow, or wrong-id response is a protocol violation, not "gate + // cleared" — treating it as clearance would leak precisely the state this path hides. + const extractCheck = (payload: FapiResponseJSON | null): ProtectCheckResource | null => { + const response = payload?.response as MaybeGatedResponse | null | undefined; + const expectedObject = gated.flow === 'signIn' ? 'sign_in' : 'sign_up'; + if (!response || response.object !== expectedObject || response.id !== gated.id) { + throw new ClerkRuntimeError('Protect check received an unexpected response while resolving', { + code: 'protect_check_execution_failed', + }); + } + return response.protect_check?.status === 'pending' ? toProtectCheckResource(response.protect_check) : null; + }; + + try { + let latest: FapiResponseJSON | null = null; + let check: ProtectCheckResource | null = gated.check; + let expiredReloads = 0; + let challengesRun = 0; + + while (check) { + throwIfAborted(ctx.signal); + + if (lifecycle.isProtectCheckExpired(check)) { + if (expiredReloads >= lifecycle.MAX_EXPIRED_RELOADS) { + throw new ClerkRuntimeError('Protect verification expired', { + code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT, + }); + } + expiredReloads += 1; + latest = await reload(); + check = extractCheck(latest); + continue; + } + + if (challengesRun >= MAX_CHAINED_CHALLENGES) { + throw new ClerkRuntimeError('Protect check chained challenge limit exceeded', { + code: 'protect_check_execution_failed', + }); + } + challengesRun += 1; + + const proofToken = await lifecycle.executeProtectCheckWithTimeout(check, host.container, { + signal: ctx.signal, + setWidgetVisible: host.setWidgetVisible, + }); + + const result = await lifecycle.submitProtectCheckProof | null>({ + proofToken, + submitProtectCheck: ({ proofToken: token }) => submit(token), + reload: async () => { + latest = await reload(); + }, + getResource: () => latest, + isCancelled: () => !!ctx.signal?.aborted, + }); + if (result.status === 'cancelled') { + throw abortedError(); + } + latest = result.resource; + check = extractCheck(latest); + } + } finally { + // Awaited so a session is not considered finished (and the single-flight not released) + // while its modal is still closing — an unawaited close could race a follow-up session's + // freshly opened modal. + await host.release(); + } + } + + private async acquireHost(clerk: Clerk, signal?: AbortSignal): Promise { + const markers = document.querySelectorAll(`#${PROTECT_CHECK_ELEMENT_ID}`); + if (markers.length > 1) { + console.warn( + `Clerk: multiple elements with id "${PROTECT_CHECK_ELEMENT_ID}" found; using the first. Keep a single placement marker.`, + ); + } + const marker = markers[0]; + if (marker) { + if (marker instanceof HTMLDivElement) { + return { + container: marker, + release: () => { + // The run owns the marker's contents, not the marker: leave the customer's node, + // drop any solved/errored widget so the next run (or their layout) starts clean. + while (marker.firstChild) { + marker.removeChild(marker.firstChild); + } + return Promise.resolve(); + }, + }; + } + console.warn( + `Clerk: the "${PROTECT_CHECK_ELEMENT_ID}" placement element must be a
; using a modal instead.`, + ); + } + + try { + await clerk.__internal_openProtectCheckModal(); + } catch { + // Components-not-ready or UI unavailable. Protect cannot fail open — the server enforces + // the gate — so surface a runtime error instead of skipping (contrast: captcha skips). + throw new ClerkRuntimeError('Protect check UI failed to open', { + code: 'protect_check_execution_failed', + }); + } + + const container = await waitForElementBounded( + `#${PROTECT_CHECK_MODAL_CONTAINER_ID}`, + MODAL_MOUNT_TIMEOUT_MS, + signal, + ); + if (!container) { + await clerk.__internal_closeProtectCheckModal().catch(() => undefined); + throwIfAborted(signal); + // Covers an older hot-loaded @clerk/ui that accepts the unknown modal name but renders + // nothing — bounded failure instead of an eternal hang (PROT-969). + throw new ClerkRuntimeError('Protect check UI failed to open', { + code: 'protect_check_execution_failed', + }); + } + + const setWrapperVisible = (visible: boolean) => { + const wrapper = document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID); + wrapper?.style.setProperty('visibility', visible ? 'visible' : 'hidden'); + wrapper?.style.setProperty('pointer-events', visible ? 'all' : 'none'); + }; + + // Reveal on the first of: the script announcing a visible widget, or the delay elapsing for + // a still-running (e.g. proof-of-transfer) check. A `false` counter-signal is ignored — the + // modal closes moments later on resolution, and re-hiding a revealed modal mid-submit reads + // as a glitch. + let revealed = false; + const reveal = () => { + if (!revealed) { + revealed = true; + setWrapperVisible(true); + } + }; + const revealTimer = setTimeout(reveal, MODAL_REVEAL_DELAY_MS); + + return { + container: container as HTMLDivElement, + setWidgetVisible: (visible: boolean) => { + if (visible) { + clearTimeout(revealTimer); + reveal(); + } + return Promise.resolve(); + }, + release: async () => { + clearTimeout(revealTimer); + await clerk.__internal_closeProtectCheckModal().catch(() => undefined); + }, + }; + } +} diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4ad63d5d01c..83f53a06828 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -13,6 +13,7 @@ import { debugLogger } from '@/utils/debug'; import { clerkMissingFapiClientInResources } from '../errors'; import type { FapiClient, FapiRequestInit, FapiResponse, FapiResponseJSON, HTTPMethod } from '../fapiClient'; import { FraudProtection } from '../fraudProtection'; +import { findPendingProtectCheck } from '../protectCheckGate'; import { type Clerk, getClientResourceFromPayload } from './internal'; export type BaseFetchOptions = ClerkResourceReloadParams & { @@ -88,7 +89,16 @@ export abstract class BaseResource { requestInit: FapiRequestInit, opts: BaseFetchOptions = {}, ): Promise | null> { - return FraudProtection.getInstance().execute(this.clerk, () => this._baseFetch(requestInit, opts)); + return FraudProtection.getInstance().execute(this.clerk, () => this._baseFetch(requestInit, opts), { + // Lets the managed Protect challenge gate issue its own PATCH/GET with full resource-call + // semantics (deferred-hydration rules, ClerkAPIResponseError on 4xx) without re-entering + // FraudProtection. + rawFetch: (init, o) => this._baseFetch(init as FapiRequestInit, o), + // Performs the client piggyback update `_baseFetch` defers for gated payloads; the gate + // publishes only when a registered host owns the pending state. + publish: payload => this._updateClient(payload), + signal: requestInit.signal ?? undefined, + }); } // TODO @userland-errors: @@ -137,7 +147,13 @@ export abstract class BaseResource { // TODO: Link to Client payload piggybacking design document if ((requestInit.method !== 'GET' || opts.forceUpdateClient) && !opts.skipUpdateClient) { - this._updateClient(payload); + // Gated payloads defer publication: emitting the intermediate `needs_protect_check` + // client state would leak the gate to listeners while the managed challenge gate is + // holding the caller's promise. The gate publishes the pending state itself when a + // registered host owns it, and every replay/PATCH publishes its own final payload here. + if (!findPendingProtectCheck(payload)) { + this._updateClient(payload); + } } if (status >= 200 && status <= 299) { diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index c11db68f590..77c7f0069c0 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -70,3 +70,9 @@ export const SUPPORTED_FAPI_VERSION = '2026-05-12'; export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; +/** + * Placement marker for Protect challenges, mirroring the `clerk-captcha` contract: when an + * element with this id exists, challenges render inline into it instead of the managed modal. + * The prebuilt protect-check cards use the same id for their container. + */ +export const PROTECT_CHECK_ELEMENT_ID = 'clerk-protect-check';