@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 +3 -6
- package/NEXTJS.md +1 -1
- package/README.md +19 -12
- package/action-runtime.mjs +41 -0
- package/actions.d.mts +70 -0
- package/actions.mjs +168 -0
- package/package.json +9 -3
- package/RELEASE.md +0 -75
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).
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
323
|
-
|
|
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.
|
|
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
|
-
"
|
|
50
|
-
"
|
|
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.
|