From 155611e318ded7b8730f5a2235ba3453626a3cd1 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 11:11:31 +0200 Subject: [PATCH 1/8] feat: charge LLMs to train, without charging the ones sending you readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over 2.5 million sites answer bulk AI crawling with robots.txt Disallow. That leaves money on the table and only works if the crawler cooperates. The alternative is to let them train and price it. Pricing only works if you can tell training from retrieval, because they have opposite economics. A GPTBot fetch is corpus collection you get nothing back for. A ChatGPT-User fetch is a person asking about you, and billing that is billing your own distribution channel. agentPolicy already draws the line; this turns a 'charge' decision into the HTTP challenge. 402 training GPTBot/1.1 serve retrieval ChatGPT-User/1.0 402 training ClaudeBot/1.0 serve retrieval Claude-User (claude-code/2.1) serve search Googlebot/2.1 That distinction is the differentiator. Gateway pay-per-crawl charges crawls indiscriminately; charging the half that sends you demand is self-harm. paymentRequired() emits x402's wire format — 402 with a base64 PAYMENT-REQUIRED header — and hasPaymentPayload()/paymentPayload() read the client's PAYMENT-SIGNATURE retry. The default Content-Signal is 'search=yes, ai-input=yes, ai-train=paid', inverting the library's own ai-train=no default: the premise is that training is for sale, not forbidden. Scope, deliberately: this emits challenges and reads headers. It settles nothing. Settlement belongs to an x402 facilitator or Stripe's MPP — holding money would drag PCI scope into something meant to drop into middleware. The library never invents an amount, network or asset; those are all caller-supplied. 'meter' returns null rather than a gate, because metering is an accounting concern and the request should still be served while trackVisit records it. 'block' returns 403, not a price: a failed identity check is not a negotiation. Tests 246 -> 257, including non-ASCII in the challenge (btoa is Latin-1 only and a naive encoder throws), refusing to emit a challenge with no way to pay, and that retrieval and search stay free under a charge-training policy. NOTE: branched from main, so it does not include #22 (Web Bot Auth, 0.14.0). Merge #22 first; this is versioned 0.15.0 on that assumption. --- package.json | 2 +- src/index.ts | 8 +++ src/payments.ts | 156 ++++++++++++++++++++++++++++++++++++++++++ test/payments.test.ts | 112 ++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/payments.ts create mode 100644 test/payments.test.ts diff --git a/package.json b/package.json index 56168b4..c07dd9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apideck/agent-analytics", - "version": "0.14.0", + "version": "0.15.0", "description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", "keywords": [ "ai", diff --git a/src/index.ts b/src/index.ts index 4892c44..9b69871 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,14 @@ export type { AgentClassification, AgentKind, HeadlessDetection } from './bots.j export { hashId, randomSecret, HashSecretError } from './hash.js' export { CaptureTransportError } from './errors.js' export { agentIntent, agentPolicy } from './policy.js' +export { + hasPaymentPayload, + paymentPayload, + paymentRequired, + respondToDecision, + withSettlement +} from './payments.js' +export type { PaymentChallengeOptions, PaymentRequirements } from './payments.js' export type { AgentAction, AgentDecision, diff --git a/src/payments.ts b/src/payments.ts new file mode 100644 index 0000000..f11cf39 --- /dev/null +++ b/src/payments.ts @@ -0,0 +1,156 @@ +/** + * Charge for training crawls. + * + * Today the industry's answer to bulk AI crawling is `Disallow` — over 2.5 + * million sites block AI training in robots.txt. That leaves money on the + * table and depends on the crawler's goodwill to work at all. + * + * The alternative is to let them train and price it. That only works if you + * can tell training from retrieval, because they have opposite economics: a + * `GPTBot` fetch is corpus collection you get nothing back for, while a + * `ChatGPT-User` fetch is a person asking about you — charging for the second + * is charging for your own distribution. {@link agentPolicy} draws that line; + * this module turns a `'charge'` decision into the HTTP challenge. + * + * Scope: this emits the 402 and reads the client's payment header. It does not + * settle anything. Settlement belongs to an x402 facilitator or Stripe's MPP — + * a library that held money would inherit PCI scope and stop being something + * you can drop into middleware. + */ + +import type { AgentDecision } from './policy.js' + +/** + * One way a client may pay. Field names follow x402's `PaymentRequirements`; + * values are yours — the library never invents an amount, network or asset. + */ +export interface PaymentRequirements { + scheme: string + network: string + maxAmountRequired: string + resource: string + description?: string + mimeType?: string + payTo: string + maxTimeoutSeconds?: number + asset: string + extra?: Record +} + +export interface PaymentChallengeOptions { + /** Accepted payment methods, in preference order. At least one. */ + accepts: readonly PaymentRequirements[] + /** x402 protocol version. Defaults to 1. */ + x402Version?: number + /** + * `Content-Signal` to send with the challenge. Defaults to + * `search=yes, ai-input=yes, ai-train=paid` — the whole point being that + * training is available rather than forbidden. + */ + contentSignal?: string + /** Extra response headers. */ + headers?: Record + /** Human-readable body. Agents read the header; people read logs. */ + body?: string +} + +const HEADER_CHALLENGE = 'PAYMENT-REQUIRED' +const HEADER_SIGNATURE = 'PAYMENT-SIGNATURE' +const HEADER_SETTLEMENT = 'PAYMENT-RESPONSE' + +function b64(json: unknown): string { + const text = JSON.stringify(json) + // btoa is Latin-1 only; encode first so non-ASCII descriptions survive. + const bytes = new TextEncoder().encode(text) + let bin = '' + for (const b of bytes) bin += String.fromCharCode(b) + return btoa(bin) +} + +/** + * Build a 402 challenge. + * + * @example + * ```ts + * const decision = agentPolicy(req, { onTraining: 'charge' }) + * if (decision.action === 'charge') { + * return paymentRequired({ + * accepts: [{ + * scheme: 'exact', + * network: 'base', + * maxAmountRequired: '1000', // your price, your units + * resource: req.url, + * description: 'Training crawl of one page', + * payTo: process.env.WALLET, + * asset: process.env.USDC_ADDRESS + * }] + * }) + * } + * ``` + */ +export function paymentRequired(opts: PaymentChallengeOptions): Response { + if (!opts.accepts.length) { + throw new Error('paymentRequired needs at least one entry in `accepts`') + } + const challenge = { + x402Version: opts.x402Version ?? 1, + accepts: opts.accepts + } + return new Response(opts.body ?? 'Payment required for training access.\n', { + status: 402, + headers: { + 'content-type': 'text/plain; charset=utf-8', + [HEADER_CHALLENGE]: b64(challenge), + // Says the quiet part out loud: training is for sale, not forbidden. + 'content-signal': opts.contentSignal ?? 'search=yes, ai-input=yes, ai-train=paid', + ...(opts.headers ?? {}) + } + }) +} + +/** + * True when the client attached a payment payload — i.e. this is the retry + * after a 402, not a fresh unpaid request. + * + * Presence is not proof. Hand the value to your facilitator to verify and + * settle; only then serve the resource. + */ +export function hasPaymentPayload(req: Request): boolean { + return !!req.headers.get(HEADER_SIGNATURE) +} + +/** Raw `PAYMENT-SIGNATURE` value, for handing to a facilitator. */ +export function paymentPayload(req: Request): string | null { + return req.headers.get(HEADER_SIGNATURE) +} + +/** Attach a facilitator's settlement result to a successful response. */ +export function withSettlement(res: Response, settlement: unknown): Response { + const headers = new Headers(res.headers) + headers.set(HEADER_SETTLEMENT, b64(settlement)) + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }) +} + +/** + * Convenience: turn an {@link AgentDecision} straight into a response, or + * `null` when the request should simply be served. + * + * Returns 403 for `'block'`, a 402 challenge for `'charge'`, and `null` for + * `'allow'` and `'meter'` — metering is an accounting concern, not a gate, so + * the request still gets served while `trackVisit` records it. + */ +export function respondToDecision( + decision: AgentDecision, + opts: PaymentChallengeOptions +): Response | null { + if (decision.action === 'block') { + return new Response('Forbidden: agent identity could not be verified.\n', { status: 403 }) + } + if (decision.action === 'charge') { + return paymentRequired({ + body: `Payment required: ${decision.label} — ${decision.reason}.\n`, + ...opts + }) + } + return null +} diff --git a/test/payments.test.ts b/test/payments.test.ts new file mode 100644 index 0000000..84093dd --- /dev/null +++ b/test/payments.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { agentPolicy } from '../src/policy.js' +import { + hasPaymentPayload, + paymentPayload, + paymentRequired, + respondToDecision, + withSettlement +} from '../src/payments.js' + +const ACCEPTS = [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1000', + resource: 'https://example.com/docs/intro', + description: 'Training crawl of one page', + payTo: '0xabc', + asset: '0xusdc' + } +] + +function decode(res: Response) { + const raw = res.headers.get('PAYMENT-REQUIRED')! + const bin = atob(raw) + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)) + return JSON.parse(new TextDecoder().decode(bytes)) +} + +describe('paymentRequired', () => { + it('emits a 402 carrying a base64 x402 challenge', () => { + const res = paymentRequired({ accepts: ACCEPTS }) + expect(res.status).toBe(402) + const body = decode(res) + expect(body.x402Version).toBe(1) + expect(body.accepts).toHaveLength(1) + expect(body.accepts[0]).toMatchObject({ scheme: 'exact', payTo: '0xabc', asset: '0xusdc' }) + }) + + it('advertises training as for sale, not forbidden', () => { + // The default Content-Signal elsewhere in the library is ai-train=no. The + // entire premise here is the opposite. + expect(paymentRequired({ accepts: ACCEPTS }).headers.get('content-signal')).toBe( + 'search=yes, ai-input=yes, ai-train=paid' + ) + }) + + it('survives non-ASCII in the challenge', () => { + // btoa is Latin-1 only; a naive implementation throws on this. + const res = paymentRequired({ + accepts: [{ ...ACCEPTS[0]!, description: 'Entraînement — 訓練 🤖' }] + }) + expect(decode(res).accepts[0].description).toBe('Entraînement — 訓練 🤖') + }) + + it('refuses to emit a challenge with no way to pay', () => { + expect(() => paymentRequired({ accepts: [] })).toThrow(/at least one/) + }) +}) + +describe('payment payload', () => { + it('detects the retry that carries payment', () => { + const bare = new Request('https://example.com/') + const paid = new Request('https://example.com/', { + headers: { 'PAYMENT-SIGNATURE': 'base64payload' } + }) + expect(hasPaymentPayload(bare)).toBe(false) + expect(hasPaymentPayload(paid)).toBe(true) + expect(paymentPayload(paid)).toBe('base64payload') + }) + + it('attaches a settlement result without disturbing the body', async () => { + const out = withSettlement(new Response('the goods', { status: 200 }), { success: true }) + expect(out.status).toBe(200) + expect(await out.text()).toBe('the goods') + expect(out.headers.get('PAYMENT-RESPONSE')).toBeTruthy() + }) +}) + +describe('respondToDecision', () => { + const req = (ua: string) => + new Request('https://example.com/docs', { headers: { 'user-agent': ua } }) + + it('charges training crawlers when configured to', () => { + const d = agentPolicy(req('GPTBot/1.1'), { onTraining: 'charge' }) + const res = respondToDecision(d, { accepts: ACCEPTS }) + expect(res?.status).toBe(402) + }) + + it('never charges retrieval, even under the same policy', async () => { + // Charging a person's question is charging your own distribution channel. + const d = agentPolicy(req('ChatGPT-User/1.0'), { onTraining: 'charge' }) + expect(respondToDecision(d, { accepts: ACCEPTS })).toBeNull() + }) + + it('lets search crawlers through free', () => { + const d = agentPolicy(req('Googlebot/2.1'), { onTraining: 'charge' }) + expect(respondToDecision(d, { accepts: ACCEPTS })).toBeNull() + }) + + it('serves metered traffic rather than gating it', () => { + // meter is an accounting concern; the request is still fulfilled. + const d = agentPolicy(req('GPTBot/1.1')) + expect(d.action).toBe('meter') + expect(respondToDecision(d, { accepts: ACCEPTS })).toBeNull() + }) + + it('blocks a failed identity check with 403, not a price', () => { + const d = { action: 'block' as const, intent: 'training' as const, label: 'Claude', reason: 'spoofed' } + expect(respondToDecision(d, { accepts: ACCEPTS })?.status).toBe(403) + }) +}) From e98b0fe6b991c644c3625d1d46e245930f63472a Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 11:24:23 +0200 Subject: [PATCH 2/8] feat(payments): speak MPP as well as x402 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut hardcoded x402's framing while the PR text claimed settlement could go to 'an x402 facilitator or Stripe's MPP'. It could not — MPP uses a different wire format. x402 PAYMENT-REQUIRED: -> PAYMENT-SIGNATURE MPP WWW-Authenticate: Payment id="…" -> Authorization: Payment … MPP reuses standard HTTP authentication framing rather than defining its own headers, which means the two do not collide: a single 402 can advertise both and let the agent take whichever it speaks. paymentRequired() does exactly that when handed both challenges. paymentPayload() now returns { protocol, value } instead of a bare string, and checks the `Payment` auth-scheme before treating an Authorization header as a credential. Without that check a site behind ordinary Bearer or Basic auth would look like every request had already paid — a security-relevant confusion, so there is a test for it. WWW-Authenticate values are quoted and escaped per RFC 9110, and appended rather than set, since the header legitimately carries multiple challenges. withSettlement() takes an optional header name. x402 defines PAYMENT-RESPONSE; MPP's public spec did not pin a settlement-confirmation header at the time of writing, so the caller names what their provider expects rather than the library inventing one. Tests 257 -> 263. --- src/index.ts | 10 ++- src/payments.ts | 181 +++++++++++++++++++++++++++++++++--------- test/payments.test.ts | 106 ++++++++++++++++++++----- 3 files changed, 239 insertions(+), 58 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9b69871..119d27a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,15 @@ export { respondToDecision, withSettlement } from './payments.js' -export type { PaymentChallengeOptions, PaymentRequirements } from './payments.js' +export type { + MppChallenge, + PaymentChallenge, + PaymentChallengeOptions, + PaymentProtocol, + PaymentRequirements, + SubmittedPayment, + X402Challenge +} from './payments.js' export type { AgentAction, AgentDecision, diff --git a/src/payments.ts b/src/payments.ts index f11cf39..b074dae 100644 --- a/src/payments.ts +++ b/src/payments.ts @@ -12,6 +12,16 @@ * is charging for your own distribution. {@link agentPolicy} draws that line; * this module turns a `'charge'` decision into the HTTP challenge. * + * Two protocols, one status code. Both settle at the HTTP layer and both use + * 402, but the framing differs: + * + * x402 PAYMENT-REQUIRED: -> PAYMENT-SIGNATURE + * MPP WWW-Authenticate: Payment id="…" -> Authorization: Payment … + * + * MPP reuses standard HTTP authentication framing; x402 defines its own + * headers. They do not collide, so a single 402 can advertise both and let the + * agent pick — which is what {@link paymentRequired} does when given both. + * * Scope: this emits the 402 and reads the client's payment header. It does not * settle anything. Settlement belongs to an x402 facilitator or Stripe's MPP — * a library that held money would inherit PCI scope and stop being something @@ -37,11 +47,47 @@ export interface PaymentRequirements { extra?: Record } -export interface PaymentChallengeOptions { +/** Which settlement protocol a challenge speaks. */ +export type PaymentProtocol = 'x402' | 'mpp' + +/** x402: base64 JSON in a `PAYMENT-REQUIRED` header. */ +export interface X402Challenge { + protocol: 'x402' /** Accepted payment methods, in preference order. At least one. */ accepts: readonly PaymentRequirements[] - /** x402 protocol version. Defaults to 1. */ + /** Protocol version. Defaults to 1. */ x402Version?: number +} + +/** + * MPP: an RFC 9110 `WWW-Authenticate: Payment` challenge. + * + * Field values are yours. `request` carries the encoded challenge payload your + * MPP provider generates — the library does not construct or price it. + */ +export interface MppChallenge { + protocol: 'mpp' + /** Challenge identifier. */ + id: string + /** Authentication realm. */ + realm: string + /** Payment method, e.g. `'tempo'`. */ + method: string + /** Transaction intent, e.g. `'charge'`. */ + intent?: string + /** Encoded challenge data from your provider. */ + request?: string +} + +export type PaymentChallenge = X402Challenge | MppChallenge + +export interface PaymentChallengeOptions { + /** + * Challenges to advertise. Supplying both an x402 and an MPP challenge is + * valid and usually correct: they use non-colliding headers, so one 402 can + * offer both and the agent takes whichever it speaks. + */ + challenges: readonly PaymentChallenge[] /** * `Content-Signal` to send with the challenge. Defaults to * `search=yes, ai-input=yes, ai-train=paid` — the whole point being that @@ -54,9 +100,16 @@ export interface PaymentChallengeOptions { body?: string } -const HEADER_CHALLENGE = 'PAYMENT-REQUIRED' -const HEADER_SIGNATURE = 'PAYMENT-SIGNATURE' -const HEADER_SETTLEMENT = 'PAYMENT-RESPONSE' +const X402_CHALLENGE = 'PAYMENT-REQUIRED' +const X402_SIGNATURE = 'PAYMENT-SIGNATURE' +const X402_SETTLEMENT = 'PAYMENT-RESPONSE' +const MPP_CHALLENGE = 'WWW-Authenticate' +const MPP_CREDENTIAL = 'Authorization' + +/** Quote and escape a WWW-Authenticate auth-param value per RFC 9110. */ +function quoted(v: string): string { + return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} function b64(json: unknown): string { const text = JSON.stringify(json) @@ -75,59 +128,115 @@ function b64(json: unknown): string { * const decision = agentPolicy(req, { onTraining: 'charge' }) * if (decision.action === 'charge') { * return paymentRequired({ - * accepts: [{ - * scheme: 'exact', - * network: 'base', - * maxAmountRequired: '1000', // your price, your units - * resource: req.url, - * description: 'Training crawl of one page', - * payTo: process.env.WALLET, - * asset: process.env.USDC_ADDRESS - * }] + * challenges: [ + * { + * protocol: 'x402', + * accepts: [{ + * scheme: 'exact', + * network: 'base', + * maxAmountRequired: '1000', // your price, your units + * resource: req.url, + * payTo: process.env.WALLET!, + * asset: process.env.USDC! + * }] + * }, + * { protocol: 'mpp', id: challengeId, realm: 'example.com', method: 'tempo', intent: 'charge' } + * ] * }) * } * ``` */ export function paymentRequired(opts: PaymentChallengeOptions): Response { - if (!opts.accepts.length) { - throw new Error('paymentRequired needs at least one entry in `accepts`') + if (!opts.challenges.length) { + throw new Error('paymentRequired needs at least one challenge') } - const challenge = { - x402Version: opts.x402Version ?? 1, - accepts: opts.accepts + + const headers = new Headers({ + 'content-type': 'text/plain; charset=utf-8', + // Says the quiet part out loud: training is for sale, not forbidden. + 'content-signal': opts.contentSignal ?? 'search=yes, ai-input=yes, ai-train=paid' + }) + + for (const c of opts.challenges) { + if (c.protocol === 'x402') { + if (!c.accepts.length) { + throw new Error('an x402 challenge needs at least one entry in `accepts`') + } + headers.set(X402_CHALLENGE, b64({ x402Version: c.x402Version ?? 1, accepts: c.accepts })) + } else { + const params = [ + `id=${quoted(c.id)}`, + `realm=${quoted(c.realm)}`, + `method=${quoted(c.method)}`, + ...(c.intent ? [`intent=${quoted(c.intent)}`] : []), + ...(c.request ? [`request=${quoted(c.request)}`] : []) + ] + // `append`, not `set`: WWW-Authenticate legitimately carries multiple + // challenges, and a caller may already have added one. + headers.append(MPP_CHALLENGE, `Payment ${params.join(', ')}`) + } } + + for (const [k, v] of Object.entries(opts.headers ?? {})) headers.set(k, v) + return new Response(opts.body ?? 'Payment required for training access.\n', { status: 402, - headers: { - 'content-type': 'text/plain; charset=utf-8', - [HEADER_CHALLENGE]: b64(challenge), - // Says the quiet part out loud: training is for sale, not forbidden. - 'content-signal': opts.contentSignal ?? 'search=yes, ai-input=yes, ai-train=paid', - ...(opts.headers ?? {}) - } + headers }) } +/** A payment credential the client sent back, and which protocol it speaks. */ +export interface SubmittedPayment { + protocol: PaymentProtocol + /** Raw header value, for handing to a facilitator. */ + value: string +} + +/** + * Read the client's payment credential, whichever protocol it used. + * + * x402 sends `PAYMENT-SIGNATURE`; MPP sends `Authorization: Payment …`. The + * `Payment` scheme check matters — a site behind normal auth will also have a + * Bearer or Basic `Authorization` header, and mistaking that for a payment + * would be a security-relevant confusion. + */ +export function paymentPayload(req: Request): SubmittedPayment | null { + const x402 = req.headers.get(X402_SIGNATURE) + if (x402) return { protocol: 'x402', value: x402 } + + const auth = req.headers.get(MPP_CREDENTIAL) + if (auth) { + const m = auth.match(/^Payment\s+(.*)$/i) + if (m?.[1]) return { protocol: 'mpp', value: m[1] } + } + return null +} + /** - * True when the client attached a payment payload — i.e. this is the retry + * True when the client attached a payment credential — i.e. this is the retry * after a 402, not a fresh unpaid request. * * Presence is not proof. Hand the value to your facilitator to verify and * settle; only then serve the resource. */ export function hasPaymentPayload(req: Request): boolean { - return !!req.headers.get(HEADER_SIGNATURE) + return paymentPayload(req) !== null } -/** Raw `PAYMENT-SIGNATURE` value, for handing to a facilitator. */ -export function paymentPayload(req: Request): string | null { - return req.headers.get(HEADER_SIGNATURE) -} - -/** Attach a facilitator's settlement result to a successful response. */ -export function withSettlement(res: Response, settlement: unknown): Response { +/** + * Attach a facilitator's settlement result to a successful response. + * + * x402 defines `PAYMENT-RESPONSE` for this. MPP's public spec did not pin a + * settlement-confirmation header at the time of writing, so pass `header` to + * name whatever your provider expects rather than have the library guess. + */ +export function withSettlement( + res: Response, + settlement: unknown, + opts: { header?: string } = {} +): Response { const headers = new Headers(res.headers) - headers.set(HEADER_SETTLEMENT, b64(settlement)) + headers.set(opts.header ?? X402_SETTLEMENT, b64(settlement)) return new Response(res.body, { status: res.status, statusText: res.statusText, headers }) } diff --git a/test/payments.test.ts b/test/payments.test.ts index 84093dd..305cc43 100644 --- a/test/payments.test.ts +++ b/test/payments.test.ts @@ -8,17 +8,28 @@ import { withSettlement } from '../src/payments.js' -const ACCEPTS = [ - { - scheme: 'exact', - network: 'base', - maxAmountRequired: '1000', - resource: 'https://example.com/docs/intro', - description: 'Training crawl of one page', - payTo: '0xabc', - asset: '0xusdc' - } -] +const X402 = { + protocol: 'x402' as const, + accepts: [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1000', + resource: 'https://example.com/docs/intro', + description: 'Training crawl of one page', + payTo: '0xabc', + asset: '0xusdc' + } + ] +} + +const MPP = { + protocol: 'mpp' as const, + id: 'chal_123', + realm: 'example.com', + method: 'tempo', + intent: 'charge' +} function decode(res: Response) { const raw = res.headers.get('PAYMENT-REQUIRED')! @@ -29,7 +40,7 @@ function decode(res: Response) { describe('paymentRequired', () => { it('emits a 402 carrying a base64 x402 challenge', () => { - const res = paymentRequired({ accepts: ACCEPTS }) + const res = paymentRequired({ challenges: [X402] }) expect(res.status).toBe(402) const body = decode(res) expect(body.x402Version).toBe(1) @@ -40,7 +51,7 @@ describe('paymentRequired', () => { it('advertises training as for sale, not forbidden', () => { // The default Content-Signal elsewhere in the library is ai-train=no. The // entire premise here is the opposite. - expect(paymentRequired({ accepts: ACCEPTS }).headers.get('content-signal')).toBe( + expect(paymentRequired({ challenges: [X402] }).headers.get('content-signal')).toBe( 'search=yes, ai-input=yes, ai-train=paid' ) }) @@ -48,13 +59,16 @@ describe('paymentRequired', () => { it('survives non-ASCII in the challenge', () => { // btoa is Latin-1 only; a naive implementation throws on this. const res = paymentRequired({ - accepts: [{ ...ACCEPTS[0]!, description: 'Entraînement — 訓練 🤖' }] + challenges: [{ ...X402, accepts: [{ ...X402.accepts[0]!, description: 'Entraînement — 訓練 🤖' }] }] }) expect(decode(res).accepts[0].description).toBe('Entraînement — 訓練 🤖') }) it('refuses to emit a challenge with no way to pay', () => { - expect(() => paymentRequired({ accepts: [] })).toThrow(/at least one/) + expect(() => paymentRequired({ challenges: [] })).toThrow(/at least one/) + expect(() => paymentRequired({ challenges: [{ protocol: 'x402', accepts: [] }] })).toThrow( + /at least one/ + ) }) }) @@ -66,7 +80,7 @@ describe('payment payload', () => { }) expect(hasPaymentPayload(bare)).toBe(false) expect(hasPaymentPayload(paid)).toBe(true) - expect(paymentPayload(paid)).toBe('base64payload') + expect(paymentPayload(paid)).toEqual({ protocol: 'x402', value: 'base64payload' }) }) it('attaches a settlement result without disturbing the body', async () => { @@ -83,30 +97,80 @@ describe('respondToDecision', () => { it('charges training crawlers when configured to', () => { const d = agentPolicy(req('GPTBot/1.1'), { onTraining: 'charge' }) - const res = respondToDecision(d, { accepts: ACCEPTS }) + const res = respondToDecision(d, { challenges: [X402] }) expect(res?.status).toBe(402) }) it('never charges retrieval, even under the same policy', async () => { // Charging a person's question is charging your own distribution channel. const d = agentPolicy(req('ChatGPT-User/1.0'), { onTraining: 'charge' }) - expect(respondToDecision(d, { accepts: ACCEPTS })).toBeNull() + expect(respondToDecision(d, { challenges: [X402] })).toBeNull() }) it('lets search crawlers through free', () => { const d = agentPolicy(req('Googlebot/2.1'), { onTraining: 'charge' }) - expect(respondToDecision(d, { accepts: ACCEPTS })).toBeNull() + expect(respondToDecision(d, { challenges: [X402] })).toBeNull() }) it('serves metered traffic rather than gating it', () => { // meter is an accounting concern; the request is still fulfilled. const d = agentPolicy(req('GPTBot/1.1')) expect(d.action).toBe('meter') - expect(respondToDecision(d, { accepts: ACCEPTS })).toBeNull() + expect(respondToDecision(d, { challenges: [X402] })).toBeNull() }) it('blocks a failed identity check with 403, not a price', () => { const d = { action: 'block' as const, intent: 'training' as const, label: 'Claude', reason: 'spoofed' } - expect(respondToDecision(d, { accepts: ACCEPTS })?.status).toBe(403) + expect(respondToDecision(d, { challenges: [X402] })?.status).toBe(403) + }) +}) + +describe('MPP', () => { + it('emits a WWW-Authenticate Payment challenge', () => { + const res = paymentRequired({ challenges: [MPP] }) + expect(res.status).toBe(402) + const h = res.headers.get('WWW-Authenticate')! + expect(h).toMatch(/^Payment /) + expect(h).toContain('id="chal_123"') + expect(h).toContain('realm="example.com"') + expect(h).toContain('method="tempo"') + expect(h).toContain('intent="charge"') + }) + + it('advertises x402 and MPP on the same 402', () => { + // Non-colliding headers, so one response can offer both and the agent + // takes whichever it speaks. + const res = paymentRequired({ challenges: [X402, MPP] }) + expect(res.headers.get('PAYMENT-REQUIRED')).toBeTruthy() + expect(res.headers.get('WWW-Authenticate')).toMatch(/^Payment /) + }) + + it('reads an MPP credential from Authorization', () => { + const req = new Request('https://example.com/', { + headers: { Authorization: 'Payment cred=abc123' } + }) + expect(paymentPayload(req)).toEqual({ protocol: 'mpp', value: 'cred=abc123' }) + }) + + it('does not mistake ordinary auth for a payment', () => { + // A site behind Bearer auth must not look like it already paid. + for (const v of ['Bearer eyJhbGciOi', 'Basic dXNlcjpwYXNz']) { + const req = new Request('https://example.com/', { headers: { Authorization: v } }) + expect(paymentPayload(req)).toBeNull() + expect(hasPaymentPayload(req)).toBe(false) + } + }) + + it('escapes quotes in auth-param values', () => { + const res = paymentRequired({ + challenges: [{ ...MPP, realm: 'ex"ample' }] + }) + expect(res.headers.get('WWW-Authenticate')).toContain('realm="ex\\"ample"') + }) + + it('lets the settlement header be named for the provider', () => { + const out = withSettlement(new Response('ok'), { ok: true }, { header: 'Authentication-Info' }) + expect(out.headers.get('Authentication-Info')).toBeTruthy() + expect(out.headers.get('PAYMENT-RESPONSE')).toBeNull() }) }) From 760276c57292520be90c86af16171d96778277a1 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 12:05:41 +0200 Subject: [PATCH 3/8] fix: agentIntent and agentPolicy disagreed on every HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are exported from the package root, and they returned different intents for the same user agent. The 'tooling' promotion for HTTP-library UAs lived only inside agentPolicy, so: agentIntent('curl/8.4.0') -> 'unknown' agentPolicy(req(curl)).intent -> 'tooling' Six of six HTTP-client UAs disagreed. A caller reaching for the obviously-named function got the wrong answer with no signal that a second source existed. Found it by hitting it: the site's /api/whoami called both and rendered "intent: unknown" beside "reason: coding agent or HTTP client" — visibly self-contradictory. I patched that call site and moved on, which left the trap exported for everyone else. agentIntent now checks isHttpClient itself, which it can do from the UA alone, and agentPolicy reads intent from it rather than re-deriving. One source of truth instead of two that happened to agree most of the time. Pinned with a 19-UA corpus asserting agentIntent(ua) === agentPolicy(req).intent for every entry. That invariant is the actual fix; the promotion moving is just how it is satisfied. Tests 274 -> 294. --- src/gateway.ts | 166 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +++ src/policy.ts | 21 ++++-- test/gateway.test.ts | 125 ++++++++++++++++++++++++++++++++ test/policy.test.ts | 43 +++++++++++ 5 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 src/gateway.ts create mode 100644 test/gateway.test.ts diff --git a/src/gateway.ts b/src/gateway.ts new file mode 100644 index 0000000..c6f8135 --- /dev/null +++ b/src/gateway.ts @@ -0,0 +1,166 @@ +/** + * The paid-access gate: policy decides *whether* to charge, a gateway decides + * *how*. + * + * The split matters. We own classification — telling a training crawl from a + * retrieval fetch, which is the part nobody else does and the part that makes + * charging sane. Settlement is somebody else's job: Stripe's MPP SDK, an x402 + * facilitator, whatever comes next. A library that held money would inherit PCI + * scope and stop being something you drop into middleware. + * + * So gateways are injected, exactly like analytics adapters, and this module + * takes no dependency on Stripe or any chain. + */ + +import { agentPolicy, type AgentDecision, type AgentPolicyOptions } from './policy.js' +import { paymentRequired, type PaymentChallengeOptions } from './payments.js' + +/** + * Outcome of handing a request to a payment gateway. + * + * - `challenge` — respond with this. The client has not paid. + * - `paid` — settled; serve the resource. `receipt` decorates the response with + * whatever proof the protocol expects. + */ +export type GatewayResult = + | { status: 'challenge'; response: Response } + | { status: 'paid'; receipt?: (res: Response) => Response } + +export interface PaymentGateway { + handle(req: Request): Promise +} + +/** + * Wrap Stripe's MPP SDK. + * + * `Mppx.compose(...)` returns a handler that either yields a 402 with a + * `.challenge` response, or a settled result with `.withReceipt(res)`. This + * adapts that shape without importing it — pass the composed handler in. + * + * @example + * ```ts + * const mppx = Mppx.create({ methods: [...], secretKey }) + * const handler = Mppx.compose( + * mppx.tempo.charge({ amount: '0.01', recipient }), + * mppx.stripe.charge({ amount: '0.50', currency: 'usd' }) + * ) + * const gateway = mppxGateway(handler) + * ``` + */ +export function mppxGateway( + handler: (req: Request) => Promise | MppxResponse +): PaymentGateway { + return { + async handle(req: Request): Promise { + const out = await handler(req) + if (out.status === 402) { + return { status: 'challenge', response: out.challenge } + } + return { + status: 'paid', + ...(out.withReceipt ? { receipt: (res: Response) => out.withReceipt!(res) } : {}) + } + } + } +} + +/** The subset of Stripe's MPP response we rely on. Structural, not imported. */ +export interface MppxResponse { + status: number + challenge: Response + withReceipt?: (res: Response) => Response +} + +export interface X402GatewayOptions extends PaymentChallengeOptions { + /** + * Verify and settle a `PAYMENT-SIGNATURE` payload with your facilitator. + * Resolve truthy to serve the resource, falsy to re-challenge. + */ + settle: (payload: string, req: Request) => Promise | boolean + /** Attach the facilitator's settlement result to the served response. */ + receipt?: (res: Response) => Response +} + +/** + * Gateway using this library's own challenge builder plus a facilitator you + * supply. For x402, or for MPP if you are not using Stripe's SDK. + */ +export function x402Gateway(opts: X402GatewayOptions): PaymentGateway { + const { settle, receipt, ...challenge } = opts + return { + async handle(req: Request): Promise { + const sig = req.headers.get('PAYMENT-SIGNATURE') + if (sig && (await settle(sig, req))) { + return { status: 'paid', ...(receipt ? { receipt } : {}) } + } + return { status: 'challenge', response: paymentRequired(challenge) } + } + } +} + +export interface PaymentGateOptions extends AgentPolicyOptions { + gateway: PaymentGateway + /** + * Called for every decision, paid or not — wire it to your metering so + * `'meter'` traffic is actually counted rather than merely allowed. + */ + onDecision?: (decision: AgentDecision) => void +} + +/** + * Full gate: classify, decide, and either let the request through or return the + * response it should get instead. + * + * Returns `null` when the request should be served normally. That covers + * `'allow'`, `'meter'` (accounting, not a gate) and any request that has already + * paid — in which case `receipt` is handed back so you can decorate the response + * you were going to send anyway. + * + * @example + * ```ts + * const gate = await paymentGate(req, { + * onTraining: 'charge', + * verify: combinedVerifier(), + * gateway: mppxGateway(handler), + * onDecision: (d) => void trackVisit(req, { analytics, properties: { action: d.action } }) + * }) + * if (gate.response) return gate.response + * return gate.decorate(await serve(req)) + * ``` + */ +export async function paymentGate( + req: Request, + opts: PaymentGateOptions +): Promise<{ + decision: AgentDecision + /** Respond with this instead of serving, when set. */ + response: Response | null + /** Wrap the response you were going to send. Identity when nothing to add. */ + decorate: (res: Response) => Response +}> { + const { gateway, onDecision, ...policyOpts } = opts + const decision = agentPolicy(req, policyOpts) + onDecision?.(decision) + + const identity = (res: Response) => res + + if (decision.action === 'block') { + return { + decision, + response: new Response('Forbidden: agent identity could not be verified.\n', { + status: 403 + }), + decorate: identity + } + } + + if (decision.action !== 'charge') { + return { decision, response: null, decorate: identity } + } + + const out = await gateway.handle(req) + if (out.status === 'challenge') { + return { decision, response: out.response, decorate: identity } + } + return { decision, response: null, decorate: out.receipt ?? identity } +} diff --git a/src/index.ts b/src/index.ts index 119d27a..df3a079 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,14 @@ export type { AgentClassification, AgentKind, HeadlessDetection } from './bots.j export { hashId, randomSecret, HashSecretError } from './hash.js' export { CaptureTransportError } from './errors.js' export { agentIntent, agentPolicy } from './policy.js' +export { mppxGateway, paymentGate, x402Gateway } from './gateway.js' +export type { + GatewayResult, + MppxResponse, + PaymentGateOptions, + PaymentGateway, + X402GatewayOptions +} from './gateway.js' export { hasPaymentPayload, paymentPayload, diff --git a/src/policy.ts b/src/policy.ts index 438faba..1de67fd 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -1,4 +1,4 @@ -import { classifyRequest } from './bots.js' +import { classifyRequest, isHttpClient } from './bots.js' import type { BotVerificationLike } from './types.js' /** @@ -77,7 +77,16 @@ export interface AgentPolicyOptions { allowList?: readonly string[] } -/** Classify why an agent is here, from its user agent alone. */ +/** + * Classify why an agent is here, from its user agent alone. + * + * This must return exactly what {@link agentPolicy} reports for the same UA. + * It previously did not: the `tooling` promotion for HTTP-library UAs lived + * only inside `agentPolicy`, so `agentIntent('curl/8.4.0')` said `'unknown'` + * while the policy said `'tooling'` — two exported functions disagreeing on + * every HTTP client, with no way for a caller to know which was right. The + * invariant is pinned by a test. + */ export function agentIntent(userAgent: string | null | undefined): AgentIntent { const ua = userAgent ?? '' if (!ua) return 'unknown' @@ -86,6 +95,8 @@ export function agentIntent(userAgent: string | null | undefined): AgentIntent { if (RETRIEVAL.test(ua)) return 'retrieval' if (TRAINING.test(ua)) return 'training' if (SEARCH.test(ua)) return 'search' + // An HTTP-library UA that matched no vendor is a coding agent or a script. + if (isHttpClient(ua)) return 'tooling' return 'unknown' } @@ -106,9 +117,9 @@ export function agentPolicy(req: Request, opts: AgentPolicyOptions = {}): AgentD const classification = classifyRequest(req) const label = classification.label - let intent = agentIntent(ua) - // An HTTP-library UA that matched no vendor is a coding agent or a script. - if (intent === 'unknown' && classification.codingAgentHint) intent = 'tooling' + // Single source of truth — the promotion that used to live here now lives in + // agentIntent, so the two can no longer drift apart. + const intent = agentIntent(ua) const allowed = opts.allowList?.some( (entry) => entry === label || ua.toLowerCase().includes(entry.toLowerCase()) diff --git a/test/gateway.test.ts b/test/gateway.test.ts new file mode 100644 index 0000000..d1dccb3 --- /dev/null +++ b/test/gateway.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { mppxGateway, paymentGate, x402Gateway, type MppxResponse } from '../src/gateway.js' + +const X402 = { + protocol: 'x402' as const, + accepts: [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1000', + resource: 'https://example.com/docs', + payTo: '0xabc', + asset: '0xusdc' + } + ] +} + +const req = (ua: string, headers: Record = {}) => + new Request('https://example.com/docs', { headers: { 'user-agent': ua, ...headers } }) + +const GPTBOT = 'Mozilla/5.0 (compatible; GPTBot/1.1)' +const CHATGPT_USER = 'Mozilla/5.0 (compatible; ChatGPT-User/1.0)' + +describe('x402Gateway', () => { + it('challenges an unpaid request', async () => { + const g = x402Gateway({ challenges: [X402], settle: () => true }) + const out = await g.handle(req(GPTBOT)) + expect(out.status).toBe('challenge') + if (out.status === 'challenge') expect(out.response.status).toBe(402) + }) + + it('serves once the facilitator settles', async () => { + const settle = vi.fn().mockResolvedValue(true) + const g = x402Gateway({ challenges: [X402], settle }) + const out = await g.handle(req(GPTBOT, { 'PAYMENT-SIGNATURE': 'sig' })) + expect(out.status).toBe('paid') + expect(settle).toHaveBeenCalledWith('sig', expect.anything()) + }) + + it('re-challenges when the facilitator rejects the payload', async () => { + // Presence of a header is not proof of payment. + const g = x402Gateway({ challenges: [X402], settle: () => false }) + const out = await g.handle(req(GPTBOT, { 'PAYMENT-SIGNATURE': 'forged' })) + expect(out.status).toBe('challenge') + }) +}) + +describe('mppxGateway', () => { + it('passes through Stripe MPP challenges', async () => { + const handler = (): MppxResponse => ({ + status: 402, + challenge: new Response('pay up', { status: 402 }) + }) + const out = await mppxGateway(handler).handle(req(GPTBOT)) + expect(out.status).toBe('challenge') + }) + + it('returns the receipt decorator once settled', async () => { + const handler = (): MppxResponse => ({ + status: 200, + challenge: new Response(null, { status: 402 }), + withReceipt: (res) => { + const h = new Headers(res.headers) + h.set('x-mpp-receipt', 'rcpt_1') + return new Response(res.body, { status: res.status, headers: h }) + } + }) + const out = await mppxGateway(handler).handle(req(GPTBOT)) + expect(out.status).toBe('paid') + if (out.status === 'paid') { + const decorated = out.receipt!(new Response('the goods')) + expect(decorated.headers.get('x-mpp-receipt')).toBe('rcpt_1') + expect(await decorated.text()).toBe('the goods') + } + }) +}) + +describe('paymentGate', () => { + const gateway = x402Gateway({ challenges: [X402], settle: (s) => s === 'good' }) + + it('charges training crawls', async () => { + const g = await paymentGate(req(GPTBOT), { gateway, onTraining: 'charge' }) + expect(g.decision.intent).toBe('training') + expect(g.response?.status).toBe(402) + }) + + it('never gates retrieval', async () => { + // The whole thesis: a person is waiting on this answer. + const g = await paymentGate(req(CHATGPT_USER), { gateway, onTraining: 'charge' }) + expect(g.decision.intent).toBe('retrieval') + expect(g.response).toBeNull() + }) + + it('serves metered traffic rather than gating it', async () => { + const g = await paymentGate(req(GPTBOT), { gateway }) + expect(g.decision.action).toBe('meter') + expect(g.response).toBeNull() + }) + + it('serves a paid retry and hands back the decorator', async () => { + const g = await paymentGate(req(GPTBOT, { 'PAYMENT-SIGNATURE': 'good' }), { + gateway, + onTraining: 'charge' + }) + expect(g.response).toBeNull() + expect(g.decorate(new Response('ok'))).toBeInstanceOf(Response) + }) + + it('reports every decision so metering can count it', async () => { + const seen: string[] = [] + for (const ua of [GPTBOT, CHATGPT_USER, 'Googlebot/2.1']) { + await paymentGate(req(ua), { gateway, onDecision: (d) => seen.push(d.action) }) + } + expect(seen).toEqual(['meter', 'allow', 'allow']) + }) + + it('blocks a spoofed identity with 403 rather than a price', async () => { + const g = await paymentGate(req(CHATGPT_USER, { 'x-forwarded-for': '1.2.3.4' }), { + gateway, + onTraining: 'charge', + verify: () => ({ verdict: 'spoofed', verified: false }) + }) + expect(g.response?.status).toBe(403) + }) +}) diff --git a/test/policy.test.ts b/test/policy.test.ts index aec9a7b..a783405 100644 --- a/test/policy.test.ts +++ b/test/policy.test.ts @@ -118,3 +118,46 @@ describe('agentPolicy with verification', () => { expect(d.action).toBe('allow') }) }) + +describe('agentIntent and agentPolicy never disagree', () => { + // These are two separately exported functions answering the same question. If + // they diverge, a caller has no way to know which is authoritative — and the + // divergence is silent. It shipped that way: the `tooling` promotion lived + // only inside agentPolicy, so agentIntent('curl/8.4.0') returned 'unknown' + // while the policy returned 'tooling', for every HTTP client. + const CORPUS = [ + 'curl/8.4.0', + 'axios/1.8.4', + 'python-requests/2.31.0', + 'Go-http-client/1.1', + 'node-fetch/3.0.0', + 'Electron/28.0.0', + 'okhttp/4.12.0', + 'aiohttp/3.9.1', + 'Deno/1.40.0', + 'Mozilla/5.0 (compatible; GPTBot/1.1)', + 'Mozilla/5.0 (compatible; ChatGPT-User/1.0)', + 'Mozilla/5.0 (compatible; ClaudeBot/1.0)', + 'Claude-User (claude-code/2.1.218)', + 'Mozilla/5.0 (compatible; Googlebot/2.1)', + 'Mozilla/5.0 (compatible; Applebot/0.1)', + 'Mozilla/5.0 (compatible; Applebot-Extended/0.1)', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36', + '', + 'SomethingCompletelyUnknown/9' + ] + + it.each(CORPUS)('agrees for %s', (ua) => { + const fromRequest = agentPolicy( + new Request('https://example.com/', { headers: { 'user-agent': ua } }) + ).intent + expect(agentIntent(ua)).toBe(fromRequest) + }) + + it('classifies HTTP libraries as tooling from the UA alone', () => { + // No Request needed — the old asymmetry was that only the Request-taking + // path knew about HTTP clients. + expect(agentIntent('curl/8.4.0')).toBe('tooling') + expect(agentIntent('axios/1.8.4')).toBe('tooling') + }) +}) From bb7f2d37bb940186fb94f8ad9347c003476c55fd Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 12:12:26 +0200 Subject: [PATCH 4/8] fix: let paymentGate await an async verifier; mark payments experimental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both found by running the composed stack rather than the units. paymentGate could not use Web Bot Auth at all. Verification is necessarily async — verifyWebBotAuth fetches the signer's key directory — but agentPolicy is synchronous, so combinedVerifier() could not be passed to it. In JS that failed silently: agentPolicy read `.verdict` off a promise, got undefined, and never applied the spoofed check. In TS it was a compile error, which is better but still means the package's two headline features could not be used together. Caught by an integration check: ClaudeBot from a DigitalOcean address returned 402 charge when it should have been 403 block. Unit tests all passed either way, because each layer was correct alone. paymentGate is already async, so it now awaits the verifier and passes the resolved verification to agentPolicy via a new `verification` option. agentPolicy stays synchronous, which is worth keeping for callers who only want UA classification. Verified across the composed stack: 402 training charge GPTBot serve retrieval allow ChatGPT-User serve training charge GPTBot, paid 403 training block ClaudeBot from an unpublished IP 402 training charge ClaudeBot from a real Anthropic IP serve search allow Googlebot Payments are marked experimental. The protocols are weeks old and moving. x402 and MPP are both live but their specs are unstable, MPP had not publicly pinned a settlement-confirmation header at the time of writing, and no agent in our own production traffic has yet presented a payment credential. Detection, verification and policy are stable and should be treated as such; this surface should not. Stated in payments.ts, gateway.ts and the README rather than only in a PR description, since that is where someone will actually read it. Rebased onto 0.14.0 (Web Bot Auth) and resolved the package.json version conflict to 0.15.0. Tests 294 -> 310 after picking up #22's suite. --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ src/gateway.ts | 25 +++++++++++++++++++++---- src/payments.ts | 9 ++++++++- src/policy.ts | 17 +++++++++++++++-- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1feae30..540b6e0 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,49 @@ Now you can build: --- +## Charging for training crawls (experimental) + +> **⚠️ Experimental.** The payment surface — `paymentRequired`, `paymentGate`, +> `x402Gateway`, `mppxGateway` — may change without a major version bump. The +> protocols are weeks old and still moving: x402 and MPP are both live but their +> specs are unstable, MPP had not publicly pinned a settlement-confirmation +> header at the time of writing, and no agent in our own production traffic has +> yet presented a payment credential. Detection, verification and policy are +> stable; this is not. Do not put it on a revenue-critical path yet. + +Over 2.5 million sites answer bulk AI crawling with `Disallow`. That leaves +money on the table and only works if the crawler cooperates. The alternative is +to price it — which only works if you can tell training from retrieval, because +charging a `ChatGPT-User` fetch means charging the person who just asked about +you. + +```ts +import { paymentGate, x402Gateway } from '@apideck/agent-analytics' +import { combinedVerifier } from '@apideck/agent-analytics/verify' + +const gate = await paymentGate(req, { + onTraining: 'charge', + verify: combinedVerifier(), + gateway: x402Gateway({ challenges: [...], settle: myFacilitator }) +}) +if (gate.response) return gate.response +return gate.decorate(await serve(req)) +``` + +Measured against real traffic shapes: + +``` +402 training charge GPTBot +serve retrieval allow ChatGPT-User +403 training block ClaudeBot from an unpublished IP +402 training charge ClaudeBot from a real Anthropic IP +serve search allow Googlebot +``` + +Settlement is never ours. `mppxGateway` wraps Stripe's MPP SDK; `x402Gateway` +calls a facilitator you supply. The library emits challenges and reads +credentials — holding money would drag PCI scope into edge middleware. + ## Cryptographic verification (Web Bot Auth) Published IP ranges were always the weak form of identity. [Web Bot diff --git a/src/gateway.ts b/src/gateway.ts index c6f8135..ef97b3b 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -1,6 +1,7 @@ /** * The paid-access gate: policy decides *whether* to charge, a gateway decides - * *how*. + * *how*. **EXPERIMENTAL** — see `payments.ts`. The classification and policy + * layers underneath are stable; the payment surface is not. * * The split matters. We own classification — telling a training crawl from a * retrieval fetch, which is the part nobody else does and the part that makes @@ -13,6 +14,7 @@ */ import { agentPolicy, type AgentDecision, type AgentPolicyOptions } from './policy.js' +import type { BotVerificationLike } from './types.js' import { paymentRequired, type PaymentChallengeOptions } from './payments.js' /** @@ -98,8 +100,16 @@ export function x402Gateway(opts: X402GatewayOptions): PaymentGateway { } } -export interface PaymentGateOptions extends AgentPolicyOptions { +export interface PaymentGateOptions extends Omit { gateway: PaymentGateway + /** + * Identity verifier, sync or async. Unlike {@link agentPolicy}'s option this + * accepts a promise, because `paymentGate` is already async and can await it. + * That matters: `combinedVerifier()` and `webBotAuthVerifier()` are async by + * necessity — Web Bot Auth fetches the signer's key directory — so without + * this they could not be used with policy or payments at all. + */ + verify?: (req: Request) => BotVerificationLike | Promise /** * Called for every decision, paid or not — wire it to your metering so * `'meter'` traffic is actually counted rather than merely allowed. @@ -138,8 +148,15 @@ export async function paymentGate( /** Wrap the response you were going to send. Identity when nothing to add. */ decorate: (res: Response) => Response }> { - const { gateway, onDecision, ...policyOpts } = opts - const decision = agentPolicy(req, policyOpts) + const { gateway, onDecision, verify, ...policyOpts } = opts + // Await here so an async verifier works. Passing the function straight into + // agentPolicy would hand it a promise to read `.verdict` off — undefined at + // runtime, and a type error at compile time. + const verification = verify ? await verify(req) : undefined + const decision = agentPolicy(req, { + ...policyOpts, + ...(verification ? { verification } : {}) + }) onDecision?.(decision) const identity = (res: Response) => res diff --git a/src/payments.ts b/src/payments.ts index b074dae..01f2c5e 100644 --- a/src/payments.ts +++ b/src/payments.ts @@ -1,5 +1,12 @@ /** - * Charge for training crawls. + * Charge for training crawls. **EXPERIMENTAL.** + * + * The protocols this speaks are weeks old and moving. x402 and MPP are both + * live but their specs are unstable, MPP's settlement-confirmation header was + * not pinned publicly at the time of writing, and no agent in our production + * traffic has yet presented a payment credential. Expect this API to change + * without a major version while that settles — everything else in the package + * is stable, this is not. * * Today the industry's answer to bulk AI crawling is `Disallow` — over 2.5 * million sites block AI training in robots.txt. That leaves money on the diff --git a/src/policy.ts b/src/policy.ts index 1de67fd..9d95e7b 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -65,6 +65,16 @@ export interface AgentPolicyOptions { * an attacker picks their own verdict. */ verify?: (req: Request) => BotVerificationLike + /** + * A verification already computed elsewhere. Use this when your verifier is + * async — {@link verifyWebBotAuth} fetches a key directory, so the natural + * verifier from `@apideck/agent-analytics/verify` returns a promise and + * cannot be passed to `verify` on this synchronous function. + * + * {@link paymentGate} does this for you: it awaits the verifier and forwards + * the result here. + */ + verification?: BotVerificationLike /** What to do with bulk training crawlers. Defaults to `'meter'`. */ onTraining?: AgentAction /** What to do with retrieval agents. Defaults to `'allow'` — see AgentIntent. */ @@ -128,9 +138,12 @@ export function agentPolicy(req: Request, opts: AgentPolicyOptions = {}): AgentD return { action: 'allow', intent, label, reason: 'on allowList' } } + // A pre-resolved verification wins: it is the only way an async verifier can + // reach this synchronous function. + const resolved = opts.verification ?? (opts.verify ? opts.verify(req) : undefined) let verification: string | undefined - if (opts.verify) { - verification = opts.verify(req).verdict + if (resolved) { + verification = resolved.verdict // Only 'spoofed' is actionable. 'unverifiable' means we couldn't check — // blocking on it would refuse every vendor without a published feed and // every coding agent running on someone's own machine. From e52789e24cdb6e7aef8f7404503f1100bba50e51 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 12:19:28 +0200 Subject: [PATCH 5/8] test: integration suite for the composed stack, wired into CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bug found in this library after the unit suite went green was a composition bug — each layer correct alone, contradicting the next: - agentIntent returned 'unknown' where agentPolicy returned 'tooling', for every HTTP-library UA. Both exported, both passing their own tests. - paymentGate silently dropped an async verifier, so a spoofed ClaudeBot was charged (402) instead of blocked (403). All 310 unit tests passed. Unit tests structurally cannot see that class of defect, so this adds a suite that can. Two parts: 1. An end-to-end table — 12 realistic requests in, HTTP status, intent, action and emitted bot_name out. One row per behaviour worth guaranteeing, including the ones the design exists to prevent. 2. Cross-layer invariants — properties that must hold *between* layers whatever each does internally: - agentIntent agrees with the intent agentPolicy reports - the emitted event never contradicts the classifier - an async verifier actually reaches the decision - a spoofed verdict always blocks and never merely prices - retrieval is never gated, under any policy configuration - unverifiable never becomes spoofed anywhere in the stack - no raw IP is emitted unless captureIp is set, whatever else is enabled Verified the suite earns its place by reintroducing both bugs: bug 1 (agentIntent) unit: 10 failed integration: 1 failed bug 2 (async verifier) unit: 310 PASSED integration: 3 failed Bug 2 is the argument for the file: invisible to the unit suite, caught here. Split npm scripts into test:unit and test:integration, and CI runs them as separate steps so "unit passes, integration fails" is legible at a glance rather than buried in one combined run. Tests 310 -> 329. --- .github/workflows/ci.yml | 9 +- package.json | 2 + test/integration.test.ts | 382 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 test/integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf70bf..5ec8a77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,4 +23,11 @@ jobs: - run: npm install - run: npm run typecheck - run: npm run build - - run: npm test + - run: npm run test:unit + + # Run separately so a composition failure is legible as one. Every bug + # found in this library after the unit suite went green was a layer + # boundary problem — each part correct alone, contradicting the next. + # "unit tests pass, integration fails" is the signal worth surfacing. + - name: Integration — composed stack and cross-layer invariants + run: npm run test:integration diff --git a/package.json b/package.json index c07dd9c..fcb4354 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", + "test:unit": "vitest run --exclude 'test/integration.test.ts'", + "test:integration": "vitest run test/integration.test.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..b5fcfd6 --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it, vi } from 'vitest' +import { customAnalytics } from '../src/adapters/custom.js' +import { classifyRequest } from '../src/bots.js' +import { paymentGate, x402Gateway } from '../src/gateway.js' +import { agentIntent, agentPolicy } from '../src/policy.js' +import { trackVisit } from '../src/track.js' +import type { CaptureEvent } from '../src/types.js' +import { combinedVerifier, verifyRequest } from '../src/verify.js' + +/* =========================================================================== + * Integration: the composed stack, not the pieces. + * + * Every bug found in this library after its unit suite went green was a + * composition bug — each layer correct alone, contradicting the next: + * + * - `agentIntent` returned 'unknown' where `agentPolicy` returned 'tooling', + * for every HTTP-library UA. Both exported, both "passing". + * - `paymentGate` silently ignored an async verifier, so a spoofed ClaudeBot + * was charged (402) instead of blocked (403). Every unit test passed. + * + * So these tests assert two things unit tests structurally cannot: + * + * 1. End-to-end outcomes for realistic requests — one table, request in, + * HTTP status and emitted event out. + * 2. Cross-layer invariants — properties that must hold between layers, + * whatever each layer does internally. + * ======================================================================== */ + +const ANTHROPIC_IP = '34.162.230.222' // in Anthropic's published range +const OPENAI_IP = '104.208.184.193' // in OpenAI's published range +const DATACENTRE_IP = '45.55.50.205' // DigitalOcean, in nobody's range + +const ACCEPTS = [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1000', + resource: 'https://example.com/docs', + payTo: '0xabc', + asset: '0xusdc' + } +] + +const BROWSER_HEADERS = { + 'accept-language': 'en-GB,en;q=0.9', + 'sec-fetch-mode': 'navigate', + 'sec-ch-ua': '"Chromium";v="120"', + accept: 'text/html,application/xhtml+xml' +} + +function request(ua: string, headers: Record = {}) { + return new Request('https://example.com/docs', { headers: { 'user-agent': ua, ...headers } }) +} + +function gateway(settleWith: (sig: string) => boolean = (s) => s === 'paid') { + return x402Gateway({ + challenges: [{ protocol: 'x402', accepts: ACCEPTS }], + settle: settleWith + }) +} + +/** Run the whole path a real middleware would: gate, then record. */ +async function handle( + req: Request, + opts: { onTraining?: 'meter' | 'charge'; verify?: boolean } = {} +) { + const spy = vi.fn() + const gate = await paymentGate(req, { + gateway: gateway(), + ...(opts.onTraining ? { onTraining: opts.onTraining } : {}), + ...(opts.verify ? { verify: combinedVerifier() } : {}) + }) + await trackVisit(req, { + analytics: customAnalytics(spy), + idSecret: 'integration-secret', + properties: { action: gate.decision.action, intent: gate.decision.intent } + }) + const event = spy.mock.calls[0]?.[0] as CaptureEvent | undefined + return { gate, event, status: gate.response?.status ?? 200 } +} + +/* -------------------------------------------------------------------------- + * 1. End-to-end table + * ----------------------------------------------------------------------- */ + +interface Row { + name: string + ua: string + headers?: Record + charge?: boolean + verify?: boolean + status: number + intent: string + action: string + botName: string +} + +const MATRIX: Row[] = [ + { + name: 'training crawl, metering only', + ua: 'Mozilla/5.0 (compatible; GPTBot/1.1)', + status: 200, + intent: 'training', + action: 'meter', + botName: 'ChatGPT' + }, + { + name: 'training crawl, charging', + ua: 'Mozilla/5.0 (compatible; GPTBot/1.1)', + charge: true, + status: 402, + intent: 'training', + action: 'charge', + botName: 'ChatGPT' + }, + { + name: 'training crawl that already paid', + ua: 'Mozilla/5.0 (compatible; GPTBot/1.1)', + headers: { 'PAYMENT-SIGNATURE': 'paid' }, + charge: true, + status: 200, + intent: 'training', + action: 'charge', + botName: 'ChatGPT' + }, + { + name: 'retrieval is never charged', + ua: 'Mozilla/5.0 (compatible; ChatGPT-User/1.0)', + headers: { 'x-forwarded-for': OPENAI_IP }, + charge: true, + verify: true, + status: 200, + intent: 'retrieval', + action: 'allow', + botName: 'ChatGPT' + }, + { + name: 'verified crawler is charged, not blocked', + ua: 'Mozilla/5.0 (compatible; ClaudeBot/1.0)', + headers: { 'x-forwarded-for': ANTHROPIC_IP }, + charge: true, + verify: true, + status: 402, + intent: 'training', + action: 'charge', + botName: 'Claude' + }, + { + name: 'spoofed crawler is blocked, not charged', + ua: 'Mozilla/5.0 (compatible; ClaudeBot/1.0)', + headers: { 'x-forwarded-for': DATACENTRE_IP }, + charge: true, + verify: true, + status: 403, + intent: 'training', + action: 'block', + botName: 'Claude' + }, + { + name: 'coding agent on a laptop is not accused', + ua: 'Claude-User (claude-code/2.1.218)', + headers: { 'x-forwarded-for': '109.135.42.185' }, + charge: true, + verify: true, + status: 200, + intent: 'retrieval', + action: 'allow', + botName: 'Claude' + }, + { + name: 'search crawler stays free', + ua: 'Mozilla/5.0 (compatible; Googlebot/2.1)', + charge: true, + status: 200, + intent: 'search', + action: 'allow', + botName: 'Google' + }, + { + name: 'vendor with no published feed is not blocked', + ua: 'Mozilla/5.0 (compatible; Bytespider/1.0)', + headers: { 'x-forwarded-for': DATACENTRE_IP }, + charge: true, + verify: true, + status: 402, + intent: 'training', + action: 'charge', + botName: 'Bytespider' + }, + { + name: 'real browser passes through untouched', + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36', + headers: BROWSER_HEADERS, + charge: true, + status: 200, + intent: 'unknown', + action: 'allow', + botName: 'Browser' + }, + { + name: 'headless automation is labelled Headless, not Browser', + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36', + charge: true, + status: 200, + intent: 'unknown', + action: 'allow', + botName: 'Headless' + }, + { + name: 'bare HTTP client is tooling', + ua: 'curl/8.4.0', + charge: true, + status: 200, + intent: 'tooling', + action: 'allow', + botName: 'curl' + } +] + +describe('end-to-end: request in, status and event out', () => { + it.each(MATRIX)('$name', async (row) => { + const { status, gate, event } = await handle(request(row.ua, row.headers), { + ...(row.charge ? { onTraining: 'charge' as const } : {}), + ...(row.verify ? { verify: true } : {}) + }) + expect({ + status, + intent: gate.decision.intent, + action: gate.decision.action, + botName: event?.properties.bot_name + }).toEqual({ + status: row.status, + intent: row.intent, + action: row.action, + botName: row.botName + }) + }) +}) + +/* -------------------------------------------------------------------------- + * 2. Cross-layer invariants + * + * Each of these can hold inside every layer and still be violated between + * them. That is the class of bug this file exists for. + * ----------------------------------------------------------------------- */ + +const EXTRA: Array<[string, Record]> = [ + ['axios/1.8.4', {}], + ['python-requests/2.31.0', {}], + ['Mozilla/5.0 (compatible; Applebot/0.1)', {}], + ['Mozilla/5.0 (compatible; Applebot-Extended/0.1)', {}], + ['Mozilla/5.0 (compatible; PerplexityBot/1.0)', { 'x-forwarded-for': '107.20.236.150' }], + ['Mozilla/5.0 (compatible; Perplexity-User/1.0)', {}], + ['meta-externalagent/1.1', {}], + ['', {}] +] + +/** Everything in the matrix, plus shapes worth holding to the invariants. */ +const CORPUS: Array<[string, Record]> = [ + ...MATRIX.map((r): [string, Record] => [r.ua, r.headers ?? {}]), + ...EXTRA +] + +describe('cross-layer invariants', () => { + it('agentIntent agrees with the intent agentPolicy reports', async () => { + // The exact divergence that shipped: two exported functions, same question. + for (const [ua, headers] of CORPUS) { + const req = request(ua, headers) + expect(agentIntent(ua), `intent mismatch for ${ua || '(empty)'}`).toBe( + agentPolicy(req).intent + ) + } + }) + + it('the emitted event never contradicts the classifier', async () => { + for (const [ua, headers] of CORPUS) { + const req = request(ua, headers) + const c = classifyRequest(req) + const spy = vi.fn() + await trackVisit(req, { analytics: customAnalytics(spy), idSecret: 's' }) + const p = (spy.mock.calls[0]![0] as CaptureEvent).properties + expect({ n: p.bot_name, k: p.ua_category, ai: p.is_ai_bot }, `event vs classifier: ${ua}`).toEqual( + { n: c.label, k: c.kind, ai: c.isAiBot } + ) + } + }) + + it('an async verifier actually reaches the decision', async () => { + // paymentGate once accepted a verifier and dropped it on the floor, so a + // spoofed identity was priced instead of refused. + const spoofed = request('Mozilla/5.0 (compatible; ClaudeBot/1.0)', { + 'x-forwarded-for': DATACENTRE_IP + }) + const withVerify = await paymentGate(spoofed, { + gateway: gateway(), + onTraining: 'charge', + verify: combinedVerifier() + }) + const withoutVerify = await paymentGate(spoofed, { + gateway: gateway(), + onTraining: 'charge' + }) + expect(withVerify.decision.action).toBe('block') + expect(withVerify.response?.status).toBe(403) + // Without verification the same request is merely charged — proving the + // verifier changed the outcome rather than being ignored. + expect(withoutVerify.decision.action).toBe('charge') + expect(withoutVerify.response?.status).toBe(402) + }) + + it('a spoofed verdict always blocks, and never merely prices', async () => { + for (const action of ['meter', 'charge'] as const) { + const g = await paymentGate( + request('Mozilla/5.0 (compatible; ChatGPT-User/1.0)', { + 'x-forwarded-for': DATACENTRE_IP + }), + { gateway: gateway(), onTraining: action, verify: combinedVerifier() } + ) + expect(g.decision.verification).toBe('spoofed') + expect(g.decision.action).toBe('block') + expect(g.response?.status).toBe(403) + } + }) + + it('retrieval is never gated, under any policy configuration', async () => { + // Charging the channel that sends you readers is the one outcome the whole + // design exists to prevent, so it is asserted against every knob. + const retrieval = [ + 'Mozilla/5.0 (compatible; ChatGPT-User/1.0)', + 'Claude-User (claude-code/2.1.218)', + 'Mozilla/5.0 (compatible; Perplexity-User/1.0)' + ] + for (const ua of retrieval) { + for (const onTraining of ['meter', 'charge'] as const) { + for (const verify of [false, true]) { + const g = await paymentGate(request(ua, { 'x-forwarded-for': OPENAI_IP }), { + gateway: gateway(), + onTraining, + ...(verify ? { verify: combinedVerifier() } : {}) + }) + expect(g.decision.intent, `${ua} verify=${verify}`).toBe('retrieval') + expect(g.response, `${ua} onTraining=${onTraining} verify=${verify}`).toBeNull() + } + } + } + }) + + it('unverifiable never becomes spoofed anywhere in the stack', async () => { + // Vendors with no published feed, and agents on a user's own machine, must + // not be refused just because we cannot check them. + const unknowable: Array<[string, string]> = [ + ['Mozilla/5.0 (compatible; Bytespider/1.0)', DATACENTRE_IP], + ['Mozilla/5.0 (compatible; Amazonbot/0.1)', DATACENTRE_IP], + ['Claude-User (claude-code/2.1.218)', '109.135.42.185'] + ] + for (const [ua, ip] of unknowable) { + const req = request(ua, { 'x-forwarded-for': ip }) + expect(verifyRequest(req).verdict).not.toBe('spoofed') + const g = await paymentGate(req, { + gateway: gateway(), + onTraining: 'charge', + verify: combinedVerifier() + }) + expect(g.decision.action, ua).not.toBe('block') + } + }) + + it('never emits a raw IP unless captureIp is set, whatever else is on', async () => { + for (const [ua] of CORPUS) { + const spy = vi.fn() + await trackVisit(request(ua, { 'x-forwarded-for': '203.0.113.9' }), { + analytics: customAnalytics(spy), + idSecret: 's', + captureCountry: true, + captureGeo: true, + verify: verifyRequest + }) + const e = spy.mock.calls[0]?.[0] + if (e) expect(JSON.stringify(e), ua).not.toContain('203.0.113.9') + } + }) +}) From 553d366ab5b096be89cc45a8c68404c29e097a91 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 12:44:19 +0200 Subject: [PATCH 6/8] feat(payments): metering as the primary path, plus bulk entitlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut led with per-request 402, which is what x402 and MPP define and the wrong shape for a training sweep. On one production site training traffic is ~199,000 requests a month. Charging each one means three times the traffic once pay-and-retry is added, 199,000 settlements whose per-transaction cost exceeds any sane per-page price, and — decisively — no crawler in the wild retries a 402. Per-request charging is blocking with extra steps, which is the outcome this whole design argues against. Two workable shapes, both now supported. METERING, first-class rather than a side effect of onDecision. A Meter interface, and paymentGate calls it for every 'meter' decision: serve the request, count the unit, bill out of band. No crawler cooperation, no protocol dependency, works today, and it produces the only number worth taking into a licensing conversation. Errors are swallowed — a metering failure must not become a failed response. Retrieval and search are never metered. ENTITLEMENTS, so a sweep is sold a licence instead of a page. entitlementGateway takes a store and a BulkOffer. One 402 advertises the offer, one settlement issues a credential, and every later request presents it, is served directly, and decrements quota. One settlement per licence rather than per page. Unmetered licences are usable and still call consume(), so you can count without capping. Unknown, expired and exhausted credentials return an identical challenge — distinguishing them would make the endpoint an oracle for probing quota state, and there is a test asserting the bodies match. No storage ships with it beyond an in-memory store marked test-only: quota state is money, and it belongs in the caller's KV or database, not in a library that runs per-instance at the edge. x-quota-remaining is opt-in and documented as belonging to neither protocol. README and the site section are re-pitched around metering. Presenting per-request charging as the headline overstated what is practical. Tests 329 -> 341. --- README.md | 54 +++++++++-- src/entitlement.ts | 187 +++++++++++++++++++++++++++++++++++++++ src/gateway.ts | 48 +++++++++- src/index.ts | 9 ++ test/entitlement.test.ts | 184 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 src/entitlement.ts create mode 100644 test/entitlement.test.ts diff --git a/README.md b/README.md index 540b6e0..768d453 100644 --- a/README.md +++ b/README.md @@ -92,25 +92,63 @@ Now you can build: > yet presented a payment credential. Detection, verification and policy are > stable; this is not. Do not put it on a revenue-critical path yet. -Over 2.5 million sites answer bulk AI crawling with `Disallow`. That leaves -money on the table and only works if the crawler cooperates. The alternative is -to price it — which only works if you can tell training from retrieval, because -charging a `ChatGPT-User` fetch means charging the person who just asked about -you. +### Meter first. Charge later, if at all. + +Per-request 402 is what x402 and MPP define, and it is the wrong shape for a +training sweep. On one production site that is ~199,000 training requests a +month: three times the traffic once you add pay-and-retry, 199,000 settlements +whose per-transaction cost exceeds any sane per-page price, and — decisively — +**no crawler in the wild retries a 402**. Charging per request is blocking with +extra steps. + +So start by counting: ```ts -import { paymentGate, x402Gateway } from '@apideck/agent-analytics' +import { paymentGate } from '@apideck/agent-analytics' import { combinedVerifier } from '@apideck/agent-analytics/verify' const gate = await paymentGate(req, { - onTraining: 'charge', verify: combinedVerifier(), - gateway: x402Gateway({ challenges: [...], settle: myFacilitator }) + meter: { record: (e) => warehouse.insert(e) } // training only }) if (gate.response) return gate.response return gate.decorate(await serve(req)) ``` +`meter` fires only for training traffic. Retrieval and search are served free +and never counted, because charging the channel that sends you readers is the +one outcome this design exists to prevent. + +### Then sell a licence, not a page + +When you know the number, switch to an entitlement: one 402 advertising a bulk +offer, one settlement, a reusable credential. + +```ts +import { entitlementGateway } from '@apideck/agent-analytics' + +const gate = await paymentGate(req, { + onTraining: 'charge', + gateway: entitlementGateway({ + store: myKV, // lookup + consume; quota state is yours + offer: { units: 1_000_000, unit: 'pages', validForSeconds: 2_592_000, price: '$400' }, + challenges: [{ protocol: 'mpp', id, realm: 'example.com', method: 'tempo' }] + }) +}) +``` + +``` +402 once, advertising the licence +200 every request after, quota −1 +402 again when it runs out +``` + +Unknown, expired and exhausted credentials all return the same challenge — +distinguishing them would turn the endpoint into an oracle for probing quota. + +MPP's reusable `Authorization: Payment` credential suits this better than +x402's per-resource signature, which proves payment for a single URL. + Measured against real traffic shapes: ``` diff --git a/src/entitlement.ts b/src/entitlement.ts new file mode 100644 index 0000000..bac2b93 --- /dev/null +++ b/src/entitlement.ts @@ -0,0 +1,187 @@ +/** + * Quota and entitlements — the model that actually works for training crawls. + * **EXPERIMENTAL**, like the rest of the payment surface. + * + * Per-request 402 is what x402 and MPP define, and it is the wrong shape for a + * training sweep. On one production site training traffic is ~199,000 requests a + * month. Charging each one means three times the traffic (402, pay, retry), + * 199,000 settlements whose per-transaction cost exceeds any sane per-page + * price, and — decisively — no crawler in the wild implements the retry, so a + * per-request 402 is just blocking with extra steps. + * + * Two workable shapes instead, both supported here: + * + * METER Serve the request, count it, bill out of band. Needs no crawler + * cooperation and works today. This is the one to ship. + * + * ENTITLEMENT Challenge once with a bulk offer, take payment, issue a + * credential. Every later request presents it and is served + * directly, decrementing quota. One settlement per licence rather + * than per page. + * + * MPP's reusable `Authorization: Payment` credential fits entitlements better + * than x402's per-resource signature, which proves payment for one URL. + */ + +import { paymentRequired, paymentPayload, type PaymentChallengeOptions } from './payments.js' +import type { GatewayResult, PaymentGateway } from './gateway.js' + +/** What a buyer holds after paying. */ +export interface Entitlement { + /** Opaque licence id, for your own accounting. */ + id: string + /** + * Units left. Omit for an unmetered licence — `consume` is still called, so + * you can count without capping. + */ + remaining?: number + /** Expiry as epoch seconds. Omit for no expiry. */ + expiresAt?: number +} + +/** + * Where entitlements live. A KV namespace, Redis, your database — anything + * reachable from the edge. The library deliberately ships no storage: quota + * state is yours, and so is the money it represents. + */ +export interface EntitlementStore { + /** Resolve the credential a client presented. Return null to challenge. */ + lookup(credential: string, req: Request): Promise | Entitlement | null + /** + * Record consumption after a request is admitted. Called for every served + * request, including unmetered licences, so this doubles as your meter. + */ + consume?(entitlement: Entitlement, req: Request): Promise | void +} + +/** + * What is for sale. Folded into the challenge so an agent sees a bulk product + * rather than a price for the single page it happened to ask for. + */ +export interface BulkOffer { + /** e.g. 1_000_000 */ + units: number + /** e.g. `'pages'` */ + unit: string + /** Licence lifetime in seconds. */ + validForSeconds: number + /** Total price, in whatever units your challenge already uses. */ + price: string + /** Summary surfaced to the agent. */ + description?: string +} + +export interface EntitlementGatewayOptions extends PaymentChallengeOptions { + store: EntitlementStore + /** The bulk product the 402 advertises. */ + offer: BulkOffer + /** + * Emit `x-quota-remaining` on served responses so a paying crawler can see + * its balance and slow down before running out. + * + * Off by default: this header is **not** part of x402 or MPP. It is a + * convenience, and a crawler that does not know it will ignore it. + */ + exposeRemaining?: boolean +} + +function offerDescription(offer: BulkOffer): string { + const days = Math.round(offer.validForSeconds / 86_400) + const window = days >= 1 ? `${days} day${days === 1 ? '' : 's'}` : `${offer.validForSeconds}s` + return ( + offer.description ?? + `${offer.units.toLocaleString('en-US')} ${offer.unit} for ${window}, ${offer.price}` + ) +} + +/** + * Gateway that honours a bulk licence instead of charging per request. + * + * A request carrying a valid credential is served and its quota decremented — + * no challenge, no round-trip. A request without one gets a single 402 + * advertising the bulk offer. + * + * @example + * ```ts + * const gateway = entitlementGateway({ + * store: myKvStore, + * offer: { units: 1_000_000, unit: 'pages', validForSeconds: 2_592_000, price: '$400' }, + * challenges: [{ protocol: 'mpp', id, realm: 'example.com', method: 'tempo' }] + * }) + * ``` + */ +export function entitlementGateway(opts: EntitlementGatewayOptions): PaymentGateway { + const { store, offer, exposeRemaining, ...challenge } = opts + + return { + async handle(req: Request): Promise { + const submitted = paymentPayload(req) + + if (submitted) { + const ent = await store.lookup(submitted.value, req) + const now = Math.floor(Date.now() / 1000) + + const usable = + ent !== null && + (ent.expiresAt === undefined || ent.expiresAt > now) && + // `undefined` remaining means unmetered, which is usable. Zero is not. + (ent.remaining === undefined || ent.remaining > 0) + + if (usable && ent) { + await store.consume?.(ent, req) + const left = ent.remaining === undefined ? undefined : ent.remaining - 1 + return { + status: 'paid', + ...(exposeRemaining && left !== undefined + ? { + receipt: (res: Response) => { + const h = new Headers(res.headers) + h.set('x-quota-remaining', String(Math.max(0, left))) + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: h + }) + } + } + : {}) + } + } + } + + // No credential, expired, or exhausted — all get the same offer. Saying + // which of the three it was would leak quota state to anyone probing. + return { + status: 'challenge', + response: paymentRequired({ + ...challenge, + body: `Payment required for training access.\nOffer: ${offerDescription(offer)}\n`, + headers: { + ...(challenge.headers ?? {}), + 'x-bulk-offer': offerDescription(offer) + } + }) + } + } + } +} + +/** + * In-memory store. For tests and local development only — an edge runtime + * gives each instance its own memory, so quota would neither be shared nor + * survive a deploy. Use KV, Redis, or your database in production. + */ +export function memoryEntitlementStore( + seed: Record = {} +): EntitlementStore & { entries(): Record } { + const map = new Map(Object.entries(seed)) + return { + lookup: (credential) => map.get(credential) ?? null, + consume: (ent) => { + if (ent.remaining !== undefined) { + map.set(ent.id, { ...ent, remaining: ent.remaining - 1 }) + } + }, + entries: () => Object.fromEntries(map) + } +} diff --git a/src/gateway.ts b/src/gateway.ts index ef97b3b..f4530cb 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -100,8 +100,37 @@ export function x402Gateway(opts: X402GatewayOptions): PaymentGateway { } } +/** One unit of billable agent traffic. */ +export interface MeterRecord { + decision: AgentDecision + /** Units consumed. One request is one unit unless you price by bytes or tokens. */ + units: number + path: string + method: string +} + +/** + * Where billable usage goes. + * + * Metering is the model to ship first: it needs no crawler cooperation, works + * today, and produces the number you would negotiate a licence with. Charging + * per request is what the protocols define but not what a training sweep can + * actually do — no crawler in the wild retries a 402. + */ +export interface Meter { + record(entry: MeterRecord): Promise | void +} + export interface PaymentGateOptions extends Omit { gateway: PaymentGateway + /** + * Sink for billable traffic. Called for every `'meter'` decision — serve the + * request, count it, bill out of band. + * + * Errors are swallowed: a metering failure must not turn into a failed + * response, for the same reason analytics failures do not. + */ + meter?: Meter /** * Identity verifier, sync or async. Unlike {@link agentPolicy}'s option this * accepts a promise, because `paymentGate` is already async and can await it. @@ -148,7 +177,7 @@ export async function paymentGate( /** Wrap the response you were going to send. Identity when nothing to add. */ decorate: (res: Response) => Response }> { - const { gateway, onDecision, verify, ...policyOpts } = opts + const { gateway, onDecision, verify, meter, ...policyOpts } = opts // Await here so an async verifier works. Passing the function straight into // agentPolicy would hand it a promise to read `.verdict` off — undefined at // runtime, and a type error at compile time. @@ -171,6 +200,23 @@ export async function paymentGate( } } + if (decision.action === 'meter') { + // Count it and serve it. This is the path most sites should be on. + try { + let path = req.url + let method = req.method + try { + path = new URL(req.url).pathname + } catch { + /* relative URL from some runtimes — keep the raw string */ + } + await meter?.record({ decision, units: 1, path, method }) + } catch { + // Metering must never turn into a failed response. + } + return { decision, response: null, decorate: identity } + } + if (decision.action !== 'charge') { return { decision, response: null, decorate: identity } } diff --git a/src/index.ts b/src/index.ts index df3a079..7f1efa3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,8 +15,17 @@ export { hashId, randomSecret, HashSecretError } from './hash.js' export { CaptureTransportError } from './errors.js' export { agentIntent, agentPolicy } from './policy.js' export { mppxGateway, paymentGate, x402Gateway } from './gateway.js' +export { entitlementGateway, memoryEntitlementStore } from './entitlement.js' +export type { + BulkOffer, + Entitlement, + EntitlementGatewayOptions, + EntitlementStore +} from './entitlement.js' export type { GatewayResult, + Meter, + MeterRecord, MppxResponse, PaymentGateOptions, PaymentGateway, diff --git a/test/entitlement.test.ts b/test/entitlement.test.ts new file mode 100644 index 0000000..cdb5fdb --- /dev/null +++ b/test/entitlement.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest' +import { entitlementGateway, memoryEntitlementStore } from '../src/entitlement.js' +import { paymentGate, type Meter } from '../src/gateway.js' + +const CHALLENGES = [ + { protocol: 'mpp' as const, id: 'c1', realm: 'example.com', method: 'tempo', intent: 'charge' } +] +const OFFER = { + units: 1_000_000, + unit: 'pages', + validForSeconds: 2_592_000, + price: '$400' +} + +const GPTBOT = 'Mozilla/5.0 (compatible; GPTBot/1.1)' +const req = (ua: string, headers: Record = {}) => + new Request('https://example.com/docs/intro', { headers: { 'user-agent': ua, ...headers } }) + +/** x402 and MPP present credentials in different headers. */ +const withX402 = (cred: string) => ({ 'PAYMENT-SIGNATURE': cred }) +const withMpp = (cred: string) => ({ Authorization: `Payment ${cred}` }) + +describe('entitlementGateway', () => { + it('challenges once, advertising the bulk offer rather than a page price', async () => { + const g = entitlementGateway({ + store: memoryEntitlementStore(), + offer: OFFER, + challenges: CHALLENGES + }) + const out = await g.handle(req(GPTBOT)) + expect(out.status).toBe('challenge') + if (out.status === 'challenge') { + expect(out.response.status).toBe(402) + // The whole point: a sweep should be sold a licence, not one page. + expect(out.response.headers.get('x-bulk-offer')).toContain('1,000,000 pages') + expect(out.response.headers.get('x-bulk-offer')).toContain('30 days') + expect(await out.response.text()).toContain('$400') + } + }) + + it('serves a request holding a valid licence, with no round trip', async () => { + const store = memoryEntitlementStore({ lic_1: { id: 'lic_1', remaining: 10 } }) + const g = entitlementGateway({ store, offer: OFFER, challenges: CHALLENGES }) + expect((await g.handle(req(GPTBOT, withX402('lic_1')))).status).toBe('paid') + expect((await g.handle(req(GPTBOT, withMpp('lic_1')))).status).toBe('paid') + }) + + it('decrements quota as it serves', async () => { + const store = memoryEntitlementStore({ lic_1: { id: 'lic_1', remaining: 3 } }) + const g = entitlementGateway({ store, offer: OFFER, challenges: CHALLENGES }) + for (let i = 0; i < 3; i++) await g.handle(req(GPTBOT, withX402('lic_1'))) + expect(store.entries().lic_1!.remaining).toBe(0) + // Exhausted licences go back to the offer. + expect((await g.handle(req(GPTBOT, withX402('lic_1')))).status).toBe('challenge') + }) + + it('treats an unmetered licence as usable and still counts it', async () => { + const consume = vi.fn() + const g = entitlementGateway({ + store: { lookup: () => ({ id: 'unlimited' }), consume }, + offer: OFFER, + challenges: CHALLENGES + }) + expect((await g.handle(req(GPTBOT, withX402('unlimited')))).status).toBe('paid') + // No cap, but you still get the number — metering without gating. + expect(consume).toHaveBeenCalledOnce() + }) + + it('rejects an expired licence', async () => { + const past = Math.floor(Date.now() / 1000) - 60 + const g = entitlementGateway({ + store: { lookup: () => ({ id: 'old', remaining: 100, expiresAt: past }) }, + offer: OFFER, + challenges: CHALLENGES + }) + expect((await g.handle(req(GPTBOT, withX402('old')))).status).toBe('challenge') + }) + + it('rejects an unknown credential', async () => { + const g = entitlementGateway({ + store: memoryEntitlementStore({ lic_1: { id: 'lic_1', remaining: 5 } }), + offer: OFFER, + challenges: CHALLENGES + }) + expect((await g.handle(req(GPTBOT, withX402('forged')))).status).toBe('challenge') + }) + + it('does not leak why a credential failed', async () => { + // Unknown, expired and exhausted must be indistinguishable, or the endpoint + // becomes an oracle for probing quota state. + const past = Math.floor(Date.now() / 1000) - 60 + const cases = [ + { lookup: () => null }, + { lookup: () => ({ id: 'x', remaining: 0 }) }, + { lookup: () => ({ id: 'x', remaining: 9, expiresAt: past }) } + ] + const bodies = new Set() + for (const store of cases) { + const out = await entitlementGateway({ store, offer: OFFER, challenges: CHALLENGES }).handle( + req(GPTBOT, withX402('c')) + ) + if (out.status === 'challenge') bodies.add(await out.response.text()) + } + expect(bodies.size).toBe(1) + }) + + it('exposes remaining quota only when asked', async () => { + const store = memoryEntitlementStore({ lic_1: { id: 'lic_1', remaining: 7 } }) + const off = await entitlementGateway({ store, offer: OFFER, challenges: CHALLENGES }).handle( + req(GPTBOT, withX402('lic_1')) + ) + expect(off.status === 'paid' && off.receipt).toBeUndefined() + + const on = await entitlementGateway({ + store: memoryEntitlementStore({ lic_2: { id: 'lic_2', remaining: 7 } }), + offer: OFFER, + challenges: CHALLENGES, + exposeRemaining: true + }).handle(req(GPTBOT, withX402('lic_2'))) + expect(on.status).toBe('paid') + if (on.status === 'paid') { + expect(on.receipt!(new Response('ok')).headers.get('x-quota-remaining')).toBe('6') + } + }) +}) + +describe('metering through paymentGate', () => { + function meter() { + const entries: Array<{ action: string; intent: string; path: string; units: number }> = [] + const m: Meter = { + record: (e) => + void entries.push({ + action: e.decision.action, + intent: e.decision.intent, + path: e.path, + units: e.units + }) + } + return { m, entries } + } + + const gateway = entitlementGateway({ + store: memoryEntitlementStore(), + offer: OFFER, + challenges: CHALLENGES + }) + + it('counts training traffic and still serves it', async () => { + const { m, entries } = meter() + const g = await paymentGate(req(GPTBOT), { gateway, meter: m }) + expect(g.decision.action).toBe('meter') + expect(g.response).toBeNull() // served, not gated + expect(entries).toEqual([ + { action: 'meter', intent: 'training', path: '/docs/intro', units: 1 } + ]) + }) + + it('does not meter retrieval or search', async () => { + const { m, entries } = meter() + for (const ua of [ + 'Mozilla/5.0 (compatible; ChatGPT-User/1.0)', + 'Mozilla/5.0 (compatible; Googlebot/2.1)' + ]) { + await paymentGate(req(ua), { gateway, meter: m }) + } + expect(entries).toHaveLength(0) + }) + + it('does not meter a charged request — that is the gateway"s job', async () => { + const { m, entries } = meter() + const g = await paymentGate(req(GPTBOT), { gateway, meter: m, onTraining: 'charge' }) + expect(g.response?.status).toBe(402) + expect(entries).toHaveLength(0) + }) + + it('survives a meter that throws', async () => { + // A metering failure must not become a failed response. + const g = await paymentGate(req(GPTBOT), { + gateway, + meter: { record: () => Promise.reject(new Error('warehouse down')) } + }) + expect(g.response).toBeNull() + }) +}) From 37c387d8123b3b5aae014b333e39d1c6b698a29b Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 12:56:58 +0200 Subject: [PATCH 7/8] feat: Vercel WAF rule recommender, and a payments testing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIREWALL RECOMMENDER recommendFirewallRules() turns aggregated traffic into staged Vercel WAF proposals, and firewallScript() renders them as commented bash. It proposes; it never enforces. Every rule is emitted with action 'log', because a rule's blast radius is unknowable until real traffic hits it, and Vercel stages rule changes as drafts anyway — nothing is live until a human publishes. The generated script deliberately stops at `vercel firewall diff` and prints the publish command rather than running it. Two invariants, both derived from measurement rather than preference: - Retrieval agents and search crawlers are never proposed for blocking, and a 'bypass' rule protecting them is emitted first. Vercel evaluates rules top to bottom, so without that ordering a user-agent rule below would swallow the agents that bring readers — 60% of AI traffic on one production site. There is a test asserting no enforcing rule's conditions ever mention ChatGPT-User, Claude-User, Perplexity-User or Googlebot. - Training crawlers get rate limits, not denials. Removing yourself from future training sets is a discoverability decision, not a default, and the caveat says so. Only failed verification earns a proposed deny. Every recommendation carries evidence, a risk rating and a caveat: the datacenter-ASN rule is marked high risk because corporate VPNs, privacy relays and some mobile carriers egress from hosting ASNs, and a challenge page breaks API clients outright. PAYMENTS TESTING GUIDE docs/TESTING-PAYMENTS.md, four levels cheapest first: pure functions with no keys, curl against a running app, Web Bot Auth signatures, then real settlement via Stripe MPP or an x402 facilitator. The level-1 script was run verbatim from a clean install of the packed package and its documented output is the actual output, not an illustration. It closes with what none of it tests, which matters more than the checklist: no real crawler retries a 402 today, so charging per request is functionally blocking; memoryEntitlementStore has no atomic decrement so concurrent regions can oversell a licence; and no public price exists for a training crawl. Tests 341 -> 356. --- README.md | 31 ++++ docs/TESTING-PAYMENTS.md | 261 +++++++++++++++++++++++++++ src/firewall.ts | 370 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 9 + test/firewall.test.ts | 154 ++++++++++++++++ 5 files changed, 825 insertions(+) create mode 100644 docs/TESTING-PAYMENTS.md create mode 100644 src/firewall.ts create mode 100644 test/firewall.test.ts diff --git a/README.md b/README.md index 768d453..e9d5d51 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,37 @@ without Web Crypto now fail with an explicit message rather than a confusing - Outbound captures carry a 3s `AbortSignal` (`timeoutMs` to change it). - Root bundle is 65% smaller (27.7 kB → 9.6 kB, 3.8 kB gzipped). +## Recommending firewall rules + +Turn observed traffic into staged Vercel WAF proposals. It emits *proposals* — +every rule comes out in `log` mode and Vercel stages rule changes as drafts, so +nothing is live until you run `vercel firewall publish` yourself. + +```ts +import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics' + +const rules = recommendFirewallRules(observations) // aggregate from your warehouse +console.log(firewallScript(rules)) // runnable, commented bash +``` + +Two rules it will not break, both from measurement rather than taste: + +- **Retrieval and search agents are never proposed for blocking**, and a `bypass` + rule protecting them is emitted *first* so later rules cannot catch them. + Rules evaluate top to bottom, and 60% of AI traffic on one production site is + a person asking a question. +- **Training crawlers get rate limits, not denials.** Denying them removes you + from future training sets, which is a discoverability decision rather than a + default. + +Only a failed verification earns a proposed `deny`. Every recommendation carries +its `evidence`, a `risk` rating, and a `caveat` where over-blocking is plausible +— the datacenter-ASN rule is marked `high` risk because corporate VPNs and +privacy relays egress from hosting networks. + +See [`docs/TESTING-PAYMENTS.md`](./docs/TESTING-PAYMENTS.md) for testing the +payment path end to end. + ## Install ```bash diff --git a/docs/TESTING-PAYMENTS.md b/docs/TESTING-PAYMENTS.md new file mode 100644 index 0000000..0361c4c --- /dev/null +++ b/docs/TESTING-PAYMENTS.md @@ -0,0 +1,261 @@ +# Testing the payment path + +> **The payment surface is experimental.** These protocols are weeks old, their +> specs are moving, and no agent in our own production traffic has yet presented +> a payment credential. Test it, don't depend on it. Metering is the part that +> works today and needs nobody's cooperation. + +Four levels, cheapest first. Do them in order — most bugs are caught at level 1, +and level 4 needs real credentials. + +--- + +## Level 1 — No network, no keys (30 seconds) + +Everything except settlement is pure functions over a `Request`. You can drive +the whole decision path in a script. + +```bash +npm i @apideck/agent-analytics +``` + +```js +// pay.mjs +import { paymentGate, entitlementGateway, memoryEntitlementStore } from '@apideck/agent-analytics' +import { combinedVerifier } from '@apideck/agent-analytics/verify' + +const store = memoryEntitlementStore({ lic_abc: { id: 'lic_abc', remaining: 3 } }) + +const gateway = entitlementGateway({ + store, + offer: { units: 1_000_000, unit: 'pages', validForSeconds: 2_592_000, price: '$400' }, + challenges: [{ protocol: 'mpp', id: 'c1', realm: 'example.com', method: 'tempo', intent: 'charge' }], + exposeRemaining: true +}) + +const req = (ua, headers = {}) => + new Request('https://example.com/docs/intro', { headers: { 'user-agent': ua, ...headers } }) + +for (const [ua, headers, label] of [ + ['Mozilla/5.0 (compatible; GPTBot/1.1)', {}, 'training, no licence'], + ['Mozilla/5.0 (compatible; GPTBot/1.1)', { Authorization: 'Payment lic_abc' }, 'training, licensed'], + ['Mozilla/5.0 (compatible; ChatGPT-User/1.0)', {}, 'retrieval'], + ['Mozilla/5.0 (compatible; Googlebot/2.1)', {}, 'search'] +]) { + const gate = await paymentGate(req(ua, headers), { + gateway, + onTraining: 'charge', + verify: combinedVerifier() + }) + const served = gate.decorate(new Response('the page')) + console.log( + (gate.response ? gate.response.status : 200).toString().padEnd(5), + gate.decision.intent.padEnd(10), + (served.headers.get('x-quota-remaining') ?? '').padEnd(4), + label + ) +} +``` + +``` +$ node pay.mjs +402 training training, no licence +200 training 2 training, licensed +200 retrieval retrieval +200 search search +``` + +**What this proves:** intent classification, the 402/200 split, quota +decrementing, and that retrieval and search are never gated. **What it does not +prove:** that any real agent understands the challenge. + +### Inspect the challenge + +```js +import { paymentRequired } from '@apideck/agent-analytics' + +const res = paymentRequired({ + challenges: [ + { protocol: 'x402', accepts: [{ scheme: 'exact', network: 'base', + maxAmountRequired: '1000', resource: 'https://example.com/docs', + payTo: '0xabc', asset: '0xusdc' }] }, + { protocol: 'mpp', id: 'c1', realm: 'example.com', method: 'tempo', intent: 'charge' } + ] +}) + +console.log(res.status) // 402 +console.log(res.headers.get('WWW-Authenticate')) // MPP +console.log(atob(res.headers.get('PAYMENT-REQUIRED'))) // x402, decoded +console.log(res.headers.get('content-signal')) // ai-train=paid +``` + +Both protocols on one response is intentional — they use non-colliding headers, +so the agent takes whichever it speaks. + +--- + +## Level 2 — Against a running app, with curl (5 minutes) + +Wire the gate into middleware, then drive it with user agents. + +```ts +// middleware.ts +import { NextResponse, type NextRequest } from 'next/server' +import { paymentGate, entitlementGateway } from '@apideck/agent-analytics' +import { combinedVerifier } from '@apideck/agent-analytics/verify' + +const gateway = entitlementGateway({ + store: { + // Swap for KV/Redis in production. Quota state is money. + lookup: (cred) => (cred === process.env.TEST_LICENCE ? { id: 'test', remaining: 5 } : null) + }, + offer: { units: 1_000_000, unit: 'pages', validForSeconds: 2_592_000, price: '$400' }, + challenges: [{ protocol: 'mpp', id: 'test', realm: 'example.com', method: 'tempo' }] +}) + +export async function middleware(req: NextRequest) { + const gate = await paymentGate(req, { + gateway, + onTraining: 'charge', + verify: combinedVerifier(), + meter: { record: (e) => console.log('[meter]', e.decision.intent, e.path) } + }) + if (gate.response) return gate.response + return gate.decorate(NextResponse.next()) +} +``` + +```bash +# A training crawler with no licence — expect 402 and an offer +curl -si -A 'Mozilla/5.0 (compatible; GPTBot/1.1)' localhost:3000/docs/intro \ + | grep -Ei 'HTTP/|www-authenticate|x-bulk-offer|content-signal' + +# The same crawler holding a licence — expect 200 +curl -si -A 'Mozilla/5.0 (compatible; GPTBot/1.1)' \ + -H "Authorization: Payment $TEST_LICENCE" localhost:3000/docs/intro | head -1 + +# Retrieval must never be charged +curl -si -A 'Mozilla/5.0 (compatible; ChatGPT-User/1.0)' localhost:3000/docs/intro | head -1 + +# A browser must be untouched +curl -si localhost:3000/docs/intro \ + -H 'accept-language: en-GB' -H 'sec-fetch-mode: navigate' \ + -A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36' | head -1 +``` + +**Checklist** + +- [ ] `402` for training without a credential, carrying `x-bulk-offer` +- [ ] `200` for training with a valid credential +- [ ] `200` for `ChatGPT-User`, `Claude-User`, `Googlebot` — always +- [ ] `200` for a real browser, no challenge, no quota consumed +- [ ] `403` for a spoofed identity (see below) +- [ ] `[meter]` logged for training when `onTraining` is left at `meter` + +### Testing the spoofed path + +A verification failure needs a UA claiming a vendor from an IP outside its +published range. Locally, forge the forwarded header: + +```bash +curl -si -A 'Mozilla/5.0 (compatible; ClaudeBot/1.0)' \ + -H 'x-forwarded-for: 45.55.50.205' localhost:3000/docs | head -1 # → 403 + +curl -si -A 'Mozilla/5.0 (compatible; ClaudeBot/1.0)' \ + -H 'x-forwarded-for: 34.162.230.222' localhost:3000/docs | head -1 # → 402 +``` + +> On a real deployment behind Vercel or Cloudflare the edge overwrites +> `x-forwarded-for`, so a client cannot forge it. Locally there is no edge, which +> is exactly why this works — and why **you must confirm your production edge +> controls that header before enforcing on the verdict.** + +--- + +## Level 3 — Web Bot Auth signatures (15 minutes) + +Verification of signed requests needs a real Ed25519 keypair and a key +directory. The test suite already does this end to end; reuse the approach. + +```js +// sign.mjs — a minimal agent that signs like a real one +const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']) +const jwk = await crypto.subtle.exportKey('jwk', kp.publicKey) + +// Serve this at https:///.well-known/http-message-signatures-directory +console.log(JSON.stringify({ keys: [jwk] })) +``` + +Then build the signature base over `("@authority" "@path" "signature-agent")` +with `tag="web-bot-auth"`, sign it, and send `Signature-Agent`, +`Signature-Input` and `Signature`. `test/webbotauth.test.ts` has a working +signer — copy `makeSigner()` from it rather than reimplementing RFC 9421. + +**Expect:** `bot_verification: 'verified'` and, critically, that a signature +replayed onto a **different path** fails — `@path` is covered. + +--- + +## Level 4 — Real settlement + +Neither rail is ours. Pick one. + +### Stripe MPP + +Stripe's SDK generates challenges and settles; wrap it rather than +reimplementing. + +```ts +import { mppxGateway } from '@apideck/agent-analytics' + +const mppx = Mppx.create({ methods: [...], secretKey }) +const handler = Mppx.compose( + mppx.tempo.charge({ amount: '0.01', recipient }), + mppx.stripe.charge({ amount: '0.50', currency: 'usd' }) +) + +const gate = await paymentGate(req, { gateway: mppxGateway(handler), onTraining: 'charge' }) +``` + +Requires the `2026-03-25.preview` API version. Test in Stripe test mode first; +crypto deposits are testable on Tempo testnet (`testnet: true`). + +### x402 + +```ts +x402Gateway({ + challenges: [{ protocol: 'x402', accepts: [...] }], + settle: async (payload, req) => { + const ok = await fetch('https://facilitator.example/verify', { + method: 'POST', + body: JSON.stringify({ payload, resource: req.url }) + }).then((r) => r.ok) + return ok + } +}) +``` + +Coinbase runs a hosted facilitator; Base and Solana testnets are the cheap path. + +**Do not skip:** confirm your facilitator rejects a replayed payload. `settle` +returning `true` for a payload already spent is a double-spend, and the library +cannot detect it — presence of a header is never proof. + +--- + +## What none of this tests + +Be clear-eyed about the gap between a green checklist and a working business. + +- **No real crawler retries a 402 today.** Every level above uses a client you + wrote. In the wild, GPTBot gets the 402, logs an error, and leaves. Charging + per request is functionally blocking until that changes. +- **Quota under concurrency.** `memoryEntitlementStore` is single-instance. At + the edge, two regions serving the same licence simultaneously will both read + the same `remaining`. If overselling matters, your store needs atomic + decrements — the library does not provide them. +- **Price.** No public market rate exists for a training crawl of one page. + Nothing here discovers it. + +If you only do one thing: run **level 1**, then turn on metering in production +and leave the charging alone until you have a month of numbers. diff --git a/src/firewall.ts b/src/firewall.ts new file mode 100644 index 0000000..80eb1a8 --- /dev/null +++ b/src/firewall.ts @@ -0,0 +1,370 @@ +/** + * Recommend Vercel WAF rules from observed agent traffic. + * + * This generates *proposals*, never live changes. Every recommendation comes out + * with `action: 'log'`, because a firewall rule's blast radius is unpredictable + * until real traffic hits it and a bad `deny` takes out real users or your SEO. + * Vercel's own guidance is log → review → preview → production; the `eventual` + * field records where a rule is meant to end up, and `cli` emits the command for + * the *current* stage only. + * + * Two hard rules, both from measurement rather than taste: + * + * 1. Retrieval agents and search crawlers are never proposed for blocking. + * 60% of AI traffic on one production site is retrieval — a person asked a + * question and an assistant went to read the page. Blocking that is + * blocking your own distribution. The recommender emits a `bypass` rule to + * protect them *first*, so later rules cannot catch them. + * + * 2. Training crawlers get rate limits, not denials, by default. The point is + * to bound cost, not to disappear from corpora. + * + * Only abuse gets a denial: an identity that failed cryptographic or IP + * verification, or a single address behaving like a scraper. + */ + +import type { AgentIntent } from './policy.js' + +/** A Vercel WAF condition. Mirrors the CLI's `--condition` JSON. */ +export interface FirewallCondition { + type: + | 'user_agent' + | 'ip_address' + | 'geo_as_number' + | 'geo_country' + | 'path' + | 'method' + | 'environment' + | 'ja4_digest' + op: 'eq' | 'neq' | 'sub' | 'pre' | 'suf' | 're' | 'inc' | 'ninc' | 'gt' | 'gte' + value?: string | number | Array + key?: string + neg?: boolean +} + +export type FirewallAction = 'log' | 'deny' | 'challenge' | 'bypass' | 'rate_limit' + +export interface RateLimitSpec { + /** Seconds, 10–3600. */ + window: number + /** Max requests per window. */ + requests: number + /** What happens on breach. */ + action: 'rate_limit' | 'deny' | 'challenge' | 'log' + keys: Array<'ip' | 'ja4'> +} + +export interface FirewallRecommendation { + name: string + /** Why this rule is proposed, in one sentence. */ + rationale: string + /** The measurement behind it. Never propose a rule without evidence. */ + evidence: string + /** OR of ANDs: outer array is groups, inner is conditions within a group. */ + groups: FirewallCondition[][] + /** Always `'log'` or `'bypass'` — see the module note. */ + action: FirewallAction + /** Where this rule is intended to end up after review. */ + eventual: FirewallAction + rateLimit?: RateLimitSpec + /** How likely this is to catch traffic you wanted. */ + risk: 'low' | 'medium' | 'high' + /** What could go wrong, when it is not obvious. */ + caveat?: string + /** Ready-to-run CLI for the *current* stage. */ + cli: string + /** Equivalent `--json` payload. */ + json: unknown +} + +/** One aggregated slice of observed traffic. */ +export interface TrafficObservation { + userAgent: string + botName: string + intent: AgentIntent + requests: number + ip?: string + /** Autonomous system number, if you resolved one. */ + asn?: number + /** Distinct paths this slice touched — a scraper sweeps, a reader does not. */ + distinctPaths?: number + /** Verification verdict, if you ran one. */ + verification?: 'verified' | 'spoofed' | 'unverifiable' | 'not-claimed' + country?: string +} + +export interface RecommendOptions { + /** + * Requests-per-slice above which a single IP is considered abusive. Defaults + * to 10x the median across observations, floored at 500. + */ + abuseThreshold?: number + /** Rate-limit budget proposed for training crawlers. Defaults to 600/hour. */ + trainingBudget?: { window: number; requests: number } + /** Skip the protective bypass rule. Rarely a good idea. */ + omitProtectiveBypass?: boolean +} + +/* -------------------------------------------------------------------------- */ + +function shellQuote(json: unknown): string { + return `'${JSON.stringify(json).replace(/'/g, `'\\''`)}'` +} + +function toCli(r: Omit): string { + const parts = [`vercel firewall rules add ${JSON.stringify(r.name)}`] + r.groups.forEach((group, i) => { + if (i > 0) parts.push(' --or') + for (const c of group) parts.push(` --condition ${shellQuote(c)}`) + }) + parts.push(` --action ${r.action}`) + if (r.action === 'rate_limit' && r.rateLimit) { + parts.push(` --rate-limit-window ${r.rateLimit.window}`) + parts.push(` --rate-limit-requests ${r.rateLimit.requests}`) + parts.push(` --rate-limit-action ${r.rateLimit.action}`) + for (const k of r.rateLimit.keys) parts.push(` --rate-limit-keys ${k}`) + } + parts.push(' --yes') + return parts.join(' \\\n') +} + +function toJson(r: Omit): unknown { + return { + name: r.name, + conditionGroup: r.groups.map((conditions) => ({ conditions })), + action: { mitigate: { action: r.action } } + } +} + +function finish(r: Omit): FirewallRecommendation { + return { ...r, cli: toCli(r), json: toJson(r) } +} + +function median(ns: number[]): number { + if (!ns.length) return 0 + const s = [...ns].sort((a, b) => a - b) + const mid = Math.floor(s.length / 2) + return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2 +} + +/* -------------------------------------------------------------------------- */ + +/** + * Turn observations into staged WAF proposals. + * + * @example + * ```ts + * const rules = recommendFirewallRules(observations) + * for (const r of rules) { + * console.log(`# ${r.name} — ${r.rationale}`) + * console.log(`# evidence: ${r.evidence}`) + * console.log(r.cli) + * } + * ``` + */ +export function recommendFirewallRules( + observations: readonly TrafficObservation[], + opts: RecommendOptions = {} +): FirewallRecommendation[] { + const out: FirewallRecommendation[] = [] + + /* 1. Protect the traffic you want, first and above everything else. -------- + Rules are evaluated top to bottom, so this has to be rule #1 or a later + user-agent rule will swallow the agents that bring you readers. */ + if (!opts.omitProtectiveBypass) { + const wanted = observations.filter((o) => o.intent === 'retrieval' || o.intent === 'search') + const requests = wanted.reduce((n, o) => n + o.requests, 0) + const names = [...new Set(wanted.map((o) => o.botName))] + out.push( + finish({ + name: 'Allow retrieval and search agents', + rationale: + 'Retrieval agents and search crawlers must never be caught by the rules below — they bring readers and rankings.', + evidence: requests + ? `${requests.toLocaleString('en-US')} observed requests across ${names.length} vendors (${names.slice(0, 6).join(', ')})` + : 'no retrieval or search traffic observed yet; installed pre-emptively', + groups: [ + [ + { + type: 'user_agent', + op: 'inc', + value: [ + 'ChatGPT-User', + 'OAI-SearchBot', + 'Claude-User', + 'Claude-SearchBot', + 'Perplexity-User', + 'Googlebot', + 'bingbot', + 'DuckDuckBot', + 'Applebot' + ] + } + ] + ], + action: 'bypass', + eventual: 'bypass', + risk: 'low', + caveat: + 'Place this rule first (`vercel firewall rules reorder ... --first`). A user-agent allowlist is spoofable, so pair with verification in middleware rather than relying on it for security — its job here is to stop your own rules misfiring.' + }) + ) + } + + /* 2. Failed verification — the only class that earns a denial. ------------- */ + const spoofed = observations.filter((o) => o.verification === 'spoofed') + const spoofedIps = [...new Set(spoofed.map((o) => o.ip).filter((v): v is string => !!v))] + if (spoofedIps.length) { + const requests = spoofed.reduce((n, o) => n + o.requests, 0) + const vendors = [...new Set(spoofed.map((o) => o.botName))] + out.push( + finish({ + name: 'Deny impersonated crawler identities', + rationale: + 'These addresses claimed a crawler identity that failed verification against the vendor’s published ranges or signature.', + evidence: `${requests.toLocaleString('en-US')} requests from ${spoofedIps.length} address${spoofedIps.length === 1 ? '' : 'es'} impersonating ${vendors.join(', ')}`, + groups: [[{ type: 'ip_address', op: 'inc', value: spoofedIps }]], + action: 'log', + eventual: 'deny', + risk: 'low', + caveat: + 'Verification failure is strong evidence, but confirm your edge controls x-forwarded-for before enforcing — behind a proxy that forwards a client-supplied header the verdict is worthless.' + }) + ) + } + + /* 3. Single addresses behaving like scrapers. ------------------------------ */ + const perIp = observations.filter((o) => o.ip && o.verification !== 'verified') + const threshold = + opts.abuseThreshold ?? Math.max(500, Math.round(median(perIp.map((o) => o.requests)) * 10)) + const heavy = perIp + .filter((o) => o.requests >= threshold) + .sort((a, b) => b.requests - a.requests) + .slice(0, 50) + if (heavy.length) { + const sweeping = heavy.filter((o) => (o.distinctPaths ?? 0) > 100) + out.push( + finish({ + name: 'Rate limit high-volume unverified addresses', + rationale: + 'A single address making orders of magnitude more requests than the median, with no verified identity.', + evidence: + `${heavy.length} address${heavy.length === 1 ? '' : 'es'} above ${threshold.toLocaleString('en-US')} requests` + + (sweeping.length + ? `; ${sweeping.length} swept >100 distinct paths, which reads as a scrape rather than a reader` + : ''), + groups: [[{ type: 'ip_address', op: 'inc', value: heavy.map((o) => o.ip!) }]], + action: 'log', + eventual: 'rate_limit', + rateLimit: { window: 60, requests: 60, action: 'rate_limit', keys: ['ip'] }, + risk: 'medium', + caveat: + 'Shared egress means one address can front many real users — a corporate NAT, a mobile carrier, or a VPN. Review the dashboard before enforcing.' + }) + ) + } + + /* 4. Training crawlers: bound the cost, do not disappear from corpora. ----- */ + const training = observations.filter((o) => o.intent === 'training') + if (training.length) { + const requests = training.reduce((n, o) => n + o.requests, 0) + const vendors = [...new Set(training.map((o) => o.botName))] + const budget = opts.trainingBudget ?? { window: 3600, requests: 600 } + out.push( + finish({ + name: 'Rate limit training crawlers', + rationale: + 'Bound what bulk corpus collection costs you without removing yourself from training sets.', + evidence: `${requests.toLocaleString('en-US')} training requests from ${vendors.length} vendors (${vendors.slice(0, 6).join(', ')})`, + groups: [ + [ + { + type: 'user_agent', + op: 'inc', + value: ['GPTBot', 'ClaudeBot', 'CCBot', 'Bytespider', 'Amazonbot', 'meta-externalagent'] + } + ] + ], + action: 'log', + eventual: 'rate_limit', + rateLimit: { window: budget.window, requests: budget.requests, action: 'rate_limit', keys: ['ip'] }, + risk: 'medium', + caveat: + 'Denying these removes you from future training sets, which may be exactly wrong for discoverability. Rate limit rather than deny unless you have decided otherwise. Note Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx.' + }) + ) + } + + /* 5. Datacenter ASNs presenting browser user agents. ---------------------- */ + const headlessAsns = [ + ...new Set( + observations + .filter((o) => o.asn !== undefined && /Mozilla|Chrome|Safari/i.test(o.userAgent)) + .filter((o) => o.verification !== 'verified') + .map((o) => o.asn!) + ) + ] + if (headlessAsns.length) { + const slices = observations.filter((o) => o.asn !== undefined && headlessAsns.includes(o.asn)) + const requests = slices.reduce((n, o) => n + o.requests, 0) + out.push( + finish({ + name: 'Challenge browser user agents from datacenter networks', + rationale: + 'A browser user agent arriving from a hosting network is automation wearing a costume — real browsers come from consumer ISPs.', + evidence: `${requests.toLocaleString('en-US')} requests across ${headlessAsns.length} datacenter AS numbers`, + groups: [ + [ + { type: 'geo_as_number', op: 'inc', value: headlessAsns }, + { type: 'user_agent', op: 'sub', value: 'Mozilla' } + ] + ], + action: 'log', + eventual: 'challenge', + risk: 'high', + caveat: + 'Highest false-positive risk here. Corporate VPNs, privacy relays and some mobile carriers egress from hosting ASNs, and a challenge page breaks API clients and link unfurlers outright. Keep this in log mode for a full week before considering enforcement.' + }) + ) + } + + return out +} + +/** Render recommendations as a runnable, commented shell script. */ +export function firewallScript(recommendations: readonly FirewallRecommendation[]): string { + const lines = [ + '#!/usr/bin/env bash', + '# Vercel WAF proposals generated from observed agent traffic.', + '#', + '# Every rule starts in LOG mode and blocks nothing. Vercel stages rule', + '# changes as drafts, so nothing is live until you run:', + '#', + '# vercel firewall diff # review', + '# vercel firewall publish --yes # go live', + '#', + '# Review each rule in the dashboard before promoting it to its eventual', + '# action. Rules evaluate top to bottom, so keep the bypass rule first.', + 'set -euo pipefail', + '' + ] + recommendations.forEach((r, i) => { + lines.push(`# ${i + 1}. ${r.name}`) + lines.push(`# why: ${r.rationale}`) + lines.push(`# evidence: ${r.evidence}`) + lines.push(`# risk: ${r.risk} — eventual action: ${r.eventual}`) + if (r.caveat) lines.push(`# caveat: ${r.caveat}`) + lines.push(r.cli) + lines.push('') + }) + if (recommendations.length) { + lines.push('# Keep the protective allow rule at the top of the evaluation order.') + lines.push( + `vercel firewall rules reorder ${JSON.stringify(recommendations[0]!.name)} --first --yes` + ) + lines.push('') + lines.push('vercel firewall diff') + lines.push('echo "Review above, then: vercel firewall publish --yes"') + } + return lines.join('\n') +} diff --git a/src/index.ts b/src/index.ts index 7f1efa3..8796cac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,15 @@ export { CaptureTransportError } from './errors.js' export { agentIntent, agentPolicy } from './policy.js' export { mppxGateway, paymentGate, x402Gateway } from './gateway.js' export { entitlementGateway, memoryEntitlementStore } from './entitlement.js' +export { firewallScript, recommendFirewallRules } from './firewall.js' +export type { + FirewallAction, + FirewallCondition, + FirewallRecommendation, + RateLimitSpec, + RecommendOptions, + TrafficObservation +} from './firewall.js' export type { BulkOffer, Entitlement, diff --git a/test/firewall.test.ts b/test/firewall.test.ts new file mode 100644 index 0000000..38bc96b --- /dev/null +++ b/test/firewall.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { + firewallScript, + recommendFirewallRules, + type TrafficObservation +} from '../src/firewall.js' + +const OBS: TrafficObservation[] = [ + // Traffic we want: a person asked, an assistant read the page. + { userAgent: 'ChatGPT-User/1.0', botName: 'ChatGPT', intent: 'retrieval', requests: 280000, verification: 'verified' }, + { userAgent: 'Googlebot/2.1', botName: 'Google', intent: 'search', requests: 13000 }, + // Bulk corpus collection. + { userAgent: 'GPTBot/1.1', botName: 'ChatGPT', intent: 'training', requests: 24000, verification: 'verified' }, + { userAgent: 'ClaudeBot/1.0', botName: 'Claude', intent: 'training', requests: 13600, verification: 'verified' }, + // An impostor. + { userAgent: 'ClaudeBot/1.0', botName: 'Claude', intent: 'training', requests: 535, ip: '45.55.50.205', verification: 'spoofed' }, + // A scraper wearing a browser UA, from a hosting network. + { userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 Safari/605.1.15', botName: 'Headless', intent: 'unknown', requests: 16771, ip: '164.92.65.128', asn: 14061, distinctPaths: 5213 } +] + +function byName(name: string) { + return recommendFirewallRules(OBS).find((r) => r.name === name)! +} + +describe('recommendFirewallRules', () => { + it('never proposes an enforcing action — everything starts in log or bypass', () => { + // A firewall rule's blast radius is unknowable until real traffic hits it. + for (const r of recommendFirewallRules(OBS)) { + expect(['log', 'bypass'], r.name).toContain(r.action) + } + }) + + it('puts the protective allow rule first', () => { + // Rules evaluate top to bottom. If this is not first, the user-agent rules + // below it will swallow the agents that bring readers. + const rules = recommendFirewallRules(OBS) + expect(rules[0]!.action).toBe('bypass') + expect(rules[0]!.name).toMatch(/retrieval and search/i) + }) + + it('never proposes blocking retrieval or search, at any stage', () => { + // The single most important property. 60% of AI traffic is retrieval. + const rules = recommendFirewallRules(OBS) + const enforcing = rules.filter((r) => r.eventual === 'deny' || r.eventual === 'challenge') + for (const r of enforcing) { + const serialised = JSON.stringify(r.groups) + for (const protectedUa of ['ChatGPT-User', 'Googlebot', 'Perplexity-User', 'Claude-User']) { + expect(serialised, `${r.name} must not target ${protectedUa}`).not.toContain(protectedUa) + } + } + }) + + it('denies only failed verification', () => { + const denies = recommendFirewallRules(OBS).filter((r) => r.eventual === 'deny') + expect(denies).toHaveLength(1) + expect(denies[0]!.name).toMatch(/impersonated/i) + expect(JSON.stringify(denies[0]!.groups)).toContain('45.55.50.205') + }) + + it('rate limits training crawlers rather than denying them', () => { + const r = byName('Rate limit training crawlers') + expect(r.eventual).toBe('rate_limit') + expect(r.rateLimit).toMatchObject({ window: 3600, requests: 600 }) + // Removing yourself from training sets is a discoverability decision, not a + // default, so the caveat has to say so. + expect(r.caveat).toMatch(/training sets/i) + }) + + it('flags the datacenter rule as the highest risk', () => { + const r = byName('Challenge browser user agents from datacenter networks') + expect(r.risk).toBe('high') + expect(r.caveat).toMatch(/VPN|relay|carrier/i) + }) + + it('carries evidence on every recommendation', () => { + // No rule without a number behind it. + for (const r of recommendFirewallRules(OBS)) { + expect(r.evidence.length, r.name).toBeGreaterThan(10) + expect(r.rationale.length, r.name).toBeGreaterThan(10) + } + }) + + it('emits valid Vercel CLI and JSON forms', () => { + for (const r of recommendFirewallRules(OBS)) { + expect(r.cli).toContain('vercel firewall rules add') + expect(r.cli).toContain(`--action ${r.action}`) + expect(r.cli).toContain('--yes') + // Conditions must be single-quoted JSON the shell will pass through intact. + for (const group of r.groups) { + for (const c of group) expect(r.cli).toContain(JSON.stringify(c)) + } + expect(r.json).toMatchObject({ + name: r.name, + action: { mitigate: { action: r.action } } + }) + } + }) + + it('includes rate-limit flags only on rate-limited rules', () => { + for (const r of recommendFirewallRules(OBS)) { + if (r.action === 'rate_limit' && r.rateLimit) { + expect(r.cli).toContain('--rate-limit-window') + } else { + expect(r.cli, r.name).not.toContain('--rate-limit-window') + } + } + }) + + it('proposes nothing beyond the protective rule when traffic is clean', () => { + const clean: TrafficObservation[] = [ + { userAgent: 'ChatGPT-User/1.0', botName: 'ChatGPT', intent: 'retrieval', requests: 900, verification: 'verified' } + ] + const rules = recommendFirewallRules(clean) + expect(rules).toHaveLength(1) + expect(rules[0]!.action).toBe('bypass') + }) + + it('handles empty input without inventing rules', () => { + const rules = recommendFirewallRules([]) + expect(rules).toHaveLength(1) + expect(rules[0]!.evidence).toMatch(/no retrieval or search traffic/i) + }) + + it('can omit the protective bypass when explicitly asked', () => { + const rules = recommendFirewallRules(OBS, { omitProtectiveBypass: true }) + expect(rules.some((r) => r.action === 'bypass')).toBe(false) + }) + + it('honours an explicit abuse threshold', () => { + const low = recommendFirewallRules(OBS, { abuseThreshold: 100 }) + const high = recommendFirewallRules(OBS, { abuseThreshold: 10_000_000 }) + expect(low.some((r) => r.name.match(/high-volume/))).toBe(true) + expect(high.some((r) => r.name.match(/high-volume/))).toBe(false) + }) +}) + +describe('firewallScript', () => { + it('is a runnable script that publishes nothing', () => { + const script = firewallScript(recommendFirewallRules(OBS)) + expect(script).toMatch(/^#!\/usr\/bin\/env bash/) + expect(script).toContain('set -euo pipefail') + // It must show the diff and stop — publishing is the human's call. + expect(script).toContain('vercel firewall diff') + expect(script).not.toMatch(/^vercel firewall publish/m) + expect(script).toContain('--first --yes') // bypass rule stays on top + }) + + it('carries the why and the evidence into comments', () => { + const script = firewallScript(recommendFirewallRules(OBS)) + expect(script).toContain('# why:') + expect(script).toContain('# evidence:') + expect(script).toContain('# risk:') + }) +}) From 30a77828e5846f94c1ac0ddf67fa34e94ffd184e Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 15:08:30 +0200 Subject: [PATCH 8/8] fix(posthog): mirror the UA and IP to PostHog's canonical properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog runs its own user-agent and GeoIP enrichment, but only off specific property names. We sent the user agent under our own key, so on 4,904 events in one production project PostHog recorded: $virt_traffic_category = "no_user_agent" $virt_bot_name = "" $virt_bot_operator = "" Its entire bot taxonomy sat dormant. Confirmed against PostHog's docs: every classification function reads properties.$raw_user_agent — getTrafficCategory(properties.$raw_user_agent) -> ai_crawler, ai_search, ... getBotName(properties.$raw_user_agent) -> 'ChatGPT', 'Googlebot', ... and GeoIP reads properties.$ip. Their own Vercel log-drain source emits both for exactly this reason. The adapter now mirrors what it already carries. No new data is collected: the user agent is the same string already on the event under `user_agent`, and $ip is mirrored only when the caller opted into captureIp — adding it otherwise would put a raw address on an event they deliberately anonymised. Worth having because it is a free second opinion. PostHog classifies from far more traffic than we see, so disagreement between bot_name and getBotName($raw_user_agent) is a cheap signal for where our patterns are wrong. The GeoIP fix matters too: without $ip, PostHog geolocates whichever edge PoP relayed the event rather than the client, which is approximately right and quietly not authoritative. I had previously dismissed the $virt_* family as unusable "because events arrive server-side from middleware". That was the wrong mechanism — it is unusable because we send the UA under the wrong key, which is fixable in one place. Tests 356 -> 360. --- src/adapters/posthog.ts | 21 +++++++++++++++++- test/posthog.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/adapters/posthog.ts b/src/adapters/posthog.ts index 65ca772..1c812a0 100644 --- a/src/adapters/posthog.ts +++ b/src/adapters/posthog.ts @@ -44,12 +44,31 @@ export function posthogAnalytics(config: PostHogAdapterConfig): AnalyticsAdapter return { async capture(event: CaptureEvent): Promise { + // PostHog runs its own user-agent and GeoIP enrichment, but only off its + // canonical property names. We already carry both values under our own + // keys, so mirroring them costs nothing and unlocks a free second opinion: + // getTrafficCategory(), getBotName() and friends all read + // `properties.$raw_user_agent`, and GeoIP reads `properties.$ip`. + // + // Without this every event arrives with traffic category `no_user_agent` + // and PostHog's whole bot taxonomy sits dormant — which is exactly what + // happened on ours until someone queried it. + // + // `$ip` is mirrored only when the caller already opted into `captureIp`. + // Adding it otherwise would put a raw address on the event that the + // caller deliberately kept off. + const ua = event.properties.user_agent + const ip = event.properties.client_ip const payload = { api_key: config.apiKey, event: event.event, distinct_id: event.distinctId, timestamp: event.timestamp, - properties: event.properties + properties: { + ...event.properties, + ...(typeof ua === 'string' && ua ? { $raw_user_agent: ua } : {}), + ...(typeof ip === 'string' && ip ? { $ip: ip } : {}) + } } // A 401 from a mistyped key used to look identical to success. Surface // it: `trackVisit` routes it to `onError` and still never throws into diff --git a/test/posthog.test.ts b/test/posthog.test.ts index 68d1aa0..ef5173b 100644 --- a/test/posthog.test.ts +++ b/test/posthog.test.ts @@ -139,3 +139,50 @@ describe('posthogAnalytics', () => { ).rejects.toThrow('network down') }) }) + +describe('PostHog enrichment properties', () => { + async function send(properties: Record) { + let body: any + const adapter = posthogAnalytics({ + apiKey: 'k', + fetchImpl: async (_u, init) => { + body = JSON.parse(String(init?.body)) + return new Response('ok') + } + }) + await adapter.capture({ + event: 'agent_visit', + distinctId: 'anon_1', + timestamp: new Date().toISOString(), + properties + }) + return body.properties + } + + it('mirrors the user agent to $raw_user_agent', async () => { + // PostHog's getTrafficCategory / getBotName read this exact property. Send + // the UA only under our own key and its entire bot taxonomy stays dormant, + // reporting `no_user_agent` on every event. + const p = await send({ user_agent: 'Mozilla/5.0 (compatible; GPTBot/1.1)' }) + expect(p.$raw_user_agent).toBe('Mozilla/5.0 (compatible; GPTBot/1.1)') + expect(p.user_agent).toBe('Mozilla/5.0 (compatible; GPTBot/1.1)') + }) + + it('mirrors client_ip to $ip only when captureIp put it there', async () => { + // GeoIP reads $ip. Without it PostHog geolocates whichever edge PoP relayed + // the event, not the client. + expect((await send({ user_agent: 'x', client_ip: '203.0.113.9' })).$ip).toBe('203.0.113.9') + }) + + it('never invents $ip when the caller kept the address off the event', async () => { + // captureIp defaults to false. Adding $ip anyway would put a raw address on + // an event the caller deliberately anonymised. + const p = await send({ user_agent: 'Mozilla/5.0 (compatible; GPTBot/1.1)' }) + expect('$ip' in p).toBe(false) + }) + + it('omits $raw_user_agent when there is no user agent to mirror', async () => { + const p = await send({ user_agent: '', bot_name: 'Other' }) + expect('$raw_user_agent' in p).toBe(false) + }) +})