@webdecoy/ai-protection 0.1.0-alpha.4 → 0.1.0-alpha.6

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
@@ -8,10 +8,10 @@ resource authorization.
8
8
 
9
9
  ## Install
10
10
 
11
- Available in `0.1.0-alpha.4` and later compatible alpha releases:
11
+ Available in `0.1.0-alpha.5` and later compatible alpha releases:
12
12
 
13
13
  ```sh
14
- npm install @webdecoy/ai-protection@0.1.0-alpha.4 @modelcontextprotocol/sdk@1.31.0
14
+ npm install @webdecoy/ai-protection@0.1.0-alpha.5 @modelcontextprotocol/sdk@1.31.0
15
15
  ```
16
16
 
17
17
  Requires Node 22.22.3+ and MCP SDK **1.31.0**. The MCP SDK is an optional peer, so
@@ -111,9 +111,12 @@ have unknown work. Never retry writes without application/provider idempotency.
111
111
  405. Reconnects do not grant fresh identity allowances.
112
112
  - Resources, prompts, tasks, sampling, elicitation, other routes and pre-existing
113
113
  MCP handlers are not wrapped. Unregistered methods/tools do not dispatch.
114
- Weighted operation budgets and complete cross-replica lifecycle acceptance
115
- remain separate work.
114
+ Weighted tool work is available in the alpha API; see [bounded work](WORK.md)
115
+ for its runtime prerequisite and application-enforced bounds. Full product
116
+ acceptance remains separate from this adapter's tested contract.
116
117
 
117
118
  This is an explicit tools integration, not transparent protection of an existing
118
119
  whole MCP server. See the [MCP transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
119
120
  and [authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
121
+
122
+ For weighted search/export limits and tenant concurrency, see [bounded tool work](WORK.md).
package/README.md CHANGED
@@ -328,3 +328,35 @@ and maps tenant membership. The [MCP adapter](MCP.md) provides a separate `/mcp`
328
328
  HTTP tool dispatch starting in `0.1.0-alpha.4`.
329
329
  It requires the optional, pinned MCP SDK peer. Optional shared quotas, concurrency and hosted
330
330
  action events are documented in the action guide and use the hosted AI Protection runtime and dashboard.
331
+
332
+ ## Weighted tool work (Alpha)
333
+
334
+ Node alpha.5 adds weighted tool-work reservations and tenant concurrency. See
335
+ [bounded tool work](WORK.md) for installation, enforced application bounds and
336
+ retry/unknown-outcome semantics. These units are separate from model usage and billing.
337
+
338
+ ### Opt-in tool caller attribution
339
+
340
+ With a runtime supporting caller evidence, set `sharedRuntime.reportCaller: true`
341
+ on `createActionProtection`. It defaults to false. Hosted reports and `onEvent`
342
+ then include `caller: {schema: 1, source: 'application_auth', id: '<digest>'}`
343
+ after successful authentication, including subsequent permission denials.
344
+ Failed authentication, rejected arguments before authentication, and unknown tools
345
+ have no caller attribution. Existing request admission reporting is unchanged.
346
+
347
+ The SDK derives this HMAC-SHA256 pseudonym from the server-owned `subjectSecret`,
348
+ a dedicated versioned domain, property ID, issuer, application tenant and subject.
349
+ Replicas must use the same secret to correlate callers. Raw subjects, issuers,
350
+ tenants, scopes, tokens and tool arguments are not added to reports. OAuth clients
351
+ and agent signers are not treated as the authenticated subject. Your authentication
352
+ hook must verify credentials and tenant membership; WebDecoy does not independently
353
+ verify those credentials from this report, and a pseudonym is not a unique person.
354
+
355
+ Use a randomly generated secret of at least 32 bytes and store it server-side.
356
+ Rotating it changes pseudonyms and also changes existing shared-limit identities
357
+ that use this secret; coordinate rotation because it can reset quota continuity.
358
+ Historical pseudonyms are not relinked. AI Protection displays a seven-day receipt
359
+ window and existing report retention purges expired records in bounded background
360
+ sweeps. Counts cover reported, consistently attributed actions only; dropped reports,
361
+ older SDKs and conflicting bindings leave gaps. Install server support before
362
+ enabling this option: older runtimes reject the additional field.
package/WORK.md ADDED
@@ -0,0 +1,123 @@
1
+ # Bounded tool work (Alpha)
2
+
3
+ The Node action and MCP APIs support optional weighted tool-work reservations.
4
+ Available in `@webdecoy/ai-protection@0.1.0-alpha.5`. Requires the
5
+ `/api/v1/sdk/ai-abuse/work` contract enabled on the hosted runtime. Go/Python model budgets
6
+ and invocation controls remain available, but do not expose this new weighted
7
+ operation API. The first integration is the TypeScript MCP tools adapter.
8
+
9
+ ## Configure at the action, before execution
10
+
11
+ ```js
12
+ limits: {
13
+ work: {
14
+ ruleId: 'search_work_v1',
15
+ windowSeconds: 60,
16
+ maxUnits: 11, // one fixed unit plus at most five rows at two units each
17
+ limits: {caller: 22, tenant: 44, tool: 88},
18
+ mode: 'enforce',
19
+ failureMode: 'closed',
20
+ measure: result => result.workUnits, // server-computed, synchronous, confirmed
21
+ },
22
+ concurrency: {
23
+ ruleId: 'search_parallel_v1', accountLimit: 1, featureLimit: 10,
24
+ mode: 'enforce', failureMode: 'closed',
25
+ },
26
+ tenantConcurrency: {
27
+ ruleId: 'tenant_search_parallel_v1', accountLimit: 2, featureLimit: 10,
28
+ mode: 'enforce', failureMode: 'closed',
29
+ },
30
+ }
31
+ ```
32
+
33
+ Use `sharedRuntime` on the action/MCP handler. No browser credentials, prompts,
34
+ arguments or results are sent to the work service. Verified caller identity and
35
+ tenant are hashed; an HMAC binds the action, policy version and canonical arguments
36
+ to the operation. Use a stable server-only subject secret. Changing that secret or
37
+ rule IDs creates new accounting identities; rotate/drain deliberately.
38
+
39
+ All three unit allowances are evaluated atomically across runtime replicas before
40
+ callback dispatch. `caller` is scoped to issuer/tenant/subject, `tenant` is scoped
41
+ to issuer/tenant, and `tool` is shared within this property's rule. Zero/omitted
42
+ limits disable that dimension; at least one positive limit is required. Distinct
43
+ tools need distinct rule IDs. Reconnecting or creating a new MCP session does not
44
+ reset the identity allowance. Invocation quotas count attempts separately; model
45
+ budgets count tokens separately. These units never become a WebDecoy billing meter.
46
+
47
+ Existing `concurrency.accountLimit` is per caller; `tenantConcurrency.accountLimit`
48
+ is per tenant. Each `featureLimit` is property/rule-wide. Admission denial releases
49
+ already acquired slots; unconfirmed work keeps its slots until lease expiry.
50
+ Lease expiry requests cooperative cancellation, not proof a remote operation stopped.
51
+
52
+ ## Enforce resource bounds in the application
53
+
54
+ `maxUnits` must be a conservative upper bound you actually enforce. The SDK provides
55
+ `context.work.maxUnits` to the callback, but cannot constrain arbitrary database or
56
+ storage work. Validate requested bounds before dispatch, apply tenant predicates and
57
+ row limits at the data source, and check byte lengths **before** appending/sending
58
+ export payload. Do not fetch an unbounded result and trim it afterward.
59
+
60
+ The [bounded search/export example](examples/mcp/work-tools.mjs) implements:
61
+
62
+ - Search: at most five rows, weight `1 + 2 × returned rows`, reserve 11 units.
63
+ - Export: at most 256 UTF-8 payload bytes, weight `16 + payload bytes`, reserve 272.
64
+ Transport framing/JSON encoding are not included in that payload-byte allowance.
65
+ - Forbidden admin: application authorization denies before any callback.
66
+
67
+ The synthetic source contains fixed small records. A real data source must enforce
68
+ its own database scan, CPU/time and storage-read bounds too; a row limit is not a
69
+ claim that a query scans only those rows. The separate weights use separate rules,
70
+ not interchangeable token or monetary estimates.
71
+
72
+ Run `node examples/mcp/start-work.mjs` after configuring the existing Auth0 example
73
+ variables and `WEBDECOY_URL`, `WEBDECOY_KEY`, `WEBDECOY_PROPERTY_ID`, and
74
+ `WEBDECOY_SUBJECT_SECRET`. The fixture binds to loopback and makes no model calls.
75
+ It requires an updated runtime. Replace its single-subject allowlist with current
76
+ application membership for an actual customer deployment.
77
+
78
+ ## Settlement, failures and retries
79
+
80
+ After a completed callback, a synchronous `measure(result, context)` may report
81
+ confirmed integer usage between zero and `maxUnits`. Without it, the fixed maximum
82
+ weight is charged. Confirmed lower usage releases the difference in the original
83
+ admission window. Never measure from untrusted request claims. Errors, cancellation,
84
+ crashes, asynchronous/invalid measurement, excess usage and missing/failed settlement
85
+ keep the conservative maximum charged. Successful results survive reporting or
86
+ settlement failure and expose unknown accounting evidence.
87
+
88
+ Unknown work is never refunded just because a lease or settlement deadline expires.
89
+ A hard ceiling depends on application-enforced maximum work, enforce/closed settings,
90
+ and bounded completion. Observe/open can execute without a confirmed reservation and
91
+ must not claim a ceiling. Detector fail-open is separate from work-state failure.
92
+
93
+ A reservation has a server-generated timestamp/UUID operation ID. For recovery across
94
+ application retries, persist an ID from `createQuotaOperationId` (exported from
95
+ `@webdecoy/ai-protection/fetch`) in trusted application state, and provide it through
96
+ `work.operationId(context)`. Do not accept an arbitrary client ID or use MCP message,
97
+ agent or session IDs as this key. Reuse requires the same caller/tenant/tool, bounds,
98
+ policy and argument binding. Conflicts deny even in open mode.
99
+
100
+ The SDK makes no automatic work-RPC or callback retries. An identical reserve replay
101
+ returns accounting evidence but **never grants another dispatch**; the SDK returns
102
+ `work_replay` (409). Lost admission replies may therefore consume capacity without
103
+ executing work. A changed retry returns `work_conflict` (409). Return stored business
104
+ results through your own idempotency layer when appropriate. Never mint a new ID to
105
+ retry an unknown write blindly. Application/provider idempotency remains necessary;
106
+ this is not an exactly-once side-effect guarantee. Identical settlement is idempotent;
107
+ conflicting or over-bound settlement is rejected without lowering the charge.
108
+
109
+ ## Windows and evidence
110
+
111
+ - Fixed UTC-aligned windows: 1–86,400 seconds. Boundary bursts are possible; use
112
+ concurrency controls separately. Limits/maxima: integer units up to 10^12.
113
+ - IDs: ten-minute admission lifetime, at most 30 seconds future skew; expired IDs
114
+ remain invalid even after receipts are purged. Settlement deadline: one hour.
115
+ - Receipts retained through the window end plus 24 hours. Unknown charge persists
116
+ for its admission window; this is not a lifetime allowance or infinite ledger.
117
+ - At most 32 work rules and 10,000 retained operations per property, including denials.
118
+ Capacity failures follow state-failure policy. Existing replay works at capacity.
119
+ These are operational bounds, not commercial plan changes.
120
+ - Reports show reserved/charged units, remaining allowance at admission and
121
+ reserved/settled/unknown/unavailable/denied/replay status. Remaining is a snapshot,
122
+ not current balance. Delivery is best effort; missing reports do not imply no work.
123
+ Local callback completion is not independent proof of a remote side effect.
@@ -1,3 +1,4 @@
1
+ import {prepareWork} from './work.mjs';
1
2
  import {prepareQuota,quotaHash} from './quota.mjs';
2
3
  import {prepareConcurrency} from './concurrency.mjs';
3
4
  import {validPropertyID} from './account.mjs';
@@ -10,7 +11,8 @@ export function prepareActionRuntime(options, definitions) {
10
11
  if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/'||url.search||url.hash||
11
12
  (url.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))||!validPropertyID(config.propertyId)||
12
13
  typeof config.webdecoyKey!=='string'||!config.webdecoyKey||/[^\x21-\x7e]/.test(config.webdecoyKey)||
13
- typeof config.subjectSecret!=='string'||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
14
+ typeof config.subjectSecret!=='string'||!config.subjectSecret.isWellFormed()||Buffer.byteLength(config.subjectSecret)<32)throw Error('Invalid action runtime');
15
+ if(config.reportCaller !== undefined && typeof config.reportCaller !== "boolean")throw Error("Invalid caller reporting option");
14
16
  const c={...config};const limits=new Map(),ruleIDs=new Set();
15
17
  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)});
16
18
  for(const [name,d] of definitions){
@@ -20,10 +22,15 @@ export function prepareActionRuntime(options, definitions) {
20
22
  const gate=prepareQuota({...c,accountQuota:{...q,idempotency:false,operationId:undefined,sessionLimit:0,subject:ctx=>subject(ctx,tenant)}});
21
23
  gates.push(async(ctx,signal)=>{const r=await gate(ctx,signal);if(r.check)r.check.id=tenant?'tenant_quota':'caller_quota';return r;});
22
24
  }
23
- let concurrency=null;
24
- if(l.concurrency){if(ruleIDs.has(l.concurrency.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(l.concurrency.ruleId);
25
- concurrency=prepareConcurrency({...c,concurrency:{...l.concurrency,subject:ctx=>subject(ctx,false)}});}
26
- limits.set(name,{gates,concurrency});
25
+ const concurrencies=[];
26
+ for(const [key,tenant] of [['concurrency',false],['tenantConcurrency',true]])if(l[key]){
27
+ const option=l[key];if(ruleIDs.has(option.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(option.ruleId);
28
+ const gate=prepareConcurrency({...c,concurrency:{...option,subject:ctx=>subject(ctx,tenant)}});
29
+ concurrencies.push(async(ctx,signal)=>{const r=await gate(ctx,signal);if(tenant)r.check.id='tenant_concurrency';return r;});
30
+ }
31
+ let work=null;
32
+ if(l.work){if(ruleIDs.has(l.work.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(l.work.ruleId);work=prepareWork(c,l.work,name,options.policyVersion);}
33
+ limits.set(name,{gates,concurrencies,work});
27
34
  }
28
35
  const reporter=createReporter({reportingTimeoutMs:c.reportingTimeoutMs??1000,maxPendingReports:c.maxPendingReports??100,
29
36
  onObservation:async(event,{signal})=>{
@@ -32,10 +39,10 @@ export function prepareActionRuntime(options, definitions) {
32
39
  const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
33
40
  degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
34
41
  action:event.decision==='deny'?'denied':event.outcome==='unknown'?'handler_error':'forwarded',
35
- tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome}};
42
+ tool_action:{action_id:event.actionId,name:event.action,policy_version:event.policyVersion,outcome:event.outcome,...(event.caller?{caller:event.caller}:{}),...(event.work?{work:event.work}:{})}};
36
43
  const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
37
44
  headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
38
45
  await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
39
46
  }});
40
- return {limits,report:event=>reporter.send(event),flush:()=>reporter.flush()};
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()};
41
48
  }
package/actions.d.mts CHANGED
@@ -13,11 +13,29 @@ export interface TrustedCaller {
13
13
  }
14
14
  export type ActionInput = null | boolean | number | string | readonly ActionInput[] | {readonly [key:string]: ActionInput};
15
15
  export interface ActionContext {
16
+ readonly work?: {readonly maxUnits:number};
16
17
  readonly caller: TrustedCaller;
17
18
  readonly args: ActionInput;
18
19
  readonly signal?: AbortSignal;
19
20
  }
21
+ export interface ActionWorkEvidence {
22
+ readonly rule_id:string;readonly mode:'observe'|'enforce';
23
+ readonly status:'reserved'|'settled'|'unknown'|'unavailable'|'denied'|'replay';
24
+ readonly reserved_units:number;readonly charged_units?:number;readonly remaining_units?:number;
25
+ }
26
+ export interface ActionWork {
27
+ ruleId:string;windowSeconds:number;maxUnits:number;
28
+ limits:{caller?:number;tenant?:number;tool?:number};
29
+ mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number;
30
+ /** Trusted server-generated persisted ID. Never use MCP request/session IDs or raw user input. */
31
+ operationId?(context:ActionContext):string;
32
+ /** Confirmed work after completed execution. No async/detached measurement; excess/unknown retains maximum. */
33
+ measure?(result:unknown,context:ActionContext):number;
34
+ }
20
35
  export interface ActionEvent {
36
+ /** Pseudonymous application-authenticated subject, not WebDecoy-verified agent identity. */
37
+ readonly caller?: {readonly schema:1;readonly source:'application_auth';readonly id:string};
38
+ readonly work?:ActionWorkEvidence;
21
39
  readonly schema: 1;
22
40
  readonly eventId: string;
23
41
  readonly timestamp: string;
@@ -33,11 +51,15 @@ export interface ActionEvent {
33
51
  }
34
52
  export interface ActionQuota {ruleId:string;limit:number;windowSeconds:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number}
35
53
  export interface ActionLimits {
54
+ tenantConcurrency?: ActionLimits['concurrency'];
55
+ work?:ActionWork;
36
56
  callerQuota?: ActionQuota;
37
57
  tenantQuota?: ActionQuota;
38
58
  concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
39
59
  }
40
60
  export interface ActionRuntime {
61
+ /** Opt in to scoped caller pseudonyms in reports. Requires a supporting runtime. Default false. */
62
+ reportCaller?:boolean;
41
63
  webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
42
64
  reportingTimeoutMs?:number;maxPendingReports?:number;
43
65
  }
package/actions.mjs CHANGED
@@ -3,7 +3,7 @@ import {abortable} from './transport.mjs';
3
3
  import {prepareActionRuntime} from './action-runtime.mjs';
4
4
 
5
5
  const token = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,95}$/;
6
- const bounded = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
6
+ const bounded = value => typeof value === 'string' && value.isWellFormed() && value.length > 0 && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value);
7
7
  const freeze = value => {
8
8
  if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
9
9
  return value;
@@ -59,7 +59,7 @@ function inputSnapshot(input) {
59
59
  return result;
60
60
  }
61
61
 
62
- /** Local protected dispatch. No network calls, cloud override, or automatic retry. */
62
+ /** Protected dispatch with local permissions and optional shared controls. Never retries execution. */
63
63
  export function createActionProtection(options) {
64
64
  if (!options || typeof options.authenticate !== 'function' || (typeof options.policyVersion !== 'string' || !token.test(options.policyVersion))) throw Error('Invalid action configuration');
65
65
  const authenticate = options.authenticate, policyVersion = options.policyVersion, sink = options.onEvent;
@@ -81,7 +81,8 @@ export function createActionProtection(options) {
81
81
  const actionId = randomUUID();
82
82
  // Unknown caller-controlled action strings are never placed in evidence.
83
83
  const action = actions.get(name), eventAction = action ? name : 'unregistered';
84
- let attempted = false,completed=false,lease;
84
+ let attempted = false,completed=false,lease,work,callerEvidence;
85
+ const leases=[];
85
86
  const checks=[];
86
87
  const deadline = new AbortController();
87
88
  const admissionSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
@@ -90,7 +91,7 @@ export function createActionProtection(options) {
90
91
  function emit(decision, reason, outcome) {
91
92
 
92
93
  const event = Object.freeze({schema:1, eventId:randomUUID(), timestamp:new Date().toISOString(), actionId, action:eventAction, policyVersion,
93
- evaluation:'local', decision, reason, attempted, outcome,checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
94
+ ...(callerEvidence?{caller:callerEvidence}:{}),evaluation:'local', decision, reason, attempted, outcome,...(work?{work:Object.freeze({...work.evidence})}:{}),checks:Object.freeze(checks.map(c=>Object.freeze({...c})))});
94
95
  if(runtime)void runtime.report(event);
95
96
  if(!sink||pendingEvents>=100)return;
96
97
  pendingEvents++;
@@ -106,6 +107,7 @@ export function createActionProtection(options) {
106
107
  let caller;
107
108
  try { caller = callerSnapshot(await evaluate(() => authenticate(authenticationContext, {signal:admissionSignal}))); }
108
109
  catch { cancelled(); deny('authentication_required',401); }
110
+ callerEvidence=runtime?.callerEvidence(caller);
109
111
  cancelled();
110
112
  if (action.requiredScopes.some(scope => !caller.scopes.includes(scope))) deny('missing_scope',403);
111
113
  const context = Object.freeze({caller, args, signal:admissionSignal});
@@ -131,38 +133,44 @@ export function createActionProtection(options) {
131
133
  const r=await gate(context,admissionSignal);checks.push(r.check);
132
134
  if(r.denial)deny(r.denial.reason,r.denial.status,r.denial.retryAfterSeconds);
133
135
  }
134
- if(controls?.concurrency){
135
- lease=await controls.concurrency(context,admissionSignal);checks.push(lease.check);
136
+ for(const gate of controls?.concurrencies??[]){
137
+ lease=await gate(context,lease?.signal??admissionSignal);leases.push(lease);checks.push(lease.check);
136
138
  if(lease.denial)deny(lease.denial.reason,lease.denial.status,lease.denial.retryAfterSeconds);
137
139
  lease.signal.throwIfAborted();
138
140
  }
141
+ if(controls?.work){
142
+ work=await controls.work(context,lease?.signal??admissionSignal);checks.push(work.check);
143
+ if(work.denial)deny(work.denial.reason,work.denial.status,work.denial.retryAfterSeconds);
144
+ }
139
145
  // Authentication may expire while ownership/policy checks are running.
140
146
  if (caller.expiresAt <= Date.now()) deny('authentication_expired',401);
141
147
  cancelled();
142
148
  clearTimeout(timer);
143
149
  attempted = true;
144
150
  emit('allow','authorized','attempted');
145
- const execution=Object.freeze({...context,signal:lease?.signal??context.signal});
151
+ const execution=Object.freeze({...context,...(work?{work:work.bound}:{}),signal:lease?.signal??context.signal});
146
152
  const result = await action.execute(execution);
147
153
  execution.signal.throwIfAborted();
148
154
  // Resolution is application completion, not proof of an external side effect.
149
155
  cancelled();
150
156
  completed=true;
151
- if(lease&&!lease.denial){
157
+ if(work){const check=await work.finish(result,true);if(check)checks.push(check);}
158
+ for(const lease of [...leases].reverse())if(!lease.denial){
152
159
  const began=performance.now();
153
- try{await lease.finish(true);}catch{checks.push({id:'concurrency_release',source:'shared',mode:lease.check.mode,decision:'unavailable',reason:'concurrency_release_unavailable',durationMs:performance.now()-began});}
160
+ try{await lease.finish(true);}catch{checks.push({id:lease.check.id==='tenant_concurrency'?'tenant_concurrency_release':'concurrency_release',source:'shared',mode:lease.check.mode,decision:'unavailable',reason:'concurrency_release_unavailable',durationMs:performance.now()-began});}
154
161
  }
155
162
  emit('allow','authorized','completed');
156
163
  return result;
157
164
  } catch (error) {
158
165
  if (!attempted && deadline.signal.aborted && !signal?.aborted) deny('admission_timeout',503);
166
+ if(work&&!completed)await work.finish(undefined,false);
159
167
  if (attempted) emit('allow',signal?.aborted ? 'execution_cancelled' : 'execution_failed','unknown');
160
168
  else if (!(error instanceof ActionDenied)) emit('deny','admission_cancelled','not_attempted');
161
169
  throw error;
162
170
  } finally {
163
171
  clearTimeout(timer);
164
172
  // Only confirmed completion (or no callback) releases; uncertain work holds.
165
- if(lease&&!lease.denial)await lease.finish(!attempted||completed).catch(()=>{});
173
+ for(const lease of [...leases].reverse())if(!lease.denial)await lease.finish(!attempted||completed).catch(()=>{});
166
174
  }
167
175
  }});
168
176
  }
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "check:package": "node scripts/check-package.mjs",
11
11
  "test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts test/mcp-types.mts"
12
12
  },
13
- "version": "0.1.0-alpha.4",
13
+ "version": "0.1.0-alpha.6",
14
14
  "exports": {
15
15
  "./fetch": {
16
16
  "types": "./fetch.d.mts",
@@ -60,7 +60,9 @@
60
60
  "action-runtime.mjs",
61
61
  "mcp.mjs",
62
62
  "mcp.d.mts",
63
- "MCP.md"
63
+ "MCP.md",
64
+ "work.mjs",
65
+ "WORK.md"
64
66
  ],
65
67
  "description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
66
68
  "license": "Apache-2.0",
package/work.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import {createQuotaOperationId,quotaHash} from './quota.mjs';
2
+ import {readJSON} from './transport.mjs';
3
+ const code=/^[a-z][a-z0-9_]{0,63}$/;
4
+ const integer=n=>Number.isSafeInteger(n)&&n>=0&&n<=1e12;
5
+ const canonical=value=>JSON.stringify(value===null||typeof value!=='object'?value:Array.isArray(value)?value.map(v=>JSON.parse(canonical(v))):Object.fromEntries(Object.keys(value).sort().map(k=>[k,JSON.parse(canonical(value[k]))])));
6
+ export function prepareWork(config,definition,name,policyVersion){
7
+ const w={mode:'observe',failureMode:'open',timeoutMs:1000,...definition};
8
+ const limits=Object.fromEntries(['caller','tenant','tool'].map(k=>[k,w.limits?.[k]??0]));
9
+ if(!code.test(w.ruleId??'')||!integer(w.maxUnits)||w.maxUnits<1||!integer(w.windowSeconds)||w.windowSeconds<1||w.windowSeconds>86400||!integer(w.timeoutMs)||w.timeoutMs<1||w.timeoutMs>10000||!['observe','enforce'].includes(w.mode)||!['open','closed'].includes(w.failureMode)||!Object.values(limits).every(integer)||!Object.values(limits).some(n=>n>0)||(w.measure!==undefined&&typeof w.measure!=='function')||(w.operationId!==undefined&&typeof w.operationId!=='function'))throw Error('Invalid tool work policy');
10
+ async function rpc(body,signal){
11
+ const response=await fetch(new URL('/api/v1/sdk/ai-abuse/work',config.webdecoyUrl),{method:'POST',redirect:'error',signal:AbortSignal.any([signal??new AbortController().signal,AbortSignal.timeout(w.timeoutMs)]),headers:{Authorization:`Bearer ${config.webdecoyKey}`,'X-WebDecoy-Property-ID':config.propertyId,'Content-Type':'application/json'},body:JSON.stringify(body)});
12
+ if(!response.ok){await response.body?.cancel();const e=Error('Work unavailable');e.status=response.status;throw e;}
13
+ const r=await readJSON(response,2048);
14
+ if(r?.schema!==1||!['allowed','granted','replay','settled'].every(k=>typeof r[k]==='boolean')||!['reserved_units','charged_units','remaining_units','retry_after_seconds'].every(k=>integer(r[k]))||r.retry_after_seconds>86400||!code.test(r.reason??''))throw Error('Invalid work response');
15
+ return r;
16
+ }
17
+ return async(ctx,signal)=>{
18
+ let operationId=w.operationId?w.operationId(ctx):createQuotaOperationId();
19
+ if(operationId&&typeof operationId.then==='function')Promise.resolve(operationId).catch(()=>{});
20
+ if(typeof operationId!=='string'||! /^[1-9][0-9]{9}\.[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(operationId))throw Error('Invalid trusted work operation ID');
21
+ const body={schema:1,operation:'reserve',operation_id:operationId,rule_id:w.ruleId,mode:w.mode,window_seconds:w.windowSeconds,limits,units:w.maxUnits,
22
+ subject:quotaHash(config.subjectSecret,'webdecoy.work.caller.v1',ctx.caller.issuer,ctx.caller.tenant,ctx.caller.subject),tenant:quotaHash(config.subjectSecret,'webdecoy.work.tenant.v1',ctx.caller.issuer,ctx.caller.tenant),binding:quotaHash(config.subjectSecret,'webdecoy.work.arguments.v1',name,policyVersion,canonical(ctx.args))};
23
+ const evidence={rule_id:w.ruleId,mode:w.mode,status:'unknown',reserved_units:w.maxUnits};
24
+ const check={id:'tool_work',source:'shared',mode:w.mode,decision:'allow',reason:'work_allowed',durationMs:0};
25
+ const began=performance.now();let grant,denial;
26
+ try{
27
+ grant=await rpc(body,signal);
28
+ if(grant.replay){denial={reason:'work_replay',status:409};evidence.status='replay';}
29
+ else if(!grant.granted){if(grant.reason!=='work_exceeded')throw Error('Invalid work denial');denial={reason:'work_exceeded',status:429,retryAfterSeconds:grant.retry_after_seconds};evidence.status='denied';}
30
+ else {if(!['work_allowed','work_exceeded'].includes(grant.reason)||(!grant.allowed&&w.mode==='enforce')||grant.reserved_units!==w.maxUnits||grant.charged_units!==w.maxUnits)throw Error('Invalid work grant');evidence.status='reserved';}
31
+ evidence.reserved_units=grant.reserved_units;evidence.charged_units=grant.charged_units;evidence.remaining_units=grant.remaining_units;
32
+ check.reason=grant.reason;check.decision=grant.allowed&&!grant.replay?'allow':'deny';
33
+ }catch(e){
34
+ grant=undefined;
35
+ signal?.throwIfAborted();check.decision='unavailable';check.reason='work_outcome_unknown';evidence.status='unavailable';
36
+ if([400,403,409,410].includes(e.status)){denial={reason:e.status===409?'work_conflict':'work_operation_rejected',status:e.status};check.decision='deny';check.reason=denial.reason;}
37
+ else if(w.mode==='enforce'&&w.failureMode==='closed')denial={reason:'work_outcome_unknown',status:503};
38
+ }
39
+ check.durationMs=performance.now()-began;
40
+ return {check,denial,evidence,bound:Object.freeze({maxUnits:w.maxUnits}),async finish(result,completed){
41
+ if(!grant?.granted||grant.replay)return;
42
+ if(!completed){evidence.status='unknown';return;}
43
+ try{
44
+ const units=w.measure?w.measure(result,ctx):w.maxUnits;
45
+ if(units&&typeof units.then==='function')Promise.resolve(units).catch(()=>{});
46
+ if(!integer(units)||units>w.maxUnits)throw Error('Unconfirmed or excess work');
47
+ const settled=await rpc({...body,operation:'settle',units});
48
+ if(!settled.settled||settled.reason!=='work_settled'||settled.charged_units!==units)throw Error('Work settlement unavailable');
49
+ evidence.status='settled';evidence.charged_units=units;
50
+ }catch{evidence.status='unknown';return {id:'tool_work_settlement',source:'shared',mode:w.mode,decision:'unavailable',reason:'work_usage_unknown',durationMs:0};}
51
+ }};
52
+ };
53
+ }