Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
146 changes: 146 additions & 0 deletions packages/client/src/clearance-renewal.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof response>) => 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; }
});
Loading
Loading