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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.20.0] - 2026-10-20

### Added

- **Clearance enforcement on Vercel, for any framework.** WebDecoy's clearance validator, the same decision the Cloudflare, AWS and Netlify validators make, now runs as Vercel middleware with no proxy in front of the site. On the paths you protect in WebDecoy, a visitor needs a clearance token: a real browser earns one on a short check page and is sent back, a client that runs no JavaScript never gets the page. Follows the site's WebDecoy mode, so it blocks nothing until you switch to Enforce, and it fails open whenever WebDecoy cannot be reached.
- `@webdecoy/node/vercel`: Routing Middleware for Astro, SvelteKit, Nuxt, Remix or static sites (`export { default } from '@webdecoy/node/vercel'`).
- `withClearance()` in `@webdecoy/nextjs` for Next.js middleware.
- `createClearanceGate()` in `@webdecoy/node` for other edge adapters.
- Configured by `WEBDECOY_SITE_KEY`, `WEBDECOY_SCANNER_ID` and `WEBDECOY_SENSOR_KEY`, shown on Enforcement, Setup. Verified search crawlers are recognised by forward-confirmed reverse DNS over HTTPS; signed agents (Web Bot Auth) pass.

## [0.19.1] - 2026-10-15

### Security
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ const wd = new WebDecoy({ rules: [webBotAuth()] }); // denies known-agent impers

Full guide: [**Verify AI agents with Web Bot Auth in Next.js**](docs/verify-ai-agents-web-bot-auth.md).

## Clearance enforcement on Vercel

With a WebDecoy account, the paths you protect can require a clearance token, checked in middleware before the page renders. A real browser earns one automatically; a scraper that never runs JavaScript never gets the page. It works with any framework on Vercel, follows the site's WebDecoy mode (monitor until you switch to enforce) and fails open.

```typescript
// middleware.ts (Astro, SvelteKit, Nuxt, Remix, static)
export { default } from '@webdecoy/node/vercel';

// middleware.ts (Next.js)
import { withClearance } from '@webdecoy/nextjs';
export default withClearance();
```

Set `WEBDECOY_SITE_KEY`, `WEBDECOY_SCANNER_ID` and `WEBDECOY_SENSOR_KEY` from **Enforcement, Setup**. Full guide: [Vercel middleware](https://docs.webdecoy.com/installation/vercel-middleware/).

## What counts as a false positive?

The zero-false-positive claim is about *design*, and it holds if you know the edge cases:
Expand Down
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export default tseslint.config(
// Lint findings here are not actionable: the fix belongs in the
// generator, and the file is overwritten on every regeneration.
'**/*.generated.ts',
// The clearance gate, bundled from WebDecoy's validator source
// (edge/vercel-validator in the main repo). Same reasoning.
'**/*.generated.js',
'**/*.generated.d.ts',
],
},

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.

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.19.1",
"version": "0.20.0",
"description": "Web Decoy browser widget - signal collection, proof-of-work, and captcha UI",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
4 changes: 2 additions & 2 deletions packages/express/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webdecoy/express",
"version": "0.19.1",
"version": "0.20.0",
"description": "Web Decoy middleware for Express.js",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -40,7 +40,7 @@
"url": "https://github.com/WebDecoy/node/issues"
},
"dependencies": {
"@webdecoy/node": "^0.19.1"
"@webdecoy/node": "^0.20.0"
},
"peerDependencies": {
"express": "^4.18.0 || ^5.0.0"
Expand Down
4 changes: 2 additions & 2 deletions packages/fastify/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webdecoy/fastify",
"version": "0.19.1",
"version": "0.20.0",
"description": "Web Decoy plugin for Fastify",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -40,7 +40,7 @@
"url": "https://github.com/WebDecoy/node/issues"
},
"dependencies": {
"@webdecoy/node": "^0.19.1",
"@webdecoy/node": "^0.20.0",
"fastify-plugin": "^4.5.1"
},
"peerDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/hono/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webdecoy/hono",
"version": "0.19.1",
"version": "0.20.0",
"description": "Web Decoy middleware for Hono \u2014 Cloudflare Workers, Bun, Deno, Node",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -44,7 +44,7 @@
"url": "https://github.com/WebDecoy/node/issues"
},
"dependencies": {
"@webdecoy/node": "^0.19.1"
"@webdecoy/node": "^0.20.0"
},
"peerDependencies": {
"hono": "^4.0.0"
Expand Down
4 changes: 2 additions & 2 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webdecoy/nextjs",
"version": "0.19.1",
"version": "0.20.0",
"description": "Web Decoy middleware for Next.js",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -42,7 +42,7 @@
"url": "https://github.com/WebDecoy/node/issues"
},
"dependencies": {
"@webdecoy/node": "^0.19.1"
"@webdecoy/node": "^0.20.0"
},
"peerDependencies": {
"next": ">=13.0.0"
Expand Down
90 changes: 90 additions & 0 deletions packages/nextjs/src/clearance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextRequest } from 'next/server';
import { withClearance } from './clearance';
import { getEdgeVerdict } from './edge-verdict';

/**
* withClearance: the gate's decision is @webdecoy/node's and tested there.
* What is tested here is what the application and the visitor receive.
*/

const ENV = { siteKey: 'org-1', scannerId: 'scanner-1', sensorKey: 'wds1.test' };
const BROWSER_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36';

const CONFIG = {
mode: 'enforce',
allow: { search_engines: true, ai_crawlers: true, monitoring: true },
keys: [],
active_credentials: [],
denied_fps: [],
generated_at: 0,
routes: ['/docs/*'],
};

const realFetch = global.fetch;
let hosts = 0;
const site = () => `https://next${++hosts}.example`;

beforeEach(() => {
global.fetch = jest.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return url.includes('/api/v1/clearance/config')
? new Response(JSON.stringify(CONFIG), { status: 200 })
: new Response('{}', { status: 200 });
}) as unknown as typeof fetch;
});

// The gate's verdict telemetry schedules a delayed flush; let timers advance
// on their own, and clear what is left so it cannot hold jest open.
beforeAll(() => {
jest.useFakeTimers({ advanceTimers: true });
});

afterAll(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

afterEach(() => {
global.fetch = realFetch;
});

describe('withClearance', () => {
it('challenges a protected page without a token, before the page renders', async () => {
const res = await withClearance(ENV)(
new NextRequest(`${site()}/docs/guide`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } })
);
expect(res.status).toBe(403);
expect(res.headers.get('x-middleware-next')).toBeNull();
expect(await res.text()).toContain('wd_clearance');
});

it('hands the page the verdict, readable with getEdgeVerdict', async () => {
const res = await withClearance(ENV)(
new NextRequest(`${site()}/pricing`, {
headers: { 'user-agent': BROWSER_UA, 'x-wd-clearance': 'valid' },
})
);
expect(res.headers.get('x-middleware-next')).toBe('1');
const forwarded = new Headers();
for (const name of (res.headers.get('x-middleware-override-headers') ?? '').split(',')) {
forwarded.set(name, res.headers.get(`x-middleware-request-${name}`) ?? '');
}
// The client's forged "valid" is gone; the gate's own verdict is there.
expect(getEdgeVerdict(forwarded).clearance).toBe('unscoped');
expect(forwarded.get('user-agent')).toBe(BROWSER_UA);
// And the verdict is not sent back to the visitor.
expect(res.headers.get('x-wd-clearance')).toBeNull();
});

it('fails open when WebDecoy cannot be reached', async () => {
global.fetch = jest.fn(async () => {
throw new Error('down');
}) as unknown as typeof fetch;
const res = await withClearance(ENV)(
new NextRequest(`${site()}/docs/guide`, { headers: { accept: 'text/html', 'user-agent': BROWSER_UA } })
);
expect(res.status).toBe(200);
expect(res.headers.get('x-middleware-next')).toBe('1');
});
});
49 changes: 49 additions & 0 deletions packages/nextjs/src/clearance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* WebDecoy clearance in Next.js middleware.
*
* The clearance gate from @webdecoy/node, the same decision WebDecoy's
* Cloudflare, AWS and Netlify validators make, run on Vercel (or any host
* running Next.js middleware) with no proxy in front. Which paths need a
* token, and whether the site monitors or enforces, is set in WebDecoy.
*
* @example
* ```typescript
* // middleware.ts
* import { withClearance } from '@webdecoy/nextjs';
*
* // Reads WEBDECOY_SITE_KEY, WEBDECOY_SCANNER_ID and WEBDECOY_SENSOR_KEY.
* export default withClearance();
*
* export const config = {
* // Everything but static assets. Keep /.well-known: the challenge uses it.
* matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
* };
* ```
*
* A page the gate lets through receives the verdict as `x-wd-clearance`;
* `getEdgeVerdict()` reads it in a route handler or server component.
*/

import { NextResponse } from 'next/server';
import type { NextRequest, NextFetchEvent } from 'next/server';
import { createClearanceGate } from '@webdecoy/node';
import type { ClearanceGateOptions } from '@webdecoy/node';

export type { ClearanceGateOptions } from '@webdecoy/node';

/** Next.js middleware that runs the clearance gate. Options default to the environment. */
export function withClearance(
options: ClearanceGateOptions = {}
): (req: NextRequest, event?: NextFetchEvent) => Promise<Response> {
const gate = createClearanceGate(options);
return async (req, event) => {
try {
const outcome = await gate(req, { waitUntil: event ? (p) => event.waitUntil(p) : undefined });
if (outcome.action === 'respond') return outcome.response;
if (!outcome.headers) return NextResponse.next();
return NextResponse.next({ request: { headers: outcome.headers } });
} catch {
return NextResponse.next(); // fail open
}
};
}
5 changes: 5 additions & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
export { withWebDecoy, withBotProtection } from './middleware';
export type { WebDecoyMiddlewareOptions, WithBotProtectionOptions } from './middleware';

// The clearance gate, in this middleware: a protected page needs a WebDecoy
// clearance token, with no proxy in front of the site.
export { withClearance } from './clearance';
export type { ClearanceGateOptions } from './clearance';

// The edge validator's verdict, readable from a route handler or server
// component — not only from middleware.
export { getEdgeVerdict } from './edge-verdict';
Expand Down
7 changes: 6 additions & 1 deletion packages/webdecoy/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
// js-with-ts: the clearance gate is a generated ES module
// (src/clearance/gate.generated.js), compiled for jest like the sources.
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: { allowJs: true } }],
},
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
13 changes: 9 additions & 4 deletions packages/webdecoy/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webdecoy/node",
"version": "0.19.1",
"version": "0.20.0",
"description": "Web Decoy SDK for Node.js - Bot detection with TLS fingerprinting",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -14,18 +14,23 @@
"require": "./dist/testing.js",
"import": "./dist/testing.mjs",
"types": "./dist/testing.d.ts"
},
"./vercel": {
"require": "./dist/vercel.js",
"import": "./dist/vercel.mjs",
"types": "./dist/vercel.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts src/testing.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts src/testing.ts --format cjs,esm --dts --watch",
"build": "tsup src/index.ts src/testing.ts src/vercel.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts src/testing.ts src/vercel.ts --format cjs,esm --dts --watch",
"test": "jest",
"lint": "eslint src --max-warnings 9",
"clean": "rm -rf dist",
"check:edge": "node ../../scripts/check-edge.mjs src/index.ts"
"check:edge": "node ../../scripts/check-edge.mjs src/index.ts src/vercel.ts"
},
"keywords": [
"web-decoy",
Expand Down
Loading
Loading