wayleave 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +64 -6
  2. package/index.d.ts +132 -4
  3. package/index.js +194 -35
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -17,10 +17,12 @@ Every request gets classified into a lane:
17
17
  - **verified_agent** — valid Ed25519 signature (Web Bot Auth profile, RFC 9421) against a key directory you trust
18
18
  - **declared_agent** — identifies as a bot, no valid signature
19
19
  - **suspected_bot** — automation fingerprints without disclosure (an *invalid* signature lands here too — faking verification is the strongest fraud signal there is)
20
- - **human** — browser-shaped traffic
20
+ - **human** — browser-shaped traffic, which means *nothing gave it away*, not that a person is there
21
21
 
22
22
  Then policy runs per lane: allow, deny, rate-limit — and on routes you price, agents get **402 Payment Required** with an x402-shaped challenge. Your human users never see a paywall.
23
23
 
24
+ Only the first lane is cryptographic. The other three are read off what the client says about itself, so a bot willing to lie reaches the human lane — see [Guarantees](#guarantees-honestly-stated), and set `strictPricedPaths` on anything you charge for.
25
+
24
26
  ## Quickstart
25
27
 
26
28
  ```js
@@ -50,15 +52,21 @@ app.use(gate.express());
50
52
  - Signature parameters are parsed as an RFC 9421 dictionary: order-independent, `alg` enforced as Ed25519, any signature label, and the signature base is built from whatever components the signer declared. Requests signed in Cloudflare's documented format verify.
51
53
  - Sub-millisecond per request. Your latency budget won't notice.
52
54
  - The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
53
- - Key directories still load from config rather than a live JWKS fetch, so key rotation is not handled. That is the next real gap.
55
+ - Key rotation has a seam but no implementation. `directories` accepts a resolver function you can point at a cache, but there is no JWKS fetcher in the box and the resolver must not block. Wiring that cache is still your job.
56
+ - **A bot that sends a browser `user-agent` and an `accept-language` header is classified `human` and crosses priced routes free.** Those two headers are the entire bypass. The `human` lane is a fall-through — it is reached by tripping none of the automation tells, which is absence of evidence, not evidence of a person. There is no TLS fingerprinting and no challenge here; that work belongs at your edge or CDN, and pretending otherwise would be the dishonest version of this list. (`verifyAgentIP` checks a *declared* operator against its published ranges — it does nothing about a request claiming to be a browser, which is this bypass.) `strictPricedPaths: true` inverts the burden on priced routes so that only a verified signature or your own `confirmHuman(req)` crosses. Recommended wherever there is a price.
57
+ - Wayleave prices **disclosure**; it does not detect **concealment**. It is a tollbooth, not a wall — it works on operators who want to be identifiable, which today is most of the ones worth billing.
54
58
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
55
59
 
56
60
  ## Status
57
61
 
58
- v0.1.5 — verification, lanes, policy, pricing, and the metering hook, all under test (44 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
62
+ v0.2.0 — verification, lanes, policy, pricing, and the metering hook, all under test (70 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, spoofed operator identity, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
59
63
 
60
64
  0.1.5 closes a hole worth naming: before it, a priced route accepted a payment proof that any agent could derive from the 402 challenge it had just been sent — free passage, recorded as revenue. Settlement confirmation is now yours to supply and denies by default. If you shipped 0.1.4 or earlier on a priced route, upgrade.
61
65
 
66
+ 0.1.6 addresses the other half of the same question. Payment could be faked; so could being human. `strictPricedPaths` lets a priced route demand positive evidence instead of accepting the absence of a bot signal as one.
67
+
68
+ 0.2.0 turns three hardcoded strategies into seams — state, metering, and key lookup — so shared storage, durable billing, and key rotation become configuration rather than a rewrite. Defaults are unchanged; see [Extending it](#extending-it).
69
+
62
70
  ## How payment actually works
63
71
 
64
72
  Pay-at-the-door, not IOUs. On a route you price:
@@ -93,6 +101,25 @@ nothing but a confirmed settlement is ever written to the ledger as billed.
93
101
 
94
102
  ## Security posture
95
103
 
104
+ - **`strictPricedPaths: true` is the recommended setting on any priced route.**
105
+ Without it the free lane is whatever fails to look automated, so the cheapest
106
+ strategy against a paywall is to look like a browser. With it, a priced route
107
+ admits exactly two things: a verified signature that pays, and a request your
108
+ own `confirmHuman(req)` vouches for. That callback fails closed — absent,
109
+ throwing, or returning anything but `true` all mean pay or leave.
110
+
111
+ ```js
112
+ const gate = new Wayleave({
113
+ pricedPaths: { '/api/premium': 0.05 },
114
+ strictPricedPaths: true,
115
+ confirmHuman: req => Boolean(req.session?.userId), // your session, not a header
116
+ verifyPayment: (proof, ctx) => facilitator.confirmSettled(proof, ctx),
117
+ });
118
+ ```
119
+
120
+ Never infer humanity from headers inside this callback. Every header a
121
+ browser sends, a bot can send too — that is the property strict mode exists
122
+ to close, and re-deriving it from `user-agent` reopens it.
96
123
  - **Rate limits key on the connection address, not `x-forwarded-for`.**
97
124
  The header is client-written unless a proxy you own overwrites it; believing
98
125
  it by default hands an attacker unlimited fresh buckets. Behind a real proxy,
@@ -105,9 +132,40 @@ nothing but a confirmed settlement is ever written to the ledger as billed.
105
132
  agent hitting two paths in the same second produces byte-identical
106
133
  signatures. Rejecting those would break real traffic. Without a signer
107
134
  nonce, replay is bounded by the expiry window and nothing tighter.
108
- - **State is per-process.** Rate limits and replay memory do not survive a
109
- restart and do not span instances — two instances mean two independent
110
- limiters. Shared state is the next real gap, alongside key rotation.
135
+ - **State is per-process by default.** Rate limits and replay memory do not
136
+ survive a restart and do not span instances. Pass a `store` implementing
137
+ `hit` / `hasNonce` / `rememberNonce` to share them. Keep it synchronous —
138
+ it runs on every request — so back a distributed store with a local view
139
+ that replicates asynchronously. Note the two uses differ in how much they
140
+ mind lag: fixed-window rate limiting tolerates approximate counts, replay
141
+ does not, because a nonce that has not replicated yet can be spent twice.
142
+ - **Key rotation is possible but not built in.** `directories` accepts a
143
+ resolver function, so point it at a cache you refresh from a JWKS endpoint
144
+ on a timer. There is no fetcher in the box, and the resolver must not block.
145
+
146
+ ## Extending it
147
+
148
+ Three seams, each defaulting to the behaviour above. Nothing here is required.
149
+
150
+ ```js
151
+ new Wayleave({
152
+ store: myRedisBackedStore, // shared rate limits + replay across instances
153
+ sink: myBufferedSink, // emit() with batching, retry, dedup
154
+ directories: url => keyCache[url],// rotatable keys, refreshed out of band
155
+ verifyAgentIP: (ip, ua) => isPublishedRange(ip, ua),
156
+ });
157
+ ```
158
+
159
+ Every metering event carries `v` (schema version) and `idempotencyKey`, so a
160
+ sink that retries can be deduplicated at the far end rather than double-billing.
161
+
162
+ `verifyAgentIP` is the one that changes a lane. A `user-agent` naming a known
163
+ operator is a claim; when that operator publishes its ranges the claim is
164
+ checkable. Returning `false` moves the request to `suspected_bot` — a false
165
+ claim of identity is a stronger signal than no claim, which is the same
166
+ reasoning that puts an invalid signature there. Return `null` for operators you
167
+ cannot check; that leaves the request `declared_agent`, because unknown is not
168
+ guilty. Throwing is treated as `null` for the same reason.
111
169
 
112
170
  ## License
113
171
 
package/index.d.ts CHANGED
@@ -60,6 +60,12 @@ export interface WayleaveRequest {
60
60
  * `trustProxy` is on.
61
61
  */
62
62
  ip?: string;
63
+ /**
64
+ * Your framework's own request, when an adapter supplied one. Never read
65
+ * for classification — it exists so `confirmHuman` can reach the session and
66
+ * cookies that the projection into this shape leaves behind.
67
+ */
68
+ raw?: unknown;
63
69
  }
64
70
 
65
71
  export interface VerifyResult {
@@ -138,6 +144,17 @@ export interface Decision {
138
144
 
139
145
  /** What the metering hook receives. It may throw; serving continues. */
140
146
  export interface MeterEvent {
147
+ /**
148
+ * Event schema version. Present so a meter that outlives one release can
149
+ * tell which shape it is reading. Currently always 1.
150
+ */
151
+ v: number;
152
+ /**
153
+ * Unique per crossing and stable across retries of the same event, so a
154
+ * sink that resends after a failure can be deduplicated at the far end.
155
+ * Namespaced per process — two instances never collide.
156
+ */
157
+ idempotencyKey: string;
141
158
  /** Unix seconds. */
142
159
  t: number;
143
160
  path: string;
@@ -155,8 +172,90 @@ export interface MeterEvent {
155
172
  /** `[pathPrefix, allow]` — first matching prefix wins. */
156
173
  export type PolicyRule = [prefix: string, allow: boolean];
157
174
 
175
+ /**
176
+ * Rate-limit counters and consumed nonces. Implement this against Redis or
177
+ * Postgres and pass it as `store` to share state across instances.
178
+ *
179
+ * Must be synchronous — it is called on every request, and an awaited round
180
+ * trip per crossing costs more than the crossing earns. Put a distributed
181
+ * backend behind a local view that replicates asynchronously.
182
+ *
183
+ * Note the two uses differ in how much they mind approximation: fixed-window
184
+ * rate limiting tolerates it, replay does not. A nonce that has not yet
185
+ * replicated is a nonce that can be spent twice.
186
+ */
187
+ export interface Store {
188
+ /** Requests by this identity in the current window. Returns the new count. */
189
+ hit(identity: string, windowSeconds: number, now: number): number;
190
+ hasNonce(nonce: string): boolean;
191
+ rememberNonce(nonce: string, expiresAt: number): void;
192
+ }
193
+
194
+ /** The default `Store`: bounded, in this process's memory, lost on restart. */
195
+ export class MemoryStore implements Store {
196
+ constructor(opts?: { maxTracked?: number });
197
+ maxTracked: number;
198
+ hit(identity: string, windowSeconds: number, now: number): number;
199
+ hasNonce(nonce: string): boolean;
200
+ rememberNonce(nonce: string, expiresAt: number): void;
201
+ }
202
+
203
+ /**
204
+ * Where metering events go. Implement `emit` to buffer, batch, retry, and
205
+ * dedup on `idempotencyKey`. It must never throw into the request path.
206
+ */
207
+ export interface Sink {
208
+ emit(event: MeterEvent): void;
209
+ }
210
+
211
+ /** The default `Sink`: calls `onEvent` and swallows whatever it throws. */
212
+ export class DirectSink implements Sink {
213
+ constructor(onEvent?: (event: MeterEvent) => void);
214
+ emit(event: MeterEvent): void;
215
+ }
216
+
217
+ /**
218
+ * Key lookup. The function form is what makes rotation possible: point it at
219
+ * a cache your app refreshes from a JWKS endpoint on a timer. Synchronous by
220
+ * design — do not fetch here.
221
+ */
222
+ export type KeyResolver = (directoryUrl: string) =>
223
+ Record<string, PublicKeyLike> | undefined;
224
+
225
+ /**
226
+ * Checks whether a request claiming to be a known operator actually came from
227
+ * that operator's published addresses.
228
+ *
229
+ * Return `true` to confirm, `false` to accuse — a false claim is a stronger
230
+ * fraud signal than silence, so it lands in `suspected_bot` alongside an
231
+ * invalid signature. Return `null`/`undefined` for "cannot check", which
232
+ * leaves the request merely `declared_agent`: unknown is not guilty. A throw
233
+ * is treated as "cannot check" too, so a DNS blip never demotes real agents.
234
+ */
235
+ export type AgentIPVerifier = (
236
+ ip: string | undefined,
237
+ userAgent: string
238
+ ) => boolean | null | undefined;
239
+
158
240
  export interface WayleaveOptions {
159
- directories?: KeyDirectories;
241
+ /** Config object, or a resolver function for rotatable keys. */
242
+ directories?: KeyDirectories | KeyResolver;
243
+ /**
244
+ * Verify that a self-declared operator is on its published addresses.
245
+ * Without it, `declared_agent` is a claim taken at face value.
246
+ */
247
+ verifyAgentIP?: AgentIPVerifier;
248
+ /**
249
+ * Rate-limit and replay state. Defaults to a bounded `MemoryStore`, which
250
+ * is per-process: two instances mean two independent limiters. Supply a
251
+ * shared implementation to fix that without touching anything else.
252
+ */
253
+ store?: Store;
254
+ /**
255
+ * Where metering events go. Defaults to a `DirectSink` wrapping `onEvent`.
256
+ * Replace it with a buffering, retrying sink when losing events costs money.
257
+ */
258
+ sink?: Sink;
160
259
  /** Per-lane allow/deny rules, evaluated in order. */
161
260
  rules?: Partial<Record<Lane, PolicyRule[]>>;
162
261
  /** Max requests per identity per window, per lane. */
@@ -172,6 +271,29 @@ export interface WayleaveOptions {
172
271
  * against the challenge you just issued.
173
272
  */
174
273
  verifyPayment?: PaymentVerifier;
274
+ /**
275
+ * Price priced routes for everything except an application-confirmed human.
276
+ * Default false.
277
+ *
278
+ * Off, the `human` lane crosses priced routes free — and that lane is a
279
+ * fall-through meaning "no automation tell was found", so a bot sending a
280
+ * browser `user-agent` and an `accept-language` header reaches it. On,
281
+ * absence of evidence stops being a free pass: only a verified signature
282
+ * (which pays) or `confirmHuman` returning true (which browses) crosses.
283
+ * Recommended wherever a route has a price on it.
284
+ */
285
+ strictPricedPaths?: boolean;
286
+ /**
287
+ * Positive proof a person is present — your session, your cookie, your
288
+ * challenge. Only consulted under `strictPricedPaths`. Fails closed: absent,
289
+ * throwing, or non-`true` all mean "pay or leave" on a priced route.
290
+ *
291
+ * Receives your framework's request under an adapter (the Express `req`,
292
+ * session and cookies intact), otherwise whatever you passed to `handle`.
293
+ * Do not infer humanity from headers here — a bot sends those too, which is
294
+ * the whole reason this callback exists.
295
+ */
296
+ confirmHuman?: (req: any) => boolean;
175
297
  /**
176
298
  * Believe `x-forwarded-for` for the client address. Default false. Turn on
177
299
  * ONLY behind a proxy you control that overwrites the header — otherwise
@@ -220,7 +342,7 @@ export function signRequest(
220
342
  export function verifySignature(
221
343
  headers: Record<string, string | string[] | undefined>,
222
344
  authority: string,
223
- directories: KeyDirectories,
345
+ directories: KeyDirectories | KeyResolver,
224
346
  now?: number
225
347
  ): VerifyResult;
226
348
 
@@ -230,8 +352,14 @@ export function verifySignature(
230
352
  */
231
353
  export function classify(
232
354
  req: WayleaveRequest,
233
- directories: KeyDirectories,
234
- now?: number
355
+ directories: KeyDirectories | KeyResolver,
356
+ now?: number,
357
+ opts?: {
358
+ /** Confirms a declared operator is on its published addresses. */
359
+ verifyAgentIP?: AgentIPVerifier;
360
+ /** Source address handed to `verifyAgentIP`. */
361
+ ip?: string;
362
+ }
235
363
  ): Classification;
236
364
 
237
365
  export class Wayleave {
package/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createPublicKey, verify as edVerify, sign as edSign,
21
- createHash } from 'node:crypto';
21
+ createHash, randomUUID } from 'node:crypto';
22
22
 
23
23
  export const LANES = Object.freeze({
24
24
  VERIFIED: 'verified_agent',
@@ -108,8 +108,28 @@ export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
108
108
  return headers;
109
109
  }
110
110
 
111
+ /**
112
+ * Key lookup, as a seam rather than an object read.
113
+ *
114
+ * `directories` may be the config object it has always been, or a function
115
+ * `(directoryUrl) => { keyid: key }`. The function form is what makes rotation
116
+ * possible without touching this file: point it at a cache your app refreshes
117
+ * from a JWKS endpoint on a timer.
118
+ *
119
+ * It MUST be synchronous. This runs on every signed request, and awaiting a
120
+ * network fetch per crossing would cost far more than the crossing earns —
121
+ * the sub-millisecond claim in the README is a real constraint, not a boast.
122
+ * Fetch on a schedule, resolve from memory.
123
+ */
124
+ function asResolver(directories) {
125
+ if (typeof directories === 'function') return directories;
126
+ const d = directories || {};
127
+ return (directoryUrl) => d[directoryUrl];
128
+ }
129
+
111
130
  /**
112
131
  * directories: { [directoryUrl]: { [keyid]: publicKey (KeyObject|PEM|raw b64) } }
132
+ * or (directoryUrl) => { [keyid]: publicKey }
113
133
  * Returns { ok, agentId, reason, nonce, expires }
114
134
  *
115
135
  * `nonce` is set only when the signer supplied RFC 9421's `nonce` parameter,
@@ -157,7 +177,7 @@ export function verifySignature(h, authority, directories,
157
177
  if (expires - created > 3600)
158
178
  return { ok: false, agentId: null, reason: 'validity window too long (replay risk)' };
159
179
 
160
- const dir = directories[directory];
180
+ const dir = asResolver(directories)(directory);
161
181
  if (!dir) return { ok: false, agentId: null, reason: `unknown key directory ${directory}` };
162
182
  let key = dir[keyid];
163
183
  if (!key) return { ok: false, agentId: null, reason: `keyid "${keyid}" not in directory` };
@@ -209,7 +229,20 @@ const AUTOMATION_TELLS = [
209
229
  ['wget', 'wget UA'],
210
230
  ];
211
231
 
212
- export function classify(req, directories, now) {
232
+ /**
233
+ * A verifier that throws must not decide the lane. Treat the failure as
234
+ * "could not check" rather than "is a forger" — a DNS timeout is not evidence
235
+ * of fraud, and demoting real agents to the fraud lane because a lookup blipped
236
+ * would be a self-inflicted outage on the traffic we most want to bill.
237
+ */
238
+ function tryVerifyIP(verify, ip, ua) {
239
+ try {
240
+ const r = verify(ip, ua);
241
+ return r === true ? true : r === false ? false : null;
242
+ } catch { return null; }
243
+ }
244
+
245
+ export function classify(req, directories, now, opts = {}) {
213
246
  const h = req.headers, ua = (h['user-agent'] || '').toLowerCase();
214
247
  const v = verifySignature(h, req.authority, directories, now);
215
248
  if (v.ok) return { lane: LANES.VERIFIED, agentId: v.agentId,
@@ -218,10 +251,25 @@ export function classify(req, directories, now) {
218
251
  if (h['signature'] || h['signature-input'])
219
252
  return { lane: LANES.SUSPECT, agentId: null,
220
253
  evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
221
- if (AGENT_UA.some(p => p.test(ua)))
254
+ if (AGENT_UA.some(p => p.test(ua))) {
255
+ // A UA naming a known operator is a claim, and claims are checkable when
256
+ // the operator publishes its addresses. Claiming to be GPTBot from an
257
+ // address OpenAI does not own is a stronger fraud signal than saying
258
+ // nothing at all — so it lands in the fraud lane, exactly like a
259
+ // signature that fails to verify. An unrecognised operator returns
260
+ // null/undefined and stays merely declared: unknown is not guilty.
261
+ const ipOk = opts.verifyAgentIP
262
+ ? tryVerifyIP(opts.verifyAgentIP, opts.ip, ua)
263
+ : null;
264
+ if (ipOk === false)
265
+ return { lane: LANES.SUSPECT, agentId: null,
266
+ evidence: [`UA claims "${ua.slice(0, 40)}"`,
267
+ `but ${opts.ip || 'the client'} is not a published address for it`] };
222
268
  return { lane: LANES.DECLARED, agentId: null,
223
269
  evidence: [`self-identified in UA: "${ua.slice(0, 40)}"`,
224
- 'no signature presented'] };
270
+ ipOk === true ? 'source address matches published range'
271
+ : 'no signature presented'] };
272
+ }
225
273
  for (const [tell, why] of AUTOMATION_TELLS)
226
274
  if (ua.includes(tell))
227
275
  return { lane: LANES.SUSPECT, agentId: null, evidence: [why] };
@@ -232,6 +280,77 @@ export function classify(req, directories, now) {
232
280
  return { lane: LANES.HUMAN, agentId: null, evidence: ['browser-shaped request'] };
233
281
  }
234
282
 
283
+ // ── state ───────────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Rate-limit counters and consumed nonces, in this process's memory.
287
+ *
288
+ * This is the seam that a shared backend replaces. Implement the same four
289
+ * methods against Redis or Postgres and pass it as `store` — the gate does not
290
+ * know the difference. Two properties any replacement must preserve:
291
+ *
292
+ * Synchronous. Called on every request. An awaited round trip per crossing
293
+ * is a worse tax than the crossing is worth; a distributed store belongs
294
+ * behind a local view that replicates asynchronously.
295
+ *
296
+ * Bounded. `maxTracked` exists because an attacker who can mint identities
297
+ * can otherwise mint memory. Evicting the oldest keeps recent arrivals
298
+ * counted, which is the useful half.
299
+ *
300
+ * A distributed store trades exactness for reach, and the two uses differ in
301
+ * how much they mind. Fixed-window rate limiting tolerates approximate counts.
302
+ * Replay does NOT: a nonce that has not replicated yet is a nonce that can be
303
+ * spent twice. Say which you chose in your deployment notes.
304
+ */
305
+ export class MemoryStore {
306
+ constructor({ maxTracked = 10000 } = {}) {
307
+ this.maxTracked = maxTracked;
308
+ this._hits = new Map();
309
+ this._window = -1;
310
+ this._seen = new Map();
311
+ }
312
+
313
+ /** Count this identity's requests in the current fixed window. */
314
+ hit(identity, windowSeconds, now) {
315
+ const w = Math.floor(now / windowSeconds);
316
+ if (w !== this._window) {
317
+ this._window = w;
318
+ this._hits.clear();
319
+ for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
320
+ }
321
+ if (this._hits.size >= this.maxTracked && !this._hits.has(identity))
322
+ this._hits.delete(this._hits.keys().next().value);
323
+ const n = (this._hits.get(identity) || 0) + 1;
324
+ this._hits.set(identity, n);
325
+ return n;
326
+ }
327
+
328
+ hasNonce(nonce) { return this._seen.has(nonce); }
329
+
330
+ rememberNonce(nonce, expiresAt) {
331
+ if (this._seen.size >= this.maxTracked)
332
+ this._seen.delete(this._seen.keys().next().value);
333
+ this._seen.set(nonce, expiresAt);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Where metering events go. The default hands each one straight to `onEvent`
339
+ * and swallows anything it throws, because serving must not depend on billing.
340
+ *
341
+ * That swallow is correct for uptime and lossy for revenue, which is the
342
+ * trade a durable sink is meant to change: buffer locally, flush in batches,
343
+ * retry on failure, and dedup on `idempotencyKey` at the far end. Implement
344
+ * `emit(event)` and pass it as `sink`. Whatever you build, it must not throw
345
+ * into the request path — this class is the last line that guarantees that.
346
+ */
347
+ export class DirectSink {
348
+ constructor(onEvent) { this.onEvent = onEvent || (() => {}); }
349
+ emit(event) {
350
+ try { this.onEvent(event); } catch { /* metering must never break serving */ }
351
+ }
352
+ }
353
+
235
354
  // ── the gate ────────────────────────────────────────────────────────────
236
355
 
237
356
  export class Wayleave {
@@ -242,6 +361,8 @@ export class Wayleave {
242
361
  * rateLimits: { [lane]: perWindow }, rateWindow: 60,
243
362
  * pricedPaths: { pathPrefix: priceUsd },
244
363
  * verifyPayment: (proof, ctx) => boolean | { ok, ref },
364
+ * strictPricedPaths: false,
365
+ * confirmHuman: (req) => boolean,
245
366
  * trustProxy: false,
246
367
  * replayProtection: true,
247
368
  * maxTracked: 10000,
@@ -262,6 +383,15 @@ export class Wayleave {
262
383
  // is something the payer could equally have made up.
263
384
  this.verifyPayment = opts.verifyPayment || null;
264
385
 
386
+ // The human lane is a fall-through: it means no automation tell was found,
387
+ // not that a person is present. Since humans are never priced, that
388
+ // asymmetry pays the spoofer — a browser User-Agent plus an
389
+ // accept-language header is the whole bypass. Strict mode inverts the
390
+ // burden on priced routes only: nothing crosses free without positive
391
+ // evidence, which is a verified signature or confirmHuman saying yes.
392
+ this.strictPricedPaths = opts.strictPricedPaths === true;
393
+ this.confirmHuman = opts.confirmHuman || null;
394
+
265
395
  // x-forwarded-for is written by the client unless a proxy you control
266
396
  // overwrites it. Trusting it by default hands every attacker an unlimited
267
397
  // supply of fresh rate-limit buckets. Opt in only behind a real proxy.
@@ -270,23 +400,40 @@ export class Wayleave {
270
400
  this.replayProtection = opts.replayProtection !== false;
271
401
  this.maxTracked = opts.maxTracked || 10000;
272
402
 
273
- this._hits = new Map(); // identity -> count, cleared each window
274
- this._window = -1;
275
- this._seen = new Map(); // signature nonce -> expiry, pruned on rollover
403
+ // Verifies that a UA claiming to be a known operator comes from that
404
+ // operator's published addresses. Absent, a declared agent is taken at
405
+ // its word — which is all "declared" ever meant.
406
+ this.verifyAgentIP = opts.verifyAgentIP || null;
407
+
408
+ // State and metering are interfaces, not implementations. Swap either for
409
+ // a shared or durable one without the gate noticing. See MemoryStore.
410
+ this.store = opts.store || new MemoryStore({ maxTracked: this.maxTracked });
411
+ this.sink = opts.sink || new DirectSink(this.onEvent);
412
+
413
+ // Identifies events from this process so a retrying sink can dedup. A
414
+ // counter alone would collide across instances; a timestamp alone would
415
+ // collide within a millisecond.
416
+ this._instance = randomUUID();
417
+ this._seq = 0;
276
418
  }
277
419
 
278
420
  /** req: { method, path, authority, headers, ip } → decision */
279
421
  handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
280
- const c = classify(req, this.directories, now);
422
+ const c = classify(req, this.directories, now,
423
+ { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
281
424
  const { lane, agentId, evidence } = c;
282
425
  const ident = agentId || `${lane}:${this._client(req)}`;
283
426
  const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
284
427
  const d = this._decide(req, lane, ident, now, proof, c);
285
- const entry = { t: now, path: req.path, lane, identity: ident,
428
+ // v is the event schema version. A meter that outlives one release has to
429
+ // know which shape it is reading; adding this after receipts exist in the
430
+ // wild would mean reconciling two formats forever.
431
+ const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
432
+ t: now, path: req.path, lane, identity: ident,
286
433
  status: d.status, why: d.why, evidence,
287
434
  billedUsd: d.billed || 0 };
288
435
  if (d.paymentRef) entry.paymentRef = d.paymentRef;
289
- try { this.onEvent(entry); } catch { /* metering must never break serving */ }
436
+ this.sink.emit(entry);
290
437
  return { lane, identity: ident, ...d, evidence };
291
438
  }
292
439
 
@@ -303,25 +450,24 @@ export class Wayleave {
303
450
  }
304
451
 
305
452
  /**
306
- * Fixed-window counting. On rollover the whole table is dropped — last
307
- * window's counts are meaningless anyway, and it keeps memory bounded to
308
- * one window's worth of identities instead of growing forever.
453
+ * Positive evidence that a person is present, which only your application
454
+ * holds — a signed-in session, a cookie you set, a challenge you passed.
455
+ * Consulted only under strictPricedPaths, and fails closed: no callback, a
456
+ * throw, or anything other than true means no free crossing of a priced
457
+ * route. Deliberately not inferred from headers; every header a browser
458
+ * sends, a bot can send too.
309
459
  */
460
+ _confirmedHuman(req) {
461
+ if (!this.confirmHuman) return false;
462
+ // Hand back the framework's own request when an adapter supplied one, so
463
+ // req.session and req.cookies are there to be checked.
464
+ try { return this.confirmHuman(req.raw || req) === true; }
465
+ catch { return false; }
466
+ }
467
+
468
+ /** Fixed-window counting, delegated to whichever store is configured. */
310
469
  _bump(ident, now) {
311
- const w = Math.floor(now / this.rateWindow);
312
- if (w !== this._window) {
313
- this._window = w;
314
- this._hits.clear();
315
- for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
316
- }
317
- // Hard ceiling so a flood of distinct identities inside a single window
318
- // cannot exhaust memory. Evicting the oldest keeps the newest arrivals
319
- // counted; the durable fix is shared state, not a bigger Map.
320
- if (this._hits.size >= this.maxTracked && !this._hits.has(ident))
321
- this._hits.delete(this._hits.keys().next().value);
322
- const n = (this._hits.get(ident) || 0) + 1;
323
- this._hits.set(ident, n);
324
- return n;
470
+ return this.store.hit(ident, this.rateWindow, now);
325
471
  }
326
472
 
327
473
  _decide(req, lane, ident, now, paymentProof, c) {
@@ -336,23 +482,32 @@ export class Wayleave {
336
482
  // Only signers that send a nonce can be held to single use; see
337
483
  // verifySignature for why a signature hash is not a safe substitute.
338
484
  if (this.replayProtection && c && c.nonce) {
339
- if (this._seen.has(c.nonce))
485
+ if (this.store.hasNonce(c.nonce))
340
486
  return { status: 403, why: 'signature already used (replay)' };
341
- if (this._seen.size >= this.maxTracked)
342
- this._seen.delete(this._seen.keys().next().value);
343
- this._seen.set(c.nonce, c.sigExpires || now + this.rateWindow);
487
+ this.store.rememberNonce(c.nonce, c.sigExpires || now + this.rateWindow);
344
488
  }
345
489
  const limit = this.rateLimits[lane];
346
490
  if (limit) {
347
491
  const n = this._bump(ident, now);
348
492
  if (n > limit) return { status: 429, why: `${n} req in window, limit ${limit}` };
349
493
  }
350
- if (lane !== LANES.HUMAN) {
494
+ // Who crosses a priced route without paying. Normally the human lane —
495
+ // which anything browser-shaped reaches. Under strictPricedPaths only an
496
+ // application-confirmed human does, so an unsigned request that merely
497
+ // looks like a browser is asked to pay like the automation it may be.
498
+ const freeOnPriced = this.strictPricedPaths
499
+ ? this._confirmedHuman(req)
500
+ : lane === LANES.HUMAN;
501
+ if (!freeOnPriced) {
351
502
  for (const [prefix, price] of Object.entries(this.pricedPaths)) {
352
503
  if (req.path.startsWith(prefix)) {
353
504
  const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
354
- if (!paymentProof)
355
- return { status: 402, why: 'payment required for agent access', challenge };
505
+ if (!paymentProof) {
506
+ const why = this.strictPricedPaths && lane === LANES.HUMAN
507
+ ? 'payment required: no signature and no confirmed human'
508
+ : 'payment required for agent access';
509
+ return { status: 402, why, challenge };
510
+ }
356
511
  const v = this._checkPayment(paymentProof, {
357
512
  price, resource: prefix, identity: ident, path: req.path, now });
358
513
  if (v.ok)
@@ -393,6 +548,10 @@ export class Wayleave {
393
548
  // The socket address, not a header. Express only populates req.ip
394
549
  // from x-forwarded-for when ITS trust proxy setting says to.
395
550
  ip: req.socket?.remoteAddress || req.connection?.remoteAddress,
551
+ // confirmHuman needs the session and cookies your framework parsed,
552
+ // none of which survive the projection above. Classification still
553
+ // reads only the fields it declares; this is for your callback.
554
+ raw: req,
396
555
  }, undefined, req.headers['x-payment-proof'] || '');
397
556
  req.wayleave = r;
398
557
  if (r.status === 200) return next();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Charge AI agents for passage across your app. Verify agent signatures (Web Bot Auth / Ed25519), classify every request, and return 402 with a price on routes you choose. Humans browse free.",
5
5
  "type": "module",
6
6
  "main": "index.js",