@webdecoy/ai-protection 0.1.0-alpha.11 → 0.1.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MCP.md CHANGED
@@ -320,3 +320,44 @@ rates, generate seeded names, record initialization events, remotely deliver dec
320
320
  configuration, or provide a labeled test-trigger flow. Configuration requires an
321
321
  application deployment. Those remain separate roadmap work. No low-false-positive
322
322
  or caller-containment guarantee is made.
323
+
324
+ ### Caller-attributed enumeration (alpha.13)
325
+
326
+ With `discovery` and `sharedRuntime.reportCaller: true`, authenticated `tools/list`
327
+ reports carry the same property-scoped pseudonym as tool calls. Raw identities,
328
+ credentials and scopes are not uploaded. Attribution stays off by default.
329
+ The hosted receiver must support caller-attributed catalogs before enabling this
330
+ SDK version; older receivers reject the new optional field without interrupting
331
+ tool listing. Listings with no visible tools also generate an empty catalog.
332
+
333
+ Caller timelines show enumeration separately from calls and decoy trips. Listing
334
+ is normal client behavior, not an enforcement decision. Large listings are split
335
+ into batches of up to 64 tools; each batch is a report, not a distinct listing
336
+ count. Unattributed older catalogs are never assigned to a caller retrospectively.
337
+ Initialization and automatic containment remain outside this feature.
338
+
339
+ ### Manual caller pauses (alpha.14)
340
+
341
+ Enable `sharedRuntime: { ...runtimeOptions, reportCaller: true, callerPause: true }`
342
+ on each protected server after updating the hosted receiver. An organization owner
343
+ or admin can then open a caller timeline and review a pause or resume for that
344
+ property. A reason is required. Pauses can expire or remain until resumed.
345
+ Concurrent stale edits are rejected and each successful change is audited.
346
+
347
+ The check runs after application permissions and before shared limits or execution,
348
+ for each admitted tool call. It adds one network request with a 1000 ms default deadline (`callerPauseTimeoutMs`, range 1–10000 ms),
349
+ no retries and no decision cache. Very short deadlines can fail open during cold API-key authentication. A saved pause applies to subsequent successful
350
+ checks; a call already admitted or running is not cancelled. Resuming needs no cache
351
+ invalidation. A restart reads current state on the next check.
352
+
353
+ Outages, timeouts, malformed responses and unavailable controls fail open with
354
+ `caller_pause_unavailable` evidence; authorization and other limits still apply.
355
+ A saved pause is not a delivery acknowledgment and cannot guarantee enforcement
356
+ while disconnected. This control does not revoke OAuth credentials.
357
+
358
+ Scope is the existing property-scoped pseudonym derived from issuer, tenant and
359
+ subject. Keep `subjectSecret` consistent across replicas. Rotating it changes the
360
+ pseudonym and existing pauses no longer match. Only opt-in Node action integrations
361
+ are covered, including this MCP adapter; Python/Go and unwrapped tools are not.
362
+ Listings and initialization remain available; this pauses new tool execution.
363
+ No automatic pause is applied after a decoy call.
package/NEXTJS.md CHANGED
@@ -13,8 +13,8 @@ Browser → customer's /api/chat → existing auth / input checks
13
13
  ```
14
14
 
15
15
  Install in the application server that owns the model call, not the browser,
16
- Next.js middleware, or the model provider. Node >=22.22.3 is required; Edge runtime
17
- is not supported. The application and provider traffic remain on customer
16
+ Next.js middleware, or the model provider. Node >=22.22.3 is required; Next.js Edge runtime
17
+ is not supported. For the experimental Cloudflare Workers adapter, use [the Workers guide](WORKERS.md). The application and provider traffic remain on customer
18
18
  infrastructure. WebDecoy receives the client IP, URL pathname (no query string),
19
19
  method, user agent, header names and accept-language/accept-encoding values.
20
20
  Request bodies, session cookies, authorization values and responses are not sent
package/README.md CHANGED
@@ -4,7 +4,7 @@ Bot and abuse protection for AI-powered applications. A small Node.js SDK that
4
4
  checks requests before your application invokes a model. Customer-defined rules run
5
5
  locally; proprietary bot detection runs in WebDecoy. See [architecture](ARCHITECTURE.md).
6
6
 
7
- **Alpha release: `0.1.0-alpha.3`.** Integration mechanics are tested;
7
+ **Alpha release: `0.1.0-alpha.12`.** Integration mechanics are tested;
8
8
  real-world detection accuracy and provider cost savings have not been established.
9
9
  Requires a WebDecoy property, a property-scoped API key, and a compatible WebDecoy
10
10
  service deployment. This repository contains the SDK, not the detection service.
@@ -16,7 +16,9 @@ npm install @webdecoy/ai-protection@alpha
16
16
  ```
17
17
 
18
18
  Node.js 22.22.3 or newer is required. The SDK has no runtime npm dependencies.
19
- Edge runtimes are not supported. Licensed under [Apache-2.0](LICENSE).
19
+ Experimental Cloudflare Workers admission support is available through the
20
+ [`/workers` entry point](WORKERS.md), with Node compatibility enabled. Workers
21
+ concurrency and budgets, and other Edge runtimes, are not supported. Licensed under [Apache-2.0](LICENSE).
20
22
 
21
23
  Set `WEBDECOY_URL=https://ai-protection.webdecoy.com` on your server.
22
24
  Create a property-scoped API key with Write Detections permission in WebDecoy,
@@ -24,7 +26,7 @@ then review results at [AI Protection](https://app.webdecoy.com/ai-protection).
24
26
 
25
27
  ## Integrate
26
28
 
27
- Create the instance once in a **server-only module**, then call it inside your
29
+ For Node.js, create the instance once in a **server-only module**, then call it inside your
28
30
  existing authenticated and validated AI endpoint:
29
31
 
30
32
  ```ts
package/WORKERS.md ADDED
@@ -0,0 +1,117 @@
1
+ # Cloudflare Workers
2
+
3
+ The `@webdecoy/ai-protection/workers` entry point provides experimental Workers
4
+ support for request admission, local rules, shared account/session quotas and
5
+ best-effort reporting. Node compatibility is required. Control-plane requests use manual redirects and
6
+ reject non-success responses without following redirects or forwarding credentials.
7
+ Local workerd tests cover
8
+ these features; a deployed staging canary remains required before production use.
9
+ Concurrency leases and budget settlement are not supported by this entry point.
10
+
11
+ ## Integration
12
+
13
+ Create a fresh protection object **inside `fetch()` for each incoming request**.
14
+ It binds that request and execution context, so `protect()` and `check()` do not
15
+ take a Request argument. Never store the returned object in module scope. This
16
+ keeps account refresh promises, report queues and `ctx.waitUntil` request-scoped.
17
+ Each request performs a fresh account lookup; the Node SDK's shared cache and
18
+ shadow baseline are not shared between Worker invocations.
19
+
20
+ ```js
21
+ import {createWorkerAIProtection} from '@webdecoy/ai-protection/workers';
22
+
23
+ export default {
24
+ async fetch(request, env, ctx) {
25
+ // Authenticate and validate first. Derive this context from server state.
26
+ const user = await authenticateAndValidate(request);
27
+ const protect = createWorkerAIProtection(request, {
28
+ webdecoyUrl: env.WEBDECOY_URL,
29
+ webdecoyKey: env.WEBDECOY_KEY,
30
+ propertyId: env.WEBDECOY_PROPERTY_ID,
31
+ subjectSecret: env.WEBDECOY_SUBJECT_SECRET,
32
+ scopeId: 'support-chat', route: '/api/chat', protectionMode: 'observe',
33
+ resolveClientIP: trustedClientIP,
34
+ accountQuota: {
35
+ ruleId: 'chat', limit: 20, windowSeconds: 60, mode: 'enforce',
36
+ failureMode: 'closed', subject: context => ({accountId: context.accountId})
37
+ }
38
+ }, ctx);
39
+ return protect(() => callModel(request.signal), {accountId: user.id});
40
+ }
41
+ };
42
+ ```
43
+
44
+ `authenticateAndValidate`, `trustedClientIP` and `callModel` are application
45
+ functions. The resolver must return an IP vouched for by your ingress, or `null`
46
+ to skip detection with degraded coverage. On direct Cloudflare ingress you can
47
+ use `request.headers.get('cf-connecting-ip')`; do not assume it identifies the
48
+ original client on Worker/service subrequests. Do not trust arbitrary forwarded
49
+ headers. See [Cloudflare's header behavior](https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-connecting-ip).
50
+
51
+ Configure Wrangler with the tested baseline:
52
+
53
+ ```toml
54
+ compatibility_date = "2026-01-01"
55
+ compatibility_flags = ["nodejs_compat"]
56
+ ```
57
+
58
+ Store API credentials and the random subject secret (at least 32 characters) as
59
+ Worker secrets. Shared quota subjects must come from authenticated server state.
60
+ For idempotent quotas, use `idempotency: true` only with a migrated runtime and
61
+ persist a server-generated operation ID if recovery must survive request loss.
62
+ `createQuotaOperationId` is also exported from the Workers entry point.
63
+
64
+ ## Streaming and lifecycle
65
+
66
+ The wrapper returns the original Response and does not read or rewrite the model
67
+ stream. Pass the application's cancellation signal to the provider. A successful
68
+ admission report describes callback/response creation, **not** stream completion,
69
+ final token usage or proof that upstream work stopped on disconnect.
70
+
71
+ Reporting is automatically attached through `task => ctx.waitUntil(task)` and is
72
+ bounded by the core reporting timeout (1000ms default, 10000ms maximum). Reporting
73
+ is best-effort; `waitUntil` is not durable delivery. Cloudflare permits up to
74
+ 30 seconds of extension after the response completes or the client disconnects.
75
+ Long-running background model work needs a separate execution design. See
76
+ [Cloudflare execution context](https://developers.cloudflare.com/workers/runtime-apis/context/).
77
+
78
+ For custom decisions, use `protect.check(trustedContext)` and report exactly once
79
+ with `protect.report(decision, outcome)`. `flush()` waits only for this request's
80
+ pending reports. The adapter rejects `concurrency` at construction; it exposes
81
+ neither `.concurrent()` nor `createAIBudget`. Using the Node entry point directly
82
+ does not establish Workers support for those features.
83
+
84
+ ## Run the fixture
85
+
86
+ From this repository root:
87
+
88
+ ```sh
89
+ npm ci
90
+ npm exec -- wrangler secret put WEBDECOY_KEY --config examples/workers/wrangler.toml
91
+ npm exec -- wrangler secret put WEBDECOY_PROPERTY_ID --config examples/workers/wrangler.toml
92
+ npm exec -- wrangler secret put WEBDECOY_SUBJECT_SECRET --config examples/workers/wrangler.toml
93
+ npm exec -- wrangler secret put EXAMPLE_TOKEN --config examples/workers/wrangler.toml
94
+ npm exec -- wrangler dev --config examples/workers/wrangler.toml
95
+ ```
96
+
97
+ For local development instead, put these four values in
98
+ `examples/workers/.dev.vars` (gitignored); no remote secret provisioning is needed.
99
+ The example imports the local SDK source. In your own project import the package
100
+ subpath shown above. Send POST `/api/chat` with `Authorization: Bearer <EXAMPLE_TOKEN>`.
101
+ The fixture returns text and invokes no model; replace its auth and callback with
102
+ your application's integration. It starts in observation mode.
103
+
104
+ ## Validation and rollout
105
+
106
+ `npm run test:workers` bundles the SDK and executes the Worker in Miniflare/workerd
107
+ with a simulated WebDecoy service. It verifies local/cloud denial, original
108
+ responses, stream passthrough, pre-cancelled requests, degraded detector outages,
109
+ simultaneous requests, asynchronous report completion and idempotent quota retry.
110
+ CI runs this alongside Node, type and package checks. Protocol fixtures do not
111
+ prove remote connectivity, database contention or detection accuracy.
112
+
113
+ Before production, deploy an owned staging Worker against the real service and
114
+ verify report persistence, entitled enforcement, shared quota contention across
115
+ invocations and service outages. Before adding concurrency/budgets, validate long
116
+ streams, midstream client disconnects, provider cancellation, heartbeat failure,
117
+ lease expiry and conservative settlement when usage is unknown.
package/account.mjs CHANGED
@@ -13,7 +13,7 @@ export function createAccountBinding({webdecoyUrl, webdecoyKey, propertyId, dete
13
13
  let next = {status:'unavailable', enforce:false};
14
14
  try {
15
15
  const response = await fetch(new URL('/api/v1/sdk/ai-abuse/config', webdecoyUrl), {
16
- headers:{Authorization:`Bearer ${webdecoyKey}`}, redirect:'error',
16
+ headers:{Authorization:`Bearer ${webdecoyKey}`}, redirect:'manual',
17
17
  signal:AbortSignal.any([signal, AbortSignal.timeout(detectorTimeoutMs)])
18
18
  });
19
19
  if (!response.ok) { await response.body?.cancel(); throw new Error('account_unavailable'); }
@@ -2,6 +2,7 @@ import {prepareWork} from './work.mjs';
2
2
  import {prepareQuota,quotaHash} from './quota.mjs';
3
3
  import {prepareConcurrency} from './concurrency.mjs';
4
4
  import {validPropertyID} from './account.mjs';
5
+ import {readJSON} from './transport.mjs';
5
6
  import {createReporter} from './reporting.mjs';
6
7
 
7
8
  export function prepareActionRuntime(options, definitions) {
@@ -13,6 +14,10 @@ export function prepareActionRuntime(options, definitions) {
13
14
  typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
14
15
  typeof config.subjectSecret!=='string'||!config.subjectSecret.isWellFormed()||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
15
16
  if(config.reportCaller !== undefined && typeof config.reportCaller !== "boolean")throw Error("Invalid caller reporting option");
17
+ if(config.callerPause !== undefined && typeof config.callerPause !== 'boolean')throw Error('Invalid caller pause option');
18
+ if(config.callerPause && !config.reportCaller)throw Error('Caller pause requires caller reporting');
19
+ const pauseTimeout=config.callerPauseTimeoutMs??1000;
20
+ if(!Number.isInteger(pauseTimeout)||pauseTimeout<1||pauseTimeout>10000)throw Error('Invalid caller pause timeout');
16
21
  const c={...config};const limits=new Map(),ruleIDs=new Set();
17
22
  const subject=(ctx,tenant)=>({accountId:tenant?quotaHash(c.subjectSecret,'webdecoy.actions.tenant.v1',ctx.caller.tenant):quotaHash(c.subjectSecret,'webdecoy.actions.caller.v1',ctx.caller.issuer,ctx.caller.tenant,ctx.caller.subject)});
18
23
  for(const [name,d] of definitions){
@@ -44,5 +49,27 @@ export function prepareActionRuntime(options, definitions) {
44
49
  headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
45
50
  await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
46
51
  }});
47
- return {limits,callerEvidence:caller=>c.reportCaller?Object.freeze({schema:1,source:'application_auth',id:quotaHash(c.subjectSecret,'webdecoy.actions.evidence.caller.v1',c.propertyId.toLowerCase(),caller.issuer,caller.tenant,caller.subject)}):undefined,report:event=>reporter.send(event),flush:()=>reporter.flush()};
52
+ const checkCallerPause = c.callerPause ? async (caller,signal) => {
53
+ signal.throwIfAborted();const started=performance.now();
54
+ const check={id:'caller_pause',source:'shared',mode:'enforce',decision:'unavailable',reason:'caller_pause_unavailable',durationMs:0};
55
+ let denial;
56
+ try {
57
+ const id=actionCallerEvidence(c,caller).id;
58
+ const response=await fetch(new URL('/api/v1/sdk/ai-abuse/caller-pause',c.webdecoyUrl),{
59
+ method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(pauseTimeout)]),
60
+ headers:{Authorization:`Bearer ${c.webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':c.propertyId},body:JSON.stringify({schema:1,caller:id})
61
+ });
62
+ if(!response.ok){await response.body?.cancel();throw Error('Caller control unavailable');}
63
+ const value=await readJSON(response,2048);
64
+ if(value.schema!==1||value.property_id!==c.propertyId.toLowerCase()||value.caller!==id||typeof value.allowed!=='boolean'||value.reason!==(value.allowed?'caller_allowed':'caller_paused'))throw Error('Invalid caller control response');
65
+ check.decision=value.allowed?'allow':'deny';check.reason=value.reason;
66
+ if(!value.allowed)denial={reason:'caller_paused',status:403};
67
+ } catch { signal.throwIfAborted(); }
68
+ check.durationMs=Math.max(0,performance.now()-started);return {check,denial};
69
+ }:null;
70
+ return {limits,checkCallerPause,callerEvidence:caller=>actionCallerEvidence(c,caller),report:event=>reporter.send(event),flush:()=>reporter.flush()};
71
+ }
72
+
73
+ export function actionCallerEvidence(config,caller) {
74
+ return config.reportCaller?Object.freeze({schema:1,source:'application_auth',id:quotaHash(config.subjectSecret,'webdecoy.actions.evidence.caller.v1',config.propertyId.toLowerCase(),caller.issuer,caller.tenant,caller.subject)}):undefined;
48
75
  }
package/actions.d.mts CHANGED
@@ -69,6 +69,10 @@ export interface ActionLimits {
69
69
  export interface ActionRuntime {
70
70
  /** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
71
71
  reportCaller?:boolean;
72
+ /** Opt-in online caller pause check before work. Requires reportCaller. Fails open on timeout (default 1000ms); no cache, retry or in-flight cancellation. */
73
+ callerPause?:boolean;
74
+ /** Caller-pause RPC deadline, 1–10000ms; default 1000ms. Short deadlines can fail open during cold authentication. */
75
+ callerPauseTimeoutMs?:number;
72
76
  webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
73
77
  reportingTimeoutMs?:number;maxPendingReports?:number;
74
78
  }
package/actions.mjs CHANGED
@@ -133,6 +133,10 @@ export function createActionProtection(options) {
133
133
  // Shared limits run only after application permission checks. Their own
134
134
  // RPC deadlines are separate from local admission and detector availability.
135
135
  clearTimeout(timer);
136
+ if(runtime?.checkCallerPause){
137
+ const r=await runtime.checkCallerPause(caller,admissionSignal);checks.push(r.check);
138
+ if(r.denial)deny(r.denial.reason,r.denial.status);
139
+ }
136
140
  const controls=runtime?.limits.get(name);
137
141
  for(const gate of controls?.gates??[]){
138
142
  const r=await gate(context,admissionSignal);checks.push(r.check);
package/admission.mjs CHANGED
@@ -45,7 +45,7 @@ export function createAdmission(options) {
45
45
  let verdict;
46
46
  try {
47
47
  const response = await fetch(new URL('/api/v1/sdk/detect', c.webdecoyUrl), {
48
- method: 'POST', redirect: 'error',
48
+ method: 'POST', redirect: 'manual',
49
49
  headers: {'Authorization': `Bearer ${c.webdecoyKey}`, 'Content-Type': 'application/json'},
50
50
  signal: AbortSignal.any([signal, AbortSignal.timeout(c.detectorTimeoutMs)]),
51
51
  body: JSON.stringify({
package/mcp.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
4
+ import { actionCallerEvidence } from './action-runtime.mjs';
4
5
  import { createActionProtection, ActionDenied } from './actions.mjs';
5
6
  import { createHash, randomUUID } from 'node:crypto';
6
7
  import { createReporter } from './reporting.mjs';
@@ -54,12 +55,12 @@ export function createProtectedMCPHandler(options) {
54
55
  const catalogReporter = options.discovery ? createReporter({
55
56
  reportingTimeoutMs: runtime.reportingTimeoutMs ?? 1000,
56
57
  maxPendingReports: runtime.maxPendingReports ?? 100,
57
- onObservation: async (names, { signal }) => {
58
+ onObservation: async ({names,caller}, { signal }) => {
58
59
  const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports', runtime.webdecoyUrl), {
59
60
  method: 'POST', redirect: 'error', signal,
60
61
  headers: { Authorization: `Bearer ${runtime.webdecoyKey}`, 'X-WebDecoy-Property-ID': runtime.propertyId, 'Content-Type': 'application/json' },
61
62
  body: JSON.stringify({ schema: 3, request_id: randomUUID(), timestamp: new Date().toISOString(), action: 'tool_discovery',
62
- tool_catalog: { server_id: serverId, source: 'tools_list', tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions, ...(decoys.has(name)?{decoy:decoys.get(name)}:{}) })) } })
63
+ tool_catalog: { server_id: serverId, source: 'tools_list', ...(caller?{caller}:{}), tools: names.map(name => ({ name, schema_hash: hashes[name], effect: tools[name].toolSchema.effect, permissions: tools[name].toolSchema.permissions, ...(decoys.has(name)?{decoy:decoys.get(name)}:{}) })) } })
63
64
  });
64
65
  await response.body?.cancel();
65
66
  if (!response.ok) throw Error('Discovery reporting unavailable');
@@ -214,7 +215,7 @@ export function createProtectedMCPHandler(options) {
214
215
  res.once('close', () => { void server.close().catch(() => { }); });
215
216
  server.setRequestHandler(ListToolsRequestSchema, async () => {
216
217
  const visible = Object.entries(tools).filter(([name, tool]) => decoys.get(name) !== 'unadvertised' && tool.requiredScopes.every(s => caller.scopes.includes(s)));
217
- for (let i=0;i<visible.length;i+=64) void catalogReporter?.send(visible.slice(i,i+64).map(([name]) => name));
218
+ for (let i=0;i<Math.max(1,visible.length);i+=64) void catalogReporter?.send({names:visible.slice(i,i+64).map(([name]) => name),caller:runtime? actionCallerEvidence(runtime,caller):undefined});
218
219
  return { tools: visible.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema, ...(tool.annotations ? {annotations:tool.annotations} : {}) })) };
219
220
  });
220
221
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
package/package.json CHANGED
@@ -6,11 +6,12 @@
6
6
  },
7
7
  "scripts": {
8
8
  "test": "node --test test/*.test.mjs",
9
- "prepublishOnly": "npm test && npm run test:types && npm run check:package",
9
+ "prepublishOnly": "npm test && npm run test:workers && npm run test:types && npm run check:package",
10
10
  "check:package": "node scripts/check-package.mjs",
11
- "test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts"
11
+ "test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts",
12
+ "test:workers": "node --test test/workers/*.test.mjs"
12
13
  },
13
- "version": "0.1.0-alpha.11",
14
+ "version": "0.1.0-alpha.14",
14
15
  "exports": {
15
16
  "./fetch": {
16
17
  "types": "./fetch.d.mts",
@@ -31,6 +32,10 @@
31
32
  "./mcp": {
32
33
  "types": "./mcp.d.mts",
33
34
  "import": "./mcp.mjs"
35
+ },
36
+ "./workers": {
37
+ "types": "./workers.d.mts",
38
+ "import": "./workers.mjs"
34
39
  }
35
40
  },
36
41
  "files": [
@@ -63,9 +68,12 @@
63
68
  "MCP.md",
64
69
  "work.mjs",
65
70
  "WORK.md",
66
- "tool-effects.mjs"
71
+ "tool-effects.mjs",
72
+ "workers.mjs",
73
+ "workers.d.mts",
74
+ "WORKERS.md"
67
75
  ],
68
- "description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
76
+ "description": "Bot and abuse protection for AI-powered applications. Request admission for Node.js and experimental Cloudflare Workers.",
69
77
  "license": "Apache-2.0",
70
78
  "repository": {
71
79
  "type": "git",
@@ -89,7 +97,10 @@
89
97
  "devDependencies": {
90
98
  "typescript": "6.0.3",
91
99
  "@modelcontextprotocol/sdk": "1.31.0",
92
- "@types/node": "22.19.15"
100
+ "@types/node": "22.19.15",
101
+ "esbuild": "^0.28.2",
102
+ "miniflare": "^4.20260730.0",
103
+ "wrangler": "^4.147.0"
93
104
  },
94
105
  "peerDependencies": {
95
106
  "@modelcontextprotocol/sdk": "1.31.0"
package/quota.mjs CHANGED
@@ -48,7 +48,7 @@ export function prepareQuota(options) {
48
48
  signal.throwIfAborted();
49
49
  try {
50
50
  const response=await fetch(new URL('/api/v1/sdk/ai-abuse/quota',webdecoyUrl),{
51
- method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(q.timeoutMs)]),
51
+ method:'POST',redirect:'manual',signal:AbortSignal.any([signal,AbortSignal.timeout(q.timeoutMs)]),
52
52
  headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},body:JSON.stringify(payload)
53
53
  });
54
54
  if(!response.ok){await response.body?.cancel();const error=Error('Quota unavailable');error.terminal=response.status<500;throw error;}
package/telemetry.mjs CHANGED
@@ -11,7 +11,7 @@ export function createTelemetry({webdecoyUrl, webdecoyKey, propertyId, reportToW
11
11
  ...(event.handler_status !== undefined ? {handler_status:event.handler_status} : {}),
12
12
  action:event.action};
13
13
  const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports',webdecoyUrl),{
14
- method:'POST',redirect:'error',signal,
14
+ method:'POST',redirect:'manual',signal,
15
15
  headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},
16
16
  body:JSON.stringify(payload)
17
17
  });
package/workers.d.mts ADDED
@@ -0,0 +1,19 @@
1
+ import type {AIProtectionOptions, ProtectionDecision, ReportOutcome} from './fetch.mjs';
2
+ export {createQuotaOperationId} from './fetch.mjs';
3
+ /** Structural type; does not require Cloudflare types in Node consumers. */
4
+ export interface WorkerExecutionContext {waitUntil(task: Promise<unknown>): void}
5
+ export type WorkerAIProtectionOptions<Context = Record<string, unknown>> =
6
+ Omit<AIProtectionOptions<Context>, 'waitUntil' | 'concurrency'>;
7
+ export interface WorkerAIProtection<Context> {
8
+ (handler: () => Response | Promise<Response>, context: Context): Promise<Response>;
9
+ check(context: Context): Promise<ProtectionDecision>;
10
+ report(decision: ProtectionDecision, outcome?: ReportOutcome): Promise<void>;
11
+ flush(): Promise<void>;
12
+ }
13
+ export interface ContextOptionalWorkerProtection extends WorkerAIProtection<Record<string, unknown>> {
14
+ (handler: () => Response | Promise<Response>, context?: Record<string, unknown>): Promise<Response>;
15
+ check(context?: Record<string, unknown>): Promise<ProtectionDecision>;
16
+ }
17
+ /** Create inside the Worker fetch handler; the returned object belongs to this request only. */
18
+ export function createWorkerAIProtection(request: Request, options: WorkerAIProtectionOptions, ctx: WorkerExecutionContext): ContextOptionalWorkerProtection;
19
+ export function createWorkerAIProtection<Context>(request: Request, options: WorkerAIProtectionOptions<Context>, ctx: WorkerExecutionContext): WorkerAIProtection<Context>;
package/workers.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import {createAIProtection} from './fetch.mjs';
2
+ export {createQuotaOperationId} from './quota.mjs';
3
+
4
+ // Call inside fetch(), once per incoming request. No request-owned I/O or ctx
5
+ // escapes into a shared instance, including the core's pending config refresh.
6
+ export function createWorkerAIProtection(request, options, ctx) {
7
+ if (!(request instanceof Request)) throw Error('Worker Request required');
8
+ if (!ctx || typeof ctx.waitUntil !== 'function') throw Error('Worker execution context required');
9
+ if (options.concurrency !== undefined) throw Error('Workers concurrency is not supported yet');
10
+ const core = createAIProtection({...options, waitUntil: task => ctx.waitUntil(task)});
11
+ const protect = (handler, context = {}) => core(request, handler, context);
12
+ return Object.assign(protect, {
13
+ check: (context = {}) => core.check(request, context),
14
+ report: core.report,
15
+ flush: core.flush
16
+ });
17
+ }