All notable changes to the Web Decoy Node.js SDK will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Requests no longer wait on an unavailable WebDecoy. After a 429, a 5xx or no answer, the client pauses calls to WebDecoy (honouring
Retry-After, otherwise 1s doubling to 60s) andprotect()fails open at once with anERRORdecision instead of waiting out the timeout on every request. The failure that starts a pause is logged as an error; requests during the pause log at debug, so an outage no longer floods your logs. - Bounded memory during an outage. Violation events are held (up to 1,000, oldest dropped) while WebDecoy is unavailable and sent when it returns; the IP enrichment cache is capped at 10,000 IPs; AI referral counting keeps existing pairs counting but stops adding new ones while an unsent batch waits.
- AI referral counts refused with 429 are kept and retried under the same batch id instead of being treated as delivered.
- Hidden-path tripwires catch obfuscated scans.
TripwireRulematched the raw request path, so a scan dressed up as//.env,/%2Eenv,/%252Eenvor/static/..%2f.git/configslipped past while a webserver still resolved it to the decoy path. The path is now canonicalized before matching (drop query/fragment, ASCII percent-decode, collapse duplicate slashes, resolve./..), and configured decoy paths are canonicalized the same way.
withBotProtection(@webdecoy/nextjs) honoursmodeand monitors by default. It ignoredmodeand returned 403 for any requestprotect()did not allow, unlikewithWebDecoyand every other adapter. It now runs your handler in monitor mode and records the verdict onreq.webdecoyDecision; setmode: 'enforce'to refuse requests. If you relied on it blocking, addmode: 'enforce'.
- ALPN in
tls_infonow reaches the server. The SDK documented the client's ALPN list astls_info.alpn_protocols, but the detection service readstls_info.alpnand ignored the other name. The field is nowalpn.alpn_protocolsstill works, is marked deprecated, and is sent asalpnwhenalpnis not set. - Web Bot Auth default directories match the platform's.
DEFAULT_SIGNED_AGENT_DIRECTORIESno longer listshttps://operator.openai.com, which no longer resolves and so never supplied keys. It now lists ChatGPT (https://chatgpt.com), Google Agent (https://agent.bot.goog, Google's AI browsing agent, not Googlebot) and WebDecoyBot (https://bot.webdecoy.com, categorymonitoring), the same set the edge validator trusts. If you pass your owndirectories, nothing changes. captcha.verifyToken()example awaits the result. The@webdecoy/nodeREADME called it withoutawait, soresult.validwas alwaysundefinedand the check always failed.
trustedJA4Headersis documented as it behaves. The self-hosted captcha reads a JA4 fingerprint from the trusted headers you list, but the built-in JA4 table is empty, so the value does not change the score and is not reported anywhere. The README's TLS fingerprinting description now says that server-side fingerprinting is JA3, fromtls_infoyou supply.
trustProxy: 'railway'. Railway's edge replaces anyX-Forwarded-Forthe client sent with exactly<client>, <edge>, so the visitor is two entries from the right. A depth of1, and the Next.js and Hono default, names Railway's edge for every visitor instead.'railway'is the same answer as a depth of2, under a name you do not have to work out. Works in every adapter.
- Fastify: pass the hop count to the plugin, not to Fastify. Since Fastify 5.12.1 a numeric server
trustProxytrusts no hop at all, sorequest.ipstays the socket address. The plugin's owntrustProxyis unaffected; its documentation now says so.
- AI referral counting. With an API key, the SDK now counts page visits that ChatGPT, Claude, Perplexity, Gemini, Copilot and other AI products send to your application, and reports the totals about once a minute for the AI Traffic page. Only a browser loading a page counts (a GET whose fetch metadata says it is a document navigation). What is sent is aggregate: the AI platform, the landing path and a count, never anything about the visitor. It needs an API key scoped to one site; turn it off with
countAIReferrals: false.
- AI search crawlers and user-triggered fetchers are no longer classified as training crawlers. Matching is by substring with training crawlers checked first, and two over-broad patterns caught other agents: every Anthropic user agent carries an
@anthropic.comcontact, so Claude-User and Claude-SearchBot matched the legacyanthropiccrawler astraining_crawler, and MistralAI-User matchedmistralthe same way. PerplexityBot is nowai_search_crawler, as Perplexity documents it (not used for training). A rule written asbot.category == "training_crawler"stops matching these agents, which is the point: a page a person asked an assistant about is not training. - ChatGPT-User is
ai_assistantand OAI-SearchBot isai_search_crawler, as OpenAI documents them, rather thantraining_crawler.
- Newly recognised: Claude-SearchBot (
ai_search_crawler); Perplexity-User, MistralAI-User and Meta-ExternalFetcher (ai_assistant); and the crawlers previously known only to the WordPress plugin. The registry now has 186 agents.
ClearanceOptions.scopeis documented as reserved. It was described as a route-group scope that limits where a token is valid. No validator enforces that: a clearance token is bound to the organization, and each protected path's verification level is the way to require stronger proof. Passingscope(ordata-scopeon the script tag) still works and still has no effect.
- A datacenter or VPN IP alone no longer forwards a request for server verification. Local analysis scored a datacenter/VPN address high enough on its own to forward the request, so a real visitor on a VPN, with a normal browser and full headers, was sent for server-side scoring exactly like a bot. The SDK now forwards on a genuine local bot signal (a bot or automation user agent, or missing headers every real browser sends) or when TLS details are available to fingerprint. A datacenter IP and an absent
Sec-CH-UAno longer force a forward, alone or together; both still travel with a request that is forwarded for another reason. Trade-off: a bot that perfectly imitates a browser from a datacenter IP, with no TLS details, is no longer forwarded on the IP alone.
-
Breaking: server-to-server traffic defaults to
https://in.webdecoy.com. The defaultapiUrlmoved fromhttps://ingest.webdecoy.com. Server-to-server calls carry no browser fingerprint, so the fronted hostname costs nothing and adds DDoS absorption and rate limiting in front of ingest. If you setapiUrlexplicitly, nothing changes.@webdecoy/clientkeeps the direct hostname, because a browser's own TLS handshake is part of what it reports. -
One adapter core. Express, Fastify, Next.js (middleware and Pages wrapper) and the fetch guard each carried their own copy of skip-path matching, the 429 and 403 payloads, and honeytoken arming — five copies of one set of decisions, and five places the next correction can fail to land. They now share
adapter-core.ts; the framework-specific response mechanics are untouched, and every honeytoken-injection test passes unchanged. Fastify keeps its awaited arming, which has no window where early requests are served without the link.
-
OpenTelemetry spans around
protect()and rule evaluation. Pass a tracer:new WebDecoy({ tracer: trace.getTracer('webdecoy') }). Injected rather than imported, so the package stays dependency-free and edge-safe — theTracertype is a structural subset of OpenTelemetry's, sotrace.getTracer()works with no adapter, and omitting it means no spans, no dependency and no behaviour change. Attributes cover the decision id (which joins a span to its dashboard row), the conclusion, the deciding rule, and whether the request cost a round trip to ingest. A tracer that throws cannot fail a request. -
Six more crawlers are recognised: DuckAssistBot, SofyaBot, Reflectionbot, xAI-SearchBot, LinkupBot, and IbouBot (classified as a search crawler).
0.13.0 - 2026-08-22
-
The client-signal path is wired end to end.
@webdecoy/clientcollected behavioural, environmental and form signals,DetectionEnginescored them, and/scorereturned a verdict — to the browser, which then forgot it. The origin never learned anything from the submission, and joining the two was left to the developer, so in practice nobody did. NowcreateCaptchaEndpoints({ signalStore })records the verdict against the browser's session, andclientSignals({ store })lets the requests that follow act on it. This is the SDK's answer to a Playwright-driven Chrome that browses only the links a human would: it has a genuine fingerprint and follows no hidden links, so no tripwire sees it, but it cannot fake having a person behind it. A request with no session isNOT_RUN, never a denial — curl and Googlebot both send nothing, and scoring silence would deny exactly the crawlers most worth keeping. Guide:docs/client-signals.md. -
@webdecoy/node/testing— helpers for the application's test suite. The SDK had hundreds of tests and a customer had none: there was no supported way to write "assert this request would be denied" against your own rules, so the first time anyone learned what the middleware does to their traffic was in production.createTestHarness()is offline by default (an API key in the environment is ignored, so a unit test never becomes a live call or files test traffic as a real detection) and gives each harness its own rule state.request()/get()/post()/botRequest()build metadata;expectDenied/expectAllowed/expectRuleStateassert on the decision and print every rule and its state on failure;protectMany()runs a rate limit to its edge without sleeping. -
A pluggable logger.
loggeraccepts anything withdebug/info/warn/error, defaulting to the previous console behaviour. Warnings and errors are no longer gated ondebug— a violation that failed to report is not diagnostic output.fromPino()wraps a pino-style logger, whose argument order is reversed; passing one directly type-checks and then silently drops every structured field. -
req.webdecoyDecision(Express, Fastify, Next.js) andc.get('webdecoyDecision')(Hono) carry the full typed decision, under the same name in every adapter.req.webdecoyremains the narrower detection response. Populated in monitor mode too, which is where it matters — that is the only place a verdict surfaces when nothing is blocked. -
llms.txtandAGENTS.md. Coding agents install dependencies now, and the repo gave them nothing to read. Both are written for that reader: the install, the reservedWebDecoy-Test/1.0verification one-liner, and the mistakes that are expensive — do not enable enforce mode on a first install, do not invent an API key, do not leave a proxied app on the defaulttrustProxy, do not callattackSignatures()a WAF. -
@webdecoy/hono— middleware for Hono, which is the default on Cloudflare Workers, Bun and Deno. Those are the runtimes the rest of the stack already sits in front of: the Cloudflare edge sensor tags every request it forwards andreadEdgeVerdict()exists so the origin can act on that tag, but there was no origin middleware there to do it. Honeytoken injection, skip paths, monitor/enforce and the 429 withRetry-Afterall work as they do elsewhere; the decision is onc.get('webdecoy'). -
createFetchGuard()— one adapter over WHATWGRequest/Response, which@webdecoy/honois a thin wrapper around and which covers Bun, Deno, Astro, Nitro, SvelteKit and Remix with no package at all. Express, Fastify and Next.js had each grown their own copy of the same decision tree — skip paths, monitor/enforce, honeytoken arming, the 429, fail-open error handling — and three copies is three places for the branch that matters to differ, which is how the leftmost-X-Forwarded-Forbug survived in two adapters after the WordPress plugin had fixed it. Included in the edge-compatibility gate. -
botPolicy()— one policy, published and enforced.BOT_REGISTRYalready carried the customer-facing categories andbots()already enforced against it, but nothing published from it, so every site hand-wrote arobots.txtthat drifted from what the code did.botPolicy({ deny, allow })returns bothrobotsTxt()andrule(), resolved from the same set — a test asserts across all 169 registry agents that the two cannot diverge. The generated file names the agents in your deny set whose operator does not document honouring robots.txt, so it says which of its own lines are only a request;policy.unenforceableis the same list in code. No API key. -
attackSignatures()— a curated attack-payload rule. Tripwires catch scanners by the path they ask for; nothing looked at what they send. Deliberately not a WAF: a small set of signatures (SQL injection, XSS, traversal, command injection,${jndi:) each chosen because it has no innocent reading in a path or query. Inspects path and query by default; bodies and headers are opt-in, and theCookieheader is never inspected at all. Every pattern is anchored or literal with no nested quantifiers, and input is truncated atmaxBytes, so a crafted payload cannot turn the rule into the denial of service it exists to catch. Covered by a 17-case false-positive corpus of ordinary traffic. -
RequestMetadata.queryand.body. The adapters now populatequery, whichattackSignatures()needs — Express'sreq.pathexcludes the query string, and that is exactly where injection payloads live.bodyis never populated automatically: buffering a body the application has not already parsed would change its streaming behaviour. -
Rate-limit counters can be shared.
RateLimitRulehard-constructed an in-memoryMapwith no seam to replace it, so on any deployment with more than one process the limit was effectivelymax × instances— and on Vercel or Lambda it reset on every cold start.rateLimit({ store })now takes aRateLimitStore.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 withfetchrather than depending on@upstash/redis.- Fails open by default when Redis is unreachable;
onError: 'closed'denies instead. Either way the outcome is visible indecision.results. Rulegained an optionalprepare(context)thatprotect()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 reportsNOT_RUNfor 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 toonBlockedasany.conclusion: 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR', withisAllowed()/isDenied()/isChallenged()/isErrored()anddeniedBy(rule).ERRORis 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 astateofRUN,DRY_RUN,NOT_RUNorCACHED.NOT_RUNis new information: afilter()rule with no IP enrichment, or awebBotAuth()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 reportsconclusion: 'DENY'withstate: 'DRY_RUN', rather than the ALLOW its action said.id— a randomdec_…id, also stamped ondetection.detection_id. The old'rule_' + Date.now()was not unique under concurrency and correlated with nothing.onBlockedreceives the full decision as a trailing argument in all three adapters, anddetectionis typed. Existing handlers are unaffected.allowedis unchanged, including failing open on error, so existing middleware keeps working.
-
characteristics— what the SDK treats as the same caller, for keyed rules and the decision cache. Defaults to['ip']; accepts'path','method','userAgent', or a function over the rule context. A rule's ownkeyBystill wins. When a characteristic is absent the key falls back to the IP, rather than bucketing every request missing that field into one bucket — which is how a limit meant for one tenant takes out anonymous traffic site-wide. -
Decision caching. A server-derived
DENYorCHALLENGEis reused for its TTL instead of re-asking the service about a caller it just answered for. Deliberately narrow:ALLOWis never cached (that is how a client that has since started misbehaving keeps sailing through, and it saves the cheap request), and rule outcomes are never cached (a rate limiter has to see every request, and a cached tripwire hit would stop the violation being reported). Configure withdecisionCache: { ttl, max }or disable withfalse.
0.12.0 - 2026-08-22
-
The client IP is no longer taken from a header the client writes. The Express and Next.js adapters read the leftmost
X-Forwarded-Forvalue and treated it as the caller's address. That value is supplied by the client on the first hop, so a single-H 'X-Forwarded-For: 1.2.3.4'bought a fresh rate-limit bucket per forged address, put an address of the caller's choosing on every violation reported to the dashboard, and reducedfilter({ expression: 'ip.tor or ip.vpn' })to an opt-in check. The captcha endpoints in both adapters had the same flaw.Forwarding headers are now believed only as far as you say they should be, counted from the right of the chain — the end written by infrastructure you control.
- New
trustProxyoption on every adapter and on the captcha endpoints:false(believe nothing), a number of trusted hops,'cloudflare'(useCF-Connecting-IP), or an array of CIDRs to walk past. - New exports from
@webdecoy/node:resolveClientIp(),normalizeIp(),ipInCidr(), and theTrustedProxiestype — so an application building its ownRequestMetadataderives the same address the middleware does. Edge-safe: nonode:net. - Addresses are normalised before use. Ports, brackets and IPv6 zone ids are stripped, IPv4-mapped IPv6 collapses to its IPv4 form so a dual-stack listener keys one client once, and anything that does not parse falls back to the peer address rather than becoming a key of its own.
- New
- Behaviour change — read this if you run behind a proxy.
- Express now defers to
req.ip, which honours the app's owntrust proxysetting and otherwise resolves to the socket address. An app already configured withapp.set('trust proxy', …)needs no change. An app behind a proxy that never configured Express will now attribute traffic to the proxy: settrust proxy, or passtrustProxyto the middleware. - Next.js reads the chain from the right and defaults to
1trusted hop, which is correct on Vercel and on any single-proxy deployment. Edge middleware has no socket to fall back on, so there is no believe-nothing default available here. Behind a CDN in front of your platform, settrustProxy: 2; behind Cloudflare with the origin locked to it,trustProxy: 'cloudflare'. - Fastify is unchanged. It already deferred to
request.ip, which was the safe answer; it gains thetrustProxyoption for parity. getIPstill overrides everything, and existinggetIPimplementations are untouched.
- Express now defers to
-
Lint runs for the first time. Every package declared
eslintand@typescript-eslintas devDependencies and raneslint src/**/*.ts, but no config file had ever existed in the tree, sonpm run lintexited 2 in all five and had done since the repo was created. There is now one flat config at the root (ESLint 9,typescript-eslint8), the duplicated per-package toolchain is gone, and CI runs lint so it cannot rot again.no-explicit-anyis a warning under a per-package budget that CI does not let grow.Two client-side changes fell out of it and are worth knowing about:
_measureJSExecution()now accumulates its arithmetic loop into a recordedmathSinkvalue, matching what the array loop already did witharrayLen. The loop previously discarded its result and could legally be optimised away entirely — which would drivemathOpstoward zero and trip the "JS execution unusually fast" automation signal on an ordinary browser. It also reportsstringLenfor the same reason. Both are additive keys on an open record.- The
HTMLFormElement.prototype.submitinterception uses rest parameters and a closure instead ofargumentsand athisalias. Behaviour is unchanged —submit()takes no arguments.
0.11.1 - 2026-08-20
- WebDecoyBot recognized in the bot registry. The SDK now classifies WebDecoy's own first-party crawler — the User-Agent behind install verification and agent-readiness scans (
WebDecoyBot/1.0,+https://bot.webdecoy.com) — as a known, low-threat monitoring crawler instead of an unknown bot. Generated from the shared Go registry; the cross-language parity test keeps it in lockstep with the server matcher.
0.11.0 - 2026-08-18
- Local Web Bot Auth verification (RFC 9421 HTTP Message Signatures, tag
web-bot-auth).detectBot(request)— verify an inbound request's agent signature and get averified/impersonation/claimed/noneverdict (with agent name/category for verified agents). Accepts a WHATWGRequestor{ method, url, headers }.webBotAuth()rule — denies impersonation of known agents in the rules engine by default;onImpersonation/onClaimed/allowCategoriesoptions.- Cached, curated directory client (Ed25519 + RSA-PSS-SHA512, JWK-thumbprint keyids); zero network on the warm path, no SSRF surface. Runs on Node and Vercel Edge / WinterCG runtimes.
- New exports:
AgentVerifier,createAgentVerifier,DirectoryCache,DEFAULT_SIGNED_AGENT_DIRECTORIES, and typesAgentVerdict,AgentStatus,AgentCategory,WebBotAuthConfig,AgentVerifierOptions,SignedAgentDirectory.
- Doc: "Verify AI agents with Web Bot Auth in Next.js" (
docs/verify-ai-agents-web-bot-auth.md).
0.10.0 - 2026-07-31
- Honeytoken injection for Fastify. Express and Next.js gained this in 0.8.x;
Fastify still generated a token and left you to place the link. The plugin now
injects a hidden trap link into HTML replies and arms the tripwire it points at.
- On by default when
apiKeyis set;honeytoken: falseopts out. - Injected in an
onSendhook, so only fulltext/htmlreplies are rewritten and Fastify recomputesContent-Length. - The token is derived from the API key, so every replica advertises and arms the same path.
- Streamed replies are not rewritten — buffering a stream to insert an anchor would discard the streaming behaviour the app asked for. The plugin logs a warning once per process with the markup to embed manually, so the gap is visible rather than silent.
- On by default when
-
Bot classification in the request path — rules can now act on who the User-Agent says it is, synchronously and with no network call.
bots()rule:bots({ categories: ['training_crawler'] }),bots({ ai: true, allow: ['perplexitybot'] }),bots({ agents: ['gptbot', 'ClaudeBot'], action: 'THROTTLE' }).- New filter namespace:
bot.known,bot.ai,bot.category,bot.name,bot.id,bot.organization,bot.score,bot.respects_robots. - New exports:
bots,BotRule,matchUserAgent,classifyUserAgent,BOT_REGISTRY,BOT_CATEGORIES, and typesBotVerdict,BotAgent,BotCategory,BotRuleConfig. - 168 known agents, matched locally. Category names match the
ai_scraper_categoryvalues shown in your dashboard. ai: truecovers training crawlers, AI search crawlers, AI agents and AI assistants. It excludessearch_crawler— blocking Googlebot would deindex your site.
This matches a self-declared User-Agent, so it acts only on agents that identify honestly. That is the right tool for cooperative crawlers and the wrong one for anything spoofing a browser; use
tripwire()for those.
0.4.0 - 2026-06-30
- Stealth-browser detection for botasaurus-class scrapers
F4tripwire rule with honeytoken support — deterministic, zero-false-positive deception
- Dropped Playwright heuristics that false-positived on real Chrome
- Documented tripwire deception and the rules engine in the README
0.3.0 - 2026-05-31
- Self-hosted detection engine ported from FCaptcha (Phase 1)
- Captcha service with proof-of-work and token issuance (Phase 2)
@webdecoy/clientbrowser widget (Phase 3)- Captcha HTTP endpoints and framework adapters (Phase 4)
- Aligned client endpoint paths across the SDK
- Switched to a shields.io dynamic npm version badge
- Bumped CI
checkout/setup-nodeactions to v5 (Node 24)
- Captcha docs, client README, and a runnable example
0.2.1 - 2026-05-29
- Corrected repository URLs to
WebDecoy/node - Updated CI to Node 20/22 and regenerated the lock file
- Bumped all packages to 0.2.1
- Added the npm publish workflow
- Removed old planning docs
0.2.0 - 2026-02-08
- Rules engine with rate limiting, request filters, and violation reporting
- Contributing guide, changelog, and CI workflow
- Implementation summary and dashboard integration guide
- Workspace dependencies for npm compatibility
0.1.0 - 2025-11-26
- Initial release of
@webdecoy/nodecore SDK - Initial release of
@webdecoy/expressmiddleware - Two-tier bot detection (local + server-side)
- TLS fingerprinting support (JA3/JA4)
- Express.js middleware integration
- TypeScript type definitions
- Basic Express example
- Comprehensive documentation
- Local analysis for suspicious headers
- Datacenter IP detection (AWS, GCP, Azure, etc.)
- User-Agent analysis for known bots
- Server-side verification API client
- Configurable threat score thresholds
- Fail-safe design (fail open on errors)
- Debug logging support
- Middleware with automatic request protection
- Custom IP extraction
- Path skipping (health checks, static assets)
- Custom block handlers
- Custom error handlers
- Detection info attached to request object
- Main README with quick start
- Package-specific README files
- Express example with setup guide
- Contributing guidelines
- MIT License