@webdecoy/ai-protection 0.1.0-alpha.1

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/budget.mjs ADDED
@@ -0,0 +1,105 @@
1
+ import {createUsageReporter} from './usage.mjs';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {quotaHash} from './quota.mjs';
4
+ const code=/^[a-z][a-z0-9_]{0,63}$/;
5
+ const uuid=/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
6
+ const limitNames=['account_tokens','account_micros','tenant_tokens','tenant_micros','feature_tokens','feature_micros'];
7
+ const integer=(v,max)=>Number.isSafeInteger(v)&&v>=0&&v<=max;
8
+ export class BudgetDenied extends Error {
9
+ constructor(reason,status,retryAfterSeconds=0){super(reason);this.name='BudgetDenied';this.status=status;this.retryAfterSeconds=retryAfterSeconds;}
10
+ }
11
+ // Explicit catalog only. Rates are integer micro-USD per million tokens; never floats.
12
+ export function budgetCost(price,inputTokens,outputTokens){
13
+ if(!integer(inputTokens,10000000)||!integer(outputTokens,10000000)||!integer(price.inputMicrosPerMillion,1000000000)||!integer(price.outputMicrosPerMillion,1000000000))throw Error('Invalid budget usage or price');
14
+ const total=BigInt(inputTokens)*BigInt(price.inputMicrosPerMillion)+BigInt(outputTokens)*BigInt(price.outputMicrosPerMillion);
15
+ return Number((total+999999n)/1000000n);
16
+ }
17
+ export function ollamaBudgetUsage(final){
18
+ if(final?.done!==true||typeof final.model!=='string'||!integer(final.prompt_eval_count,10000000)||!integer(final.eval_count,10000000))return null;
19
+ return {provider:'ollama',model:final.model,inputTokens:final.prompt_eval_count,outputTokens:final.eval_count};
20
+ }
21
+ export function createAIBudget(options){
22
+ const o={mode:'observe',failureMode:'open',timeoutMs:1000,maxRuntimeMs:300000,...options};
23
+ const url=new URL(o.webdecoyUrl);
24
+ if((url.protocol!=='https:'&&!(url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname)))||url.username||url.password||url.search||url.hash||url.pathname!=='/'||!uuid.test(o.propertyId??'')||o.propertyId==='00000000-0000-0000-0000-000000000000'||typeof o.webdecoyKey!=='string'||!o.webdecoyKey.trim()||/[\r\n]/.test(o.webdecoyKey)||!code.test(o.ruleId??'')||typeof o.subject!=='function'||typeof o.subjectSecret!=='string'||!o.subjectSecret.isWellFormed()||Buffer.byteLength(o.subjectSecret)<32||!['observe','enforce'].includes(o.mode)||!['open','closed'].includes(o.failureMode)||!integer(o.windowSeconds,86400)||o.windowSeconds<1||!integer(o.timeoutMs,10000)||o.timeoutMs<1||!integer(o.maxRuntimeMs,900000)||o.maxRuntimeMs<1)throw Error('Invalid budget configuration');
25
+ const limits=Object.fromEntries(limitNames.map(n=>[n,o.limits?.[n]??0]));
26
+ if(!Object.values(limits).every(v=>integer(v,1e12))||!Object.values(limits).some(v=>v>0))throw Error('Invalid budget limits');
27
+ const prices=new Map();
28
+ for(const [id,p] of Object.entries(o.prices??{})){
29
+ if(!code.test(id)||!code.test(p.provider??'')||typeof p.model!=='string'||!p.model.length||p.model.length>128||!p.model.isWellFormed())throw Error('Invalid budget price');
30
+ budgetCost(p,0,0);prices.set(id,Object.freeze({...p}));
31
+ }
32
+ if(!prices.size||prices.size>64)throw Error('Explicit bounded price catalog required');
33
+ const reporter=createUsageReporter(o);
34
+ async function rpc(body,signal){
35
+ const res=await fetch(new URL('/api/v1/sdk/ai-abuse/budget',url),{method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(o.timeoutMs)]),headers:{Authorization:`Bearer ${o.webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':o.propertyId},body:JSON.stringify(body)});
36
+ if(!res.ok){await res.body?.cancel();throw Error('Budget unavailable');}
37
+ const reader=res.body.getReader();const chunks=[];let size=0;
38
+ try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>2048)throw Error('Invalid budget response');chunks.push(value);}}finally{await reader.cancel().catch(()=>{});reader.releaseLock();}
39
+ const r=JSON.parse(Buffer.concat(chunks).toString('utf8'));
40
+ if(r?.schema!==1||typeof r.allowed!=='boolean'||typeof r.granted!=='boolean'||typeof r.overrun!=='boolean'||!code.test(r.reason??'')||!integer(r.retry_after_seconds,86400)||(r.granted&&(!uuid.test(r.reservation_id??'')||r.reservation_id==='00000000-0000-0000-0000-000000000000')))throw Error('Invalid budget response');
41
+ return r;
42
+ }
43
+ return Object.freeze({flush:()=>reporter.flush(),async run(context,call,work,callerSignal=new AbortController().signal){
44
+ callerSignal.throwIfAborted();
45
+ const price=prices.get(call.priceId);
46
+ if(!price||!integer(call.maxInputTokens,10000000)||!integer(call.maxOutputTokens,10000000)||call.maxInputTokens+call.maxOutputTokens<1||typeof work!=='function')throw Error('Known price and conservative token bounds required');
47
+ if(call.requestId!==undefined&&(!uuid.test(call.requestId)||call.requestId==='00000000-0000-0000-0000-000000000000'))throw Error('Invalid requestId');
48
+ const bound=Object.freeze({provider:price.provider,model:price.model,maxInputTokens:call.maxInputTokens,maxOutputTokens:call.maxOutputTokens});
49
+ const micros=budgetCost(price,bound.maxInputTokens,bound.maxOutputTokens);
50
+ let body,grant,started=false,knownUsage;
51
+ const callId=randomUUID(),requestId=call.requestId,priceId=call.priceId;
52
+ const emit=(phase,reason)=>reporter.send({schema:1,call_id:callId,phase,timestamp:new Date().toISOString(),
53
+ ...(requestId?{request_id:requestId}:{}),...(grant?.granted?{reservation_id:grant.reservation_id}:{}),
54
+ rule_id:o.ruleId,mode:o.mode,reason,started,would_deny:grant?.allowed===false&&grant?.granted===true,
55
+ price_id:priceId,input_rate:price.inputMicrosPerMillion,output_rate:price.outputMicrosPerMillion,
56
+ reserved_tokens:bound.maxInputTokens+bound.maxOutputTokens,reserved_micros:micros,
57
+ ...(knownUsage??{})});
58
+ try{
59
+ const s=o.subject(context);
60
+ if(s&&typeof s.then==='function'){Promise.resolve(s).catch(()=>{});throw Error('Async subject');}
61
+ for(const id of [s?.accountId,s?.organizationId])if(typeof id!=='string'||!id.isWellFormed()||!id.length||Buffer.byteLength(id)>256)throw Error('Invalid budget subject');
62
+ body={schema:1,operation:'reserve',rule_id:o.ruleId,mode:o.mode,nonce:callId,subject:quotaHash(o.subjectSecret,'webdecoy.budget.v1',o.propertyId.toLowerCase(),o.ruleId,'account',s.accountId),tenant:quotaHash(o.subjectSecret,'webdecoy.budget.v1',o.propertyId.toLowerCase(),o.ruleId,'tenant',s.organizationId),window_seconds:o.windowSeconds,limits,tokens:bound.maxInputTokens+bound.maxOutputTokens,micros};
63
+ grant=await rpc(body,callerSignal);
64
+ if(grant.granted&&(!['budget_allowed','budget_exceeded'].includes(grant.reason)||(o.mode==='enforce'&&!grant.allowed)))throw Error('Invalid budget grant');
65
+ if(!grant.granted&&!['budget_exceeded','budget_replay'].includes(grant.reason))throw Error('Invalid budget denial');
66
+ }catch{
67
+ grant=null;
68
+ if(callerSignal.aborted){emit('finish','cancelled');callerSignal.throwIfAborted();}
69
+ if(o.mode==='enforce'&&o.failureMode==='closed'){emit('finish','budget_unavailable');throw new BudgetDenied('budget_unavailable',503);}
70
+ grant=null;
71
+ }
72
+ if(grant&&!grant.granted){emit('finish',grant.reason);throw new BudgetDenied(grant.reason,429,Math.max(1,grant.retry_after_seconds));}
73
+ const signal=AbortSignal.any([callerSignal,AbortSignal.timeout(o.maxRuntimeMs)]);
74
+ if(signal.aborted){emit('finish','cancelled');signal.throwIfAborted();}
75
+ // Provider errors propagate; the conservative reservation stays charged.
76
+ let result;
77
+ try {
78
+ started=true;emit('start','provider_attempt');
79
+ result=await work({...bound,signal});
80
+ if(!result||!result.finished||typeof result.finished.then!=='function')throw Error('Provider completion Promise required; reservation retained');
81
+ }catch(error){emit('finish',signal.aborted?'cancelled':'provider_error');throw error;}
82
+ const completion=Promise.resolve(result.finished);completion.catch(()=>{});
83
+ const outcome=(reason,overrun=false)=>({reason,overrun,reserved:!!grant,wouldDeny:grant?.allowed===false});
84
+ const accounting=(async()=>{
85
+ let listener;
86
+ try{
87
+ if(signal.aborted)return outcome(grant?'budget_usage_unknown':'budget_unavailable');
88
+ const stopped=new Promise((_,reject)=>{listener=()=>reject(Error('Provider stopped without confirmed usage'));if(signal.aborted)listener();else signal.addEventListener('abort',listener,{once:true});});
89
+ const usage=await Promise.race([completion,stopped]);
90
+ if(!usage||usage.provider!==price.provider||usage.model!==price.model||!integer(usage.inputTokens,10000000)||!integer(usage.outputTokens,10000000))return outcome('budget_usage_unknown');
91
+ const tokens=usage.inputTokens+usage.outputTokens,cost=budgetCost(price,usage.inputTokens,usage.outputTokens);
92
+ knownUsage={input_tokens:usage.inputTokens,output_tokens:usage.outputTokens,cost_micros:cost};
93
+ if(!grant)return outcome('budget_unavailable');
94
+ const overrun=usage.inputTokens>bound.maxInputTokens||usage.outputTokens>bound.maxOutputTokens;
95
+ const settle={...body,operation:'settle',reservation_id:grant.reservation_id,tokens,micros:cost};delete settle.nonce;
96
+ try{
97
+ const r=await rpc(settle,new AbortController().signal);
98
+ return outcome(r.allowed&&r.reason==='budget_settled'?'budget_settled':'budget_settlement_unavailable',overrun||r.overrun);
99
+ }catch{return outcome('budget_settlement_unavailable',overrun);}
100
+ }catch{return outcome(grant?'budget_usage_unknown':'budget_unavailable');}
101
+ finally{if(listener)signal.removeEventListener('abort',listener);}
102
+ })().then(async result=>{await emit('finish',result.reason);return result;});
103
+ return {value:result.value,accounting,callId};
104
+ }});
105
+ }
@@ -0,0 +1,72 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {quotaHash} from './quota.mjs';
3
+ const code=/^[a-z][a-z0-9_]{0,63}$/;
4
+ const uuid=/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
5
+ export function prepareConcurrency(options){
6
+ if(options.concurrency===undefined)return null;
7
+ const {webdecoyUrl,webdecoyKey,propertyId}=options;
8
+ const q={mode:'observe',failureMode:'open',ttlSeconds:30,maxSeconds:300,timeoutMs:1000,subjectSecret:options.subjectSecret,...options.concurrency};
9
+ if(!code.test(q.ruleId??'')||typeof q.subject!=='function'||typeof q.subjectSecret!=='string'||!q.subjectSecret.isWellFormed()||Buffer.byteLength(q.subjectSecret)<32||
10
+ !['observe','enforce'].includes(q.mode)||!['open','closed'].includes(q.failureMode)||
11
+ !Number.isInteger(q.accountLimit)||q.accountLimit<1||q.accountLimit>1000||!Number.isInteger(q.featureLimit)||q.featureLimit<q.accountLimit||q.featureLimit>10000||
12
+ !Number.isInteger(q.ttlSeconds)||q.ttlSeconds<6||q.ttlSeconds>120||!Number.isInteger(q.maxSeconds)||q.maxSeconds<q.ttlSeconds||q.maxSeconds>900||
13
+ !Number.isInteger(q.timeoutMs)||q.timeoutMs<1||q.timeoutMs>q.ttlSeconds*1000/6)throw Error('Invalid concurrency configuration');
14
+ async function rpc(body,signal){
15
+ const res=await fetch(new URL('/api/v1/sdk/ai-abuse/concurrency',webdecoyUrl),{method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(q.timeoutMs)]),headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},body:JSON.stringify(body)});
16
+ if(!res.ok){await res.body?.cancel();throw Error('Concurrency unavailable');}
17
+ const reader=res.body.getReader();const chunks=[];let size=0;
18
+ try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>2048)throw Error('Invalid concurrency response');chunks.push(value);}}finally{await reader.cancel().catch(()=>{});reader.releaseLock();}
19
+ const r=JSON.parse(Buffer.concat(chunks).toString('utf8'));
20
+ if(r?.schema!==1||typeof r.allowed!=='boolean'||typeof r.granted!=='boolean'||!code.test(r.reason??'')||!Number.isInteger(r.retry_after_seconds)||r.retry_after_seconds<0||r.retry_after_seconds>q.maxSeconds||
21
+ (r.granted&&(!uuid.test(r.lease_id)||r.lease_id==='00000000-0000-0000-0000-000000000000'||!Number.isInteger(r.valid_for_ms)||r.valid_for_ms<=0||r.valid_for_ms>q.ttlSeconds*1000)))throw Error('Invalid concurrency response');
22
+ return r;
23
+ }
24
+ return async (context,callerSignal)=>{
25
+ callerSignal.throwIfAborted();const began=performance.now();
26
+ const check={id:'concurrency',source:'shared',mode:q.mode,decision:'unavailable',reason:'concurrency_unavailable',durationMs:0};
27
+ let body,grant;
28
+ try{
29
+ const subject=q.subject(context);
30
+ if(subject&&typeof subject.then==='function'){Promise.resolve(subject).catch(()=>{});throw Error('Async subject');}
31
+ if(typeof subject?.accountId!=='string'||!subject.accountId.isWellFormed()||!subject.accountId.length||Buffer.byteLength(subject.accountId)>256)throw Error('Invalid concurrency subject');
32
+ body={schema:1,operation:'acquire',rule_id:q.ruleId,mode:q.mode,nonce:randomUUID(),subject:quotaHash(q.subjectSecret,'webdecoy.account-quota.v1',propertyId.toLowerCase(),q.ruleId,'account',subject.accountId),account_limit:q.accountLimit,feature_limit:q.featureLimit,ttl_seconds:q.ttlSeconds,max_seconds:q.maxSeconds};
33
+ grant=await rpc(body,callerSignal);
34
+ if(grant.granted&&(!['concurrency_allowed','concurrency_exceeded'].includes(grant.reason)||(q.mode==='enforce'&&!grant.allowed)))throw Error('Invalid lease grant');
35
+ if(!grant.granted&&!['concurrency_exceeded','concurrency_replay'].includes(grant.reason))throw Error('Invalid denial');
36
+ }catch{
37
+ callerSignal.throwIfAborted();check.durationMs=performance.now()-began;
38
+ return {check,signal:callerSignal,finish:async()=>{},...(q.mode==='enforce'&&q.failureMode==='closed'?{denial:{reason:check.reason,status:503}}:{})};
39
+ }
40
+ callerSignal.throwIfAborted();check.durationMs=performance.now()-began;check.decision=grant.allowed?'allow':'deny';check.reason=grant.reason;
41
+ if(!grant.granted)return {check,denial:{reason:grant.reason,status:429,retryAfterSeconds:Math.max(1,grant.retry_after_seconds)}};
42
+ const controller=new AbortController();const signal=AbortSignal.any([callerSignal,controller.signal]);
43
+ let deadline=began+grant.valid_for_ms,stopped=false,renewTimer,renewing=Promise.resolve(),finishing;
44
+ const abort=()=>controller.abort(Error('Concurrency lease lost or expired'));
45
+ const hardTimer=setTimeout(abort,Math.max(0,began+q.maxSeconds*1000-performance.now()));
46
+ const owned={...body,lease_id:grant.lease_id};delete owned.nonce;
47
+ function schedule(){
48
+ if(stopped||signal.aborted)return;
49
+ const remaining=deadline-performance.now();if(remaining<=q.timeoutMs){abort();return;}
50
+ renewTimer=setTimeout(()=>{renewing=renew();},Math.min(q.ttlSeconds*1000/3,remaining/3));
51
+ }
52
+ async function renew(){
53
+ const sent=performance.now();
54
+ try{
55
+ const safeWindow=Math.floor(deadline-performance.now()-100);if(safeWindow<=0)throw Error('Expired');
56
+ const r=await rpc({...owned,operation:'renew'},AbortSignal.any([signal,AbortSignal.timeout(safeWindow)]));
57
+ if(!r.granted||!r.allowed||r.reason!=='concurrency_renewed'||r.lease_id!==grant.lease_id)throw Error('Lost');
58
+ deadline=sent+r.valid_for_ms;schedule();
59
+ }catch{abort();}
60
+ }
61
+ signal.addEventListener('abort',()=>{clearTimeout(renewTimer);clearTimeout(hardTimer)},{once:true});
62
+ schedule();
63
+ return {check,signal,finish(confirmed){
64
+ if(finishing)return finishing;
65
+ finishing=(async()=>{
66
+ const release=confirmed&&!signal.aborted;stopped=true;clearTimeout(renewTimer);clearTimeout(hardTimer);
67
+ controller.abort(Error('Protected work ended'));await renewing;
68
+ if(release){const r=await rpc({...owned,operation:'release'},new AbortController().signal);if(!r.allowed||r.reason!=='concurrency_released')throw Error('Concurrency release unavailable');}
69
+ })();return finishing;
70
+ }};
71
+ };
72
+ }
package/fetch.d.mts ADDED
@@ -0,0 +1,140 @@
1
+ export type Mode = 'observe' | 'enforce';
2
+ export interface LocalRule<Context> {
3
+ /** Stable non-sensitive code: lowercase letter followed by <=63 letters/digits/underscores. */
4
+ id: string;
5
+ /** Defaults to observe. Independent of cloud mode/availability and dashboard setting. */
6
+ mode?: Mode;
7
+ /** Defaults to closed for an enforced rule error. Does not change detector failure mode. */
8
+ failureMode?: 'open' | 'closed';
9
+ /** Synchronous, deterministic. Use authenticated server state; never a client-supplied identity/plan. */
10
+ evaluate(context: Context): {allowed: boolean; reason?: string; status?: 403 | 429};
11
+ }
12
+ export interface CheckResult {
13
+ readonly id: string;
14
+ readonly source: 'local' | 'remote' | 'shared';
15
+ readonly mode: Mode;
16
+ readonly decision: 'allow' | 'deny' | 'challenge' | 'unavailable' | 'skipped';
17
+ readonly reason: string;
18
+ readonly durationMs: number;
19
+ /** Local recovery ID for opt-in v2 quota admissions; omitted from central report payload. */
20
+ readonly operationId?: string;
21
+ }
22
+ export interface ProtectionDecision {
23
+ readonly id: string;
24
+ readonly conclusion: 'allow' | 'deny';
25
+ readonly reason: string;
26
+ readonly status?: number;
27
+ readonly retryAfterSeconds?: number;
28
+ readonly degraded: boolean;
29
+ readonly checks: readonly CheckResult[];
30
+ }
31
+ export interface ReportOutcome {
32
+ handlerAttempted?: boolean;
33
+ status?: number;
34
+ cancelled?: boolean;
35
+ handlerError?: boolean;
36
+ }
37
+ export interface AIProtectionOptions<Context = Record<string, unknown>> {
38
+ webdecoyUrl: string;
39
+ webdecoyKey: string;
40
+ propertyId: string;
41
+ scopeId: string;
42
+ subjectSecret: string;
43
+ /** Return only an address vouched for by hosting ingress. Null skips cloud detection. */
44
+ resolveClientIP(request: Request, runtime: {signal: AbortSignal}): string | null | Promise<string | null>;
45
+ /** Bounds waiting for the trusted resolver; defaults to 1000ms, maximum 10000. */
46
+ clientIPTimeoutMs?: number;
47
+ /** Explicit route template when URL paths contain identifiers. Never derived from browser input. */
48
+ route?: string;
49
+ /** Applies to cloud bot detection only. */
50
+ protectionMode?: Mode;
51
+ /** Opt-in browser tag evidence. Exact first-party HTTPS origin; no API clients require cookies. */
52
+ browserEvidenceOrigin?: string;
53
+ detectorFailureMode?: 'open' | 'closed';
54
+ detectorTimeoutMs?: number;
55
+ baselineLimit?: number;
56
+ baselineWindowMs?: number;
57
+ concurrency?: {
58
+ ruleId: string; subjectSecret?: string; accountLimit: number; featureLimit: number;
59
+ ttlSeconds?: number; maxSeconds?: number; timeoutMs?: number;
60
+ mode?: Mode; failureMode?: 'open' | 'closed';
61
+ subject(context: Context): {accountId: string};
62
+ };
63
+ rules?: readonly LocalRule<Context>[];
64
+ /** Shared admission quota. Configure only from trusted server code. */
65
+ accountQuota?: {
66
+ ruleId: string;
67
+ /** Defaults to the existing subjectSecret. Must match across replicas. */
68
+ subjectSecret?: string;
69
+ limit: number;
70
+ windowSeconds: number;
71
+ /** Optional stricter session cap, always beneath the account cap. */
72
+ sessionLimit?: number;
73
+ mode?: Mode;
74
+ /** Defaults to open; closed is an explicit availability tradeoff. */
75
+ failureMode?: 'open' | 'closed';
76
+ /** Per attempt; v2 permits at most two attempts. */
77
+ timeoutMs?: number;
78
+ /** Opt-in schema 2; requires a migrated runtime. Never falls back to schema 1. */
79
+ idempotency?: boolean;
80
+ /** Stable ID from authenticated server state for recovery of the SAME logical admission. */
81
+ operationId?(context: Context): string;
82
+ subject(context: Context): {accountId: string; sessionId?: string};
83
+ };
84
+ /** Async best-effort sink. Context/body/raw exceptions are never included by the SDK. */
85
+ onObservation?(event: Record<string, unknown>, options: {signal: AbortSignal}): void | Promise<void>;
86
+ /** Use hosting waitUntil or Next.js after(() => task) inside a request scope. */
87
+ waitUntil?(task: Promise<void>): void;
88
+ /** Send bounded decision/outcome metadata to WebDecoy. Defaults to true; independent of the local sink. */
89
+ reportToWebDecoy?: boolean;
90
+ reportingTimeoutMs?: number;
91
+ maxPendingReports?: number;
92
+ }
93
+ export interface AIProtection<Context> {
94
+ (request: Request, handler: () => Response | Promise<Response>, context: Context): Promise<Response>;
95
+ concurrent(request: Request, handler: (runtime: {signal: AbortSignal}) =>
96
+ {response: Response; finished: Promise<void>} | Promise<{response: Response; finished: Promise<void>}>, context: Context): Promise<Response>;
97
+ check(request: Request, context: Context): Promise<ProtectionDecision>;
98
+ report(decision: ProtectionDecision, outcome?: ReportOutcome): Promise<void>;
99
+ /** Wait for currently pending reports, bounded by each report's timeout. */
100
+ flush(): Promise<void>;
101
+ }
102
+ /** Existing two-argument usage remains valid when no typed context is required. */
103
+ export interface ContextOptionalProtection extends AIProtection<Record<string, unknown>> {
104
+ (request: Request, handler: () => Response | Promise<Response>, context?: Record<string, unknown>): Promise<Response>;
105
+ check(request: Request, context?: Record<string, unknown>): Promise<ProtectionDecision>;
106
+ }
107
+ export function createAIProtection(options: AIProtectionOptions): ContextOptionalProtection;
108
+ export function createAIProtection<Context>(options: AIProtectionOptions<Context>): AIProtection<Context>;
109
+
110
+ /** All monetary values use integer micro-USD. Zero limits disable that scope/unit. */
111
+ export interface BudgetLimits {
112
+ account_tokens?: number; account_micros?: number;
113
+ tenant_tokens?: number; tenant_micros?: number;
114
+ feature_tokens?: number; feature_micros?: number;
115
+ }
116
+ export interface BudgetPrice {
117
+ provider: string; model: string;
118
+ inputMicrosPerMillion: number; outputMicrosPerMillion: number;
119
+ }
120
+ export interface BudgetUsage {provider:string; model:string; inputTokens:number; outputTokens:number}
121
+ export interface BudgetOutcome {reason:string; overrun:boolean; reserved:boolean; wouldDeny:boolean}
122
+ export interface BudgetOptions<T> {
123
+ webdecoyUrl:string; webdecoyKey:string; propertyId:string; ruleId:string;
124
+ subjectSecret:string; windowSeconds:number; limits:BudgetLimits;
125
+ mode?:Mode; failureMode?:'open'|'closed'; timeoutMs?:number; maxRuntimeMs?:number;
126
+ reportToWebDecoy?:boolean; waitUntil?:(task:Promise<void>)=>void; reportingTimeoutMs?:number; maxPendingReports?:number;
127
+ prices:Record<string,BudgetPrice>;
128
+ subject(context:T):{accountId:string; organizationId:string};
129
+ }
130
+ export interface BudgetCall {requestId?:string; priceId:string; maxInputTokens:number; maxOutputTokens:number}
131
+ export class BudgetDenied extends Error {status:number;retryAfterSeconds:number}
132
+ export function budgetCost(price:BudgetPrice,inputTokens:number,outputTokens:number):number;
133
+ export function ollamaBudgetUsage(final:unknown):BudgetUsage|null;
134
+ export function createAIBudget<T>(options:BudgetOptions<T>):{
135
+ flush():Promise<void>;
136
+ run<V>(context:T,call:BudgetCall,work:(runtime:{provider:string;model:string;maxInputTokens:number;maxOutputTokens:number;signal:AbortSignal})=>{value:V;finished:Promise<BudgetUsage|null>}|Promise<{value:V;finished:Promise<BudgetUsage|null>}>,signal?:AbortSignal):Promise<{value:V;accounting:Promise<BudgetOutcome>;callId:string}>;
137
+ };
138
+
139
+ /** Create on the server and persist if recovery must survive process/request loss. */
140
+ export function createQuotaOperationId(): string;
package/fetch.mjs ADDED
@@ -0,0 +1,165 @@
1
+ export {createQuotaOperationId} from './quota.mjs';
2
+ import {abortable} from './transport.mjs';
3
+ import {browserEvidenceCheck} from './browser-evidence.mjs';
4
+ export {createAIBudget, BudgetDenied, budgetCost, ollamaBudgetUsage} from './budget.mjs';
5
+ import { prepareConcurrency } from './concurrency.mjs';
6
+ import { prepareQuota } from './quota.mjs';
7
+ import { isIP } from 'node:net';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { createAdmission } from './admission.mjs';
10
+ import { prepareRules, evaluateRules } from './rules.mjs';
11
+ import { createReporter } from './reporting.mjs';
12
+ import { createTelemetry } from './telemetry.mjs';
13
+
14
+ // Node runtime only. Authentication and input validation belong before this API.
15
+ export function createAIProtection(options) {
16
+ if (typeof options.resolveClientIP !== 'function') throw new Error('resolveClientIP is required');
17
+ const clientIPTimeoutMs=options.clientIPTimeoutMs??1000;
18
+ if(!Number.isInteger(clientIPTimeoutMs)||clientIPTimeoutMs<1||clientIPTimeoutMs>10000)throw Error('Invalid clientIPTimeoutMs');
19
+ if(options.route!==undefined&&(typeof options.route!=='string'||!options.route.startsWith('/')||options.route.length>512||/[?#\r\n]/.test(options.route)))throw Error('Invalid normalized route');
20
+ const admission = createAdmission(options);
21
+ const rules = prepareRules(options.rules);
22
+ const quota=prepareQuota(options);
23
+ const concurrency=prepareConcurrency(options);
24
+ const telemetry = createTelemetry(options);
25
+ const localSink = options.onObservation ?? (event => console.log(JSON.stringify(event)));
26
+ const reporter = createReporter({...options, onObservation:async (event, runtime) => {
27
+ // Start telemetry before handing a separate event copy to the customer's sink.
28
+ // A sink cannot mutate the wire payload or prevent the other destination running.
29
+ const deliveries = await Promise.allSettled([
30
+ telemetry(event, runtime), Promise.resolve().then(() => localSink(structuredClone(event), runtime))
31
+ ]);
32
+ if (deliveries.some(result => result.status === 'rejected')) throw new Error('Report delivery failed');
33
+ }});
34
+ // Private bookkeeping: no context, Request, body or raw rule errors retained.
35
+ const observations = new WeakMap();
36
+ function finish(observation, checks, denial) {
37
+ const decision = Object.freeze({id:observation.request_id,
38
+ conclusion:denial ? 'deny' : 'allow', reason:denial?.reason ?? 'allowed',
39
+ ...(denial ? {status:denial.status,...(denial.retryAfterSeconds ? {retryAfterSeconds:denial.retryAfterSeconds} : {})} : {}),
40
+ degraded:checks.some(c => c.decision === 'unavailable' || c.reason === 'client_ip_unavailable'),
41
+ checks:Object.freeze(checks.map(c => Object.freeze(c)))});
42
+ observations.set(decision, {...observation, handler_attempted:false, decision:decision.conclusion,
43
+ reason:decision.reason === 'allowed' && observation.reason ? observation.reason : decision.reason,
44
+ degraded:decision.degraded, checks:decision.checks});
45
+ return decision;
46
+ }
47
+ async function check(request, context = {}) {
48
+ request.signal.throwIfAborted();
49
+ // Only caller-supplied SERVER context enters rules. Never infer identity or
50
+ // account plan from body/header values, and never serialize context remotely.
51
+ const local = evaluateRules(rules, context, request.signal);
52
+ const skipped = reason => ({event:'webdecoy_admission_skipped', schema:1,
53
+ request_id:randomUUID(), property_id:options.propertyId, timestamp:new Date().toISOString(),
54
+ reason, action:local.denial ? 'denied' : 'forwarded', upstream_attempted:false});
55
+ if (local.denial) {
56
+ return finish(skipped('local_denial'), [...local.checks,
57
+ {id:'webdecoy',source:'remote',mode:options.protectionMode ?? 'enforce',decision:'skipped',reason:'local_denial',durationMs:0}], local.denial);
58
+ }
59
+ const shared=await quota(context,request.signal);
60
+ if(shared.check)local.checks.push(shared.check);
61
+ if(shared.denial) return finish({...skipped('quota_denial'),action:shared.denial.status===503?'denied_unavailable':'denied'}, [...local.checks,
62
+ {id:'webdecoy',source:'remote',mode:options.protectionMode ?? 'enforce',decision:'skipped',reason:'quota_denial',durationMs:0}],shared.denial);
63
+ const resolverController=new AbortController();
64
+ const resolverSignal=AbortSignal.any([request.signal,resolverController.signal]);
65
+ const timer=setTimeout(()=>resolverController.abort(),clientIPTimeoutMs);
66
+ let ip;
67
+ try {
68
+ ip=await abortable(Promise.resolve().then(()=>{resolverSignal.throwIfAborted();return options.resolveClientIP(request,{signal:resolverSignal});}),resolverSignal);
69
+ } catch(error) {
70
+ request.signal.throwIfAborted();
71
+ if(!resolverController.signal.aborted)throw error;
72
+ ip=null; // Resolver timeout is degraded coverage, never an abuse verdict.
73
+ } finally {clearTimeout(timer);}
74
+
75
+ request.signal.throwIfAborted();
76
+ if (typeof ip !== 'string' || !isIP(ip)) {
77
+ return finish(skipped('client_ip_unavailable'), [...local.checks,
78
+ {id:'webdecoy',source:'remote',mode:options.protectionMode ?? 'enforce',decision:'skipped',reason:'client_ip_unavailable',durationMs:0}]);
79
+ }
80
+ const result = await admission.check({ip, method:request.method,
81
+ path:options.route??new URL(request.url).pathname,
82
+ headers:Object.fromEntries(request.headers), signal:request.signal});
83
+ const observation = result.observation;
84
+ const remote = {id:'webdecoy',source:'remote',mode:observation.mode,
85
+ decision:observation.detector_decision === 'block' ? 'deny' : observation.detector_decision,
86
+ reason:observation.account_status !== 'verified' ? observation.account_status
87
+ : observation.detector_decision === 'unavailable' ? 'detector_unavailable' : `detector_${observation.detector_decision}`,
88
+ durationMs:observation.account_ms + observation.detector_ms};
89
+ const browserChecks=options.browserEvidenceOrigin?[browserEvidenceCheck(observation.browser_evidence,observation.mode)]:[];
90
+ const decision = finish(observation, [...local.checks, remote,...browserChecks],
91
+ result.allowed ? undefined : {reason:result.error,status:result.status});
92
+ if (request.signal.aborted) {
93
+ // No usable decision on cancellation; still emit a best-effort outcome.
94
+ void report(decision, {cancelled:true});
95
+ request.signal.throwIfAborted();
96
+ }
97
+ return decision;
98
+ }
99
+ function report(decision, outcome = {}) {
100
+ const event = observations.get(decision);
101
+ if (!event) return Promise.resolve(); // Once per decision; foreign decisions cannot inject events.
102
+ observations.delete(decision);
103
+ // Explicit fields only. Never spread application objects into telemetry.
104
+ event.handler_attempted = outcome.handlerAttempted === true;
105
+ if (Number.isInteger(outcome.status) && outcome.status >= 100 && outcome.status <= 599) event.handler_status = outcome.status;
106
+ if (outcome.cancelled === true) event.action = 'cancelled';
107
+ else if (outcome.handlerError === true) event.action = 'handler_error';
108
+ return reporter.send(event);
109
+ }
110
+ async function protect(request, handler, context = {}) {
111
+ if(concurrency)throw Error("Use protect.concurrent with an explicit completion promise when concurrency is configured");
112
+ const decision = await check(request, context);
113
+ const outcome = {handlerAttempted:false};
114
+ try {
115
+ request.signal.throwIfAborted();
116
+ if (decision.conclusion === 'deny') return Response.json({error:decision.reason, request_id:decision.id}, {
117
+ status:decision.status, headers:{'Cache-Control':'no-store', 'X-WebDecoy-Request-ID':decision.id,...(decision.retryAfterSeconds?{'Retry-After':String(decision.retryAfterSeconds)}:{})}
118
+ });
119
+ outcome.handlerAttempted = true;
120
+ const response = await handler();
121
+ outcome.status = response.status;
122
+ return response; // Original response/body: no stream reader or rewriting.
123
+ } catch (error) {
124
+ outcome.cancelled = request.signal.aborted;
125
+ outcome.handlerError = !outcome.cancelled;
126
+ throw error;
127
+ } finally { void report(decision, outcome); }
128
+ }
129
+ async function untilStopped(value,signal){
130
+ let stop;
131
+ const aborted=new Promise((_,reject)=>{stop=()=>reject(signal.reason??Error('Protected work cancelled'));if(signal.aborted)stop();else signal.addEventListener('abort',stop,{once:true});});
132
+ try{return await Promise.race([Promise.resolve(value),aborted]);}finally{signal.removeEventListener('abort',stop);}
133
+ }
134
+ async function concurrent(request,handler,context={}) {
135
+ if(!concurrency)throw Error('Concurrency configuration required');
136
+ const admitted=await check(request,context);
137
+ if(admitted.conclusion==='deny'){
138
+ void report(admitted,{handlerAttempted:false});
139
+ return Response.json({error:admitted.reason,request_id:admitted.id},{status:admitted.status,headers:{'Cache-Control':'no-store',...(admitted.retryAfterSeconds?{'Retry-After':String(admitted.retryAfterSeconds)}:{})}});
140
+ }
141
+ const lease=await concurrency(context,request.signal);
142
+ const event=observations.get(admitted);observations.delete(admitted);
143
+ const decision=finish({...event,action:lease.denial?'denied':'forwarded'},[...admitted.checks,lease.check],lease.denial);
144
+ if(lease.denial){void report(decision,{handlerAttempted:false});return Response.json({error:decision.reason,request_id:decision.id},{status:decision.status,headers:{'Cache-Control':'no-store',...(decision.retryAfterSeconds?{'Retry-After':String(decision.retryAfterSeconds)}:{})}});}
145
+ let attempted=false;
146
+ try {
147
+ lease.signal.throwIfAborted();attempted=true;
148
+ const result=await untilStopped(handler({signal:lease.signal}),lease.signal);
149
+ if(!(result?.response instanceof Response)||!result.finished||typeof result.finished.then!=='function')throw Error('Return {response, finished}: finished must track provider/stream completion');
150
+ const completion=(async()=>{
151
+ let completed=false;
152
+ try{await untilStopped(result.finished,lease.signal);completed=!lease.signal.aborted;}catch{}
153
+ try{await lease.finish(completed);}catch{completed=false;}
154
+ await report(decision,{handlerAttempted:true,status:result.response.status,cancelled:request.signal.aborted,handlerError:!completed});
155
+ })();
156
+ completion.catch(()=>{});
157
+ if(options.waitUntil)options.waitUntil(completion);
158
+ return result.response; // Unchanged stream; app supplies the actual lifecycle.
159
+ }catch(error){
160
+ await lease.finish(false).catch(()=>{});
161
+ void report(decision,{handlerAttempted:attempted,cancelled:request.signal.aborted,handlerError:true});throw error;
162
+ }
163
+ }
164
+ return Object.assign(protect, {check, report, concurrent, flush:reporter.flush});
165
+ }
@@ -0,0 +1,68 @@
1
+ import { createHmac } from 'node:crypto';
2
+
3
+ // Shadow-only fixed window per IP. Bounded state; saturation is reported as
4
+ // unknown, never as an allow or block. This is not a production rate limiter.
5
+ export function createBaseline({limit, windowMs, maxKeys = 10000}) {
6
+ const buckets = new Map();
7
+ let currentWindow;
8
+ return (key, now = Date.now()) => {
9
+ const window = Math.floor(now / windowMs);
10
+ if (window !== currentWindow) { buckets.clear(); currentWindow = window; }
11
+ if (!buckets.has(key) && buckets.size >= maxKeys) return 'unknown';
12
+ const count = (buckets.get(key) ?? 0) + 1;
13
+ buckets.set(key, count);
14
+ return count > limit ? 'block' : 'allow';
15
+ };
16
+ }
17
+
18
+ export function observationSubject(secret, scopeId, ip) {
19
+ // Rotate daily; never put raw IPs, prompts, user agents or session tokens in logs.
20
+ return createHmac('sha256', secret).update(`observation:${scopeId}:${new Date().toISOString().slice(0, 10)}:${ip}`).digest('hex');
21
+ }
22
+
23
+ export function summarize(events, labels) {
24
+ const byID = new Map();
25
+ for (const label of labels) {
26
+ if (!label.request_id || !['legitimate', 'abuse'].includes(label.label) || byID.has(label.request_id)) throw new Error('Invalid or duplicate independent label');
27
+ byID.set(label.request_id, label.label);
28
+ }
29
+ const records = events.filter(e => e.event === 'webdecoy_admission');
30
+ for (const e of records) {
31
+ if (typeof e.request_id !== 'string' || !e.request_id ||
32
+ !['allow','block','challenge','unavailable'].includes(e.detector_decision) ||
33
+ !['allow','block','unknown'].includes(e.baseline_decision) ||
34
+ !Number.isFinite(e.detector_ms) || e.detector_ms < 0) throw new Error('Invalid admission event');
35
+ }
36
+ const eventIDs = new Set(records.map(e => e.request_id));
37
+ if (eventIDs.size !== records.length) throw new Error('Duplicate admission events');
38
+ const matched = records.filter(e => byID.has(e.request_id));
39
+ const policies = {};
40
+ for (const policy of ['rate_limit', 'webdecoy', 'combined']) {
41
+ const counts = {legitimate: 0, false_blocks: 0, abuse: 0, detected_abuse: 0, unknown: 0};
42
+ for (const e of matched) {
43
+ const rate = e.baseline_decision === 'unknown' ? null : e.baseline_decision === 'block';
44
+ // Fail-open policy: unavailable detections do not block. Track them separately.
45
+ const detected = ['block', 'challenge'].includes(e.detector_decision);
46
+ const blocked = policy === 'rate_limit' ? rate : policy === 'webdecoy' ? detected : (detected || rate);
47
+ if (blocked === null) { counts.unknown++; continue; }
48
+ if (byID.get(e.request_id) === 'legitimate') { counts.legitimate++; if (blocked) counts.false_blocks++; }
49
+ else { counts.abuse++; if (blocked) counts.detected_abuse++; }
50
+ }
51
+ policies[policy] = {...counts,
52
+ false_block_rate: counts.legitimate ? counts.false_blocks / counts.legitimate : null,
53
+ abuse_detection_rate: counts.abuse ? counts.detected_abuse / counts.abuse : null};
54
+ }
55
+ const latency = records.map(e => e.detector_ms).filter(Number.isFinite).sort((a,b) => a-b);
56
+ const admissionLatency = records.map(e => e.detector_ms + (e.account_ms ?? 0)).sort((a,b) => a-b);
57
+ return {requests: records.length, labeled_requests: matched.length,
58
+ unmatched_labels: [...byID.keys()].filter(id => !eventIDs.has(id)).length,
59
+ account_unavailable: records.filter(e => e.account_status && e.account_status !== 'verified').length,
60
+ effective_modes: {observe: records.filter(e => e.mode === 'observe').length, enforce: records.filter(e => e.mode === 'enforce').length},
61
+ unavailable_checks: records.filter(e => e.detector_decision === 'unavailable').length,
62
+ admission_p95_ms: admissionLatency.length ? admissionLatency[Math.ceil(admissionLatency.length * .95) - 1] : null,
63
+ detector_p95_ms: latency.length ? latency[Math.ceil(latency.length * .95) - 1] : null,
64
+ baseline_unknown: records.filter(e => e.baseline_decision === 'unknown').length,
65
+ additional_abuse_caught: matched.filter(e => byID.get(e.request_id) === 'abuse' && e.baseline_decision === 'allow' && ['block','challenge'].includes(e.detector_decision)).length,
66
+ policies, savings: 'Not measured: requires actual model usage and pricing; blocked counts are not dollar savings.',
67
+ limitations: ['Per-request metrics; labels must be independent of verdicts.', 'Shadow fixed-window per-IP baseline, one gateway process; not application-native controls.', 'No statistical confidence or production accuracy claim from a synthetic sample.']};
68
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@webdecoy/ai-protection",
3
+ "type": "module",
4
+ "engines": {
5
+ "node": ">=22.22.3"
6
+ },
7
+ "scripts": {
8
+ "test": "node --test test/*.test.mjs",
9
+ "prepublishOnly": "npm test && npm run test:types && npm run check:package",
10
+ "check:package": "node scripts/check-package.mjs",
11
+ "test:types": "tsc --strict --noEmit --module nodenext --target es2022 test/types.mts"
12
+ },
13
+ "version": "0.1.0-alpha.1",
14
+ "exports": {
15
+ "./fetch": {
16
+ "types": "./fetch.d.mts",
17
+ "import": "./fetch.mjs"
18
+ },
19
+ ".": {
20
+ "types": "./fetch.d.mts",
21
+ "import": "./fetch.mjs"
22
+ },
23
+ "./browser": {
24
+ "types": "./browser.d.mts",
25
+ "import": "./browser.mjs"
26
+ }
27
+ },
28
+ "files": [
29
+ "fetch.mjs",
30
+ "fetch.d.mts",
31
+ "admission.mjs",
32
+ "account.mjs",
33
+ "observation.mjs",
34
+ "README.md",
35
+ "NEXTJS.md",
36
+ "LICENSE",
37
+ "rules.mjs",
38
+ "reporting.mjs",
39
+ "ARCHITECTURE.md",
40
+ "telemetry.mjs",
41
+ "quota.mjs",
42
+ "concurrency.mjs",
43
+ "budget.mjs",
44
+ "browser.mjs",
45
+ "browser.d.mts",
46
+ "browser-evidence.mjs",
47
+ "usage.mjs",
48
+ "transport.mjs",
49
+ "RELEASE.md",
50
+ "NOTICE"
51
+ ],
52
+ "description": "Bot and abuse protection for AI-powered applications. Server-side request admission for Node.js.",
53
+ "license": "Apache-2.0",
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/WebDecoy/ai-protection.git"
57
+ },
58
+ "homepage": "https://github.com/WebDecoy/ai-protection#readme",
59
+ "bugs": {
60
+ "url": "https://github.com/WebDecoy/ai-protection/issues"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public",
64
+ "tag": "alpha"
65
+ },
66
+ "keywords": [
67
+ "ai",
68
+ "security",
69
+ "bot-protection",
70
+ "nextjs",
71
+ "webdecoy"
72
+ ],
73
+ "devDependencies": {
74
+ "typescript": "6.0.3"
75
+ }
76
+ }