@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/README.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# WebDecoy AI Protection
|
|
2
|
+
|
|
3
|
+
Bot and abuse protection for AI-powered applications. A small Node.js SDK that
|
|
4
|
+
checks requests before your application invokes a model. Customer-defined rules run
|
|
5
|
+
locally; proprietary bot detection runs in WebDecoy. See [architecture](ARCHITECTURE.md).
|
|
6
|
+
|
|
7
|
+
**Alpha release: `0.1.0-alpha.1`.** Integration mechanics are tested;
|
|
8
|
+
real-world detection accuracy and provider cost savings have not been established.
|
|
9
|
+
Requires a WebDecoy property, a property-scoped API key, and a compatible WebDecoy
|
|
10
|
+
service deployment. This repository contains the SDK, not the detection service.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @webdecoy/ai-protection@alpha
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Node.js 22.22.3 or newer is required. The SDK has no runtime npm dependencies.
|
|
19
|
+
Edge runtimes are not supported. Licensed under [Apache-2.0](LICENSE).
|
|
20
|
+
|
|
21
|
+
Set `WEBDECOY_URL=https://ai-protection.webdecoy.com` on your server.
|
|
22
|
+
Create a property-scoped API key with Write Detections permission in WebDecoy,
|
|
23
|
+
then review results at [AI Protection](https://app.webdecoy.com/ai-protection).
|
|
24
|
+
|
|
25
|
+
## Integrate
|
|
26
|
+
|
|
27
|
+
Create the instance once in a **server-only module**, then call it inside your
|
|
28
|
+
existing authenticated and validated AI endpoint:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createAIProtection } from '@webdecoy/ai-protection';
|
|
32
|
+
|
|
33
|
+
const protect = createAIProtection({
|
|
34
|
+
webdecoyUrl: process.env.WEBDECOY_URL!,
|
|
35
|
+
webdecoyKey: process.env.WEBDECOY_KEY!,
|
|
36
|
+
propertyId: process.env.WEBDECOY_PROPERTY_ID!,
|
|
37
|
+
subjectSecret: process.env.WEBDECOY_SUBJECT_SECRET!, // random, >=32 characters
|
|
38
|
+
scopeId: 'support-chat',
|
|
39
|
+
route: '/api/chat', // fixed route template; never a user ID or raw URL
|
|
40
|
+
protectionMode: 'observe',
|
|
41
|
+
resolveClientIP: trustedClientIP, // implement for your ingress; see setup guide
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Inside your route, AFTER auth, origin checks, input validation and quotas:
|
|
45
|
+
// return protect(request, () => callYourModelAndReturnResponse(request.signal));
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`trustedClientIP` and `callYourModelAndReturnResponse` represent your application's
|
|
49
|
+
existing infrastructure/model code; they are not SDK exports. The protected
|
|
50
|
+
callback returns a standard `Response` or `Promise<Response>`.
|
|
51
|
+
|
|
52
|
+
See the [Next.js / AI SDK guide](NEXTJS.md) and the [runnable local example](examples/nextjs).
|
|
53
|
+
The guide documents trusted IP handling, cancellation and the complete integration flow.
|
|
54
|
+
|
|
55
|
+
## What happens
|
|
56
|
+
|
|
57
|
+
1. Your application validates and authenticates the request.
|
|
58
|
+
2. Local rules evaluate server-supplied context. An enforced local denial stops
|
|
59
|
+
immediately, without waiting for cloud detection. Otherwise the SDK verifies its WebDecoy
|
|
60
|
+
property/account binding and sends request metadata.
|
|
61
|
+
3. Cloud observation records bot verdicts without enforcing them. Each local
|
|
62
|
+
rule has its own observe/enforce mode; explicit customer policies still apply.
|
|
63
|
+
4. With both local and account enforcement enabled on an entitled plan, block and
|
|
64
|
+
challenge verdicts return HTTP 403 before the callback.
|
|
65
|
+
5. An allowed response streams through unchanged.
|
|
66
|
+
|
|
67
|
+
WebDecoy outages **allow requests by default**. A missing or mismatched account
|
|
68
|
+
binding falls back to observation and skips scoring. Missing trusted IPs also skip
|
|
69
|
+
scoring and emit a degraded-coverage event. Successful chat alone does not prove
|
|
70
|
+
protection is connected. Cancelled requests never start the protected callback. Enforced local rule errors
|
|
71
|
+
return 503 by default; this is separate from remote detector failure behavior.
|
|
72
|
+
A challenge verdict has no interactive verification UI in this alpha.
|
|
73
|
+
|
|
74
|
+
## Data and scope
|
|
75
|
+
|
|
76
|
+
The SDK sends IP address, method, URL pathname (not query), user agent, header
|
|
77
|
+
names, accept-language and accept-encoding values for detection. Separate reporting
|
|
78
|
+
sends decision/check metadata and handler outcomes; see [the full schema](ARCHITECTURE.md#reporting-and-hosting-lifecycle). It does **not** send request
|
|
79
|
+
bodies, prompts, session cookies, authorization values or model responses.
|
|
80
|
+
When browser evidence is explicitly enabled, only the WebDecoy receipt cookie is sent. Avoid sensitive
|
|
81
|
+
identifiers in URL paths. Keys stay in your server environment.
|
|
82
|
+
|
|
83
|
+
This SDK provides request admission, not prompt-injection filtering, verified
|
|
84
|
+
agent identity, model/tool authorization or spending caps. Keep your existing
|
|
85
|
+
authentication, origin checks, request limits and user quotas. A metadata verdict
|
|
86
|
+
is not proof that a caller is human.
|
|
87
|
+
|
|
88
|
+
## Explicit decisions and reporting
|
|
89
|
+
|
|
90
|
+
The callable wrapper remains available. For custom enforcement use
|
|
91
|
+
`protect.check(request, trustedContext)`, inspect `conclusion`, `reason`,
|
|
92
|
+
`degraded` and `checks`, then call `protect.report(decision, outcome)` once.
|
|
93
|
+
Reporting is best-effort and separate from the decision. Attach `waitUntil` to
|
|
94
|
+
hosting lifecycle support; see [the contract and examples](ARCHITECTURE.md).
|
|
95
|
+
Application outcomes, including local denials and unavailable checks, are sent
|
|
96
|
+
asynchronously to WebDecoy and your observation sink. The dashboard identifies
|
|
97
|
+
these as SDK reports, separately from detector evidence. Central delivery requires
|
|
98
|
+
the compatible reporting endpoint; `reportToWebDecoy: false` disables it.
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
Required: `webdecoyUrl` (HTTPS AI Protection API origin; HTTP allowed only on loopback),
|
|
103
|
+
`webdecoyKey`, `propertyId`, `scopeId`, `subjectSecret`, `resolveClientIP`.
|
|
104
|
+
|
|
105
|
+
Optional: `protectionMode` (`enforce` by default; start pilots with `observe`),
|
|
106
|
+
`detectorFailureMode` (`open` by default), `detectorTimeoutMs` (1000),
|
|
107
|
+
`baselineLimit` (10), `baselineWindowMs` (60000), `rules` (none),
|
|
108
|
+
`onObservation` (JSON stdout), `waitUntil` (hosting lifecycle hook),
|
|
109
|
+
`reportingTimeoutMs` (1000), `maxPendingReports` (100), and `reportToWebDecoy` (true).
|
|
110
|
+
The baseline is a process-local shadow comparison, not an enforced rate limit.
|
|
111
|
+
|
|
112
|
+
Verified account bindings cache for 60 seconds; failures cache for 5 seconds.
|
|
113
|
+
A cold binding lookup and detector call each have their own timeout (roughly two
|
|
114
|
+
seconds total with defaults). Account changes are eventually consistent.
|
|
115
|
+
Observation records describe admission and callback response creation, not stream
|
|
116
|
+
completion, model usage, blocked spend or successful provider cancellation.
|
|
117
|
+
|
|
118
|
+
## Develop
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
npm ci
|
|
122
|
+
npm test
|
|
123
|
+
npm run test:types
|
|
124
|
+
npm run check:package
|
|
125
|
+
cd examples/nextjs
|
|
126
|
+
npm ci
|
|
127
|
+
npm run build -- --webpack
|
|
128
|
+
npm test
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Tests use a local detector and local AI model; no live keys or paid inference.
|
|
132
|
+
[Release instructions](RELEASING.md) cover publishing the public npm package.
|
|
133
|
+
|
|
134
|
+
## Shared account quotas (opt-in)
|
|
135
|
+
|
|
136
|
+
Configure `accountQuota` from trusted server code, after authenticating and
|
|
137
|
+
validating the application's request:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
accountQuota: {
|
|
141
|
+
ruleId: 'chat_v1', limit: 20, windowSeconds: 60,
|
|
142
|
+
mode: 'enforce', failureMode: 'open',
|
|
143
|
+
subject: authenticated => ({accountId: authenticated.databaseId}),
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The subject callback is synchronous. Do not pass IDs or plan claims copied from
|
|
148
|
+
browser headers/body. Optional `sessionLimit` and `sessionId` add a stricter session
|
|
149
|
+
cap beneath the account cap; rotating sessions does not reset the account limit.
|
|
150
|
+
The quota rule is independent of cloud/dashboard detection mode. It defaults to
|
|
151
|
+
observation, with a one-second timeout and fail-open for state errors. Use
|
|
152
|
+
`failureMode: 'closed'` explicitly to return 503 when a hard quota cannot be checked.
|
|
153
|
+
Enforced exhaustion returns 429 and `Retry-After`; explicit `check` callers use
|
|
154
|
+
`decision.retryAfterSeconds` and must enforce the decision themselves.
|
|
155
|
+
|
|
156
|
+
This needs the shared quota backend (migration 78 and `/api/v1/sdk/ai-abuse/quota`).
|
|
157
|
+
Observation and enforcement share counters. In default schema 1, each allowed
|
|
158
|
+
admission consumes a unit, including retries and requests later cancelled/blocked
|
|
159
|
+
by another check; there are no automatic retries or refunds. Opt-in schema 2
|
|
160
|
+
adds bounded recovery of the same admission (see below). Counters
|
|
161
|
+
use fixed UTC windows: up to twice the limit can pass across a window boundary.
|
|
162
|
+
This does not bound concurrent inference or establish model-cost savings.
|
|
163
|
+
|
|
164
|
+
The account ID is HMAC-SHA256 pseudonymized with the property and rule ID before
|
|
165
|
+
transmission; no raw context or prompt is added to quota/report payloads. The
|
|
166
|
+
secret defaults to `subjectSecret` and must be identical across replicas. An
|
|
167
|
+
explicit `accountQuota.subjectSecret` can separate quota identity from other SDK
|
|
168
|
+
observations. Rotating it resets quotas; coordinate rotation after the longest
|
|
169
|
+
window. These identifiers are correlatable pseudonyms, not anonymous data.
|
|
170
|
+
Backend retention removes expired buckets on access and in an hourly sweep;
|
|
171
|
+
healthy maximum retention is the 24-hour maximum window plus one sweep, with
|
|
172
|
+
possible extension during cleanup failure/backlog.
|
|
173
|
+
|
|
174
|
+
One rule ID binds immutable limit/window/session-limit settings. Inconsistent
|
|
175
|
+
replicas receive an unavailable check, governed by the configured failure policy;
|
|
176
|
+
new policy versions deliberately create fresh counters. Bounds are 32 policies
|
|
177
|
+
and 10,000 active account/session buckets per property. Quota-store errors and
|
|
178
|
+
capacity limits do not change the detector's separate failure policy. State can
|
|
179
|
+
commit just before a timeout, so a failed check does not prove no unit was used.
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## Distributed concurrency (unpublished, #1373)
|
|
183
|
+
|
|
184
|
+
Optional concurrency policy shares per-account and property/feature capacity
|
|
185
|
+
across app replicas. Defaults are observe/open; detector failure policy is
|
|
186
|
+
independent. Authenticate first and derive the account ID from trusted server
|
|
187
|
+
state. Keep rule IDs and subject secrets identical across replicas.
|
|
188
|
+
|
|
189
|
+
Configure `concurrency: {ruleId: 'chat_v1', accountLimit: 2, featureLimit: 20,
|
|
190
|
+
subject: user => ({accountId: user.databaseId})}` and call
|
|
191
|
+
`protect.concurrent(request, async ({signal}) => ({response, finished}), user)`.
|
|
192
|
+
Propagate signal to the provider. `finished` must be a Promise resolving only
|
|
193
|
+
when all protected work has ended. The original Response is returned untouched.
|
|
194
|
+
Use the host's waitUntil hook where required and validate host execution limits.
|
|
195
|
+
The ordinary callable wrapper rejects concurrency configuration; `check` alone
|
|
196
|
+
does not acquire a lease.
|
|
197
|
+
|
|
198
|
+
Heartbeat TTL defaults to 30 seconds and maximum runtime to 300 seconds.
|
|
199
|
+
Confirmed completion releases immediately. Errors, cancellation, crashes and
|
|
200
|
+
lease loss retain capacity until maximum runtime, because cancellation is not
|
|
201
|
+
proof a remote provider stopped. Upstream work must honor cancellation and have
|
|
202
|
+
a real runtime bound. Fail-open outages cannot guarantee a concurrency cap.
|
|
203
|
+
Released replay tombstones remain 24 hours: the pilot cap is 10,000 granted
|
|
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
|
+
|
|
208
|
+
|
|
209
|
+
## Upstream model budgets (unpublished, #1374)
|
|
210
|
+
|
|
211
|
+
Opt-in token and integer micro-USD budgets reserve a conservative maximum before
|
|
212
|
+
each provider attempt and reconcile only confirmed usage. Configure account,
|
|
213
|
+
customer-organization and feature limits, a fixed UTC window, a trusted subject
|
|
214
|
+
callback, and an explicit versioned model price catalog. Rates use micro-USD per
|
|
215
|
+
million input/output tokens. Unknown prices are rejected before work; an explicit
|
|
216
|
+
zero rate is permitted for intentionally free model usage, not unmeasured hosting.
|
|
217
|
+
|
|
218
|
+
Use exported `createAIBudget(options)`, then
|
|
219
|
+
`budget.run(user, {priceId, maxInputTokens, maxOutputTokens}, work, signal)`.
|
|
220
|
+
The callback gets `{provider, model, maxInputTokens, maxOutputTokens, signal}`
|
|
221
|
+
and returns `{value, finished: Promise<BudgetUsage|null>}`. The result contains
|
|
222
|
+
the identical `value` and an `accounting` Promise; keep the latter alive using
|
|
223
|
+
host waitUntil where needed. `BudgetDenied` carries 429/503 and Retry-After
|
|
224
|
+
metadata before work. Missing/rejected usage retains the maximum charge.
|
|
225
|
+
|
|
226
|
+
Defaults are observe/open, independently of detector availability. A hard budget
|
|
227
|
+
requires explicit enforce/closed plus correctly enforced input/output bounds,
|
|
228
|
+
accurate complete prices/usage and no hidden provider retries. Every retry,
|
|
229
|
+
fallback and tool-loop model call needs a fresh reservation. Cancellation/crash/
|
|
230
|
+
missing usage never automatically refunds charges. An actual overrun records debt
|
|
231
|
+
and signals overrun but cannot undo an already-billed call. Fixed-window accounting
|
|
232
|
+
is based on admission time, not the provider's invoice period.
|
|
233
|
+
|
|
234
|
+
There is an Ollama final-usage normalizer for native generate/chat metadata;
|
|
235
|
+
other provider clients need a reviewed application adapter. The SDK never parses
|
|
236
|
+
or stores prompts/outputs to meter usage. This does not change WebDecoy plans or
|
|
237
|
+
create a subscription meter. The private app's `integrations/ai-abuse/BUDGETS.md`
|
|
238
|
+
contains examples, supported workloads, privacy, capacity and release gates.
|
|
239
|
+
|
|
240
|
+
## Optional browser evidence
|
|
241
|
+
|
|
242
|
+
Add `data-runtime-evidence="true"` to the existing WebDecoy scanner tag and set
|
|
243
|
+
`browserEvidenceOrigin` to the exact HTTPS site origin (no trailing slash).
|
|
244
|
+
Requires the compatible ingest/CDN deployment and a same-origin AI endpoint.
|
|
245
|
+
The SDK forwards only the property-specific WebDecoy receipt, never the other
|
|
246
|
+
cookies. The signed observation expires after 60 seconds and is bound to the
|
|
247
|
+
property, origin, IP and user agent. Missing or invalid evidence fails open and
|
|
248
|
+
adds an unavailable `browser_evidence` check; a clean receipt never overrides
|
|
249
|
+
another denial. This is optional risk evidence, not proof of a human or identity.
|
|
250
|
+
Start in observe mode; real-world accuracy has not been established.
|
|
251
|
+
|
|
252
|
+
In browser code, optionally `await prepareBrowserEvidence()` from
|
|
253
|
+
`@webdecoy/ai-protection/browser` before your existing chat fetch. The helper
|
|
254
|
+
waits at most 1500ms by default and returns `{available: boolean}`. Continue the
|
|
255
|
+
request regardless; server admission decides. Load the opted-in tag first.
|
|
256
|
+
|
|
257
|
+
## Model-attempt reports
|
|
258
|
+
|
|
259
|
+
Budget hooks now send separate start/finish events to WebDecoy automatically.
|
|
260
|
+
Each attempt has a random call ID; pass the admission decision's request ID in
|
|
261
|
+
`requestId` on the budget call (`decision.id`). The run returns `callId`.
|
|
262
|
+
Keep `run.accounting` alive with host lifecycle support, then `budget.flush()`
|
|
263
|
+
can drain queued reports. Budget reporting accepts `waitUntil`,
|
|
264
|
+
`reportingTimeoutMs`, `maxPendingReports` and `reportToWebDecoy`.
|
|
265
|
+
|
|
266
|
+
The dashboard labels callback starts and final usage as SDK-reported and joins
|
|
267
|
+
retained reservations to confirm accounting. Neither is a provider invoice.
|
|
268
|
+
Missing usage is unknown, and avoided cost is unavailable—not inferred from
|
|
269
|
+
request denials. Usage events contain numeric tokens, configured rates and price/
|
|
270
|
+
rule codes, but no prompts, responses, model names or raw user identities. Use
|
|
271
|
+
non-sensitive price/rule codes. Reporting remains bounded and best effort;
|
|
272
|
+
failures do not change provider results, trigger retries or refund charges.
|
|
273
|
+
Requires the compatible usage endpoint; old backends may log reporting failures.
|
|
274
|
+
|
|
275
|
+
## Release and runtime contract
|
|
276
|
+
|
|
277
|
+
The SDK is published under Apache-2.0 on the `alpha` npm dist-tag. WebDecoy's
|
|
278
|
+
hosted detection service is separate and is not included in this package.
|
|
279
|
+
|
|
280
|
+
Control-plane JSON responses are capped at 64 KiB (2 KiB for stateful controls).
|
|
281
|
+
Detector/config timeouts default to 1000ms each, maximum 10000ms. Trusted IP
|
|
282
|
+
resolution has its own `clientIPTimeoutMs` (1000ms default, maximum 10000ms) and
|
|
283
|
+
receives `{signal}` as its second argument. On timeout, cloud detection is skipped
|
|
284
|
+
with degraded coverage. Resolver exceptions remain application errors; caller
|
|
285
|
+
cancellation always prevents the callback. Resolvers must cooperate with abort.
|
|
286
|
+
|
|
287
|
+
Set `route` to a stable template such as `/accounts/{id}/chat` when paths contain
|
|
288
|
+
identifiers. Otherwise the pathname is used, with query/fragment omitted. The SDK
|
|
289
|
+
does not trust forwarding headers automatically. Header names and the documented
|
|
290
|
+
metadata values still leave the app; never place secrets in those values.
|
|
291
|
+
|
|
292
|
+
Reporting timeouts and queue capacity each have a maximum of 10000 (ms/events).
|
|
293
|
+
Application rules and hooks must not block the event loop. There is no unconditional
|
|
294
|
+
wall-clock SLA for arbitrary customer code or uncooperative hosting runtimes.
|
|
295
|
+
See RELEASE.md for supported versions, installation and release checks.
|
|
296
|
+
|
|
297
|
+
### Recovering an uncertain quota admission (opt-in)
|
|
298
|
+
|
|
299
|
+
After your runtime supports quota schema 2, set `accountQuota.idempotency: true`.
|
|
300
|
+
The SDK generates one server-side operation ID and retries the quota RPC at most
|
|
301
|
+
once after transport/5xx/malformed-response failures, keeping the same ID and
|
|
302
|
+
payload. `timeoutMs` applies per attempt (at most twice that time overall).
|
|
303
|
+
Cancellation stops retries; HTTP 4xx stops retries; there is no schema-1 fallback.
|
|
304
|
+
Legacy configuration remains schema 1 with no automatic retry.
|
|
305
|
+
|
|
306
|
+
To recover the same admission across requests/processes, generate and persist
|
|
307
|
+
`createQuotaOperationId()` in trusted server state, and supply
|
|
308
|
+
`accountQuota.operationId: context => context.persistedOperationId`. The local
|
|
309
|
+
quota check exposes `operationId`; it is omitted from central reports. Never take
|
|
310
|
+
this ID directly from an untrusted browser or reuse it for different operations.
|
|
311
|
+
|
|
312
|
+
IDs expire ten minutes after creation (database clock, 30-second forward skew
|
|
313
|
+
allowance). Expired IDs are rejected even after receipt cleanup. Capacity is
|
|
314
|
+
10,000 retained operations/property, including denials. Identical replays return
|
|
315
|
+
the original decision; changed payloads conflict. An unresolved response is
|
|
316
|
+
`account_quota_outcome_unknown`, distinct from a known quota denial. Existing
|
|
317
|
+
open/closed settings still apply. After expiry, do not mint a fresh ID to retry an
|
|
318
|
+
unknown operation blindly. The stored quota count/retry hint is an original-window
|
|
319
|
+
snapshot, not current quota state.
|
|
320
|
+
|
|
321
|
+
Only admission is deduplicated. Repeated application/model calls still require
|
|
322
|
+
application-level idempotency. Deploy migration 83, grants and runtime support
|
|
323
|
+
before enabling this option; no package publication is required for local testing.
|
package/RELEASE.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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.
|
package/account.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {readJSON,abortable} from './transport.mjs';
|
|
2
|
+
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
|
|
3
|
+
export const validPropertyID = value => typeof value === 'string' && uuid.test(value) && value !== '00000000-0000-0000-0000-000000000000';
|
|
4
|
+
|
|
5
|
+
// Short, bounded cache. Never reuse a paid grant after expiry on a failed refresh.
|
|
6
|
+
export function createAccountBinding({webdecoyUrl, webdecoyKey, propertyId, detectorTimeoutMs, now = Date.now}) {
|
|
7
|
+
let cached, until = 0, pending;
|
|
8
|
+
return async signal => {
|
|
9
|
+
signal.throwIfAborted();
|
|
10
|
+
if (cached && now() < until) return cached;
|
|
11
|
+
if (pending) return abortable(pending,signal);
|
|
12
|
+
pending = (async () => {
|
|
13
|
+
let next = {status:'unavailable', enforce:false};
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(new URL('/api/v1/sdk/ai-abuse/config', webdecoyUrl), {
|
|
16
|
+
headers:{Authorization:`Bearer ${webdecoyKey}`}, redirect:'error',
|
|
17
|
+
signal:AbortSignal.any([signal, AbortSignal.timeout(detectorTimeoutMs)])
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error('account_unavailable'); }
|
|
20
|
+
const value = await readJSON(response);
|
|
21
|
+
if (value.schema !== 1 || !validPropertyID(value.property_id) || !validPropertyID(value.organization_id) ||
|
|
22
|
+
!['observe','enforce'].includes(value.mode) || value.observe !== true || typeof value.enforce !== 'boolean') throw new Error('invalid_account');
|
|
23
|
+
next = value.property_id.toLowerCase() === propertyId.toLowerCase()
|
|
24
|
+
? {status:'verified', enforce:value.enforce, mode:value.mode, organization_id:value.organization_id, property_id:value.property_id}
|
|
25
|
+
: {status:'property_mismatch', enforce:false};
|
|
26
|
+
} catch { /* Unknown account state keeps chat available in observation. */ }
|
|
27
|
+
cached = next;
|
|
28
|
+
until = now() + (next.status === 'verified' ? 60000 : 5000);
|
|
29
|
+
return next;
|
|
30
|
+
})().finally(() => { pending = undefined; });
|
|
31
|
+
return abortable(pending,signal);
|
|
32
|
+
};
|
|
33
|
+
}
|
package/admission.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {readJSON} from './transport.mjs';
|
|
2
|
+
import {prepareBrowserOrigin,browserEvidenceInput} from './browser-evidence.mjs';
|
|
3
|
+
import { createAccountBinding, validPropertyID } from './account.mjs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createBaseline, observationSubject } from './observation.mjs';
|
|
6
|
+
|
|
7
|
+
// Adapters authenticate/validate their request and resolve a trustworthy client IP
|
|
8
|
+
// before calling check. No model calls, sessions, routes or response rewriting here.
|
|
9
|
+
export function createAdmission(options) {
|
|
10
|
+
const c = {protectionMode: 'enforce', detectorFailureMode: 'open', detectorTimeoutMs: 1000,
|
|
11
|
+
baselineLimit: 10, baselineWindowMs: 60000,
|
|
12
|
+
onObservation: event => console.log(JSON.stringify(event)), ...options};
|
|
13
|
+
if (!['observe', 'enforce'].includes(c.protectionMode) || !['open', 'closed'].includes(c.detectorFailureMode)) throw new Error('Invalid admission mode');
|
|
14
|
+
for (const key of ['detectorTimeoutMs', 'baselineLimit', 'baselineWindowMs']) {
|
|
15
|
+
if (!Number.isSafeInteger(c[key]) || c[key] <= 0) throw new Error(`Invalid ${key}`);
|
|
16
|
+
}
|
|
17
|
+
if(c.detectorTimeoutMs>10000)throw Error('detectorTimeoutMs must be <=10000');
|
|
18
|
+
const url = new URL(c.webdecoyUrl);
|
|
19
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash ||
|
|
20
|
+
(url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('Invalid detector origin');
|
|
21
|
+
if (!c.webdecoyKey || /[\r\n]/.test(c.webdecoyKey) || !c.scopeId || typeof c.subjectSecret !== 'string' || c.subjectSecret.length < 32 || typeof c.onObservation !== 'function') throw new Error('Invalid admission configuration');
|
|
22
|
+
if (!validPropertyID(c.propertyId)) throw new Error('An existing WebDecoy propertyId is required');
|
|
23
|
+
const browserOrigin=prepareBrowserOrigin(c.browserEvidenceOrigin);
|
|
24
|
+
const accountBinding = createAccountBinding(c);
|
|
25
|
+
const baseline = createBaseline({limit: c.baselineLimit, windowMs: c.baselineWindowMs});
|
|
26
|
+
return {
|
|
27
|
+
async check({ip, method, path, headers, signal = new AbortController().signal}) {
|
|
28
|
+
const requestId = randomUUID();
|
|
29
|
+
const accountStarted = performance.now();
|
|
30
|
+
const account = await accountBinding(signal);
|
|
31
|
+
const accountMs = Math.round((performance.now() - accountStarted) * 100) / 100;
|
|
32
|
+
const mode = account.status === 'verified' && account.enforce && account.mode === 'enforce' ? c.protectionMode : 'observe';
|
|
33
|
+
const observation = {event: 'webdecoy_admission', schema: 1, request_id: requestId,
|
|
34
|
+
timestamp: new Date().toISOString(), mode, requested_mode: c.protectionMode, property_id: c.propertyId,
|
|
35
|
+
account_status: account.status, account_ms: accountMs,
|
|
36
|
+
subject: observationSubject(c.subjectSecret, c.scopeId, ip),
|
|
37
|
+
baseline_limit: c.baselineLimit, baseline_window_ms: c.baselineWindowMs,
|
|
38
|
+
baseline_decision: baseline(ip), detector_decision: 'unavailable',
|
|
39
|
+
detector_ms: 0, action: 'unavailable', upstream_attempted: false};
|
|
40
|
+
if (account.status !== 'verified') {
|
|
41
|
+
observation.action = 'forwarded';
|
|
42
|
+
return {observation, allowed:true};
|
|
43
|
+
}
|
|
44
|
+
const checkStarted = performance.now();
|
|
45
|
+
let verdict;
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(new URL('/api/v1/sdk/detect', c.webdecoyUrl), {
|
|
48
|
+
method: 'POST', redirect: 'error',
|
|
49
|
+
headers: {'Authorization': `Bearer ${c.webdecoyKey}`, 'Content-Type': 'application/json'},
|
|
50
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(c.detectorTimeoutMs)]),
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
browser_evidence: browserEvidenceInput(browserOrigin,c.propertyId,headers),
|
|
53
|
+
decision_mode: 'unified_v1',
|
|
54
|
+
ai_admission: {request_id:requestId, mode},
|
|
55
|
+
request_metadata: {method, path, ip, user_agent: headers['user-agent'] ?? '', timestamp: Date.now()},
|
|
56
|
+
cs: {hn: Object.keys(headers), al: headers['accept-language'] ?? '', ae: headers['accept-encoding'] ?? ''},
|
|
57
|
+
local_analysis: {needs_verification: true}
|
|
58
|
+
})
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error('detector_http'); }
|
|
61
|
+
verdict = await readJSON(response);
|
|
62
|
+
// Older servers ignore unknown request fields; require an explicit
|
|
63
|
+
// acknowledgement so a legacy metadata-only allow cannot look protected.
|
|
64
|
+
if (verdict?.decision_mode !== 'unified_v1') throw new Error('unsupported_decision_mode');
|
|
65
|
+
if (!['allow', 'block', 'challenge'].includes(verdict?.decision)) throw new Error('invalid_verdict');
|
|
66
|
+
observation.detector_decision = verdict.decision;
|
|
67
|
+
if(browserOrigin)observation.browser_evidence=['allow','block','challenge','missing','invalid'].includes(verdict.browser_evidence)?verdict.browser_evidence:'unsupported';
|
|
68
|
+
} catch {
|
|
69
|
+
if (mode === 'enforce' && c.detectorFailureMode === 'closed') {
|
|
70
|
+
observation.action = 'denied_unavailable';
|
|
71
|
+
return {observation, allowed: false, status: 503, error: 'protection_unavailable'};
|
|
72
|
+
}
|
|
73
|
+
// A failed check is not an abuse verdict. Preserve availability without
|
|
74
|
+
// relaxing origin, session, input, capacity or upstream authentication checks.
|
|
75
|
+
console.warn(JSON.stringify({event: 'webdecoy_check_unavailable', action: 'allowed_without_verdict'}));
|
|
76
|
+
verdict = {decision: 'allow'};
|
|
77
|
+
} finally { observation.detector_ms = Math.round((performance.now() - checkStarted) * 100) / 100; }
|
|
78
|
+
// A challenge is never silently accepted; interactive clearance is a later integration.
|
|
79
|
+
observation.action = mode === 'enforce' && verdict.decision !== 'allow' ? 'denied' : 'forwarded';
|
|
80
|
+
if (observation.action === 'denied') return {observation, allowed: false, status: 403, error: verdict.decision === 'challenge' ? 'verification_required' : 'request_denied'};
|
|
81
|
+
return {observation, allowed: true};
|
|
82
|
+
},
|
|
83
|
+
record(observation) {
|
|
84
|
+
try { c.onObservation(observation); } catch { console.warn('Observation sink failed'); }
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function prepareBrowserOrigin(raw){
|
|
2
|
+
if(raw===undefined)return null;
|
|
3
|
+
const u=new URL(raw);
|
|
4
|
+
if(u.protocol!=='https:'||u.origin!==raw||u.username||u.password)throw Error('browserEvidenceOrigin must be an exact HTTPS origin');
|
|
5
|
+
return raw;
|
|
6
|
+
}
|
|
7
|
+
export function browserEvidenceInput(origin,property,headers){
|
|
8
|
+
if(!origin)return undefined;
|
|
9
|
+
const name='__Host-wd_runtime_'+property.toLowerCase();
|
|
10
|
+
const entries=(headers.cookie??'').split(';').map(p=>p.trim()).filter(p=>p.startsWith(name+'='));
|
|
11
|
+
const token=entries.length===1?entries[0].slice(name.length+1):'';
|
|
12
|
+
return {origin,token:token.length<=4096&&/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)?token:''};
|
|
13
|
+
}
|
|
14
|
+
export function browserEvidenceCheck(status,mode){
|
|
15
|
+
const known=['allow','block','challenge'].includes(status);
|
|
16
|
+
return {id:'browser_evidence',source:'remote',mode,decision:known?(status==='block'?'deny':status):'unavailable',reason:'browser_evidence_'+(known||['missing','invalid'].includes(status)?status:'unsupported'),durationMs:0};
|
|
17
|
+
}
|
package/browser.d.mts
ADDED
package/browser.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Browser-only optional entrypoint; no server key, Node imports or scoring code.
|
|
2
|
+
export function prepareBrowserEvidence({timeoutMs=1500}={}) {
|
|
3
|
+
if(!Number.isInteger(timeoutMs)||timeoutMs<1||timeoutMs>5000)throw Error('Invalid browser evidence timeout');
|
|
4
|
+
if(typeof window==='undefined'||typeof window.dispatchEvent!=='function')return Promise.resolve({available:false});
|
|
5
|
+
return new Promise(resolve=>{
|
|
6
|
+
let ended=false;
|
|
7
|
+
const finish=available=>{if(ended)return;ended=true;clearTimeout(timer);resolve({available:available===true})};
|
|
8
|
+
const timer=setTimeout(()=>finish(false),timeoutMs);
|
|
9
|
+
try{window.dispatchEvent(new CustomEvent('webdecoy:runtime-prepare',{detail:{done:finish}}))}catch{finish(false)}
|
|
10
|
+
});
|
|
11
|
+
}
|