From 90f1794d09a177365bdd9b9d82a0b7f169ae8d5c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 8 Oct 2026 22:13:18 -0500 Subject: [PATCH 1/3] fix(security): send credential headers by name only in detection reports Detection reports carried the request's full header map, so a visitor's session cookie, bearer token or API key header was sent with every report. toWireDetectionRequest now replaces credential header values with an empty string and keeps only WebDecoy's own wd_clearance cookie. Rules still see the full request. --- CHANGELOG.md | 4 + packages/webdecoy/src/client.ts | 34 +++--- packages/webdecoy/src/invariants.test.ts | 19 +++ packages/webdecoy/src/report-headers.test.ts | 119 +++++++++++++++++++ packages/webdecoy/src/report-headers.ts | 49 ++++++++ packages/webdecoy/src/tls-info-wire.test.ts | 2 +- packages/webdecoy/src/types.ts | 5 +- 7 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 packages/webdecoy/src/report-headers.test.ts create mode 100644 packages/webdecoy/src/report-headers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d7411..cbf2874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Credential headers never leave your server.** Detection reports carried the request's full header map, so a visitor's session cookie, `Authorization` bearer token or API key header was sent to WebDecoy with every report. Reports now send those headers by name only, with an empty value; the one cookie kept is WebDecoy's own `wd_clearance`. Rules and characteristics still see the full request, so a rate-limit key derived from `x-api-key` behaves as before. Credential headers are matched by name (`cookie`, `auth`, `token`, `secret`, `password`, `session`, `csrf`, `api-key` and similar), and WebDecoy's ingest service applies the same rule to reports from older SDK versions. + ## [0.18.4] - 2026-10-02 ### Fixed diff --git a/packages/webdecoy/src/client.ts b/packages/webdecoy/src/client.ts index e27a666..92f01b8 100644 --- a/packages/webdecoy/src/client.ts +++ b/packages/webdecoy/src/client.ts @@ -9,6 +9,7 @@ import type { AIReferralBatch } from './referrals/referral-counter'; import { SDKDetectionRequest, SDKDetectionResponse } from './types'; +import { reportableHeaders } from './report-headers'; import type { ViolationEvent, IPEnrichmentData } from './rules/types'; export interface ClientConfig { @@ -295,21 +296,26 @@ export class WebDecoyClient { } /** - * Shape a detection request for the wire. The detection service reads the - * client's ALPN list from `tls_info.alpn`; the SDK once documented it as - * `alpn_protocols`, which the service ignores. Callers still passing the old - * name get it sent under the name the service reads, and the old key is never - * sent. + * Shape a detection request for the wire. The one place a report takes its + * final form, so everything it must never carry is removed here. + * + * Credential header values (cookies, bearer tokens, API keys) are dropped; see + * {@link reportableHeaders}. + * + * The detection service reads the client's ALPN list from `tls_info.alpn`; the + * SDK once documented it as `alpn_protocols`, which the service ignores. + * Callers still passing the old name get it sent under the name the service + * reads, and the old key is never sent. */ export function toWireDetectionRequest(request: SDKDetectionRequest): SDKDetectionRequest { - const tls = request.request_metadata.tls_info; - if (!tls || tls.alpn_protocols === undefined) return request; - const { alpn_protocols, ...rest } = tls; - return { - ...request, - request_metadata: { - ...request.request_metadata, - tls_info: { ...rest, alpn: rest.alpn ?? alpn_protocols }, - }, + const metadata = { + ...request.request_metadata, + headers: reportableHeaders(request.request_metadata.headers ?? {}), }; + const tls = metadata.tls_info; + if (tls && tls.alpn_protocols !== undefined) { + const { alpn_protocols, ...rest } = tls; + metadata.tls_info = { ...rest, alpn: rest.alpn ?? alpn_protocols }; + } + return { ...request, request_metadata: metadata }; } diff --git a/packages/webdecoy/src/invariants.test.ts b/packages/webdecoy/src/invariants.test.ts index 3e8768b..57d789a 100644 --- a/packages/webdecoy/src/invariants.test.ts +++ b/packages/webdecoy/src/invariants.test.ts @@ -209,3 +209,22 @@ describe('the edge build stays edge-compatible', () => { } }); }); + +describe('one answer to "what may a report carry"', () => { + it('every detection report is shaped by toWireDetectionRequest', () => { + // That function removes credential header values. A second call site for + // the detect endpoint would send the visitor's cookies and tokens again, + // and read perfectly reasonably while doing it. + const callers = sourceFiles().flatMap((f) => hits(f, /\/api\/v1\/sdk\/detect\b/)); + expect(callers).toHaveLength(1); + expect(callers[0]).toMatch(/webdecoy[\\/]src[\\/]client\.ts:/); + + const client = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'client.ts'), 'utf8'); + const call = client.slice( + client.indexOf("'/api/v1/sdk/detect'"), + client.indexOf("'/api/v1/sdk/detect'") + 120 + ); + expect(call).toContain('toWireDetectionRequest(request)'); + expect(client).toMatch(/headers: reportableHeaders\(/); + }); +}); diff --git a/packages/webdecoy/src/report-headers.test.ts b/packages/webdecoy/src/report-headers.test.ts new file mode 100644 index 0000000..717583b --- /dev/null +++ b/packages/webdecoy/src/report-headers.test.ts @@ -0,0 +1,119 @@ +import { WebDecoyClient, toWireDetectionRequest } from './client'; +import { CREDENTIAL_HEADER, reportableHeaders } from './report-headers'; +import type { SDKDetectionRequest } from './types'; + +function request(headers: Record): SDKDetectionRequest { + return { + request_metadata: { method: 'GET', path: '/', ip: '203.0.113.7', headers, timestamp: 0 }, + local_analysis: { + suspicious_headers: false, + missing_sec_ch_ua: false, + datacenter_ip: false, + local_score: 0, + needs_verification: true, + flags: [], + }, + }; +} + +// What the Express, Fastify, Next.js and Hono adapters hand the SDK: the +// request's full header map, lowercased. +const visitor = { + 'user-agent': 'Mozilla/5.0', + accept: 'text/html', + cookie: 'sid=visitor-session; wd_clearance=v1.token; theme=dark', + authorization: 'Bearer visitor-token', + 'proxy-authorization': 'Basic dXNlcjpwYXNz', + 'x-api-key': 'k_live_123', + 'x-csrf-token': 'c', + 'x-hasura-admin-secret': 's', + 'x-middleware-subrequest': 'middleware', + 'signature-agent': '"https://agent.example"', +}; + +describe('credential headers in detection reports', () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + }); + + it('never sends a credential value, and keeps every name', async () => { + let body = ''; + global.fetch = jest.fn(async (_url: any, init: any) => { + body = init.body; + return new Response( + JSON.stringify({ + decision: 'allow', + confidence: 0, + threat_level: 'MINIMAL', + bot_detected: false, + detection_id: 'd-1', + rule_enforced: false, + }), + { status: 200 }, + ); + }) as any; + const client = new WebDecoyClient({ + apiKey: 'sk_test_key', + apiUrl: 'https://ingest.example', + timeout: 1000, + debug: false, + tlsRejectUnauthorized: true, + }); + await client.detect(request({ ...visitor })); + + for (const secret of [ + 'visitor-session', + 'visitor-token', + 'dXNlcjpwYXNz', + 'k_live_123', + 'theme=dark', + ]) { + expect(body).not.toContain(secret); + } + const sent = JSON.parse(body).request_metadata.headers; + expect(Object.keys(sent).sort()).toEqual(Object.keys(visitor).sort()); + expect(sent['user-agent']).toBe('Mozilla/5.0'); + expect(sent['x-middleware-subrequest']).toBe('middleware'); + expect(sent['signature-agent']).toBe('"https://agent.example"'); + }); + + it("keeps WebDecoy's own clearance cookie and nothing else", () => { + expect(reportableHeaders({ cookie: 'a=1; wd_clearance=v1.tok=en; b=2' }).cookie).toBe( + 'wd_clearance=v1.tok=en' + ); + expect(reportableHeaders({ Cookie: 'sid=1' }).Cookie).toBe(''); + expect(reportableHeaders({ cookie: 'not_wd_clearance=x' }).cookie).toBe(''); + }); + + it('leaves the request the rules see untouched', () => { + const req = request({ ...visitor }); + toWireDetectionRequest(req); + expect(req.request_metadata.headers).toEqual(visitor); + }); + + it('matches credential names, not the evidence', () => { + for (const name of [ + 'Cookie', + 'set-cookie', + 'authorization', + 'x-auth-token', + 'x-vault-token', + 'apikey', + 'x-session-id', + ]) { + expect(CREDENTIAL_HEADER.test(name)).toBe(true); + } + for (const name of [ + 'user-agent', + 'accept', + 'referer', + 'x-forwarded-for', + 'sec-ch-ua', + 'signature', + 'next-action', + ]) { + expect(CREDENTIAL_HEADER.test(name)).toBe(false); + } + }); +}); diff --git a/packages/webdecoy/src/report-headers.ts b/packages/webdecoy/src/report-headers.ts new file mode 100644 index 0000000..f51432d --- /dev/null +++ b/packages/webdecoy/src/report-headers.ts @@ -0,0 +1,49 @@ +/** + * The request headers a detection report may carry. + * + * A credential header belongs to your visitor: their session cookie, their + * bearer token, an API key. WebDecoy never needs its value to score a request, + * so the value never leaves your server. The name is still sent, because a + * client that guesses an admin secret is itself evidence. + * + * Only the outgoing report is redacted. Rules and characteristics run on the + * full request before this, so a rate-limit key derived from `x-api-key` works + * exactly as before. + */ + +/** + * Header names whose values are credentials. A substring pattern rather than a + * list, so names nobody listed (`x-hasura-admin-secret`, `x-vault-token`) are + * caught too. WebDecoy's ingest service enforces the same pattern on its side, + * so an SDK that predates this file is covered as well. + */ +export const CREDENTIAL_HEADER = + /cookie|auth|token|secret|passw|session|credential|csrf|xsrf|api[-_]?key|access[-_]?key|private[-_]?key/i; + +/** + * WebDecoy's own clearance cookie. It is issued by WebDecoy, carries no + * credential for your site, and is how a cleared client is recognised across + * requests, so it is the one cookie a report keeps. + */ +const CLEARANCE_COOKIE = 'wd_clearance'; + +/** `headers` with every credential value removed. Names are kept. */ +export function reportableHeaders(headers: Record): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (!CREDENTIAL_HEADER.test(name)) { + out[name] = value; + continue; + } + out[name] = name.toLowerCase() === 'cookie' ? clearanceCookieOnly(value) : ''; + } + return out; +} + +function clearanceCookieOnly(cookie: string): string { + return String(cookie ?? '') + .split(';') + .map((pair) => pair.trim()) + .filter((pair) => pair.slice(0, pair.indexOf('=')).trim() === CLEARANCE_COOKIE) + .join('; '); +} diff --git a/packages/webdecoy/src/tls-info-wire.test.ts b/packages/webdecoy/src/tls-info-wire.test.ts index dd7fb52..c29eeda 100644 --- a/packages/webdecoy/src/tls-info-wire.test.ts +++ b/packages/webdecoy/src/tls-info-wire.test.ts @@ -81,6 +81,6 @@ describe('tls_info on the wire', () => { it('leaves a request without the deprecated name untouched', () => { const req = request({ cipher_suites: [4865], alpn: ['h2'] }); - expect(toWireDetectionRequest(req)).toBe(req); + expect(toWireDetectionRequest(req)).toEqual(req); }); }); diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index 14c4713..5d68dbb 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -191,7 +191,10 @@ export interface RequestMetadata { /** User-Agent header */ user_agent?: string; - /** All request headers */ + /** + * All request headers. Rules see every value; a detection report sends + * credential headers (cookies, tokens, API keys) by name only. + */ headers: Record; /** From aaf4acb4133b52e485eac59cb36b846b19b510a5 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Mon, 12 Oct 2026 19:53:29 -0500 Subject: [PATCH 2/3] Fix browser clearance renewal and prepare SDK 0.18.5 (#63) * Keep browser clearance renewal running across token lifetimes * Prepare SDK 0.18.5 release and document clearance lifecycle --- CHANGELOG.md | 8 + README.md | 8 + llms.txt | 9 + package-lock.json | 20 +- packages/client/README.md | 25 +++ packages/client/package.json | 2 +- packages/client/src/clearance-renewal.test.ts | 146 ++++++++++++ packages/client/src/clearance.ts | 212 +++++++++++++----- packages/express/package.json | 4 +- packages/fastify/package.json | 4 +- packages/hono/package.json | 4 +- packages/nextjs/package.json | 4 +- packages/webdecoy/package.json | 2 +- 13 files changed, 372 insertions(+), 76 deletions(-) create mode 100644 packages/client/src/clearance-renewal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d7411..adaa3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.5] - 2026-10-12 + +### Fixed + +- **Browser clearance keeps renewing during long sessions.** The refresh callback previously returned while the cookie was present and scheduled no successor; starting with an existing cookie also scheduled no renewal. `startClearance()` now keeps one lifecycle per page, renews before expiry, notices cookie changes from another tab, and checks again when the page becomes visible. Repeated startup and behavioral upgrades share requests and cached fingerprint collection. +- **Clearance mint failures are bounded.** Requests time out after 10 seconds and transient failures back off from one minute to five minutes. An explicit server refusal stops automatic attempts on that page. Cookie expiry is only a bounded scheduling hint; server and edge verification remain authoritative. +- **Accepted behavioral aggregates remain on renewal requests.** The server re-evaluates the evidence on each mint. Browser clearance still does not intercept or automatically replay application fetch/XHR requests. + ## [0.18.4] - 2026-10-02 ### Fixed diff --git a/README.md b/README.md index 901a66b..98cfea2 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,14 @@ if (!result.allowed) { | [@webdecoy/hono](https://www.npmjs.com/package/@webdecoy/hono) | [![npm](https://img.shields.io/npm/v/@webdecoy/hono.svg)](https://www.npmjs.com/package/@webdecoy/hono) | Hono middleware (Workers, Bun, Deno) | | [@webdecoy/client](https://www.npmjs.com/package/@webdecoy/client) | [![npm](https://img.shields.io/npm/v/@webdecoy/client.svg)](https://www.npmjs.com/package/@webdecoy/client) | Browser-side signal collector | +## Browser session clearance + +`@webdecoy/client` 0.18.5 or later keeps browser clearance renewing during long +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). + ## One bot policy, published and enforced `botPolicy()` produces both the `robots.txt` you publish and the rule that diff --git a/llms.txt b/llms.txt index 673d8bf..1a84bb2 100644 --- a/llms.txt +++ b/llms.txt @@ -49,6 +49,15 @@ dashboard. - `@webdecoy/client` — browser signal collector and proof-of-work captcha - `@webdecoy/node/testing` — test-suite helpers, offline by default +Browser session clearance (`@webdecoy/client` 0.18.5+): call +`startClearance({ siteKey: 'YOUR_PUBLISHABLE_ORGANIZATION_ID' })`. Startup is +deferred to idle; fresh cookies are reused and renewed before expiry. Repeated +startup shares one lifecycle per page. Mint requests time out after 10 seconds; +transient failures back off from one minute to five minutes. Explicit refusal +stops automatic attempts on that page. Cookie expiry is only a scheduling hint; +server/edge verification remains authoritative. The client does not intercept +or replay application fetch/XHR requests. See packages/client/README.md. + For Bun, Deno, Astro, Nitro, SvelteKit or Remix use `createFetchGuard()` from `@webdecoy/node` directly — no adapter package needed. diff --git a/package-lock.json b/package-lock.json index bb9669b..3f7adb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10407,7 +10407,7 @@ }, "packages/client": { "name": "@webdecoy/client", - "version": "0.18.4", + "version": "0.18.5", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", @@ -10424,10 +10424,10 @@ }, "packages/express": { "name": "@webdecoy/express", - "version": "0.18.4", + "version": "0.18.5", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.4" + "@webdecoy/node": "^0.18.5" }, "devDependencies": { "@types/express": "^4.17.21", @@ -10448,10 +10448,10 @@ }, "packages/fastify": { "name": "@webdecoy/fastify", - "version": "0.18.4", + "version": "0.18.5", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.4", + "@webdecoy/node": "^0.18.5", "fastify-plugin": "^4.5.1" }, "devDependencies": { @@ -10472,10 +10472,10 @@ }, "packages/hono": { "name": "@webdecoy/hono", - "version": "0.18.4", + "version": "0.18.5", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.4" + "@webdecoy/node": "^0.18.5" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10495,10 +10495,10 @@ }, "packages/nextjs": { "name": "@webdecoy/nextjs", - "version": "0.18.4", + "version": "0.18.5", "license": "MIT", "dependencies": { - "@webdecoy/node": "^0.18.4" + "@webdecoy/node": "^0.18.5" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -10518,7 +10518,7 @@ }, "packages/webdecoy": { "name": "@webdecoy/node", - "version": "0.18.4", + "version": "0.18.5", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.11", diff --git a/packages/client/README.md b/packages/client/README.md index 51059b2..4b21908 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -65,6 +65,31 @@ const result = await WebDecoyCaptcha.execute('pk_live_...', { action: 'login' }) if (result.success) { /* result.token */ } ``` +## Session clearance + +```js +import { startClearance } from '@webdecoy/client'; + +startClearance({ siteKey: 'YOUR_PUBLISHABLE_ORGANIZATION_ID' }); +``` + +With version **0.18.5 or later**, the client defers initial minting to browser +idle, reuses fresh `wd_clearance` cookies, and renews before expiry throughout +long sessions. Repeated startup shares one lifecycle and collected fingerprint +per page. Cookie changes from another tab are noticed during periodic checks; +returning to a visible page checks clearance again. + +Mint requests time out after 10 seconds. Transient failures retry with backoff +from one minute to five minutes; explicit server refusal stops automatic +attempts on that page. Unsigned expiry is only a scheduling hint. The server and +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. + ## What it collects | Group | Signals | diff --git a/packages/client/package.json b/packages/client/package.json index 380b859..fdb448c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/client", - "version": "0.18.4", + "version": "0.18.5", "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-renewal.test.ts b/packages/client/src/clearance-renewal.test.ts new file mode 100644 index 0000000..edd57d6 --- /dev/null +++ b/packages/client/src/clearance-renewal.test.ts @@ -0,0 +1,146 @@ +/** @jest-environment jsdom */ +import { startClearance, _resetClearanceForTests } from './clearance'; +import { EnvironmentalCollector } from './collectors/environment'; +import { watchInteraction } from './clearance-behavior'; +jest.mock('./sha256', () => ({ sha256: jest.fn(async () => 'test-fingerprint') })); +jest.mock('./collectors/environment', () => ({ EnvironmentalCollector: jest.fn().mockImplementation(() => ({ + _getCanvasHash: () => null, _getWebGLInfo: () => null, +})) })); +jest.mock('./clearance-behavior', () => ({ watchInteraction: jest.fn() })); +const opts = { siteKey: 'test-site', behavior: false }; +let cookie = ''; +let fetchMock: jest.Mock; +const token = (ttl = 1800) => btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttl })) + '.fixture-signature'; +const response = (ttl = 1800) => ({ ok: true, json: async () => ({ granted: true, token: token(ttl), expires_in: ttl }) }); +const advance = async (ms: number) => { await jest.advanceTimersByTimeAsync(ms); }; +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-10-12T00:00:00Z')); + cookie = ''; + Object.defineProperty(document, 'cookie', { configurable: true, get: () => cookie, set: value => { cookie = value.split(';')[0]; } }); + fetchMock = jest.fn(async () => response()); + globalThis.fetch = fetchMock; + jest.clearAllMocks(); +}); +afterEach(() => { _resetClearanceForTests(); jest.useRealTimers(); }); +test('active clearance renews through two lifetimes and reuses collected fingerprint', async () => { + startClearance(opts); + await advance(1200); + expect(fetchMock).toHaveBeenCalledTimes(1); + await advance(3_600_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(EnvironmentalCollector).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); +}); +test('startup with fresh cookie schedules renewal without minting on every start', async () => { + cookie = 'wd_clearance=' + token(); + startClearance(opts); startClearance(opts); startClearance(opts); + await advance(1200); + expect(fetchMock).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(1); + await advance(1_680_000); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); +test('an existing cookie with unknown expiry has bounded renewal', async () => { + cookie = 'wd_clearance=legacy-cookie'; + startClearance(opts); + await advance(1200); + expect(fetchMock).not.toHaveBeenCalled(); + await advance(120_000); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); +test('network failures back off and visibility events cannot bypass the delay', async () => { + fetchMock.mockRejectedValue(new Error('offline')); + startClearance(opts); + await advance(1200); + expect(fetchMock).toHaveBeenCalledTimes(1); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + for (let i = 0; i < 5; i++) document.dispatchEvent(new Event('visibilitychange')); + await advance(59_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await advance(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + await advance(120_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(jest.getTimerCount()).toBe(1); +}); +test('a refused renewal is terminal and cannot replace clearance', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ granted: false }) }); + startClearance(opts); + await advance(1200); + await advance(3_600_000); + document.dispatchEvent(new Event('visibilitychange')); + startClearance(opts); + await advance(1200); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cookie).toBe(''); + expect(jest.getTimerCount()).toBe(0); +}); +test('short server TTLs do not create a rapid renewal loop', async () => { + fetchMock.mockImplementation(async () => response(60)); + startClearance(opts); + await advance(1200); + await advance(39_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await advance(1); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); +test('a cookie renewed by another tab postpones local minting', async () => { + cookie = 'wd_clearance=' + token(); + startClearance(opts); + await advance(1200); + await advance(1_600_000); + cookie = 'wd_clearance=' + token(); + await advance(80_000); + expect(fetchMock).not.toHaveBeenCalled(); +}); +test('a hung mint is aborted and retries with bounded delay', async () => { + fetchMock.mockImplementation((_url, init) => new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(new Error('aborted'))); + })); + startClearance(opts); + await advance(1200); + await advance(10_000); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); + expect(jest.getTimerCount()).toBe(1); + await advance(60_000); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); +test('repeated startup coalesces an in-flight mint', async () => { + let finish!: (value: ReturnType) => void; + fetchMock.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + startClearance(opts); startClearance(opts); + await advance(1200); + document.dispatchEvent(new Event('visibilitychange')); + startClearance(opts); + expect(fetchMock).toHaveBeenCalledTimes(1); + finish(response()); + await advance(0); + expect(jest.getTimerCount()).toBe(1); +}); +test('behavioral upgrade shares the lifecycle and keeps accepted evidence on renewal', async () => { + startClearance({ ...opts, behavior: true }); + await advance(1200); + const behavior = { samples: 30, duration_ms: 2000 }; + (watchInteraction as jest.Mock).mock.calls[0][0](behavior); + await advance(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + await advance(1_680_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(JSON.parse(fetchMock.mock.calls[2][1].body).behavior).toEqual(behavior); + expect(EnvironmentalCollector).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); +}); +test('native idle scheduling is bound to the window and has a deadline', async () => { + const ric = jest.fn(function(this: Window, callback: () => void, options: { timeout: number }) { + expect(this).toBe(window); + expect(options.timeout).toBe(1200); + callback(); + }); + Object.defineProperty(window, 'requestIdleCallback', { configurable: true, value: ric }); + try { + startClearance(opts); + await advance(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { delete (window as unknown as { requestIdleCallback?: unknown }).requestIdleCallback; } +}); diff --git a/packages/client/src/clearance.ts b/packages/client/src/clearance.ts index e43667b..ac8a250 100644 --- a/packages/client/src/clearance.ts +++ b/packages/client/src/clearance.ts @@ -8,8 +8,8 @@ * normal browsing so the loop covers monitor mode, not just the enforce-mode * challenge interstitial. * - * PERFORMANCE: the mint is deferred to idle, runs at most once per session (skips - * when a fresh cookie already exists), and never solves a proof-of-work. The only + * PERFORMANCE: the initial mint is deferred to idle, fresh cookies are reused, + * renewal is bounded, and minting never solves a proof-of-work. The only * device-tier work — one canvas + one WebGL read — is done by the collector, once. * computeDeviceFP itself does NO canvas/WebGL: it consumes the collector's output. */ @@ -88,6 +88,12 @@ interface MintResponse { expires_in?: number; } +type MintResult = { token: string; expiresIn: number } | { denied: true } | null; +const REFRESH_MARGIN_MS = 120_000; +const COOKIE_CHECK_MS = 60_000; +const MAX_TOKEN_AGE_MS = 1_800_000; +const MINT_TIMEOUT_MS = 10_000; + /** Call the public issuance endpoint. Returns null on any failure (fail open). * `behavior`, when present, carries the session's interaction aggregates and * can earn the token a graded 'human-likely' trust level. */ @@ -97,7 +103,9 @@ async function mint( fp: string, scope: string, behavior?: BehaviorAggregate, -): Promise<{ token: string; expiresIn: number } | null> { +): Promise { + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), MINT_TIMEOUT_MS); try { const res = await fetch(ingestUrl.replace(/\/$/, '') + '/api/v1/clearance', { method: 'POST', @@ -111,14 +119,18 @@ async function mint( headless: /HeadlessChrome/.test(navigator.userAgent), ...(behavior ? { behavior } : {}), }), + signal: abort.signal, }); + if (!res.ok) return null; const out = (await res.json()) as MintResponse; if (out.granted && out.token) { - return { token: out.token, expiresIn: out.expires_in || 1800 }; + const ttl = out.expires_in; + return { token: out.token, expiresIn: typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 1800) : 1800 }; } + if (out.granted === false) return { denied: true }; } catch { /* fail open */ - } + } finally { clearTimeout(timeout); } return null; } @@ -139,10 +151,10 @@ function setClearanceCookie(token: string, maxAgeSec: number): void { /** Defer work to browser idle time, with a timeout fallback. */ function whenIdle(fn: () => void): void { - const ric = (window as unknown as { requestIdleCallback?: (cb: () => void) => void }) + const ric = (window as unknown as { requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => void }) .requestIdleCallback; if (typeof ric === 'function') { - ric(fn); + ric.call(window, fn, { timeout: 1200 }); } else { setTimeout(fn, 1200); } @@ -172,80 +184,158 @@ export interface ClearanceOptions { behavior?: boolean; } -/** One behavioral upgrade per page load, no matter how many times clearance is - * started or refreshed — the token refresh loop re-enters startClearance, and - * each entry must not attach another set of listeners. */ +/** One behavioral upgrade per page load; repeated startup must not add listeners. */ let upgradeStarted = false; +interface ClearanceSession { + key: string; + opts: ClearanceOptions; + token: string | null; + expiresAt: number; + renewAt: number; + retryAt: number; + timer?: ReturnType; + running: boolean; + stopped: boolean; + failures: number; + fingerprint?: Promise; + behavior?: BehaviorAggregate; + lastBehavior?: BehaviorAggregate; + wake: () => void; +} +let session: ClearanceSession | undefined; + +// The unsigned payload is ONLY a scheduling hint. Server and edge verification +// remain authoritative. Remember the bound on first seeing a token, so a bogus +// distant expiry cannot postpone renewal forever. +function expiryHint(token: string): number { + try { + const encoded = token.split('.')[0]; + if (encoded.length > 8192) return Date.now() + COOKIE_CHECK_MS + REFRESH_MARGIN_MS; + const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const exp = JSON.parse(atob(base64)).exp; + if (typeof exp === 'number' && Number.isFinite(exp) && exp > 0) { + return Math.min(exp * 1000, Date.now() + MAX_TOKEN_AGE_MS); + } + } catch { /* old or malformed cookies get one bounded grace period */ } + return Date.now() + COOKIE_CHECK_MS + REFRESH_MARGIN_MS; +} + +function schedule(s: ClearanceSession, delay: number): void { + if (s.timer !== undefined) clearTimeout(s.timer); + if (s !== session || s.stopped) return; + s.timer = setTimeout(() => { s.timer = undefined; void maintain(s); }, Math.max(1000, delay)); +} + +async function maintain(s: ClearanceSession): Promise { + if (s !== session || s.stopped || s.running) return; + const token = readCookie(CLEARANCE_COOKIE); + if (token !== s.token) { + s.token = token; + s.expiresAt = token ? expiryHint(token) : 0; + s.renewAt = s.expiresAt - Math.min(REFRESH_MARGIN_MS, Math.max(0, s.expiresAt - Date.now()) / 3); + } + if (s.retryAt > Date.now()) { + schedule(s, s.retryAt - Date.now()); + return; + } + if (!s.behavior && token && s.renewAt > Date.now()) { + schedule(s, Math.min(COOKIE_CHECK_MS, s.renewAt - Date.now())); + return; + } + s.running = true; + const behavior = s.behavior; + s.behavior = undefined; + try { + if (!s.fingerprint) { + s.fingerprint = (async () => { + const env = new EnvironmentalCollector(); + return computeDeviceFP({ canvasHash: env._getCanvasHash(), webglInfo: env._getWebGLInfo() }); + })(); + } + const fp = await s.fingerprint; + const result = await mint(s.opts.ingestUrl || DEFAULT_INGEST_URL, s.opts.siteKey, fp, s.opts.scope || '', behavior || s.lastBehavior); + if (s !== session) return; // an old in-flight request cannot replace a new site's cookie + if (result && 'token' in result) { + setClearanceCookie(result.token, result.expiresIn); + s.token = readCookie(CLEARANCE_COOKIE); + s.expiresAt = Date.now() + result.expiresIn * 1000; + s.renewAt = s.expiresAt - Math.min(REFRESH_MARGIN_MS, result.expiresIn * 1000 / 3); + s.failures = 0; + if (behavior) s.lastBehavior = behavior; + s.retryAt = 0; + schedule(s, Math.min(COOKIE_CHECK_MS, s.renewAt - Date.now())); + } else if (result && 'denied' in result) { + // Refusal is not an outage. Do not repeatedly attempt to replace denied + // clearance; the edge still decides what the existing cookie may access. + s.stopped = true; + if (s.timer !== undefined) clearTimeout(s.timer); + } else { + s.failures++; + const delay = Math.min(300_000, COOKIE_CHECK_MS * Math.pow(2, Math.min(s.failures - 1, 3))); + s.retryAt = Date.now() + delay; + schedule(s, delay); + } + } catch { + s.fingerprint = undefined; + s.failures++; + const delay = Math.min(300_000, COOKIE_CHECK_MS * Math.pow(2, Math.min(s.failures - 1, 3))); + s.retryAt = Date.now() + delay; + schedule(s, delay); + } finally { + s.running = false; + if (s.behavior && !s.stopped && s === session) schedule(s, 1000); + } +} + /** * Watch for real interaction and, once there is enough of it, re-mint the * session's token with the behavioral evidence attached. * - * The device fingerprint is computed INSIDE the callback, not up front: it is - * the only expensive step (one canvas + one WebGL read), and a session that - * never interacts must never pay for it. A failed upgrade leaves the existing - * clean token untouched. + * Upgrade only after real interaction, sharing the renewal request and cached + * fingerprint. A failed upgrade leaves the existing cookie untouched. */ -function startBehavioralUpgrade(ingestUrl: string, siteKey: string, scope: string): void { +function startBehavioralUpgrade(s: ClearanceSession): void { if (upgradeStarted) return; upgradeStarted = true; watchInteraction((behavior) => { - void (async () => { - try { - const env = new EnvironmentalCollector(); - const fp = await computeDeviceFP({ - canvasHash: env._getCanvasHash(), - webglInfo: env._getWebGLInfo(), - }); - const minted = await mint(ingestUrl, siteKey, fp, scope, behavior); - if (minted) setClearanceCookie(minted.token, minted.expiresIn); - } catch { - /* fail open — the session keeps whatever token it already had */ - } - })(); + if (s !== session || s.stopped) return; + s.behavior = behavior; + void maintain(s); }); } /** - * Start silent clearance minting. Idempotent guard, deferred to idle, at most once - * per session: if a fresh cookie already exists it only schedules a refresh; only - * an absent/near-expiry cookie triggers a collect + mint. Never blocks paint or - * interaction; failures are swallowed (the edge validator fails open regardless). + * Start one silent clearance lifecycle. Initial work is deferred to idle; fresh + * cookies schedule renewal and only absent/near-expiry cookies trigger minting. + * Transient failures back off; server refusals stop automatic attempts. The edge + * remains authoritative, including when the browser cannot obtain clearance. */ export function startClearance(opts: ClearanceOptions): void { if (!opts.siteKey || typeof document === 'undefined') return; const ingestUrl = opts.ingestUrl || DEFAULT_INGEST_URL; - const scope = opts.scope || ''; - - const run = async (): Promise => { - if (readCookie(CLEARANCE_COOKIE)) { - // Already cleared this session; re-check periodically to refresh near expiry. - return; - } - const env = new EnvironmentalCollector(); - const fp = await computeDeviceFP({ - canvasHash: env._getCanvasHash(), - webglInfo: env._getWebGLInfo(), - }); - const minted = await mint(ingestUrl, opts.siteKey, fp, scope); - if (minted) { - setClearanceCookie(minted.token, minted.expiresIn); - // Re-mint a little before expiry so a real browser stays continuously cleared. - const refreshMs = Math.max(60_000, (minted.expiresIn - 120) * 1000); - setTimeout(() => startClearance(opts), refreshMs); + const key = JSON.stringify([ingestUrl.replace(/\/$/, ''), opts.siteKey, opts.scope || '']); + if (!session || session.key !== key) { + if (session) { + if (session.timer !== undefined) clearTimeout(session.timer); + document.removeEventListener('visibilitychange', session.wake); } - }; - - whenIdle(() => { - void run(); - }); + upgradeStarted = false; + const s: ClearanceSession = { + key, opts: { ...opts }, token: null, expiresAt: 0, renewAt: 0, retryAt: 0, running: false, stopped: false, failures: 0, + wake: () => { if (document.visibilityState === 'visible') void maintain(s); }, + }; + session = s; + document.addEventListener('visibilitychange', s.wake); + whenIdle(() => { void maintain(s); }); + } // Independent of the mint above: a session that already holds a token can // still upgrade it once the visitor interacts, and one that mints a fresh // clean token upgrades that. Never blocks, never fires without interaction. if (opts.behavior !== false) { - startBehavioralUpgrade(ingestUrl, opts.siteKey, scope); + startBehavioralUpgrade(session); } } @@ -253,3 +343,13 @@ export function startClearance(opts: ClearanceOptions): void { export function _resetBehavioralUpgradeForTests(): void { upgradeStarted = false; } + +/** Reset the renewal lifecycle. Test-only. */ +export function _resetClearanceForTests(): void { + if (session) { + if (session.timer !== undefined) clearTimeout(session.timer); + document.removeEventListener('visibilitychange', session.wake); + } + session = undefined; + upgradeStarted = false; +} diff --git a/packages/express/package.json b/packages/express/package.json index b52e314..6aaa6de 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/express", - "version": "0.18.4", + "version": "0.18.5", "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.4" + "@webdecoy/node": "^0.18.5" }, "peerDependencies": { "express": "^4.18.0 || ^5.0.0" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 40a0f2b..82ae7a7 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/fastify", - "version": "0.18.4", + "version": "0.18.5", "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.4", + "@webdecoy/node": "^0.18.5", "fastify-plugin": "^4.5.1" }, "peerDependencies": { diff --git a/packages/hono/package.json b/packages/hono/package.json index 881b189..26134b4 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/hono", - "version": "0.18.4", + "version": "0.18.5", "description": "Web Decoy middleware for Hono — Cloudflare Workers, Bun, Deno, Node", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -44,7 +44,7 @@ "url": "https://github.com/WebDecoy/node/issues" }, "dependencies": { - "@webdecoy/node": "^0.18.4" + "@webdecoy/node": "^0.18.5" }, "peerDependencies": { "hono": "^4.0.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 786ee21..6a0ddf5 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/nextjs", - "version": "0.18.4", + "version": "0.18.5", "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.4" + "@webdecoy/node": "^0.18.5" }, "peerDependencies": { "next": ">=13.0.0" diff --git a/packages/webdecoy/package.json b/packages/webdecoy/package.json index ef62039..7ce321e 100644 --- a/packages/webdecoy/package.json +++ b/packages/webdecoy/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/node", - "version": "0.18.4", + "version": "0.18.5", "description": "Web Decoy SDK for Node.js - Bot detection with TLS fingerprinting", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 8f498e4c7f378cedb9b6702fef88cc94ff3d263c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Mon, 12 Oct 2026 20:22:19 -0500 Subject: [PATCH 3/3] Add opt-in bounded API clearance recovery for browser reads (#64) --- CHANGELOG.md | 7 + README.md | 4 +- llms.txt | 2 + package-lock.json | 20 +- packages/client/README.md | 75 ++++++- packages/client/package.json | 2 +- packages/client/src/clearance-fetch.test.ts | 205 ++++++++++++++++++++ packages/client/src/clearance-fetch.ts | 161 +++++++++++++++ packages/client/src/global.ts | 3 + packages/client/src/index.ts | 2 + packages/express/package.json | 4 +- packages/fastify/package.json | 4 +- packages/hono/package.json | 6 +- packages/nextjs/package.json | 4 +- packages/webdecoy/package.json | 2 +- 15 files changed, 477 insertions(+), 24 deletions(-) create mode 100644 packages/client/src/clearance-fetch.test.ts create mode 100644 packages/client/src/clearance-fetch.ts 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",