@webdecoy/ai-protection 0.1.0-alpha.1 → 0.1.0-alpha.3

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/ARCHITECTURE.md CHANGED
@@ -119,15 +119,13 @@ values in rule IDs/reason codes.
119
119
 
120
120
  Ingest deduplicates by organization/property/request ID: the first accepted report
121
121
  wins, including its receipt time. There are no automatic retries. Reports are
122
- bounded to 32 KiB and 36 checks (32 local rules, quota, concurrency, cloud and browser evidence). The pilot endpoint
123
- limits traffic to 6000 reports/minute per source IP with a burst of 200; excess
124
- reports are dropped by this SDK after a generic warning, without affecting chat.
122
+ bounded to 32 KiB and 36 checks (32 local rules, quota, concurrency, cloud and browser evidence). Rate-limited reports are dropped by this SDK after a generic warning, without affecting chat.
125
123
 
126
124
  The dashboard presents these as **SDK-reported application decisions**, separate
127
125
  from server-derived detector evidence. They are not summed into detection counts,
128
126
  charged as detections, or treated as proof of blocked inference or savings. The
129
127
  same request ID lets users correlate the two sources. Counts use receipt time;
130
- reports have a seven-day window and hourly retention cleanup. A prolonged outage
128
+ report availability depends on service retention. A prolonged outage
131
129
  can prevent delivery, so this is not complete audit coverage.
132
130
 
133
131
  `onObservation(event, {signal})` can return a promise. `report()` catches rejection,
@@ -162,5 +160,4 @@ provider attempt; it is not an automatic middleware spending cap. Both controls
162
160
  default to observe/open; enforce/closed is an explicit state-availability tradeoff.
163
161
  Usage events are separate from schema-1 request reports and include numeric rates,
164
162
  tokens and call/request/reservation UUIDs. They do not include raw identities or
165
- model content. See README and RELEASE.md; backend contracts are maintained in the
166
- private app repository's integrations/ai-abuse documentation.
163
+ model content. See the README for configuration and integration examples.
package/NEXTJS.md CHANGED
@@ -132,7 +132,7 @@ enabling the pilot; absent/unavailable reporting never changes request decisions
132
132
  `upstream_attempted` remains false: this adapter cannot observe model activity.
133
133
  - Account policy caches and per-call timeouts are documented in README. Cold remote admission can wait roughly two seconds with defaults, plus up to
134
134
  one second of IP resolution. Optional quota, lease acquisition and reservation
135
- each add their own deadline. See RELEASE.md.
135
+ each add their own deadline. See the README configuration section.
136
136
 
137
137
  Use the AI SDK's documented `consumeSseStream: consumeStream` handling alongside
138
138
  `abortSignal` when returning UI streams. Provider cancellation/billing remains
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.1`.** Integration mechanics are tested;
7
+ **Alpha release: `0.1.0-alpha.3`.** 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.
@@ -129,7 +129,7 @@ npm test
129
129
  ```
130
130
 
131
131
  Tests use a local detector and local AI model; no live keys or paid inference.
132
- [Release instructions](RELEASING.md) cover publishing the public npm package.
132
+ Source and release history are available in this public repository.
133
133
 
134
134
  ## Shared account quotas (opt-in)
135
135
 
@@ -153,7 +153,7 @@ observation, with a one-second timeout and fail-open for state errors. Use
153
153
  Enforced exhaustion returns 429 and `Retry-After`; explicit `check` callers use
154
154
  `decision.retryAfterSeconds` and must enforce the decision themselves.
155
155
 
156
- This needs the shared quota backend (migration 78 and `/api/v1/sdk/ai-abuse/quota`).
156
+ This uses the hosted `/api/v1/sdk/ai-abuse/quota` endpoint.
157
157
  Observation and enforcement share counters. In default schema 1, each allowed
158
158
  admission consumes a unit, including retries and requests later cancelled/blocked
159
159
  by another check; there are no automatic retries or refunds. Opt-in schema 2
@@ -179,7 +179,7 @@ capacity limits do not change the detector's separate failure policy. State can
179
179
  commit just before a timeout, so a failed check does not prove no unit was used.
180
180
 
181
181
 
182
- ## Distributed concurrency (unpublished, #1373)
182
+ ## Distributed concurrency
183
183
 
184
184
  Optional concurrency policy shares per-account and property/feature capacity
185
185
  across app replicas. Defaults are observe/open; detector failure policy is
@@ -202,11 +202,9 @@ proof a remote provider stopped. Upstream work must honor cancellation and have
202
202
  a real runtime bound. Fail-open outages cannot guarantee a concurrency cap.
203
203
  Released replay tombstones remain 24 hours: the pilot cap is 10,000 granted
204
204
  acquisitions/day/property and 32 policies/property. This is not a throughput SLA.
205
- The private app repository's `integrations/ai-abuse/CONCURRENCY.md` documents the
206
- wire contract, failure behavior, deployment order and validation evidence.
207
205
 
208
206
 
209
- ## Upstream model budgets (unpublished, #1374)
207
+ ## Upstream model budgets
210
208
 
211
209
  Opt-in token and integer micro-USD budgets reserve a conservative maximum before
212
210
  each provider attempt and reconcile only confirmed usage. Configure account,
@@ -234,8 +232,7 @@ is based on admission time, not the provider's invoice period.
234
232
  There is an Ollama final-usage normalizer for native generate/chat metadata;
235
233
  other provider clients need a reviewed application adapter. The SDK never parses
236
234
  or stores prompts/outputs to meter usage. This does not change WebDecoy plans or
237
- create a subscription meter. The private app's `integrations/ai-abuse/BUDGETS.md`
238
- contains examples, supported workloads, privacy, capacity and release gates.
235
+ create a subscription meter.
239
236
 
240
237
  ## Optional browser evidence
241
238
 
@@ -292,7 +289,7 @@ metadata values still leave the app; never place secrets in those values.
292
289
  Reporting timeouts and queue capacity each have a maximum of 10000 (ms/events).
293
290
  Application rules and hooks must not block the event loop. There is no unconditional
294
291
  wall-clock SLA for arbitrary customer code or uncooperative hosting runtimes.
295
- See RELEASE.md for supported versions, installation and release checks.
292
+ See the installation and configuration sections above for supported versions and setup.
296
293
 
297
294
  ### Recovering an uncertain quota admission (opt-in)
298
295
 
@@ -319,5 +316,15 @@ unknown operation blindly. The stored quota count/retry hint is an original-wind
319
316
  snapshot, not current quota state.
320
317
 
321
318
  Only admission is deduplicated. Repeated application/model calls still require
322
- application-level idempotency. Deploy migration 83, grants and runtime support
323
- before enabling this option; no package publication is required for local testing.
319
+ application-level idempotency. The hosted runtime must support quota schema 2 before enabling this option.
320
+
321
+ ## Action authorization (Alpha)
322
+
323
+ Version `0.1.0-alpha.3` includes an action boundary at `@webdecoy/ai-protection/actions`. See the
324
+ [record-action example and integration contract](examples/actions/README.md).
325
+ This API requires your server authentication and application authorization.
326
+ An [Auth0 access-token example](examples/auth0/README.md) verifies signed tokens
327
+ and maps tenant membership. The [TypeScript MCP integration](examples/mcp/README.md)
328
+ protects stateless HTTP tool dispatch in a source-only example; an exported MCP
329
+ package entrypoint remains a follow-up. Optional shared quotas, concurrency and hosted
330
+ action events are documented in the action guide and use the hosted AI Protection runtime and dashboard.
@@ -0,0 +1,41 @@
1
+ import {prepareQuota,quotaHash} from './quota.mjs';
2
+ import {prepareConcurrency} from './concurrency.mjs';
3
+ import {validPropertyID} from './account.mjs';
4
+ import {createReporter} from './reporting.mjs';
5
+
6
+ export function prepareActionRuntime(options, definitions) {
7
+ const config=options.sharedRuntime;
8
+ if(!config){if([...definitions.values()].some(d=>d.limits))throw Error('Action limits require sharedRuntime');return null;}
9
+ const url=new URL(config.webdecoyUrl);
10
+ if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/'||url.search||url.hash||
11
+ (url.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))||!validPropertyID(config.propertyId)||
12
+ 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
+ const c={...config};const limits=new Map(),ruleIDs=new Set();
15
+ 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
+ for(const [name,d] of definitions){
17
+ const l=d.limits??{},gates=[];
18
+ for(const [key,tenant]of [['callerQuota',false],['tenantQuota',true]])if(l[key]){
19
+ const q=l[key];if(ruleIDs.has(q.ruleId))throw Error('Action limits require distinct rule IDs');ruleIDs.add(q.ruleId);
20
+ const gate=prepareQuota({...c,accountQuota:{...q,idempotency:false,operationId:undefined,sessionLimit:0,subject:ctx=>subject(ctx,tenant)}});
21
+ 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
+ }
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});
27
+ }
28
+ const reporter=createReporter({reportingTimeoutMs:c.reportingTimeoutMs??1000,maxPendingReports:c.maxPendingReports??100,
29
+ onObservation:async(event,{signal})=>{
30
+ const checks=[{id:'action_boundary',source:'local',mode:'enforce',decision:event.decision,reason:event.reason,duration_ms:0},
31
+ ...event.checks.map(check=>({id:check.id,source:check.source,mode:check.mode,decision:check.decision,reason:check.reason,duration_ms:check.durationMs}))];
32
+ const payload={schema:2,request_id:event.eventId,timestamp:event.timestamp,decision:event.decision,reason:event.reason,
33
+ degraded:event.checks.some(c=>c.decision==='unavailable'),checks,handler_attempted:event.attempted,
34
+ 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}};
36
+ const response=await fetch(new URL('/api/v1/sdk/ai-abuse/reports',url),{method:'POST',redirect:'error',signal,
37
+ headers:{Authorization:`Bearer ${c.webdecoyKey}`,'X-WebDecoy-Property-ID':c.propertyId,'Content-Type':'application/json'},body:JSON.stringify(payload)});
38
+ await response.body?.cancel();if(!response.ok)throw Error('Action reporting unavailable');
39
+ }});
40
+ return {limits,report:event=>reporter.send(event),flush:()=>reporter.flush()};
41
+ }
package/actions.d.mts ADDED
@@ -0,0 +1,70 @@
1
+ /** Contract supplied by verified server authentication, never parsed from tool arguments. */
2
+ export interface TrustedCaller {
3
+ readonly schema: 1;
4
+ readonly subject: string;
5
+ readonly tenant: string;
6
+ readonly issuer: string;
7
+ readonly authenticationMethod: string;
8
+ /** Unix milliseconds; checked again immediately before execution. */
9
+ readonly expiresAt: number;
10
+ readonly scopes: readonly string[];
11
+ /** OAuth client identity, separate from subject; not an agent signer. */
12
+ readonly clientId?: string;
13
+ }
14
+ export type ActionInput = null | boolean | number | string | readonly ActionInput[] | {readonly [key:string]: ActionInput};
15
+ export interface ActionContext {
16
+ readonly caller: TrustedCaller;
17
+ readonly args: ActionInput;
18
+ readonly signal?: AbortSignal;
19
+ }
20
+ export interface ActionEvent {
21
+ readonly schema: 1;
22
+ readonly eventId: string;
23
+ readonly timestamp: string;
24
+ readonly checks: readonly {id:string;source:string;mode:string;decision:string;reason:string;durationMs:number}[];
25
+ readonly actionId: string;
26
+ readonly action: string;
27
+ readonly policyVersion: string;
28
+ readonly evaluation: 'local';
29
+ readonly decision: 'allow' | 'deny';
30
+ readonly reason: string;
31
+ readonly attempted: boolean;
32
+ readonly outcome: 'not_attempted' | 'attempted' | 'completed' | 'unknown';
33
+ }
34
+ export interface ActionQuota {ruleId:string;limit:number;windowSeconds:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';timeoutMs?:number}
35
+ export interface ActionLimits {
36
+ callerQuota?: ActionQuota;
37
+ tenantQuota?: ActionQuota;
38
+ concurrency?: {ruleId:string;accountLimit:number;featureLimit:number;mode?:'observe'|'enforce';failureMode?:'open'|'closed';ttlSeconds?:number;maxSeconds?:number;timeoutMs?:number};
39
+ }
40
+ export interface ActionRuntime {
41
+ webdecoyUrl:string;webdecoyKey:string;propertyId:string;subjectSecret:string;
42
+ reportingTimeoutMs?:number;maxPendingReports?:number;
43
+ }
44
+ export interface ActionDefinition {
45
+ limits?: ActionLimits;
46
+ requiredScopes: readonly string[];
47
+ validate(args: ActionInput): boolean | Promise<boolean>;
48
+ authorize(context: ActionContext): boolean | Promise<boolean>;
49
+ /** Additional restrictive policy; cannot override application authorization. */
50
+ policy?(context: ActionContext): boolean | Promise<boolean>;
51
+ /** Await all protected work. Do not return a live stream or detached task. */
52
+ execute(context: ActionContext): unknown | Promise<unknown>;
53
+ }
54
+ export class ActionDenied extends Error {
55
+ readonly reason: string;
56
+ readonly status: number;
57
+ readonly actionId: string;
58
+ readonly retryAfterSeconds?: number;
59
+ constructor(reason: string, status: number, actionId: string);
60
+ }
61
+ export function createActionProtection<T>(options: {
62
+ policyVersion: string;
63
+ sharedRuntime?: ActionRuntime;
64
+ /** Bounds local admission checks; shared controls have their own RPC deadlines. Default 1000 ms, max 10000. */
65
+ admissionTimeoutMs?: number;
66
+ authenticate(context: T, options: {signal?: AbortSignal}): TrustedCaller | Promise<TrustedCaller>;
67
+ actions: Record<string, ActionDefinition>;
68
+ /** Local best-effort observer. sharedRuntime enables independent hosted reporting. */
69
+ onEvent?(event: ActionEvent): void | Promise<void>;
70
+ }): {flush():Promise<void>;run(action: string, args: ActionInput, authenticationContext: T, options?: {signal?: AbortSignal}): Promise<unknown>};
package/actions.mjs ADDED
@@ -0,0 +1,168 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {abortable} from './transport.mjs';
3
+ import {prepareActionRuntime} from './action-runtime.mjs';
4
+
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);
7
+ const freeze = value => {
8
+ if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
9
+ return value;
10
+ };
11
+
12
+ export class ActionDenied extends Error {
13
+ constructor(reason, status, actionId) {
14
+ super(reason); this.name = 'ActionDenied'; this.reason = reason; this.status = status; this.actionId = actionId;
15
+ }
16
+ }
17
+
18
+ // Authentication is owned by the application's server integration. This validates
19
+ // its contract; it does not verify a bearer token, signer, or model-supplied claim.
20
+ function callerSnapshot(value) {
21
+ if (!value || value.schema !== 1 || !bounded(value.subject) || !bounded(value.tenant) ||
22
+ !bounded(value.issuer) || !bounded(value.authenticationMethod) ||
23
+ !Number.isSafeInteger(value.expiresAt) || value.expiresAt <= Date.now() ||
24
+ !Array.isArray(value.scopes) || value.scopes.length > 64 || value.scopes.some(s => !bounded(s)) ||
25
+ (value.clientId !== undefined && !bounded(value.clientId))) throw Error('Invalid authenticated caller');
26
+ return freeze({schema:1, subject:value.subject, tenant:value.tenant, issuer:value.issuer,
27
+ authenticationMethod:value.authenticationMethod, expiresAt:value.expiresAt,
28
+ scopes:[...new Set(value.scopes)], ...(value.clientId === undefined ? {} : {clientId:value.clientId})});
29
+ }
30
+
31
+ // No accessors, class instances, cycles, non-finite numbers, or unbounded trees.
32
+ // Copy before any await so a caller cannot swap arguments while policy runs.
33
+ function inputSnapshot(input) {
34
+ let nodes = 0;
35
+ const seen = new Set();
36
+ function copy(value, depth) {
37
+ if (++nodes > 2048 || depth > 12) throw Error('Input too complex');
38
+ if (value === null || typeof value === 'boolean') return value;
39
+ if (typeof value === 'string') { if (value.length > 16384) throw Error('Input too large'); return value; }
40
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
41
+ if (!value || typeof value !== 'object' || seen.has(value) ||
42
+ (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) throw Error('Invalid JSON input');
43
+ if (Array.isArray(value) && value.length > 2048) throw Error('Input array too large');
44
+ const keys = Object.keys(value);
45
+ if (keys.length > 2048) throw Error('Input too complex');
46
+ seen.add(value);
47
+ const out = Array.isArray(value) ? [] : Object.create(null);
48
+ for (const key of keys) {
49
+ if (Array.isArray(value) && !/^(0|[1-9][0-9]*)$/.test(key)) throw Error('Invalid array key');
50
+ const d = Object.getOwnPropertyDescriptor(value, key);
51
+ if (!d || !('value' in d) || key.length > 512 || ['__proto__','constructor','prototype'].includes(key)) throw Error('Invalid input key');
52
+ out[key] = copy(d.value, depth + 1);
53
+ }
54
+ seen.delete(value);
55
+ return Object.freeze(out);
56
+ }
57
+ const result = copy(input, 0);
58
+ if (Buffer.byteLength(JSON.stringify(result)) > 16384) throw Error('Input too large');
59
+ return result;
60
+ }
61
+
62
+ /** Local protected dispatch. No network calls, cloud override, or automatic retry. */
63
+ export function createActionProtection(options) {
64
+ if (!options || typeof options.authenticate !== 'function' || (typeof options.policyVersion !== 'string' || !token.test(options.policyVersion))) throw Error('Invalid action configuration');
65
+ const authenticate = options.authenticate, policyVersion = options.policyVersion, sink = options.onEvent;
66
+ if (sink !== undefined && typeof sink !== 'function') throw Error('Invalid event sink');
67
+ const admissionTimeoutMs = options.admissionTimeoutMs ?? 1000;
68
+ if (!Number.isInteger(admissionTimeoutMs) || admissionTimeoutMs < 1 || admissionTimeoutMs > 10000) throw Error('Invalid admission timeout');
69
+ const actions = new Map();
70
+ for (const [name, definition] of Object.entries(options.actions ?? {})) {
71
+ if (!token.test(name) || !definition || !Array.isArray(definition.requiredScopes) || definition.requiredScopes.length > 64 ||
72
+ definition.requiredScopes.some(s => !bounded(s)) ||
73
+ ['validate','authorize','execute'].some(k => typeof definition[k] !== 'function') ||
74
+ (definition.policy !== undefined && typeof definition.policy !== 'function')) throw Error('Invalid action definition');
75
+ actions.set(name, Object.freeze({...definition,requiredScopes:Object.freeze([...definition.requiredScopes])}));
76
+ }
77
+ if (!actions.size || actions.size > 128) throw Error('Expected 1–128 actions');
78
+ const runtime=prepareActionRuntime(options,actions);
79
+ let pendingEvents = 0;
80
+ return Object.freeze({flush:async()=>{await runtime?.flush();},async run(name, input, authenticationContext, {signal} = {}) {
81
+ const actionId = randomUUID();
82
+ // Unknown caller-controlled action strings are never placed in evidence.
83
+ const action = actions.get(name), eventAction = action ? name : 'unregistered';
84
+ let attempted = false,completed=false,lease;
85
+ const checks=[];
86
+ const deadline = new AbortController();
87
+ const admissionSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
88
+ const timer = setTimeout(() => deadline.abort(new DOMException('Action admission timed out','TimeoutError')), admissionTimeoutMs);
89
+ const evaluate = fn => abortable(Promise.resolve().then(() => { admissionSignal.throwIfAborted(); return fn(); }), admissionSignal);
90
+ function emit(decision, reason, outcome) {
91
+
92
+ 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
+ if(runtime)void runtime.report(event);
95
+ if(!sink||pendingEvents>=100)return;
96
+ pendingEvents++;
97
+ Promise.resolve().then(() => sink(event)).catch(() => {}).finally(() => { pendingEvents--; });
98
+ }
99
+ function deny(reason, status, retryAfterSeconds) { emit('deny', reason, 'not_attempted'); const e=new ActionDenied(reason,status,actionId);if(retryAfterSeconds)e.retryAfterSeconds=retryAfterSeconds;throw e; }
100
+ function cancelled() { (attempted ? signal : admissionSignal)?.throwIfAborted(); }
101
+ try {
102
+ cancelled();
103
+ if (!action) deny('action_not_registered',403);
104
+ let args;
105
+ try { args = inputSnapshot(input); } catch { deny('invalid_arguments',400); }
106
+ let caller;
107
+ try { caller = callerSnapshot(await evaluate(() => authenticate(authenticationContext, {signal:admissionSignal}))); }
108
+ catch { cancelled(); deny('authentication_required',401); }
109
+ cancelled();
110
+ if (action.requiredScopes.some(scope => !caller.scopes.includes(scope))) deny('missing_scope',403);
111
+ const context = Object.freeze({caller, args, signal:admissionSignal});
112
+ let valid;
113
+ try { valid = await evaluate(() => action.validate(args)); } catch { cancelled(); deny('invalid_arguments',400); }
114
+ cancelled();
115
+ if (valid !== true) deny('invalid_arguments',400);
116
+ let authorized;
117
+ try { authorized = await evaluate(() => action.authorize(context)); } catch { cancelled(); deny('authorization_unavailable',503); }
118
+ cancelled();
119
+ if (authorized !== true) deny('permission_denied',403);
120
+ if (action.policy) {
121
+ let permitted;
122
+ try { permitted = await evaluate(() => action.policy(context)); } catch { cancelled(); deny('policy_unavailable',503); }
123
+ cancelled();
124
+ if (permitted !== true) deny('policy_denied',403);
125
+ }
126
+ // Shared limits run only after application permission checks. Their own
127
+ // RPC deadlines are separate from local admission and detector availability.
128
+ clearTimeout(timer);
129
+ const controls=runtime?.limits.get(name);
130
+ for(const gate of controls?.gates??[]){
131
+ const r=await gate(context,admissionSignal);checks.push(r.check);
132
+ if(r.denial)deny(r.denial.reason,r.denial.status,r.denial.retryAfterSeconds);
133
+ }
134
+ if(controls?.concurrency){
135
+ lease=await controls.concurrency(context,admissionSignal);checks.push(lease.check);
136
+ if(lease.denial)deny(lease.denial.reason,lease.denial.status,lease.denial.retryAfterSeconds);
137
+ lease.signal.throwIfAborted();
138
+ }
139
+ // Authentication may expire while ownership/policy checks are running.
140
+ if (caller.expiresAt <= Date.now()) deny('authentication_expired',401);
141
+ cancelled();
142
+ clearTimeout(timer);
143
+ attempted = true;
144
+ emit('allow','authorized','attempted');
145
+ const execution=Object.freeze({...context,signal:lease?.signal??context.signal});
146
+ const result = await action.execute(execution);
147
+ execution.signal.throwIfAborted();
148
+ // Resolution is application completion, not proof of an external side effect.
149
+ cancelled();
150
+ completed=true;
151
+ if(lease&&!lease.denial){
152
+ 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});}
154
+ }
155
+ emit('allow','authorized','completed');
156
+ return result;
157
+ } catch (error) {
158
+ if (!attempted && deadline.signal.aborted && !signal?.aborted) deny('admission_timeout',503);
159
+ if (attempted) emit('allow',signal?.aborted ? 'execution_cancelled' : 'execution_failed','unknown');
160
+ else if (!(error instanceof ActionDenied)) emit('deny','admission_cancelled','not_attempted');
161
+ throw error;
162
+ } finally {
163
+ clearTimeout(timer);
164
+ // Only confirmed completion (or no callback) releases; uncertain work holds.
165
+ if(lease&&!lease.denial)await lease.finish(!attempted||completed).catch(()=>{});
166
+ }
167
+ }});
168
+ }
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"
12
12
  },
13
- "version": "0.1.0-alpha.1",
13
+ "version": "0.1.0-alpha.3",
14
14
  "exports": {
15
15
  "./fetch": {
16
16
  "types": "./fetch.d.mts",
@@ -23,6 +23,10 @@
23
23
  "./browser": {
24
24
  "types": "./browser.d.mts",
25
25
  "import": "./browser.mjs"
26
+ },
27
+ "./actions": {
28
+ "types": "./actions.d.mts",
29
+ "import": "./actions.mjs"
26
30
  }
27
31
  },
28
32
  "files": [
@@ -46,8 +50,10 @@
46
50
  "browser-evidence.mjs",
47
51
  "usage.mjs",
48
52
  "transport.mjs",
49
- "RELEASE.md",
50
- "NOTICE"
53
+ "NOTICE",
54
+ "actions.mjs",
55
+ "actions.d.mts",
56
+ "action-runtime.mjs"
51
57
  ],
52
58
  "description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
53
59
  "license": "Apache-2.0",
package/RELEASE.md DELETED
@@ -1,75 +0,0 @@
1
- # Supported installation and release candidate
2
-
3
- Private alpha candidate `@webdecoy/ai-protection@0.1.0-alpha.1`.
4
- Repository creation, public visibility, license approval and registry publication
5
- are separate owner decisions. The current manifest has `private: true` to prevent
6
- accidental publication. Do not advertise npm installation until it is released.
7
-
8
- ## Supported surface
9
-
10
- - Node standard Request/Response on Node >=22.22.3; tests on 22.22.3 and 26.5.0.
11
- - Next.js Node Route Handler fixture: Next 16.3.6, React 19.3.0, AI SDK 7.0.117.
12
- The tested provider is a deterministic local model, not an arbitrary SDK adapter.
13
- - Browser `./browser` entrypoint only for optional same-origin HTTPS receipt
14
- preparation. It does not contain the server key or server SDK.
15
- - No server SDK claim for Cloudflare/Vercel Edge, Deno, Bun, generic WordPress or
16
- Flowise. Other Node frameworks may call the Fetch API; adapters are not validated.
17
-
18
- ## Install privately
19
-
20
- ```sh
21
- npm ci --ignore-scripts
22
- npm test
23
- npm run test:types
24
- npm run check:package
25
- npm pack --ignore-scripts --pack-destination /your/private/artifact-directory
26
- ```
27
-
28
- Install the exact tarball in the application (`npm install /path/to/file.tgz`) and
29
- commit its lockfile or retain the artifact in an approved private store. Do not
30
- make customer deployments depend on an absolute path to this development checkout.
31
- The Next.js example uses a local file link only for development and is not included
32
- in the package. A public npm command becomes valid only after owner-approved release.
33
-
34
- The package check builds the real tarball, verifies its exact file allowlist,
35
- checks zero runtime/optional dependencies and no install/postinstall hooks, prints
36
- integrity and installs it into a separate temporary consumer. It tests exports
37
- without access to the source checkout. No scripts execute during consumer install.
38
-
39
- ## Availability and latency
40
-
41
- Default configured network waits before provider invocation:
42
-
43
- - IP resolver: <=1000ms waiting; callback must be cooperative, timeout degrades open.
44
- - Account/config: <=1000ms on cache miss (verified grant cached 60s, failures 5s).
45
- - Detection: <=1000ms; default failure policy open.
46
- - Optional quota: <=1000ms; default observe/open.
47
- - Optional concurrency acquire: <=1000ms; default observe/open.
48
- - Each optional budget reservation: <=1000ms; default observe/open.
49
-
50
- With a synchronous resolver, basic cold/warm admission has up to 2s/1s of configured
51
- remote waits. A resolver that stalls adds up to 1s then skips cloud calls. An async
52
- resolver that succeeds near its timeout can make cold admission approach 3s.
53
- All three optional controls can add another 3s before the first model attempt.
54
- Model runtime, auth/database work, CPU scheduling, event-loop stalls and customer
55
- rules are outside these configured waits. This is not a latency SLA. Each custom
56
- local rule must be synchronous and cheap. Never use remote I/O inside a local rule.
57
-
58
- Reports are asynchronous with bounded timeout/queue. `waitUntil` or Next `after`
59
- keeps delivery alive; response streaming is not modified. Budget accounting needs
60
- its own lifecycle wait until provider completion. On shutdown, stop accepting and
61
- drain requests/model work, then flush admission and budget reporters. Hard process
62
- termination can lose reports and leave conservative charges.
63
-
64
- ## Release gate
65
-
66
- Before changing `private` or publishing: owner approves repository/license choices,
67
- verify npm identity and scope permission, pin the release commit and supported
68
- backend contracts, rerun checks, inspect the artifact/integrity and publish the
69
- exact reviewed candidate under `alpha`. Never commit tokens or bypass a failed
70
- registry authorization check. Backend scorer/keys remain private.
71
-
72
- The private application issue #1377 tracks the deployment/contract matrix and full
73
- evidence. Production capacity and arbitrary-provider behavior are not implied by
74
- passing local fixtures. The package contains no detector engine or executable
75
- remote policies.