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
148 changes: 148 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Installing WebDecoy

Instructions for a coding agent asked to add bot protection to an application.
For the full API surface see [`llms.txt`](./llms.txt); this file is the install,
what to verify, and the mistakes that are expensive.

## Pick the package

| The app uses | Install | Import |
|---|---|---|
| Express | `@webdecoy/express` | `webdecoy()` middleware |
| Fastify | `@webdecoy/fastify` | `webdecoy` plugin |
| Next.js | `@webdecoy/nextjs` | `withWebDecoy()` in `middleware.ts` |
| Hono, Workers, Bun, Deno | `@webdecoy/hono` | `webdecoy()` middleware |
| Anything else with a `fetch` handler | `@webdecoy/node` | `createFetchGuard()` |

Every adapter depends on `@webdecoy/node`, which comes in automatically.

## Install

```typescript
import { webdecoy } from '@webdecoy/express';
import { tripwire, rateLimit } from '@webdecoy/node';

app.use(webdecoy({
rules: [tripwire(), rateLimit({ max: 100, window: 60 })],
skipPaths: ['/health', '/metrics'],
}));
```

Mount it **before** the application's routes and after any body parser. No API
key is required — tripwires and rate limiting run entirely in-process.

## Verify, and show the user the receipt

```bash
curl -A "WebDecoy-Test/1.0" http://localhost:3000/
```

`WebDecoy-Test/` is a reserved User-Agent. It always produces a labelled test
detection through the real pipeline, and never fires the application's own rules.
With an API key it appears in the dashboard; without one, the result says so
explicitly rather than pretending.

Then confirm a real tripwire:

```bash
curl -i http://localhost:3000/.env # 403 in enforce mode; logged in monitor mode
curl -i http://localhost:3000/ # 200, unaffected
```

An install is not finished until one of these has produced a visible result.
Report which one you ran and what it returned.

## Do not do these

- **Do not set `mode: 'enforce'` on a first install.** The default is `'monitor'`
and it is the default deliberately: the middleware records what it would have
done and serves the request. Switching to enforce is the user's decision once
they have seen a day of traffic. Say so rather than deciding for them.
- **Do not invent an API key or add one the user has not given you.** The local
rules are the whole product for a keyless install. If the user wants the
dashboard, point them at https://app.webdecoy.com and let them paste the key.
- **Do not add `filter()` without a key.** It needs IP enrichment; without one it
reports `NOT_RUN` and does nothing.
- **Do not leave a proxied app on the default `trustProxy`.** Forwarding headers
are not believed by default because the client writes the leftmost value. If
the app sits behind Cloudflare, a load balancer or a platform proxy, either set
the framework's own trust-proxy setting (Express `app.set('trust proxy', 1)`,
Fastify `{ trustProxy: 1 }`) or pass `trustProxy` to the middleware. Getting
this wrong attributes every request to the proxy and applies rate limits
site-wide.
- **Do not describe `attackSignatures()` as a WAF.** It is a small curated set of
unambiguous injection payloads. Turning on `inspect: ['body']` on a CMS will
produce false positives; start it with `dryRun: true`.
- **Do not rely on in-process rate limits across replicas.** On more than one
process the limit becomes `max × instances`. Use `upstashRateLimitStore()` or
say plainly that the limit is per-instance.

## Common additions

Control AI crawlers, publishing and enforcing one policy:

```typescript
import { botPolicy } from '@webdecoy/node';

const policy = botPolicy({ deny: ['training_crawler'], allow: ['perplexitybot'] });
app.get('/robots.txt', (_req, res) => res.type('text/plain').send(policy.robotsTxt()));
app.use(webdecoy({ rules: [policy.rule(), tripwire()] }));
```

Verify AI agents cryptographically (RFC 9421, no key, no network on the warm
path):

```typescript
import { webBotAuth } from '@webdecoy/node';
app.use(webdecoy({ rules: [webBotAuth(), tripwire()] }));
```

Shared rate limits across replicas:

```typescript
import { rateLimit, upstashRateLimitStore } from '@webdecoy/node';

rateLimit({
max: 100,
window: 60,
store: upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
}),
});
```

## Write a test for it

```typescript
import { createTestHarness, get, expectDenied, expectAllowed } from '@webdecoy/node/testing';
import { tripwire } from '@webdecoy/node';

const wd = createTestHarness({ rules: [tripwire()] });

test('a scanner is denied and a visitor is not', async () => {
expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' });
expectAllowed(await wd.protect(get('/')));
});
```

Offline by default — an API key in the environment is ignored unless
`allowNetwork: true`, so this never files test traffic as a real detection.

## Reading a verdict in application code

```typescript
const decision = req.webdecoyDecision; // Express, Fastify, Next.js
const decision = c.get('webdecoyDecision'); // Hono

decision?.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'
decision?.deniedBy('tripwire')
decision?.results // every rule: RUN | DRY_RUN | NOT_RUN | CACHED
```

`webdecoyDecision` means the same thing in every adapter. `req.webdecoy` is the
older, narrower detection response and is still populated — do not confuse the
two.

In monitor mode this is the only place the verdict surfaces, so an app that
wants to log or meter denials reads it here.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`@webdecoy/node/testing`** — helpers for the *application's* test suite. The SDK had hundreds of tests and a customer had none: there was no supported way to write "assert this request would be denied" against your own rules, so the first time anyone learned what the middleware does to their traffic was in production. `createTestHarness()` is offline by default (an API key in the environment is ignored, so a unit test never becomes a live call or files test traffic as a real detection) and gives each harness its own rule state. `request()`/`get()`/`post()`/`botRequest()` build metadata; `expectDenied`/`expectAllowed`/`expectRuleState` assert on the decision and print every rule and its state on failure; `protectMany()` runs a rate limit to its edge without sleeping.

- **A pluggable logger.** `logger` accepts anything with `debug`/`info`/`warn`/`error`, defaulting to the previous console behaviour. Warnings and errors are no longer gated on `debug` — a violation that failed to report is not diagnostic output. `fromPino()` wraps a pino-style logger, whose argument order is reversed; passing one directly type-checks and then silently drops every structured field.

- **`req.webdecoyDecision`** (Express, Fastify, Next.js) and `c.get('webdecoyDecision')` (Hono) carry the full typed decision, under the same name in every adapter. `req.webdecoy` remains the narrower detection response. Populated in monitor mode too, which is where it matters — that is the only place a verdict surfaces when nothing is blocked.

- **`llms.txt` and `AGENTS.md`.** Coding agents install dependencies now, and the repo gave them nothing to read. Both are written for that reader: the install, the reserved `WebDecoy-Test/1.0` verification one-liner, and the mistakes that are expensive — do not enable enforce mode on a first install, do not invent an API key, do not leave a proxied app on the default `trustProxy`, do not call `attackSignatures()` a WAF.

- **`@webdecoy/hono`** — middleware for Hono, which is the default on Cloudflare Workers, Bun and Deno. Those are the runtimes the rest of the stack already sits in front of: the Cloudflare edge sensor tags every request it forwards and `readEdgeVerdict()` exists so the origin can act on that tag, but there was no origin middleware there to do it. Honeytoken injection, skip paths, monitor/enforce and the 429 with `Retry-After` all work as they do elsewhere; the decision is on `c.get('webdecoy')`.

- **`createFetchGuard()`** — one adapter over WHATWG `Request`/`Response`, which `@webdecoy/hono` is a thin wrapper around and which covers Bun, Deno, Astro, Nitro, SvelteKit and Remix with no package at all. Express, Fastify and Next.js had each grown their own copy of the same decision tree — skip paths, monitor/enforce, honeytoken arming, the 429, fail-open error handling — and three copies is three places for the branch that matters to differ, which is how the leftmost-`X-Forwarded-For` bug survived in two adapters after the WordPress plugin had fixed it. Included in the edge-compatibility gate.
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,42 @@ missing that field together.

All TypeScript types are exported (`WebDecoyConfig`, `RequestMetadata`, `ProtectResult`, `Rule`, `TripwireConfig`, `RateLimitConfig`, `FilterConfig`, `Honeytoken`, …).

## Testing your rules

```typescript
import { createTestHarness, get, expectDenied, expectAllowed } from '@webdecoy/node/testing';
import { tripwire } from '@webdecoy/node';

const wd = createTestHarness({ rules: [tripwire()] });

test('a scanner is denied and a visitor is not', async () => {
expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' });
expectAllowed(await wd.protect(get('/')));
});
```

The harness is **offline by default** — an API key in the environment is ignored
unless you pass `allowNetwork: true`, so a unit test never turns into a live call
or files test traffic as a real detection. Each harness gets its own rule state,
so rate-limit counters do not leak between cases.

`protectMany(sdk, request, n)` runs a rate limit to its edge without sleeping.
Assertion failures print every rule and its state, because "expected false to be
true" tells you nothing about which of six rules was supposed to fire.

## Logging

Diagnostics default to `console`, with everything below `warn` gated on `debug`.
Pass any object with `debug`/`info`/`warn`/`error`:

```typescript
new WebDecoy({ logger: myLogger });
new WebDecoy({ logger: fromPino(pino()) }); // pino's argument order is reversed
```

`fromPino()` exists because passing a pino instance directly type-checks and then
silently drops every structured field.

## Examples

See [examples](./examples) for complete working setups — e.g. [express-basic](./examples/express-basic).
Expand Down
140 changes: 140 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# WebDecoy Node SDK

> Deterministic bot detection for Node.js, Express, Fastify, Next.js and Hono.
> Catches scrapers with honeypot paths rather than fingerprinting. The local
> rules need no account, no API key and no network.

The core idea: a hidden path a real user can never reach, so any request for it
is automated *by construction*. That detects intent, which a better fingerprint
cannot spoof away. Fingerprint- and challenge-based detection loses to
purpose-built stealth scrapers; a tripwire does not.

## Install and verify in three lines

```bash
npm install @webdecoy/express
```

```typescript
import express from 'express';
import { webdecoy } from '@webdecoy/express';
import { tripwire, rateLimit } from '@webdecoy/node';

const app = express();

app.use(webdecoy({
rules: [tripwire(), rateLimit({ max: 100, window: 60 })],
skipPaths: ['/health'],
}));
```

Confirm it works:

```bash
curl -A "WebDecoy-Test/1.0" http://localhost:3000/
```

That User-Agent is reserved. It always produces a labelled test detection
through the real pipeline, and is excluded from stats, billing and enforcement.
Without an API key the verdict says so rather than pretending the test reached a
dashboard.

## Packages

- `@webdecoy/node` — core SDK, all rules, `createFetchGuard()`
- `@webdecoy/express` — Express middleware
- `@webdecoy/fastify` — Fastify plugin
- `@webdecoy/nextjs` — Next.js middleware
- `@webdecoy/hono` — Hono middleware (Cloudflare Workers, Bun, Deno)
- `@webdecoy/client` — browser signal collector and proof-of-work captcha
- `@webdecoy/node/testing` — test-suite helpers, offline by default

For Bun, Deno, Astro, Nitro, SvelteKit or Remix use `createFetchGuard()` from
`@webdecoy/node` directly — no adapter package needed.

## Rules

All local, all keyless unless noted. First DENY or THROTTLE wins.

- `tripwire({ paths?, prefixes?, patterns?, includeDefaults? })` — honeypot
paths. On by default when no rules are configured.
- `rateLimit({ max, window, algorithm?, keyBy?, store? })` — fixed or sliding
window. In-process by default; pass `upstashRateLimitStore()` when running
more than one replica, or the limit becomes `max × instances`.
- `bots({ categories?, agents?, ai?, allow? })` — act on self-declared agents.
- `botPolicy({ deny, allow })` — one object producing both `robotsTxt()` and
`rule()`, so the published policy and the enforced one cannot drift.
- `webBotAuth()` — verify AI-agent HTTP signatures locally (RFC 9421). Denies
impersonation of known agents.
- `attackSignatures({ inspect?, exclude? })` — a small curated set of injection
payloads. Not a WAF, and should not be described as one.
- `filter({ expression })` — expression language over IP reputation and geo.
**Requires an API key** for enrichment.
- `honeytoken()` / `siteHoneytoken()` — the hidden decoy link a tripwire guards.
The framework middleware injects it automatically when an API key is present.

## Config that matters

```typescript
new WebDecoy({
apiKey: process.env.WEBDECOY_API_KEY, // optional; local rules work without it
rules: [...],
characteristics: ['ip'], // what counts as the same caller
decisionCache: { ttl: 60_000 }, // reuse of server-derived denials
logger: myLogger, // defaults to console, gated on debug
});
```

Middleware options: `mode` (`'monitor'` default, `'enforce'` to block),
`skipPaths`, `trustProxy`, `getIP`, `onBlocked`, `honeytoken`.

## What `protect()` returns

```typescript
const d = await wd.protect(metadata);

d.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'
d.allowed // true for ALLOW and ERROR (fail open)
d.deniedBy('tripwire') // which rule, without string-matching
d.results // every rule: RUN | DRY_RUN | NOT_RUN | CACHED
d.id // 'dec_…'
```

`ERROR` means no verdict was reached; the request is served anyway.

The same object is on `req.webdecoyDecision` (Express, Fastify, Next.js) and
`c.get('webdecoyDecision')` (Hono), in monitor mode too. `req.webdecoy` is the
older, narrower detection response.

## Things to get right

- **Default to `mode: 'monitor'`.** Do not enable enforce on a first install.
Nobody adopts a defence that breaks their site on day one.
- **Set `trustProxy` if the app is behind a proxy.** Forwarding headers are not
believed by default, because the client writes the leftmost value. Express and
Fastify defer to the framework's own trust-proxy setting; Next.js defaults to
one hop.
- **`filter()` needs an API key.** Without enrichment it reports `NOT_RUN`.
- **Rate limits are per-process** unless given a shared store.
- **`attackSignatures()` inspects path and query only** by default. Turn on
bodies or headers with `dryRun: true` first.

## Testing an install

```typescript
import { createTestHarness, get, expectDenied, expectAllowed } from '@webdecoy/node/testing';

const wd = createTestHarness({ rules: [tripwire()] });
expectDenied(await wd.protect(get('/.env')), { rule: 'tripwire' });
expectAllowed(await wd.protect(get('/')));
```

Offline by default: an API key in the environment is ignored unless
`allowNetwork: true`, so a unit test never files traffic as a real detection.

## Links

- README: https://github.com/WebDecoy/node#readme
- 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
2 changes: 1 addition & 1 deletion packages/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "jest --passWithNoTests",
"lint": "eslint src --max-warnings 12",
"lint": "eslint src --max-warnings 10",
"clean": "rm -rf dist"
},
"keywords": [
Expand Down
21 changes: 19 additions & 2 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,13 +312,13 @@
return intercepting;
};

(res as any).write = function (chunk: any, ...rest: any[]): boolean {

Check warning on line 315 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 315 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 315 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 315 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 315 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 315 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
if (!shouldIntercept()) return originalWrite(chunk, ...rest);
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return true;
};

(res as any).end = function (chunk: any, ...rest: any[]): any {

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 321 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
try {
if (!shouldIntercept()) return originalEnd(chunk, ...rest);
if (chunk && typeof chunk !== 'function') {
Expand Down Expand Up @@ -347,9 +347,14 @@
// rules". An earlier draft of this put the check after them and would
// have shipped exactly the bug it exists to fix.
if (mode === 'monitor') {
(req as any).webdecoy = result.detection;
// `webdecoy` is the detection response and has been since 0.1, so it
// stays what it is. `webdecoyDecision` is the full typed verdict —
// conclusion, every rule's outcome, deniedBy() — and carries the same
// name in every adapter, which `webdecoy` cannot.
req.webdecoy = result.detection;
req.webdecoyDecision = result;
(req as any).webdecoyEdge = result.edge;

Check warning on line 356 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 356 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
(req as any).webdecoyWouldBlock = !result.allowed;

Check warning on line 357 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type
return next();
}

Expand Down Expand Up @@ -381,12 +386,17 @@
// Handle the result
if (result.allowed) {
// Attach detection info to request for downstream use
(req as any).webdecoy = result.detection;
// `webdecoy` is the detection response and has been since 0.1, so it
// stays what it is. `webdecoyDecision` is the full typed verdict —
// conclusion, every rule's outcome, deniedBy() — and carries the same
// name in every adapter, which `webdecoy` cannot.
req.webdecoy = result.detection;
req.webdecoyDecision = result;
// And what the edge validator said, typed. A handler can branch on
// req.webdecoyEdge.isScript instead of string-matching x-wd-class, and
// `present: false` tells it the edge was never in front of this request —
// which is no information, not a clean bill of health.
(req as any).webdecoyEdge = result.edge;

Check warning on line 399 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type
return next();
} else {
// Block the request
Expand Down Expand Up @@ -415,6 +425,13 @@
detection_id: string;
rule_enforced: boolean;
};
/**
* The full typed verdict: `conclusion`, every rule's outcome including the
* ones that dry-ran or never ran, and `deniedBy()`. Populated in monitor
* mode too, which is where it matters — that is the only place a verdict
* surfaces when nothing is blocked.
*/
webdecoyDecision?: import('@webdecoy/node').ProtectResult;
/** What the edge validator said about this request. */
webdecoyEdge?: EdgeVerdict;
}
Expand Down
Loading