@apideck/agent-analytics 0.11.0 → 0.13.0

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/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { T as TrackVisitOptions, C as CaptureEvent, A as AnalyticsAdapter } from './types-DKqlfVz6.js';
1
+ import { T as TrackVisitOptions, B as BotVerificationLike, C as CaptureEvent, A as AnalyticsAdapter } from './types-sQoQK-ox.js';
2
2
  export { posthogAnalytics } from './adapters/posthog.js';
3
3
  export { webhookAnalytics } from './adapters/webhook.js';
4
4
 
5
5
  /**
6
- * Capture an event describing the incoming request. Fire-and-forget: awaits
7
- * the adapter but swallows errors so a downed analytics backend never breaks
8
- * the response path. Callers typically don't await the returned promise.
6
+ * Capture an event describing the incoming request. Fire-and-forget: awaits the
7
+ * adapter but routes errors to {@link TrackVisitOptions.onError} rather than
8
+ * letting them reach the response path. Callers typically don't await it.
9
9
  *
10
10
  * By default, captures every request so coding-agent traffic (axios, curl,
11
11
  * Electron, …) shows up alongside branded crawlers. Set `onlyBots: true` to
@@ -131,110 +131,120 @@ declare function classifyAgent(userAgent: string | null | undefined): AgentClass
131
131
  declare function classifyRequest(req: Request): AgentClassification;
132
132
 
133
133
  /**
134
- * djb2 hash returning an 8-char hex string prefixed with `anon_`. Used to
135
- * build stable anonymous distinct-ids from `ip:ua:...` tuples without
136
- * collecting identifying data. Not cryptographic — collisions are fine for
137
- * analytics segmentation.
138
- */
139
- declare function hashId(input: string): string;
140
-
141
- /**
142
- * Verdict on whether a request's claimed crawler identity holds up against the
143
- * vendor's published IP ranges.
134
+ * Keyed, non-reversible anonymous identifiers.
144
135
  *
145
- * - `'verified'` the UA claims a vendor we can check, and the client IP is
146
- * inside that vendor's published ranges. High confidence.
147
- * - `'spoofed'` the UA claims a vendor we can check, and the IP is **not**
148
- * in its ranges. Someone is impersonating the crawler.
149
- * - `'unverifiable'` the UA claims a vendor that publishes no feed we bundle
150
- * (Bytespider, Amazon, Meta, …), or no usable client IP was available.
151
- * - `'not-claimed'` the UA doesn't claim a verifiable crawler at all. This is
152
- * the normal verdict for browsers and HTTP clients; it is *not* a negative
153
- * finding.
136
+ * The previous implementation was an unsalted 32-bit djb2 over `ip:userAgent`.
137
+ * Because the user agent is emitted in plaintext on the same event, an attacker
138
+ * held half the preimage and only had to search the IPv4 space recovering a
139
+ * residential IP took 75 seconds single-threaded. That is pseudonymisation, not
140
+ * anonymisation, and it does not survive GDPR Recital 26.
141
+ *
142
+ * This uses HMAC-SHA-256 with a caller-supplied secret, truncated to 64 bits.
143
+ * Web Crypto is available on Vercel Edge, Cloudflare Workers, Deno and Node 18+.
154
144
  */
155
- type VerificationVerdict = 'verified' | 'spoofed' | 'unverifiable' | 'not-claimed';
156
- /** Why a request could not be judged. Only set when verdict is 'unverifiable'. */
157
- type UnverifiableReason = 'no-published-ranges' | 'client-side-agent' | 'no-client-ip';
158
- interface BotVerification {
159
- verdict: VerificationVerdict;
160
- reason?: UnverifiableReason;
161
- /** Vendor label the UA claims, when it claims one. */
162
- claimed: string | null;
163
- /**
164
- * Convenience boolean for filtering: `true` only for `'verified'`, `false`
165
- * only for `'spoofed'`, `null` when no judgement was possible. Deliberately
166
- * tri-state — collapsing "unverifiable" into `false` would brand every
167
- * Bytespider and Amazonbot hit an impostor.
168
- */
169
- verified: boolean | null;
145
+ /** Thrown when a secret is missing or unusable. */
146
+ declare class HashSecretError extends Error {
147
+ constructor(message: string);
170
148
  }
171
- /** Vendor labels this build can produce a verified/spoofed verdict for. */
172
- declare function verifiableVendors(): readonly string[];
173
149
  /**
174
- * Check a claimed crawler identity against the vendor's published IP ranges.
150
+ * Hash `input` under `secret`, returning `anon_` followed by 16 hex characters
151
+ * (64 bits — collision-free well past any realistic distinct-visitor count).
175
152
  *
176
- * Pass the client IP you already trust on Vercel and Cloudflare that is the
177
- * first hop of `x-forwarded-for`. If your edge doesn't strip client-supplied
178
- * `X-Forwarded-For`, an attacker controls this value and a `'verified'` verdict
179
- * means nothing; verify your proxy's behaviour before relying on it.
153
+ * The secret must be stable across instances for identifiers to be comparable
154
+ * over time, and secret from anyone who can read your events: publishing it
155
+ * makes the identifier exactly as reversible as the old implementation was.
156
+ * Rotating it deliberately breaks continuity, which is correct behaviour for a
157
+ * privacy-preserving id.
180
158
  */
181
- declare function verifyBotIdentity(userAgent: string | null | undefined, ip: string | null | undefined): BotVerification;
159
+ declare function hashId(input: string, secret: string): Promise<string>;
182
160
  /**
183
- * Extract the client IP the way {@link trackVisit} does first hop of
184
- * `x-forwarded-for`, falling back to the platform-specific headers.
161
+ * Generate a random secret. Used as the default when none is configured, so the
162
+ * privacy-preserving path is the one you get by doing nothing. Identifiers are
163
+ * then only stable within a single instance's lifetime — set a real secret when
164
+ * you need them comparable across instances and over time.
185
165
  */
186
- declare function clientIpFromRequest(req: Request): string;
187
- /** Verify straight from a request object. */
188
- declare function verifyRequest(req: Request): BotVerification;
166
+ declare function randomSecret(): string;
167
+
168
+ /** Thrown when the analytics backend rejects, errors, or times out a capture. */
169
+ declare class CaptureTransportError extends Error {
170
+ readonly status: number | undefined;
171
+ readonly body: string | undefined;
172
+ constructor(message: string, status?: number, body?: string);
173
+ }
189
174
 
190
175
  /**
191
- * Published crawler IP ranges, vendor by vendor.
192
- *
193
- * GENERATED FILE — do not edit by hand. Regenerate with:
176
+ * What to do with an agent request.
194
177
  *
195
- * node scripts/refresh-bot-ranges.mjs
196
- *
197
- * Keys match the labels {@link parseBotName} returns, so a claimed identity
198
- * maps to its range list without a translation table.
199
- *
200
- * Only vendors that publish a machine-readable feed appear here. A vendor's
201
- * absence means "cannot be verified", never "not a real bot" — see
202
- * {@link verifyBotIdentity} for how that distinction is surfaced.
178
+ * - `'allow'` — serve it, free. Humans, search crawlers, and the retrieval
179
+ * agents you *want* reading your site.
180
+ * - `'meter'` — serve it, but count it as billable. Bulk corpus collection.
181
+ * - `'charge'` don't serve it until it pays (HTTP 402).
182
+ * - `'block'` — refuse. Failed identity verification, mostly.
183
+ */
184
+ type AgentAction = 'allow' | 'meter' | 'charge' | 'block';
185
+ /**
186
+ * Why an agent fetched the page. This is the distinction the whole module
187
+ * exists for, and no other signal on the request carries it.
203
188
  *
204
- * Freshness is the whole ballgame: these lists rotate. Almost every OpenAI
205
- * prefix is an Azure block and every Anthropic prefix is GCP or similar, so
206
- * "came from a datacenter" proves nothing on its own — only membership in the
207
- * current published list does. A stale snapshot produces false 'spoofed'
208
- * verdicts on legitimate crawlers, which is the failure mode to fear.
189
+ * - `'retrieval'` a person asked a question and the assistant went to read
190
+ * the page for them. This is *demand*: the agent is a distribution channel,
191
+ * and charging for it is charging for your own marketing.
192
+ * - `'training'` — bulk corpus collection for model training. You get nothing
193
+ * back per fetch, which is where a price makes sense.
194
+ * - `'search'` — classic index crawlers. Blocking these costs you SEO.
195
+ * - `'tooling'` — coding agents and HTTP clients. Usually developers using
196
+ * your docs; treat like retrieval unless you see abuse.
197
+ * - `'unknown'` — everything else, including real browsers.
209
198
  */
210
- /** When these ranges were captured from the vendor feeds (UTC). */
211
- declare const BOT_RANGES_CAPTURED_AT = "2026-08-02T00:00:00Z";
212
- declare const BOT_IP_RANGES: Readonly<Record<string, readonly string[]>>;
213
- /** Vendor labels this build can verify. Anything else yields a null verdict. */
214
- declare const VERIFIABLE_VENDORS: readonly string[];
215
-
216
- interface V4Range {
217
- net: number;
218
- mask: number;
219
- }
220
- interface V6Range {
221
- net: bigint;
222
- bits: number;
199
+ type AgentIntent = 'retrieval' | 'training' | 'search' | 'tooling' | 'unknown';
200
+ interface AgentDecision {
201
+ action: AgentAction;
202
+ intent: AgentIntent;
203
+ /** Vendor label, same string `parseBotName` returns. */
204
+ label: string;
205
+ /** Identity verdict, when a verifier was supplied. */
206
+ verification?: string;
207
+ /** Short human-readable justification — log it, don't parse it. */
208
+ reason: string;
223
209
  }
224
- interface CompiledRanges {
225
- v4: V4Range[];
226
- v6: V6Range[];
210
+ interface AgentPolicyOptions {
211
+ /**
212
+ * Identity verifier. Import `verifyRequest` from
213
+ * `@apideck/agent-analytics/verify` and pass it here to have a `spoofed`
214
+ * verdict produce `'block'`.
215
+ *
216
+ * Injected rather than imported so the published IP range tables only reach
217
+ * bundles that use them. Only meaningful when your edge controls
218
+ * `x-forwarded-for`: behind a proxy that forwards a client-supplied header,
219
+ * an attacker picks their own verdict.
220
+ */
221
+ verify?: (req: Request) => BotVerificationLike;
222
+ /** What to do with bulk training crawlers. Defaults to `'meter'`. */
223
+ onTraining?: AgentAction;
224
+ /** What to do with retrieval agents. Defaults to `'allow'` — see AgentIntent. */
225
+ onRetrieval?: AgentAction;
226
+ /** What to do with search indexers. Defaults to `'allow'`. */
227
+ onSearch?: AgentAction;
228
+ /** What to do with coding agents and HTTP clients. Defaults to `'allow'`. */
229
+ onTooling?: AgentAction;
230
+ /** Vendor labels or UA substrings always allowed, whatever the intent. */
231
+ allowList?: readonly string[];
227
232
  }
233
+ /** Classify why an agent is here, from its user agent alone. */
234
+ declare function agentIntent(userAgent: string | null | undefined): AgentIntent;
228
235
  /**
229
- * Pre-compile a list of CIDR strings into numeric form. Invalid entries are
230
- * dropped rather than thrown a malformed line in a vendor's published feed
231
- * shouldn't take down the whole check.
236
+ * Decide what to do with a request. Pure classification plus policy — no
237
+ * payment rails, no network calls, nothing to configure beyond the four
238
+ * intent knobs.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * const decision = agentPolicy(req, { verify: true, onTraining: 'charge' })
243
+ * if (decision.action === 'block') return new Response(null, { status: 403 })
244
+ * if (decision.action === 'charge') return paymentRequired(decision)
245
+ * ```
232
246
  */
233
- declare function compileRanges(cidrs: readonly string[]): CompiledRanges;
234
- /** True when `ip` falls inside any range in the pre-compiled set. */
235
- declare function ipInRanges(ip: string, ranges: CompiledRanges): boolean;
236
- /** Convenience wrapper — compiles on every call, so prefer {@link ipInRanges}. */
237
- declare function ipInCidr(ip: string, cidr: string): boolean;
247
+ declare function agentPolicy(req: Request, opts?: AgentPolicyOptions): AgentDecision;
238
248
 
239
249
  /**
240
250
  * Escape hatch for wiring a callback directly as an analytics adapter.
@@ -248,4 +258,4 @@ declare function ipInCidr(ip: string, cidr: string): boolean;
248
258
  */
249
259
  declare function customAnalytics(capture: (event: CaptureEvent) => Promise<void> | void): AnalyticsAdapter;
250
260
 
251
- export { AI_BOT_PATTERN, type AgentClassification, type AgentKind, AnalyticsAdapter, BOT_IP_RANGES, BOT_RANGES_CAPTURED_AT, type BotVerification, CaptureEvent, type CompiledRanges, HTTP_CLIENT_PATTERN, type HeadlessDetection, TrackVisitOptions, VERIFIABLE_VENDORS, type VerificationVerdict, classifyAgent, classifyRequest, clientIpFromRequest, compileRanges, customAnalytics, detectHeadless, firstUserAgentProduct, hashId, ipInCidr, ipInRanges, isAiBot, isHttpClient, parseBotName, trackVisit, verifiableVendors, verifyBotIdentity, verifyRequest };
261
+ export { AI_BOT_PATTERN, type AgentAction, type AgentClassification, type AgentDecision, type AgentIntent, type AgentKind, type AgentPolicyOptions, AnalyticsAdapter, BotVerificationLike, CaptureEvent, CaptureTransportError, HTTP_CLIENT_PATTERN, HashSecretError, type HeadlessDetection, TrackVisitOptions, agentIntent, agentPolicy, classifyAgent, classifyRequest, customAnalytics, detectHeadless, firstUserAgentProduct, hashId, isAiBot, isHttpClient, parseBotName, randomSecret, trackVisit };