@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/ARCHITECTURE.md +166 -0
- package/LICENSE +202 -0
- package/NEXTJS.md +177 -0
- package/NOTICE +2 -0
- package/README.md +323 -0
- package/RELEASE.md +75 -0
- package/account.mjs +33 -0
- package/admission.mjs +87 -0
- package/browser-evidence.mjs +17 -0
- package/browser.d.mts +2 -0
- package/browser.mjs +11 -0
- package/budget.mjs +105 -0
- package/concurrency.mjs +72 -0
- package/fetch.d.mts +140 -0
- package/fetch.mjs +165 -0
- package/observation.mjs +68 -0
- package/package.json +76 -0
- package/quota.mjs +78 -0
- package/reporting.mjs +43 -0
- package/rules.mjs +46 -0
- package/telemetry.mjs +21 -0
- package/transport.mjs +20 -0
- package/usage.mjs +17 -0
package/quota.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {createHmac,randomUUID} from 'node:crypto';
|
|
2
|
+
export function createQuotaOperationId() { return `${Math.floor(Date.now()/1000)}.${randomUUID()}`; }
|
|
3
|
+
const operationPattern=/^[1-9][0-9]{9}\.[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
4
|
+
const code = /^[a-z][a-z0-9_]{0,63}$/;
|
|
5
|
+
export function quotaHash(secret, ...parts) {
|
|
6
|
+
const h = createHmac('sha256', secret);
|
|
7
|
+
for (const part of parts) {
|
|
8
|
+
const bytes = Buffer.from(part, 'utf8');
|
|
9
|
+
const length = Buffer.alloc(4); length.writeUInt32BE(bytes.length);
|
|
10
|
+
h.update(length); h.update(bytes);
|
|
11
|
+
}
|
|
12
|
+
return h.digest('hex');
|
|
13
|
+
}
|
|
14
|
+
export function prepareQuota(options) {
|
|
15
|
+
if (options.accountQuota === undefined) return async () => ({});
|
|
16
|
+
const {webdecoyUrl,webdecoyKey,propertyId}=options;
|
|
17
|
+
const q = {mode:'observe',failureMode:'open',timeoutMs:1000,sessionLimit:0,idempotency:false,
|
|
18
|
+
subjectSecret:options.subjectSecret,...options.accountQuota};
|
|
19
|
+
if (typeof q.idempotency!=='boolean' || (q.operationId!==undefined&&(!q.idempotency||typeof q.operationId!=='function')) || !code.test(q.ruleId ?? '') || typeof q.subject !== 'function' ||
|
|
20
|
+
typeof q.subjectSecret !== 'string' || !q.subjectSecret.isWellFormed() || Buffer.byteLength(q.subjectSecret)<32 ||
|
|
21
|
+
!['observe','enforce'].includes(q.mode) || !['open','closed'].includes(q.failureMode) ||
|
|
22
|
+
!Number.isInteger(q.limit) || q.limit<1 || q.limit>1000000 ||
|
|
23
|
+
!Number.isInteger(q.windowSeconds) || q.windowSeconds<1 || q.windowSeconds>86400 ||
|
|
24
|
+
!Number.isInteger(q.sessionLimit) || q.sessionLimit<0 || q.sessionLimit>q.limit ||
|
|
25
|
+
!Number.isInteger(q.timeoutMs) || q.timeoutMs<1 || q.timeoutMs>10000) throw Error('Invalid account quota configuration');
|
|
26
|
+
return async (context, signal) => {
|
|
27
|
+
signal.throwIfAborted(); const started=performance.now();
|
|
28
|
+
const check={id:'account_quota',source:'shared',mode:q.mode,decision:'unavailable',reason:'account_quota_unavailable',durationMs:0};
|
|
29
|
+
let denial;
|
|
30
|
+
try {
|
|
31
|
+
const subject=q.subject(context);
|
|
32
|
+
if (subject && typeof subject.then==='function') {Promise.resolve(subject).catch(()=>{});throw Error('Async subject');}
|
|
33
|
+
const valid=value=>typeof value==='string'&&value.length>0&&value.isWellFormed()&&Buffer.byteLength(value)<=256;
|
|
34
|
+
if (!valid(subject?.accountId) || (subject.sessionId!==undefined&&subject.sessionId!==''&&!valid(subject.sessionId)) || (q.sessionLimit>0&&!valid(subject.sessionId))) throw Error('Invalid subject');
|
|
35
|
+
const parts=['webdecoy.account-quota.v1',propertyId.toLowerCase(),q.ruleId];
|
|
36
|
+
let operationId;
|
|
37
|
+
if(q.idempotency){
|
|
38
|
+
operationId=q.operationId?q.operationId(context):createQuotaOperationId();
|
|
39
|
+
if(operationId&&typeof operationId.then==='function'){Promise.resolve(operationId).catch(()=>{});throw Error('Async operation ID');}
|
|
40
|
+
if(typeof operationId!=='string'||!operationPattern.test(operationId))throw Error('Invalid operation ID');
|
|
41
|
+
check.operationId=operationId;
|
|
42
|
+
}
|
|
43
|
+
const payload={schema:q.idempotency?2:1,...(operationId?{operation_id:operationId}:{}),rule_id:q.ruleId,subject:quotaHash(q.subjectSecret,...parts,'account',subject.accountId),
|
|
44
|
+
limit:q.limit,window_seconds:q.windowSeconds,session_limit:q.sessionLimit};
|
|
45
|
+
if(q.sessionLimit>0)payload.session=quotaHash(q.subjectSecret,...parts,'session',subject.accountId,subject.sessionId);
|
|
46
|
+
let result;
|
|
47
|
+
for(let attempt=0;attempt<(q.idempotency?2:1);attempt++){
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
try {
|
|
50
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/quota',webdecoyUrl),{
|
|
51
|
+
method:'POST',redirect:'error',signal:AbortSignal.any([signal,AbortSignal.timeout(q.timeoutMs)]),
|
|
52
|
+
headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},body:JSON.stringify(payload)
|
|
53
|
+
});
|
|
54
|
+
if(!response.ok){await response.body?.cancel();const error=Error('Quota unavailable');error.terminal=response.status<500;throw error;}
|
|
55
|
+
const reader=response.body.getReader();const chunks=[];let size=0;
|
|
56
|
+
try {for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>2048)throw Error('Invalid quota response');chunks.push(value);}}
|
|
57
|
+
finally {await reader.cancel().catch(()=>{});reader.releaseLock();}
|
|
58
|
+
result=JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
59
|
+
if(result?.schema!==(q.idempotency?2:1)||(q.idempotency&&result.operation_id!==operationId)||typeof result.allowed!=='boolean'||!Number.isInteger(result.remaining)||result.remaining<0||result.remaining>q.limit||
|
|
60
|
+
!Number.isInteger(result.reset_at)||result.reset_at<=0||!Number.isInteger(result.retry_after_seconds)||
|
|
61
|
+
(result.allowed&&(result.reason!=='account_quota_allowed'||result.retry_after_seconds!==0))||
|
|
62
|
+
(!result.allowed&&(result.reason!=='account_quota_exceeded'||result.retry_after_seconds<1||result.retry_after_seconds>q.windowSeconds)))throw Error('Invalid quota response');
|
|
63
|
+
break;
|
|
64
|
+
} catch(error) {
|
|
65
|
+
signal.throwIfAborted();
|
|
66
|
+
if(q.idempotency&&!error.terminal)check.reason='account_quota_outcome_unknown';
|
|
67
|
+
if(!q.idempotency||attempt===1||error.terminal)throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
check.decision=result.allowed?'allow':'deny';check.reason=result.reason;
|
|
71
|
+
if(!result.allowed&&q.mode==='enforce')denial={reason:result.reason,status:429,retryAfterSeconds:result.retry_after_seconds};
|
|
72
|
+
} catch {
|
|
73
|
+
if(q.mode==='enforce'&&q.failureMode==='closed')denial={reason:check.reason,status:503};
|
|
74
|
+
}
|
|
75
|
+
signal.throwIfAborted();check.durationMs=performance.now()-started;
|
|
76
|
+
return {check,denial};
|
|
77
|
+
};
|
|
78
|
+
}
|
package/reporting.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Best-effort reporting is independent of admission. Sinks must honor the signal
|
|
2
|
+
// and avoid synchronous CPU work. A host lifecycle hook keeps serverless work alive.
|
|
3
|
+
export function createReporter({onObservation = event => console.log(JSON.stringify(event)),
|
|
4
|
+
waitUntil, reportingTimeoutMs = 1000, maxPendingReports = 100}) {
|
|
5
|
+
if (typeof onObservation !== 'function' || (waitUntil !== undefined && typeof waitUntil !== 'function'))
|
|
6
|
+
throw new Error('Invalid reporting hooks');
|
|
7
|
+
for (const value of [reportingTimeoutMs, maxPendingReports])
|
|
8
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error('Invalid reporting bounds');
|
|
9
|
+
if(reportingTimeoutMs>10000||maxPendingReports>10000)throw Error('Reporting bounds exceed maximum');
|
|
10
|
+
const pending = new Set();
|
|
11
|
+
return {
|
|
12
|
+
send(event) {
|
|
13
|
+
if (pending.size >= maxPendingReports) {
|
|
14
|
+
console.warn('WebDecoy report dropped: queue full');
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
let timer;
|
|
19
|
+
const timeout = new Promise(resolve => {
|
|
20
|
+
timer = setTimeout(() => {
|
|
21
|
+
controller.abort();
|
|
22
|
+
console.warn('WebDecoy report timed out');
|
|
23
|
+
resolve();
|
|
24
|
+
}, reportingTimeoutMs);
|
|
25
|
+
});
|
|
26
|
+
const delivery = Promise.resolve().then(() => onObservation(event, {signal:controller.signal})).catch(() => {
|
|
27
|
+
console.warn('WebDecoy report failed');
|
|
28
|
+
});
|
|
29
|
+
const task = Promise.race([delivery, timeout]).finally(() => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
pending.delete(task);
|
|
32
|
+
});
|
|
33
|
+
pending.add(task);
|
|
34
|
+
try {
|
|
35
|
+
const scheduled = waitUntil?.(task);
|
|
36
|
+
if (scheduled && typeof scheduled.then === 'function')
|
|
37
|
+
Promise.resolve(scheduled).catch(() => console.warn('WebDecoy reporting lifecycle hook failed'));
|
|
38
|
+
} catch { console.warn('WebDecoy reporting lifecycle hook failed'); }
|
|
39
|
+
return task;
|
|
40
|
+
},
|
|
41
|
+
async flush() { await Promise.all([...pending]); }
|
|
42
|
+
};
|
|
43
|
+
}
|
package/rules.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const code = /^[a-z][a-z0-9_]{0,63}$/;
|
|
2
|
+
export function prepareRules(rules = []) {
|
|
3
|
+
if (!Array.isArray(rules) || rules.length > 32) throw new Error('rules must be an array of at most 32 rules');
|
|
4
|
+
const ids = new Set(['webdecoy','account_quota','concurrency','browser_evidence']);
|
|
5
|
+
return rules.map(rule => {
|
|
6
|
+
if (!rule || typeof rule.id !== 'string' || !code.test(rule.id) || ids.has(rule.id) || typeof rule.evaluate !== 'function')
|
|
7
|
+
throw new Error('Local rules require unique stable IDs and evaluate functions');
|
|
8
|
+
const mode = rule.mode ?? 'observe';
|
|
9
|
+
const failureMode = rule.failureMode ?? 'closed';
|
|
10
|
+
if (!['observe','enforce'].includes(mode) || !['open','closed'].includes(failureMode)) throw new Error('Invalid local rule mode');
|
|
11
|
+
ids.add(rule.id);
|
|
12
|
+
return {id:rule.id, mode, failureMode, evaluate:rule.evaluate};
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function evaluateRules(rules, context, signal) {
|
|
17
|
+
const checks = [];
|
|
18
|
+
let denial;
|
|
19
|
+
for (const rule of rules) {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const started = performance.now();
|
|
22
|
+
let result;
|
|
23
|
+
try {
|
|
24
|
+
const value = rule.evaluate(context);
|
|
25
|
+
// Local checks are synchronous and deterministic. Never leave a rejected
|
|
26
|
+
// promise unhandled if an async rule is accidentally supplied.
|
|
27
|
+
if (value && typeof value.then === 'function') {
|
|
28
|
+
Promise.resolve(value).catch(() => {});
|
|
29
|
+
throw new Error('Async local rule');
|
|
30
|
+
}
|
|
31
|
+
if (!value || typeof value.allowed !== 'boolean' ||
|
|
32
|
+
(value.reason !== undefined && (typeof value.reason !== 'string' || !code.test(value.reason))) ||
|
|
33
|
+
(value.status !== undefined && ![403,429].includes(value.status))) throw new Error('Invalid local rule result');
|
|
34
|
+
result = {id:rule.id, source:'local', mode:rule.mode, decision:value.allowed ? 'allow' : 'deny',
|
|
35
|
+
reason:value.reason ?? (value.allowed ? 'rule_allowed' : 'rule_denied')};
|
|
36
|
+
if (!value.allowed && rule.mode === 'enforce') denial ??= {reason:result.reason,status:value.status ?? 403};
|
|
37
|
+
} catch {
|
|
38
|
+
result = {id:rule.id, source:'local', mode:rule.mode, decision:'unavailable', reason:'local_rule_error'};
|
|
39
|
+
if (rule.mode === 'enforce' && rule.failureMode === 'closed') denial ??= {reason:'local_rule_error',status:503};
|
|
40
|
+
}
|
|
41
|
+
result.durationMs = Math.round((performance.now() - started) * 100) / 100;
|
|
42
|
+
checks.push(result);
|
|
43
|
+
}
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
return {checks, denial};
|
|
46
|
+
}
|
package/telemetry.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Explicit wire schema. Never serialize the full observation or application context.
|
|
2
|
+
export function createTelemetry({webdecoyUrl, webdecoyKey, propertyId, reportToWebDecoy = true}) {
|
|
3
|
+
if (typeof reportToWebDecoy !== 'boolean') throw new Error('Invalid reportToWebDecoy');
|
|
4
|
+
return async (event, {signal}) => {
|
|
5
|
+
if (!reportToWebDecoy) return;
|
|
6
|
+
const payload = {schema:1, request_id:event.request_id, timestamp:event.timestamp,
|
|
7
|
+
decision:event.decision, reason:event.reason, degraded:event.degraded,
|
|
8
|
+
checks:event.checks.map(check => ({id:check.id,source:check.source,mode:check.mode,
|
|
9
|
+
decision:check.decision,reason:check.reason,duration_ms:check.durationMs})),
|
|
10
|
+
handler_attempted:event.handler_attempted,
|
|
11
|
+
...(event.handler_status !== undefined ? {handler_status:event.handler_status} : {}),
|
|
12
|
+
action:event.action};
|
|
13
|
+
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/reports',webdecoyUrl),{
|
|
14
|
+
method:'POST',redirect:'error',signal,
|
|
15
|
+
headers:{Authorization:`Bearer ${webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':propertyId},
|
|
16
|
+
body:JSON.stringify(payload)
|
|
17
|
+
});
|
|
18
|
+
await response.body?.cancel();
|
|
19
|
+
if (!response.ok) throw new Error('WebDecoy reporting unavailable');
|
|
20
|
+
};
|
|
21
|
+
}
|
package/transport.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Bound control-plane responses independently of the customer's model stream.
|
|
2
|
+
export async function readJSON(response, maxBytes = 65536) {
|
|
3
|
+
const reader=response.body?.getReader();
|
|
4
|
+
if(!reader)throw Error('Missing WebDecoy response');
|
|
5
|
+
let size=0;const chunks=[];
|
|
6
|
+
try {
|
|
7
|
+
for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;
|
|
8
|
+
if(size>maxBytes)throw Error('Oversized WebDecoy response');chunks.push(value);}
|
|
9
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
10
|
+
} finally {await reader.cancel().catch(()=>{});reader.releaseLock();}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Each waiter owns cancellation, even when an account refresh is shared.
|
|
14
|
+
export async function abortable(promise, signal) {
|
|
15
|
+
if(signal.aborted){Promise.resolve(promise).catch(()=>{});signal.throwIfAborted();}
|
|
16
|
+
let listener;
|
|
17
|
+
const aborted=new Promise((_,reject)=>{listener=()=>reject(signal.reason);signal.addEventListener('abort',listener,{once:true});});
|
|
18
|
+
try{return await Promise.race([promise,aborted]);}
|
|
19
|
+
finally{signal.removeEventListener('abort',listener);}
|
|
20
|
+
}
|
package/usage.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {createReporter} from './reporting.mjs';
|
|
2
|
+
|
|
3
|
+
// Events are constructed internally from bounded numeric fields, never application
|
|
4
|
+
// context, prompts, responses, provider errors or credentials.
|
|
5
|
+
export function createUsageReporter(options) {
|
|
6
|
+
if(options.reportToWebDecoy!==undefined&&typeof options.reportToWebDecoy!=='boolean')throw Error('Invalid reportToWebDecoy');
|
|
7
|
+
return createReporter({...options,onObservation:async(event,{signal})=>{
|
|
8
|
+
if(options.reportToWebDecoy===false)return;
|
|
9
|
+
const response=await fetch(new URL('/api/v1/sdk/ai-abuse/usage',options.webdecoyUrl),{
|
|
10
|
+
method:'POST',redirect:'error',signal,
|
|
11
|
+
headers:{Authorization:`Bearer ${options.webdecoyKey}`,'Content-Type':'application/json','X-WebDecoy-Property-ID':options.propertyId},
|
|
12
|
+
body:JSON.stringify(event)
|
|
13
|
+
});
|
|
14
|
+
await response.body?.cancel();
|
|
15
|
+
if(!response.ok)throw Error('Usage reporting unavailable');
|
|
16
|
+
}});
|
|
17
|
+
}
|