From 7eff8724d38d912d13470651b42a9ed1181e6d5e Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Mon, 12 Oct 2026 19:37:57 -0500 Subject: [PATCH 1/2] Keep browser clearance renewal running across token lifetimes --- packages/client/src/clearance-renewal.test.ts | 146 ++++++++++++ packages/client/src/clearance.ts | 212 +++++++++++++----- 2 files changed, 302 insertions(+), 56 deletions(-) create mode 100644 packages/client/src/clearance-renewal.test.ts 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; +} From a3d5f2df9c1e5d99bd08f1bf25b366cf727cccfb Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Mon, 12 Oct 2026 19:51:00 -0500 Subject: [PATCH 2/2] 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/express/package.json | 4 ++-- packages/fastify/package.json | 4 ++-- packages/hono/package.json | 4 ++-- packages/nextjs/package.json | 4 ++-- packages/webdecoy/package.json | 2 +- 11 files changed, 70 insertions(+), 20 deletions(-) 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/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",