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

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rate-limit counters can be shared.** `RateLimitRule` hard-constructed an in-memory `Map` with no seam to replace it, so on any deployment with more than one process the limit was effectively `max × instances` — and on Vercel or Lambda it reset on every cold start. `rateLimit({ store })` now takes a `RateLimitStore`.
- `upstashRateLimitStore({ url, token })` ships in the core package. Upstash speaks Redis over HTTP, which is the only shape that works on Vercel Edge, Workers and Deno, where an ordinary client cannot open a socket. It calls the REST API with `fetch` rather than depending on `@upstash/redis`.
- Fails open by default when Redis is unreachable; `onError: 'closed'` denies instead. Either way the outcome is visible in `decision.results`.
- `Rule` gained an optional `prepare(context)` that `protect()` awaits before evaluation — the same pre-fetch already used for IP enrichment and Web Bot Auth. `evaluate()` stays synchronous, so the default in-memory path is unchanged and allocation-free.
- The synchronous `evaluateRules()` cannot consume a networked store and now reports `NOT_RUN` for such a rule, rather than allowing silently. A rate limiter that has quietly stopped limiting looks identical to one that is working.

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `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.
- `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.
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const wd = new WebDecoy({
});
```

- **`rateLimit({ max, window, algorithm?, action?, keyBy?})`** — fixed or sliding window, keyed by IP (or a custom function). No key.
- **`rateLimit({ max, window, algorithm?, action?, keyBy?, store? })`** — fixed or sliding window, keyed by IP (or a custom function). No key. See [shared rate limits](#rate-limits-across-more-than-one-process) before you run two replicas.
- **`tripwire({ paths?, prefixes?, patterns?, includeDefaults? })`** — deterministic honeypot-path detection. No key.
- **`webBotAuth({ onImpersonation?, onClaimed?, allowCategories? })`** — verify AI-agent signatures (Web Bot Auth / RFC 9421) locally; deny impersonators of known agents. No key.
- **`filter({ expression, action? })`** — an expression language over IP reputation/geo (e.g. `ip.tor`, `ip.country in ["CN", "RU"]`). Requires an API key for enrichment.
Expand DownExpand Up@@ -204,6 +204,40 @@ if (!result.allowed) {
| [@webdecoy/nextjs](https://www.npmjs.com/package/@webdecoy/nextjs) | [![npm](https://img.shields.io/npm/v/@webdecoy/nextjs.svg)](https://www.npmjs.com/package/@webdecoy/nextjs) | Next.js middleware |
| [@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 |

## Rate limits across more than one process

`rateLimit()` counts in this process's memory by default. That is correct for a
single process and wrong the moment you run two: the effective limit becomes
`max × instances`, and on Vercel or Lambda it also resets on every cold start.

Point the rule at a shared store to make one limit one limit:

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

const store = upstashRateLimitStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const wd = new WebDecoy({
rules: [rateLimit({ max: 100, window: 60, store })],
});
```

Upstash speaks Redis over HTTP, so this works on Vercel Edge, Cloudflare Workers
and Deno, where an ordinary Redis client cannot open a socket. It uses `fetch`
directly — no `@upstash/redis` dependency.

If Redis is unreachable the store **fails open** and the request is allowed; pass
`onError: 'closed'` to deny instead. Either way the outcome appears in
`decision.results`, so it does not look like a normal evaluation.

Any object implementing `RateLimitStore` works. A store that returns promises is
consumed during `protect()`'s async pre-fetch; a `sync` store is consumed inline.
The synchronous `evaluateRules()` cannot consume a networked store, and reports
`NOT_RUN` rather than allowing silently.

## Client IP behind a proxy

Rate limits, IP enrichment and every detection we record are keyed on the caller's address, so it matters that the address is real. `X-Forwarded-For` is written by the client for the first hop — the leftmost value in it is whatever the caller decided to send — so the middleware believes it only as far as you say it should, counted from the **right** of the chain, which is the end your own infrastructure wrote.
Expand Down
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ export {
injectHoneytokenLink,
isInjectableHtml,
HONEYTOKEN_BASE_PATH,
MemoryRateLimitStore,
upstashRateLimitStore,
UpstashRateLimitStore,
} from './rules';

export type {
Expand All@@ -118,6 +121,11 @@ export type {
HoneytokenLinkProps,
ViolationEvent,
IPEnrichmentData,
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
UpstashStoreOptions,
} from './rules';

// Local Web Bot Auth verification (RFC 9421, tag "web-bot-auth")
Expand Down
9 changes: 9 additions & 0 deletions packages/webdecoy/src/rules/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ export { BotRule } from './bot-rule';
export { WebBotAuthRule, webBotAuth } from './web-bot-auth-rule';
export { honeytoken } from './honeytoken';
export { InMemoryRateLimiter } from './rate-limiter';
export { MemoryRateLimitStore } from './rate-limit-store';
export { upstashRateLimitStore, UpstashRateLimitStore } from './upstash-store';

export type {
Rule,
Expand All@@ -25,6 +27,13 @@ export type {
} from './types';
export type { WebBotAuthConfig } from './web-bot-auth-rule';
export type { HoneytokenOptions, Honeytoken } from './honeytoken';
export type {
RateLimitStore,
SyncRateLimitStore,
RateLimitOutcome,
RateLimitConsume,
} from './rate-limit-store';
export type { UpstashStoreOptions } from './upstash-store';

import { RateLimitRule } from './rate-limit-rule';
import { FilterRule } from './filter-rule';
Expand Down
65 changes: 52 additions & 13 deletions packages/webdecoy/src/rules/rate-limit-rule.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,20 +3,21 @@
* Implements the Rule interface using InMemoryRateLimiter
*/

import { InMemoryRateLimiter } from './rate-limiter';
import { Rule, RuleContext, RuleResult, RateLimitConfig } from './types';
import { MemoryRateLimitStore } from './rate-limit-store';
import type { RateLimitStore, RateLimitOutcome, RateLimitConsume } from './rate-limit-store';

export class RateLimitRule implements Rule {
readonly name: string;
private limiter: InMemoryRateLimiter;
private store: RateLimitStore;
private config: Required<
Pick<RateLimitConfig, 'max' | 'window' | 'algorithm' | 'action' | 'dryRun'>
> &
Pick<RateLimitConfig, 'keyBy'>;

constructor(config: RateLimitConfig) {
this.name = `rate-limit:${config.max}/${config.window}s`;
this.limiter = new InMemoryRateLimiter();
this.store = config.store ?? new MemoryRateLimitStore();
this.config = {
max: config.max,
window: config.window,
Expand All@@ -27,17 +28,55 @@ export class RateLimitRule implements Rule {
};
}

/**
* Which bucket this request counts against.
*
* Precedence: this rule's own keyBy, then the SDK-wide characteristics, then
* the IP. `context.key` is always populated, so the last fallback only matters
* for a context built by hand.
*/
private keyFor(context: RuleContext): string {
return this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
}

private consumption(context: RuleContext): RateLimitConsume {
return {
key: this.keyFor(context),
max: this.config.max,
windowMs: this.config.window * 1000,
algorithm: this.config.algorithm,
};
}

/** Consume from a networked store before evaluation. No-op for a sync store. */
async prepare(context: RuleContext): Promise<void> {
if (this.store.sync) return;
const outcome = await this.store.consume(this.consumption(context));
context.prepared ??= {};
context.prepared[this.name] = outcome;
}

evaluate(context: RuleContext): RuleResult {
// Precedence: this rule's own keyBy, then the SDK-wide characteristics,
// then the IP. `context.key` is always populated, so the last fallback only
// matters for a context built by hand.
const key = this.config.keyBy ? this.config.keyBy(context) : (context.key ?? context.ip);
const windowMs = this.config.window * 1000;
let result: RateLimitOutcome;

const result =
this.config.algorithm === 'sliding'
? this.limiter.checkSlidingWindow(key, this.config.max, windowMs)
: this.limiter.checkFixedWindow(key, this.config.max, windowMs);
if (this.store.sync) {
result = this.store.consume(this.consumption(context)) as RateLimitOutcome;
} else {
const prepared = context.prepared?.[this.name] as RateLimitOutcome | undefined;
if (!prepared) {
// A networked store that was never consumed. Saying so beats allowing
// silently: a rate limiter that has quietly stopped limiting looks
// identical to one that is working.
return {
action: 'ALLOW',
rule: this.name,
state: 'NOT_RUN',
reason:
'Rate limit uses an async store and was not prepared — call protect() or evaluateRulesAsync()',
};
}
result = prepared;
}

if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
Expand DownExpand Up@@ -68,6 +107,6 @@ export class RateLimitRule implements Rule {
}

destroy(): void {
this.limiter.destroy();
void this.store.destroy?.();
}
}
Loading
Loading