@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/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
@@ -0,0 +1,2 @@
1
+ /** Refresh the installed browser tag's short-lived evidence; never blocks on failure. */
2
+ export function prepareBrowserEvidence(options?:{timeoutMs?:number}):Promise<{available:boolean}>;
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
+ }