diff --git a/CHANGELOG.md b/CHANGELOG.md index adaa3de..deeae79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.0] - 2026-10-12 + +### Added +- Explicit `createClearanceFetch()` adapter and `readClearanceChallenge()` parser for the version-1 edge API challenge contract. Available as library exports and `window.WebDecoyClearance` in the standalone bundle. +- GET/HEAD recovery with one retry, shared recovery per adapter/protected origin, a bounded deadline, cancellation, and local observation hooks. Applications provide their own clearance flow; refusal remains server-owned. +- Policy refusals, ordinary origin 403s, unsupported contracts, omitted credentials, redirects, offline state, and all mutations do not trigger automatic recovery. No global fetch interception or XHR handling. + ## [0.18.5] - 2026-10-12 ### Fixed diff --git a/README.md b/README.md index 98cfea2..4621195 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,9 @@ if (!result.allowed) { sessions, including startup with an existing cookie. It coalesces repeated startup, bounds mint failures with a timeout and backoff, and stops automatic attempts when the server refuses clearance. Application fetch/XHR requests are -not intercepted or replayed. See the [browser client guide](packages/client/README.md#session-clearance). +not intercepted or replayed globally. Version 0.19.0 adds an opt-in +`createClearanceFetch()` helper for one GET/HEAD retry through your own clearance +flow; mutations and policy refusals never recover automatically. See the [browser client guide](packages/client/README.md#session-clearance). ## One bot policy, published and enforced diff --git a/llms.txt b/llms.txt index 1a84bb2..4736f2b 100644 --- a/llms.txt +++ b/llms.txt @@ -147,3 +147,5 @@ Offline by default: an API key in the environment is ignored unless - Web Bot Auth guide: https://github.com/WebDecoy/node/blob/main/docs/verify-ai-agents-web-bot-auth.md - Dashboard: https://app.webdecoy.com - Docs: https://docs.webdecoy.com + +API recovery (`@webdecoy/client` 0.19.0+): `createClearanceFetch({ origin, recover, timeoutMs?, onEvent? })` returns an explicit fetch adapter. `recover({ origin, challenge, signal })` must earn/install the protected origin's clearance cookie and resolve true; `startClearance()` is not awaitable recovery. Exact v1 challenge headers, GET/HEAD only, one retry, shared callback per adapter, 10s default/60s max recovery-and-retry deadline. Refusal/timeout returns the original response; caller abort/network errors reject. Mutations, ordinary 403, revoked/crawler-refused/agent-impersonation, redirects and omitted credentials never recover. Cross-origin needs include credentials, host cookie and approved CORS exposing the challenge headers. No global interception, XHR support or automatic telemetry. Standalone global: `window.WebDecoyClearance`. diff --git a/package-lock.json b/package-lock.json index 3f7adb6..a87128a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10407,7 +10407,7 @@ }, "packages/client": { "name": "@webdecoy/client", - "version": "0.18.5", + "version": "0.19.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", @@ -10424,10 +10424,10 @@ }, "packages/express": { "name": "@webdecoy/express", - "version": "0.18.5", + "version": "0.19.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.5" + "@webdecoy/node": "^0.19.0" }, "devDependencies": { "@types/express": "^4.17.21", @@ -10448,10 +10448,10 @@ }, "packages/fastify": { "name": "@webdecoy/fastify", - "version": "0.18.5", + "version": "0.19.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.5", + "@webdecoy/node": "^0.19.0", "fastify-plugin": "^4.5.1" }, "devDependencies": { @@ -10472,10 +10472,10 @@ }, "packages/hono": { "name": "@webdecoy/hono", - "version": "0.18.5", + "version": "0.19.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.5" + "@webdecoy/node": "^0.19.0" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10495,10 +10495,10 @@ }, "packages/nextjs": { "name": "@webdecoy/nextjs", - "version": "0.18.5", + "version": "0.19.0", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.5" + "@webdecoy/node": "^0.19.0" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10518,7 +10518,7 @@ }, "packages/webdecoy": { "name": "@webdecoy/node", - "version": "0.18.5", + "version": "0.19.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", diff --git a/packages/client/README.md b/packages/client/README.md index 4b21908..215db39 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -87,8 +87,8 @@ edge validator continue to verify every token and enforce revocation. The cookie is Secure, SameSite Lax and host-only. Accepted interaction aggregates remain on renewal requests, and the server evaluates them again; pass `behavior: false` to disable the behavioral upgrade. The client does not intercept -or retry application fetch/XHR requests: an edge API challenge is a JSON 403 -that your application must handle explicitly. +or retry application fetch/XHR requests globally. An edge API challenge is a JSON +403; the opt-in helper below handles safe reads through your application’s flow. ## What it collects @@ -131,3 +131,74 @@ and Next.js adapters and the full detection/scoring reference. ## License MIT + +## API challenge recovery + +With `@webdecoy/client` **0.19.0 or later** and an updated edge validator, opt in +for the API reads your application wants to recover: + +```ts +import { createClearanceFetch } from '@webdecoy/client'; +import { earnClearance } from './clearance-flow'; // your application's flow + +const fetchProtected = createClearanceFetch({ + origin: location.origin, + timeoutMs: 10_000, + recover: ({ origin, challenge, signal }) => + earnClearance({ origin, reason: challenge.reason, signal }), + onEvent: event => console.debug(event.type, event.status), +}); + +const controller = new AbortController(); +const response = await fetchProtected('/api/catalog', { + credentials: 'same-origin', + signal: controller.signal, // your application's AbortController +}); +// Handle response.ok/status/body just as with native fetch. +``` + +`earnClearance` is application code, not an SDK export. It must earn and install +the host's `wd_clearance` cookie through your configured issuance/verification +flow and return `true` only when ready to retry. `startClearance()` returns +`void`; awaiting it does not wait for a token. A self-hosted captcha widget token +is not a clearance cookie. A stronger-grade response needs a flow that earns the +route's required grade; simply replacing a token cannot grant that grade. + +The helper retries **GET/HEAD once** after a recognized recoverable challenge. +Concurrent calls through one adapter share one recovery callback for its +configured origin; that callback receives the first challenge's reason. Use one +adapter for each origin/recovery flow. The edge evaluates every retry again. +POST/PATCH/PUT/DELETE and other methods pass through once with their original +body. There is no mutation replay opt-in and no XHR interception. The standalone +script exposes the same helpers at `window.WebDecoyClearance`. + +Recovery and the retry share a deadline after the first challenge: 10 seconds by +default, configurable from 1 ms to 60 seconds. Refusal, resolver rejection, +offline state, or deadline expiry returns the original readable challenge +response. Caller cancellation and fetch network errors reject as native fetch +does. One cancelled caller does not cancel other callers waiting for the shared +flow; when none remain, the recovery signal aborts. Your flow must stop network +work and close its UI on that signal. If it ignores cancellation, later calls +remain attached to that stopped attempt until it settles, rather than opening +overlapping recovery dialogs. Browsers without `AbortSignal.any()` return the +challenge without attempting recovery. + +Only version-1 responses with the exact WebDecoy challenge headers qualify. +Ordinary application 403s, unsupported/malformed contracts, revoked sessions, +crawler-policy refusals and agent impersonation do not trigger recovery. +Redirected responses, different origins and `credentials: 'omit'` also do not. +The helper preserves request options and leaves response bodies/streams unread. +It adds no request headers or CORS permission. Cross-origin calls need +`credentials: 'include'`, the protected host's cookie, and an application-approved +CORS policy that exposes the four challenge headers; otherwise the browser +cannot identify the response. A cookie set on the page's host does not clear a +different API host. + +`onEvent` reports `challenge-served`, `recovery-attempted`, +`recovery-succeeded`/`recovery-failed`, and the retried `enforcement-outcome` +(status). Attempts are per waiting request; count callback invocations separately +for shared flows. Success means the retry returned an OK response, not a new +trust grade. These local observations send no telemetry automatically and must +remain distinct from the validator's verdict counts. Observation failures cannot +change the request. Interactive application flows must provide keyboard access, +focus restoration, an announced status and cancellation; the helper renders no UI. diff --git a/packages/client/package.json b/packages/client/package.json index fdb448c..cbcdce1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/client", - "version": "0.18.5", + "version": "0.19.0", "description": "Web Decoy browser widget - signal collection, proof-of-work, and captcha UI", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/client/src/clearance-fetch.test.ts b/packages/client/src/clearance-fetch.test.ts new file mode 100644 index 0000000..8c68a5b --- /dev/null +++ b/packages/client/src/clearance-fetch.test.ts @@ -0,0 +1,205 @@ +/** @jest-environment node */ +import { createClearanceFetch, readClearanceChallenge, type ClearanceRecoveryContext } from './clearance-fetch'; + +const origin = 'https://protected.example'; +function challenged(reason = 'missing', recoverable = true, version = '1') { + return new Response(JSON.stringify({ error: 'clearance required' }), { status: 403, headers: { + 'x-wd-clearance': 'challenged', 'x-wd-challenge': version, + 'x-wd-challenge-reason': reason, 'x-wd-challenge-recoverable': String(recoverable), + } }); +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const flush = async () => { for (let i = 0; i < 15; i++) await Promise.resolve(); }; +afterEach(() => jest.useRealTimers()); + +test.each(['GET', 'HEAD'])('%s retries once and preserves request options', async method => { + const first = challenged(); + const fetcher = jest.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(new Response('ok')); + const events = jest.fn(); + const recover = jest.fn(async () => true); + const fetch = createClearanceFetch({ origin, recover, fetch: fetcher, onEvent: events }); + const result = await fetch(new Request(origin + '/data', { method, credentials: 'include', headers: { authorization: 'fixture', 'x-custom': 'value' }, cache: 'no-cache', redirect: 'error' })); + expect(await result.text()).toBe('ok'); + expect(first.bodyUsed).toBe(false); + expect(fetcher).toHaveBeenCalledTimes(2); + const retry = fetcher.mock.calls[1][0] as Request; + expect([retry.method, retry.credentials, retry.cache, retry.redirect, retry.headers.get('authorization'), retry.headers.get('x-custom')]).toEqual([method, 'include', 'no-cache', 'error', 'fixture', 'value']); + expect(recover).toHaveBeenCalledTimes(1); + expect(events.mock.calls.map(call => call[0].type)).toEqual(['challenge-served', 'recovery-attempted', 'enforcement-outcome', 'recovery-succeeded']); +}); + +test('concurrent challenges coalesce and retry each read only once', async () => { + const pending = deferred(); + const recover = jest.fn(() => pending.promise); + const calls: Record = {}; + const fetcher = jest.fn(async (input: RequestInfo | URL) => { + const request = input as Request; + calls[request.url] = (calls[request.url] ?? 0) + 1; + return calls[request.url] === 1 ? challenged() : new Response('ok'); + }); + const fetch = createClearanceFetch({ origin, recover, fetch: fetcher }); + const requests = [fetch(origin + '/one'), fetch(origin + '/two')]; + await flush(); + expect(recover).toHaveBeenCalledTimes(1); + pending.resolve(true); + expect((await Promise.all(requests)).map(response => response.status)).toEqual([200, 200]); + expect(Object.values(calls)).toEqual([2, 2]); +}); + +test.each(['POST', 'PATCH', 'PUT', 'DELETE'])('%s sends original body once without recovery', async method => { + const response = challenged(); + const fetcher = jest.fn(async (_input: RequestInfo | URL) => response); + const recover = jest.fn(async () => true); + const fetch = createClearanceFetch({ origin, recover, fetch: fetcher }); + const request = new Request(origin + '/mutation', { method, body: 'action=once' }); + expect(await fetch(request)).toBe(response); + expect(fetcher.mock.calls[0][0]).toBe(request); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(recover).not.toHaveBeenCalled(); + expect(request.bodyUsed).toBe(false); +}); + +test.each([ + new Response('origin forbidden', { status: 403 }), challenged('revoked'), challenged('crawler-refused'), + challenged('agent-impersonation'), challenged('denied'), challenged('missing', false), + challenged('missing', true, '2'), challenged('unknown'), +])('ordinary, policy and unsupported responses never recover (%#)', async response => { + const recover = jest.fn(async () => true); + const fetcher = jest.fn(async () => response); + expect(await createClearanceFetch({ origin, recover, fetch: fetcher })(origin + '/data')).toBe(response); + expect(recover).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(1); +}); + +test('a second challenge is returned intact without a recovery loop', async () => { + const second = challenged('insufficient-trust'); + const fetcher = jest.fn().mockResolvedValueOnce(challenged()).mockResolvedValueOnce(second); + const recover = jest.fn(async () => true); + expect(await createClearanceFetch({ origin, recover, fetch: fetcher })(origin + '/data')).toBe(second); + expect(await second.text()).toContain('clearance required'); + expect(recover).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(2); +}); + +test('stronger-grade recovery receives its reason, and refusal/rejection leaves the response readable', async () => { + for (const outcome of [false, new Error('mint denied')]) { + const response = challenged('insufficient-trust'); + const recover = jest.fn(async (_context: ClearanceRecoveryContext) => { if (outcome instanceof Error) throw outcome; return outcome; }); + const fetcher = jest.fn(async () => response); + expect(await createClearanceFetch({ origin, recover, fetch: fetcher })(origin + '/data')).toBe(response); + expect(recover.mock.calls[0][0].challenge.reason).toBe('insufficient-trust'); + expect(response.bodyUsed).toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + } +}); + +test('timeout aborts a hung resolver, returns the original response and prevents overlapping ignored cancellation', async () => { + jest.useFakeTimers(); + const response = challenged(); + const recover = jest.fn((_context: { signal: AbortSignal }) => new Promise(() => {})); + const fetch = createClearanceFetch({ origin, timeoutMs: 100, recover, fetch: jest.fn(async () => response) }); + const result = fetch(origin + '/data'); + await flush(); + await jest.advanceTimersByTimeAsync(100); + expect(await result).toBe(response); + expect(recover.mock.calls[0][0].signal.aborted).toBe(true); + expect(await fetch(origin + '/another')).toBe(response); + expect(recover).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); +}); + +test('one cancelled waiter cannot cancel another; cancelling all stops the shared resolver', async () => { + const pending = deferred(); + const recover = jest.fn((_context: { signal: AbortSignal }) => pending.promise); + const counts: Record = {}; + const fetcher = jest.fn(async (input: RequestInfo | URL) => { + const request = input as Request; + counts[request.url] = (counts[request.url] ?? 0) + 1; + return counts[request.url] === 1 ? challenged() : new Response('ok'); + }); + const fetch = createClearanceFetch({ origin, recover, fetch: fetcher }); + const controller = new AbortController(); + const first = fetch(origin + '/one', { signal: controller.signal }); + const rejection = expect(first).rejects.toMatchObject({ name: 'AbortError' }); + const second = fetch(origin + '/two'); + await flush(); + controller.abort(); + await rejection; + expect(recover.mock.calls[0][0].signal.aborted).toBe(false); + pending.resolve(true); + expect((await second).status).toBe(200); + + const onlyController = new AbortController(); + const onlyRecover = jest.fn((_context: { signal: AbortSignal }) => new Promise(() => {})); + const only = createClearanceFetch({ origin, recover: onlyRecover, fetch: jest.fn(async () => challenged()) })(origin, { signal: onlyController.signal }); + const onlyRejection = expect(only).rejects.toMatchObject({ name: 'AbortError' }); + await flush(); + onlyController.abort(); + await onlyRejection; + expect(onlyRecover.mock.calls[0][0].signal.aborted).toBe(true); +}); + +test('the retry shares the deadline, and network errors keep fetch rejection semantics', async () => { + jest.useFakeTimers(); + const original = challenged(); + const fetcher = jest.fn().mockResolvedValueOnce(original).mockImplementationOnce((_request: Request) => new Promise(() => {})); + const result = createClearanceFetch({ origin, timeoutMs: 100, recover: async () => true, fetch: fetcher })(origin); + await flush(); + await jest.advanceTimersByTimeAsync(100); + expect(await result).toBe(original); + expect(fetcher.mock.calls[1][0].signal.aborted).toBe(true); + const error = new TypeError('offline'); + await expect(createClearanceFetch({ origin, recover: async () => true, fetch: jest.fn().mockRejectedValue(error) })(origin)).rejects.toBe(error); +}); + +test('different origins, omitted credentials and redirected responses cannot start recovery', async () => { + for (const [url, init, response] of [ + ['https://other.example/data', {}, challenged()], + [origin + '/data', { credentials: 'omit' }, challenged()], + [origin + '/data', {}, Object.defineProperty(challenged(), 'redirected', { value: true })], + ] as [string, RequestInit, Response][]) { + const recover = jest.fn(async () => true); + expect(await createClearanceFetch({ origin, recover, fetch: jest.fn(async () => response) })(url, init)).toBe(response); + expect(recover).not.toHaveBeenCalled(); + } +}); + +test('observation errors cannot change recovery and policy flags cannot override refusals', async () => { + expect(readClearanceChallenge(challenged('revoked', true))?.recoverable).toBe(false); + const fetcher = jest.fn().mockResolvedValueOnce(challenged()).mockResolvedValueOnce(new Response('ok')); + expect((await createClearanceFetch({ origin, recover: async () => true, fetch: fetcher, onEvent: () => { throw new Error('observer'); } })(origin)).status).toBe(200); +}); + +test('caller cancellation remains attached to the retry response body after headers return', async () => { + const controller = new AbortController(); + const fetcher = jest.fn().mockResolvedValueOnce(challenged()).mockResolvedValueOnce(new Response('ok')); + await createClearanceFetch({ origin, recover: async () => true, fetch: fetcher })(origin, { signal: controller.signal }); + const retry = fetcher.mock.calls[1][0] as Request; + expect(retry.signal.aborted).toBe(false); + controller.abort(); + expect(retry.signal.aborted).toBe(true); +}); + +test('offline and cross-origin credential boundaries return the challenge without minting', async () => { + const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location'); + try { + const recover = jest.fn(async () => true); + const response = challenged(); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: false } }); + const fetch = createClearanceFetch({ origin, recover, fetch: jest.fn(async () => response) }); + expect(await fetch(origin)).toBe(response); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } }); + Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin: 'https://frontend.example' } }); + expect(await fetch(origin)).toBe(response); + expect(recover).not.toHaveBeenCalled(); + } finally { + if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); else delete (globalThis as any).navigator; + if (locationDescriptor) Object.defineProperty(globalThis, 'location', locationDescriptor); else delete (globalThis as any).location; + } +}); diff --git a/packages/client/src/clearance-fetch.ts b/packages/client/src/clearance-fetch.ts new file mode 100644 index 0000000..217f355 --- /dev/null +++ b/packages/client/src/clearance-fetch.ts @@ -0,0 +1,161 @@ +/** An explicit fetch adapter. Does not replace window.fetch or intercept XHR. */ +export type ClearanceChallengeReason = 'missing' | 'invalid' | 'insufficient-trust' | 'revoked' | 'crawler-refused' | 'agent-impersonation' | 'denied'; +export interface ClearanceChallenge { + version: 1; + reason: ClearanceChallengeReason; + recoverable: boolean; +} +export interface ClearanceRecoveryContext { + origin: string; + challenge: ClearanceChallenge; + /** Recovery UI/network work must stop when this signal aborts. */ + signal: AbortSignal; +} +export interface ClearanceFetchEvent { + type: 'challenge-served' | 'recovery-attempted' | 'recovery-succeeded' | 'recovery-failed' | 'enforcement-outcome'; + origin: string; + method: string; + challenge: ClearanceChallenge; + status?: number; +} +export interface ClearanceFetchOptions { + /** One explicitly trusted protected origin. Use one adapter per origin/recovery flow. */ + origin: string; + /** Earn and install clearance for this origin; true permits one safe-read retry. */ + recover(context: ClearanceRecoveryContext): Promise; + /** Recovery and retry deadline after the challenge; default 10s, maximum 60s. */ + timeoutMs?: number; + fetch?: typeof fetch; + /** Local observation hook; no telemetry is sent automatically. */ + onEvent?(event: ClearanceFetchEvent): void; +} + +const reasons: readonly string[] = ['missing', 'invalid', 'insufficient-trust', 'revoked', 'crawler-refused', 'agent-impersonation', 'denied']; +const recoverableReasons: readonly string[] = ['missing', 'invalid', 'insufficient-trust']; + +/** Header-only recognition keeps the application response body/stream untouched. */ +export function readClearanceChallenge(response: Response): ClearanceChallenge | null { + if (response.status !== 403 || response.headers.get('x-wd-clearance') !== 'challenged' || response.headers.get('x-wd-challenge') !== '1') return null; + const reason = response.headers.get('x-wd-challenge-reason'); + const recovery = response.headers.get('x-wd-challenge-recoverable'); + if (!reason || !reasons.includes(reason) || (recovery !== 'true' && recovery !== 'false')) return null; + // An erroneous/malicious recoverable bit can never override a policy refusal. + return { version: 1, reason: reason as ClearanceChallengeReason, recoverable: recovery === 'true' && recoverableReasons.includes(reason) }; +} + +function abortError(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The operation was aborted', 'AbortError'); +} + +function abortable(promise: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(abortError(signal)); return; } + const onAbort = () => reject(abortError(signal)); + signal.addEventListener('abort', onAbort, { once: true }); + void promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +interface Recovery { + controller: AbortController; + promise: Promise; + users: number; +} + +/** + * GET/HEAD only, at most one retry per call. A callback's success is not trust: + * the validator re-evaluates the retried request. Refusal/timeout returns the + * original challenge response; caller cancellation and fetch errors reject. + */ +export function createClearanceFetch(options: ClearanceFetchOptions): typeof fetch { + const parsedOrigin = new URL(options.origin); + if (!['http:', 'https:'].includes(parsedOrigin.protocol) || parsedOrigin.username || parsedOrigin.password || parsedOrigin.pathname !== '/' || parsedOrigin.search || parsedOrigin.hash) throw new TypeError('A protected HTTP(S) origin is required'); + const origin = parsedOrigin.origin; + const timeoutMs = options.timeoutMs ?? 10_000; + if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) throw new RangeError('timeoutMs must be between 1 and 60000'); + const nativeFetch = options.fetch ?? globalThis.fetch.bind(globalThis); + let recovery: Recovery | undefined; + + function observe(type: ClearanceFetchEvent['type'], method: string, challenge: ClearanceChallenge, status?: number): void { + try { options.onEvent?.({ type, origin, method, challenge, status }); } catch { /* Observation cannot change a request. */ } + } + + function joinRecovery(challenge: ClearanceChallenge): Recovery { + if (!recovery) { + const controller = new AbortController(); + const entry: Recovery = { controller, users: 0, promise: Promise.resolve(false) }; + recovery = entry; + const timer = setTimeout(() => controller.abort(new DOMException('Clearance recovery timed out', 'TimeoutError')), timeoutMs); + controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true }); + // Defer invocation until the entry is installed, including synchronous callbacks. + const actual = Promise.resolve().then(() => options.recover({ origin, challenge, signal: controller.signal })); + entry.promise = abortable(actual, controller.signal).then(value => value === true, () => false); + // If a resolver ignores cancellation, keep it coalesced until it settles: + // another request must not launch a second still-running recovery UI. + const settled = () => { clearTimeout(timer); if (recovery === entry) recovery = undefined; }; + void actual.then(settled, settled); + } + recovery.users++; + return recovery; + } + + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + // Pass mutations/streams through verbatim: no cloning or body buffering. + if (method !== 'GET' && method !== 'HEAD') { + const response = await nativeFetch(input, init); + const url = input instanceof Request ? input.url : String(input); + const target = new URL(url, typeof document !== 'undefined' ? document.baseURI : undefined); + const challenge = readClearanceChallenge(response); + if (challenge && target.origin === origin && !response.redirected && (!response.url || new URL(response.url).origin === origin)) observe('challenge-served', method, challenge, response.status); + return response; + } + const requestInput = input instanceof Request ? input : new URL(String(input), typeof document !== 'undefined' ? document.baseURI : undefined).href; + const request = new Request(requestInput, init); + const response = await nativeFetch(request); + const challenge = readClearanceChallenge(response); + if (!challenge || new URL(request.url).origin !== origin || response.redirected || (response.url && new URL(response.url).origin !== origin)) return response; + observe('challenge-served', method, challenge, response.status); + const pageOrigin = typeof location !== 'undefined' ? location.origin : origin; + if (!challenge.recoverable || request.credentials === 'omit' || (pageOrigin !== origin && request.credentials !== 'include') || (typeof navigator !== 'undefined' && navigator.onLine === false) || typeof AbortSignal.any !== 'function') return response; + if (request.signal.aborted) throw abortError(request.signal); + + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(abortError(request.signal)); + request.signal.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout(() => { timedOut = true; controller.abort(new DOMException('Clearance recovery timed out', 'TimeoutError')); }, timeoutMs); + const entry = joinRecovery(challenge); + observe('recovery-attempted', method, challenge); + let released = false; + const release = () => { + if (released) return; + released = true; + if (--entry.users === 0) entry.controller.abort(); + }; + try { + const ready = await abortable(entry.promise, controller.signal); + release(); + if (!ready || controller.signal.aborted) { + observe('recovery-failed', method, challenge); + return response; + } + // The caller's signal must still cancel response-body consumption after + // we return the headers and remove the recovery listener below. + const retrySignal = AbortSignal.any([request.signal, controller.signal]); + const retried = await abortable(nativeFetch(new Request(request, { signal: retrySignal })), controller.signal); + observe('enforcement-outcome', method, readClearanceChallenge(retried) ?? challenge, retried.status); + observe(retried.ok ? 'recovery-succeeded' : 'recovery-failed', method, challenge, retried.status); + return retried; + } catch (error) { + observe('recovery-failed', method, challenge); + if (request.signal.aborted) throw abortError(request.signal); + if (timedOut) return response; + throw error; + } finally { + release(); + clearTimeout(timer); + request.signal.removeEventListener('abort', onAbort); + } + }; +} diff --git a/packages/client/src/global.ts b/packages/client/src/global.ts index 178e723..bc5ac34 100644 --- a/packages/client/src/global.ts +++ b/packages/client/src/global.ts @@ -12,11 +12,13 @@ import { WebDecoyCaptcha } from './client'; import { startClearance } from './clearance'; +import { createClearanceFetch, readClearanceChallenge } from './clearance-fetch'; // Capture the script tag before any async hop invalidates document.currentScript. const selfScript = document.currentScript as HTMLScriptElement | null; (window as unknown as Record).WebDecoyCaptcha = WebDecoyCaptcha; +(window as unknown as Record).WebDecoyClearance = { createClearanceFetch, readClearanceChallenge }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => WebDecoyCaptcha.autoInit()); @@ -34,3 +36,4 @@ if (siteKey) { } export { WebDecoyCaptcha }; +export { createClearanceFetch, readClearanceChallenge }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 91b9e5d..1e1f6eb 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -27,6 +27,8 @@ export { FormAnalyzer, getFormAnalyzer } from './collectors/form'; export { sha256 } from './sha256'; export { startClearance, computeDeviceFP, CLEARANCE_COOKIE, FP_VERSION, DEFAULT_INGEST_URL } from './clearance'; export type { ClearanceOptions, DeviceFPInputs } from './clearance'; +export { createClearanceFetch, readClearanceChallenge } from './clearance-fetch'; +export type { ClearanceChallenge, ClearanceChallengeReason, ClearanceRecoveryContext, ClearanceFetchOptions, ClearanceFetchEvent } from './clearance-fetch'; // Behavioral human-likelihood. Exported so the collection contract is // inspectable by anyone integrating — summarizeBehavior IS the published list // of what we send. diff --git a/packages/express/package.json b/packages/express/package.json index 6aaa6de..d5a0eb0 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/express", - "version": "0.18.5", + "version": "0.19.0", "description": "Web Decoy middleware for Express.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,7 +40,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.18.5" + "@webdecoy/node": "^0.19.0" }, "peerDependencies": { "express": "^4.18.0 || ^5.0.0" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 82ae7a7..1139d8d 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/fastify", - "version": "0.18.5", + "version": "0.19.0", "description": "Web Decoy plugin for Fastify", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,7 +40,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.18.5", + "@webdecoy/node": "^0.19.0", "fastify-plugin": "^4.5.1" }, "peerDependencies": { diff --git a/packages/hono/package.json b/packages/hono/package.json index 26134b4..0517db4 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,7 +1,7 @@ { "name": "@webdecoy/hono", - "version": "0.18.5", - "description": "Web Decoy middleware for Hono — Cloudflare Workers, Bun, Deno, Node", + "version": "0.19.0", + "description": "Web Decoy middleware for Hono \u2014 Cloudflare Workers, Bun, Deno, Node", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -44,7 +44,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.18.5" + "@webdecoy/node": "^0.19.0" }, "peerDependencies": { "hono": "^4.0.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6a0ddf5..cdada32 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/nextjs", - "version": "0.18.5", + "version": "0.19.0", "description": "Web Decoy middleware for Next.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,7 +42,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.18.5" + "@webdecoy/node": "^0.19.0" }, "peerDependencies": { "next": ">=13.0.0" diff --git a/packages/webdecoy/package.json b/packages/webdecoy/package.json index 7ce321e..1be9e20 100644 --- a/packages/webdecoy/package.json +++ b/packages/webdecoy/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/node", - "version": "0.18.5", + "version": "0.19.0", "description": "Web Decoy SDK for Node.js - Bot detection with TLS fingerprinting", "main": "./dist/index.js", "types": "./dist/index.d.ts",