Skip to content

Commit c05d7d2

Browse files
committed
feat(sdk): a typed decision, and characteristics for who the caller is
protect() returned { allowed, detection } and the adapters typed the value they handed to onBlocked as `any`, so integrating meant destructuring a blob and string-matching to find out which rule fired. Three things the SDK already knew had nowhere to go: - Which rules ran. The engine collapsed to one deciding rule plus a violations array, so a filter rule that never ran for want of enrichment was indistinguishable from one that ran and passed. Both reported ALLOW. Every rule now appears in results[] with RUN / DRY_RUN / NOT_RUN / CACHED, and a dry-run rule that matched reports DENY -- what it WOULD have done is the reason you turned it on. - "Challenge this one." We ship a proof-of-work captcha and the decision type could not express it, so it was reachable only by hand. - A stable id. 'rule_' + Date.now() is not unique under concurrency and correlates with nothing. characteristics answers a separate question the SDK had also hard-coded: rate limits keyed on IP, which is the wrong subject for an authenticated API and the wrong subject for us specifically, since the actor model exists because IP is not identity. An absent characteristic falls back to the IP rather than keying under empty -- otherwise one tenant's limit takes out all anonymous traffic, invisibly. Decision caching is deliberately narrow: server-derived DENY and CHALLENGE only. Caching ALLOW is how a client that has since started misbehaving keeps sailing through, and rule outcomes must not be cached because a rate limiter has to see every request. allowed is unchanged, including failing open on error. 327 tests pass, and the any budgets drop (fastify to zero). Closes WebDecoy/app#730 Closes WebDecoy/app#731
1 parent 45c3796 commit c05d7d2

20 files changed

Lines changed: 1039 additions & 146 deletions

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
13+
- `conclusion: 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'`, with `isAllowed()` / `isDenied()` / `isChallenged()` / `isErrored()` and `deniedBy(rule)`. `ERROR` is a distinct conclusion, so a caller can tell "allowed" from "never decided" — both still serve the request.
14+
- `results` — every configured rule in evaluation order with a `state` of `RUN`, `DRY_RUN`, `NOT_RUN` or `CACHED`. `NOT_RUN` is new information: a `filter()` rule with no IP enrichment, or a `webBotAuth()` rule on a request with no host, used to report ALLOW, which reads as "checked and fine" rather than "never checked". A dry-run rule that matched now reports `conclusion: 'DENY'` with `state: 'DRY_RUN'`, rather than the ALLOW its action said.
15+
- `id` — a random `dec_…` id, also stamped on `detection.detection_id`. The old `'rule_' + Date.now()` was not unique under concurrency and correlated with nothing.
16+
- `onBlocked` receives the full decision as a trailing argument in all three adapters, and `detection` is typed. Existing handlers are unaffected.
17+
- `allowed` is unchanged, including failing open on error, so existing middleware keeps working.
18+
19+
- **`characteristics`** — what the SDK treats as the same caller, for keyed rules and the decision cache. Defaults to `['ip']`; accepts `'path'`, `'method'`, `'userAgent'`, or a function over the rule context. A rule's own `keyBy` still wins. When a characteristic is absent the key falls back to the IP, rather than bucketing every request missing that field into one bucket — which is how a limit meant for one tenant takes out anonymous traffic site-wide.
20+
21+
- **Decision caching.** A server-derived `DENY` or `CHALLENGE` is reused for its TTL instead of re-asking the service about a caller it just answered for. Deliberately narrow: `ALLOW` is never cached (that is how a client that has since started misbehaving keeps sailing through, and it saves the cheap request), and rule outcomes are never cached (a rate limiter has to see every request, and a cached tripwire hit would stop the violation being reported). Configure with `decisionCache: { ttl, max }` or disable with `false`.
22+
1023
## [0.12.0] - 2026-08-22
1124

1225
### Fixed

‎README.md‎

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -255,26 +255,58 @@ Additional local rules for the `rules` array. `filter()` requires an API key for
255255

256256
Local Web Bot Auth verification (RFC 9421). `webBotAuth()` returns a `Rule` that denies agent impersonation; `detectBot(request)` returns the verdict directly for custom handling. See the [Web Bot Auth guide](docs/verify-ai-agents-web-bot-auth.md). Exported types: `AgentVerdict`, `AgentStatus`, `AgentCategory`, `WebBotAuthConfig`, `AgentVerifierOptions`, `SignedAgentDirectory`.
257257

258-
### `protect(metadata, options?): Promise<ProtectResult>`
258+
### `protect(metadata, options?): Promise<Decision>`
259259

260-
Full analysis of a request (platform feature). Returns a decision:
260+
Full analysis of a request. Returns a typed decision:
261261

262262
```typescript
263-
interface ProtectResult {
264-
allowed: boolean;
265-
detection: {
266-
decision: 'allow' | 'block' | 'challenge';
267-
confidence: number; // 0–100 threat score
268-
threat_level: 'MINIMAL' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
269-
bot_detected: boolean;
270-
bot_type?: string; // e.g. "curl", "selenium"
271-
detection_id: string;
272-
rule_enforced: boolean;
273-
};
274-
error?: string;
275-
}
263+
const d = await wd.protect(metadata);
264+
265+
d.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'
266+
d.allowed // true for ALLOW and ERROR (fail open)
267+
d.id // 'dec_…', correlates with the dashboard
268+
d.isDenied() // narrowing helpers
269+
d.deniedBy('tripwire') // which rule, without string-matching
270+
d.results // every rule, in order, and what it concluded
271+
d.detection // the service's response, as before
272+
d.edge // what the edge validator said
273+
```
274+
275+
`results` is the part worth knowing about. Every configured rule appears, with a
276+
`state`:
277+
278+
| `state` | Meaning |
279+
|---|---|
280+
| `RUN` | Evaluated, and its conclusion counts. |
281+
| `DRY_RUN` | Evaluated; conclusion recorded but not enforced. |
282+
| `NOT_RUN` | Could not evaluate — a signal it needs was absent (a `filter()` with no IP enrichment, a `webBotAuth()` on a request with no host). |
283+
| `CACHED` | Not evaluated; a prior decision for this key was reused. |
284+
285+
`NOT_RUN` is the one that used to be invisible: such a rule reported ALLOW, which
286+
reads as "checked and fine" rather than "never checked". A dry-run rule that
287+
matched reports `conclusion: 'DENY'` with `state: 'DRY_RUN'` — what it *would*
288+
have done is the reason you turned it on.
289+
290+
`ERROR` is not a synonym for `DENY`. It means no verdict was reached, and the
291+
request is allowed through.
292+
293+
### `characteristics` — what counts as the same caller
294+
295+
Rate limits and the decision cache key on the client IP by default. On an
296+
authenticated API that is usually the wrong subject:
297+
298+
```typescript
299+
const wd = new WebDecoy({
300+
characteristics: [(ctx) => ctx.headers['x-api-key']],
301+
rules: [rateLimit({ max: 100, window: 60 })],
302+
});
276303
```
277304

305+
Built-ins are `'ip'`, `'path'`, `'method'`, `'userAgent'`; a function derives
306+
anything else. A rule's own `keyBy` still wins. If a characteristic is absent on
307+
a request the key falls back to the IP, rather than bucketing every request
308+
missing that field together.
309+
278310
All TypeScript types are exported (`WebDecoyConfig`, `RequestMetadata`, `ProtectResult`, `Rule`, `TripwireConfig`, `RateLimitConfig`, `FilterConfig`, `Honeytoken`, …).
279311

280312
## Examples

‎packages/express/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
1919
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
2020
"test": "jest --passWithNoTests",
21-
"lint": "eslint src --max-warnings 14",
21+
"lint": "eslint src --max-warnings 12",
2222
"clean": "rm -rf dist"
2323
},
2424
"keywords": [

‎packages/express/src/middleware.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44

55
import { Request, Response, NextFunction } from 'express';
66
import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node';
7-
import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node';
7+
import type {
8+
EdgeVerdict,
9+
SiteHoneytoken,
10+
TrustedProxies,
11+
ProtectResult,
12+
SDKDetectionResponse,
13+
} from '@webdecoy/node';
814
import {
915
siteHoneytoken,
1016
injectHoneytokenLink,
@@ -85,8 +91,18 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
8591
* `next` is passed so a handler can record the verdict and continue — the
8692
* omission that made monitoring impossible. Call exactly one of `next()` or a
8793
* response method.
94+
*
95+
* `decision` is the full typed verdict — `conclusion`, every rule's outcome
96+
* including the ones that dry-ran or never ran, and `deniedBy('tripwire')` —
97+
* for handlers that need to know WHY rather than just THAT.
8898
*/
89-
onBlocked?: (req: Request, res: Response, detection: any, next: NextFunction) => void;
99+
onBlocked?: (
100+
req: Request,
101+
res: Response,
102+
detection: SDKDetectionResponse,
103+
next: NextFunction,
104+
decision: ProtectResult,
105+
) => void;
90106

91107
/**
92108
* Custom function to handle errors
@@ -135,7 +151,7 @@ function resolveIP(req: Request, trustProxy: TrustedProxies | undefined): string
135151
function defaultOnBlocked(
136152
req: Request,
137153
res: Response,
138-
detection: any,
154+
detection: SDKDetectionResponse,
139155
_next: NextFunction,
140156
): void {
141157
res.status(403).json({
@@ -369,7 +385,7 @@ export function webdecoy(
369385
return next();
370386
} else {
371387
// Block the request
372-
return onBlocked(req, res, result.detection, next);
388+
return onBlocked(req, res, result.detection, next, result);
373389
}
374390
} catch (error) {
375391
onError(req, res, error as Error);

‎packages/fastify/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
1919
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
2020
"test": "jest --passWithNoTests",
21-
"lint": "eslint src --max-warnings 2",
21+
"lint": "eslint src --max-warnings 0",
2222
"clean": "rm -rf dist"
2323
},
2424
"keywords": [

‎packages/fastify/src/plugin.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import {
1616
resolveClientIp,
1717
normalizeIp,
1818
} from '@webdecoy/node';
19-
import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node';
19+
import type {
20+
EdgeVerdict,
21+
SiteHoneytoken,
22+
TrustedProxies,
23+
ProtectResult,
24+
SDKDetectionResponse,
25+
} from '@webdecoy/node';
2026

2127
export interface WebDecoyPluginOptions extends ProtectOptions {
2228
/**
@@ -72,10 +78,19 @@ export interface WebDecoyPluginOptions extends ProtectOptions {
7278
getIP?: (req: FastifyRequest) => string;
7379

7480
/**
75-
* Custom function to handle blocked requests
76-
* By default, returns 403 Forbidden
81+
* Called when a request would be blocked.
82+
*
83+
* `detection` is the detection response, as before. `decision` is the full
84+
* typed verdict — `conclusion`, every rule's outcome including the ones that
85+
* dry-ran or never ran, and `deniedBy('tripwire')` — for handlers that need to
86+
* know WHY rather than just THAT.
7787
*/
78-
onBlocked?: (req: FastifyRequest, reply: FastifyReply, detection: any) => void;
88+
onBlocked?: (
89+
req: FastifyRequest,
90+
reply: FastifyReply,
91+
detection: SDKDetectionResponse,
92+
decision: ProtectResult,
93+
) => void;
7994

8095
/**
8196
* Custom function to handle errors
@@ -118,7 +133,11 @@ function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined):
118133
/**
119134
* Default blocked request handler
120135
*/
121-
function defaultOnBlocked(req: FastifyRequest, reply: FastifyReply, detection: any): void {
136+
function defaultOnBlocked(
137+
req: FastifyRequest,
138+
reply: FastifyReply,
139+
detection: SDKDetectionResponse,
140+
): void {
122141
reply.status(403).send({
123142
error: 'Forbidden',
124143
message: 'Access denied by Web Decoy protection',
@@ -303,7 +322,7 @@ async function webdecoyPluginImpl(
303322
req.webdecoyEdge = result.edge;
304323
} else {
305324
// Block the request
306-
onBlocked(req, reply, result.detection);
325+
onBlocked(req, reply, result.detection, result);
307326
}
308327
} catch (error) {
309328
onError(req, reply, error as Error);

‎packages/nextjs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
1919
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
2020
"test": "jest --passWithNoTests",
21-
"lint": "eslint src --max-warnings 5",
21+
"lint": "eslint src --max-warnings 3",
2222
"clean": "rm -rf dist",
2323
"check:edge": "node ../../scripts/check-edge.mjs src/index.ts"
2424
},

‎packages/nextjs/src/middleware.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
resolveClientIp,
1212
normalizeIp,
1313
} from '@webdecoy/node';
14-
import type { TrustedProxies } from '@webdecoy/node';
14+
import type { TrustedProxies, ProtectResult, SDKDetectionResponse } from '@webdecoy/node';
1515

1616
export interface WebDecoyMiddlewareOptions extends ProtectOptions {
1717
/**
@@ -58,7 +58,19 @@ export interface WebDecoyMiddlewareOptions extends ProtectOptions {
5858
* Custom function to handle blocked requests
5959
* By default, returns 403 Forbidden JSON response
6060
*/
61-
onBlocked?: (req: NextRequest, detection: any) => NextResponse;
61+
/**
62+
* Called when a request would be blocked.
63+
*
64+
* `detection` is the detection response, as before. `decision` is the full
65+
* typed verdict — `conclusion`, every rule's outcome including the ones that
66+
* dry-ran or never ran, and `deniedBy('tripwire')` — for handlers that need to
67+
* know WHY rather than just THAT.
68+
*/
69+
onBlocked?: (
70+
req: NextRequest,
71+
detection: SDKDetectionResponse,
72+
decision: ProtectResult,
73+
) => NextResponse;
6274

6375
/**
6476
* Custom function to handle errors
@@ -103,7 +115,7 @@ function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): st
103115
/**
104116
* Default blocked request handler
105117
*/
106-
function defaultOnBlocked(req: NextRequest, detection: any): NextResponse {
118+
function defaultOnBlocked(req: NextRequest, detection: SDKDetectionResponse): NextResponse {
107119
return NextResponse.json(
108120
{
109121
error: 'Forbidden',
@@ -274,7 +286,7 @@ export function withWebDecoy(
274286
}
275287
return NextResponse.next({ request: { headers: requestHeaders } });
276288
} else {
277-
return onBlocked(req, result.detection);
289+
return onBlocked(req, result.detection, result);
278290
}
279291
} catch (error) {
280292
const errorResponse = onError(req, error as Error);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* What the SDK considers "the same caller".
3+
*
4+
* WHY THIS MODULE EXISTS
5+
*
6+
* `rateLimit({ keyBy })` was the only place a caller could change what a rule
7+
* keyed on, and it was per-rule. Everything else — the decision cache, and any
8+
* future keyed rule — was IP-only.
9+
*
10+
* That is the wrong subject for exactly the traffic worth limiting. On an
11+
* authenticated API the meaningful caller is a user id or an API key, not an
12+
* address shared by a whole office or rotated through a proxy pool. It is also
13+
* the wrong subject for us specifically: the actor model exists because IP is
14+
* not identity.
15+
*/
16+
17+
import type { RuleContext } from './rules/types';
18+
19+
/**
20+
* One component of the key that identifies a caller.
21+
*
22+
* A string names a field of the request; a function derives whatever you like
23+
* from the context (a decoded JWT subject, a tenant id, an API key header).
24+
*/
25+
export type Characteristic =
26+
| 'ip'
27+
| 'path'
28+
| 'method'
29+
| 'userAgent'
30+
| ((context: RuleContext) => string | undefined);
31+
32+
/** The default: one bucket per client address. */
33+
export const DEFAULT_CHARACTERISTICS: readonly Characteristic[] = ['ip'];
34+
35+
function resolveOne(context: RuleContext, c: Characteristic): string | undefined {
36+
if (typeof c === 'function') {
37+
try {
38+
return c(context) || undefined;
39+
} catch {
40+
// A characteristic that throws is a bug in the caller's code, but it must
41+
// not take the request down. Treat it as absent and fall back below.
42+
return undefined;
43+
}
44+
}
45+
switch (c) {
46+
case 'ip':
47+
return context.ip || undefined;
48+
case 'path':
49+
return context.path || undefined;
50+
case 'method':
51+
return context.method || undefined;
52+
case 'userAgent':
53+
return context.userAgent || undefined;
54+
}
55+
}
56+
57+
/**
58+
* Derive the key identifying this caller.
59+
*
60+
* If any characteristic is absent the whole key falls back to the IP. The
61+
* alternative — a key with an empty component — silently merges every request
62+
* missing that field into one bucket, so an unauthenticated request would share
63+
* a rate limit with every other unauthenticated request. That is the failure
64+
* mode where a limit meant for one tenant takes out anonymous traffic site-wide,
65+
* and it is invisible until it happens.
66+
*/
67+
export function deriveKey(
68+
context: RuleContext,
69+
characteristics: readonly Characteristic[] = DEFAULT_CHARACTERISTICS,
70+
): string {
71+
if (characteristics.length === 0) return context.ip;
72+
73+
const parts: string[] = [];
74+
for (const c of characteristics) {
75+
const value = resolveOne(context, c);
76+
if (value === undefined) return context.ip;
77+
parts.push(value);
78+
}
79+
return parts.join('|');
80+
}

0 commit comments

Comments
 (0)