wayleave 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -51,14 +53,18 @@ app.use(gate.express());
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
55
  - Key directories still load from config rather than a live JWKS fetch, so key rotation is not handled. That is the next real gap.
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, no IP verification, and no challenge here; that work belongs at your edge or CDN, and pretending otherwise would be the dishonest version of this list. `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.1.6 — verification, lanes, policy, pricing, and the metering hook, all under test (55 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, 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
+
62
68
  ## How payment actually works
63
69
 
64
70
  Pay-at-the-door, not IOUs. On a route you price:
@@ -93,6 +99,25 @@ nothing but a confirmed settlement is ever written to the ledger as billed.
93
99
 
94
100
  ## Security posture
95
101
 
102
+ - **`strictPricedPaths: true` is the recommended setting on any priced route.**
103
+ Without it the free lane is whatever fails to look automated, so the cheapest
104
+ strategy against a paywall is to look like a browser. With it, a priced route
105
+ admits exactly two things: a verified signature that pays, and a request your
106
+ own `confirmHuman(req)` vouches for. That callback fails closed — absent,
107
+ throwing, or returning anything but `true` all mean pay or leave.
108
+
109
+ ```js
110
+ const gate = new Wayleave({
111
+ pricedPaths: { '/api/premium': 0.05 },
112
+ strictPricedPaths: true,
113
+ confirmHuman: req => Boolean(req.session?.userId), // your session, not a header
114
+ verifyPayment: (proof, ctx) => facilitator.confirmSettled(proof, ctx),
115
+ });
116
+ ```
117
+
118
+ Never infer humanity from headers inside this callback. Every header a
119
+ browser sends, a bot can send too — that is the property strict mode exists
120
+ to close, and re-deriving it from `user-agent` reopens it.
96
121
  - **Rate limits key on the connection address, not `x-forwarded-for`.**
97
122
  The header is client-written unless a proxy you own overwrites it; believing
98
123
  it by default hands an attacker unlimited fresh buckets. Behind a real proxy,
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 {
@@ -172,6 +178,29 @@ export interface WayleaveOptions {
172
178
  * against the challenge you just issued.
173
179
  */
174
180
  verifyPayment?: PaymentVerifier;
181
+ /**
182
+ * Price priced routes for everything except an application-confirmed human.
183
+ * Default false.
184
+ *
185
+ * Off, the `human` lane crosses priced routes free — and that lane is a
186
+ * fall-through meaning "no automation tell was found", so a bot sending a
187
+ * browser `user-agent` and an `accept-language` header reaches it. On,
188
+ * absence of evidence stops being a free pass: only a verified signature
189
+ * (which pays) or `confirmHuman` returning true (which browses) crosses.
190
+ * Recommended wherever a route has a price on it.
191
+ */
192
+ strictPricedPaths?: boolean;
193
+ /**
194
+ * Positive proof a person is present — your session, your cookie, your
195
+ * challenge. Only consulted under `strictPricedPaths`. Fails closed: absent,
196
+ * throwing, or non-`true` all mean "pay or leave" on a priced route.
197
+ *
198
+ * Receives your framework's request under an adapter (the Express `req`,
199
+ * session and cookies intact), otherwise whatever you passed to `handle`.
200
+ * Do not infer humanity from headers here — a bot sends those too, which is
201
+ * the whole reason this callback exists.
202
+ */
203
+ confirmHuman?: (req: any) => boolean;
175
204
  /**
176
205
  * Believe `x-forwarded-for` for the client address. Default false. Turn on
177
206
  * ONLY behind a proxy you control that overwrites the header — otherwise
package/index.js CHANGED
@@ -242,6 +242,8 @@ export class Wayleave {
242
242
  * rateLimits: { [lane]: perWindow }, rateWindow: 60,
243
243
  * pricedPaths: { pathPrefix: priceUsd },
244
244
  * verifyPayment: (proof, ctx) => boolean | { ok, ref },
245
+ * strictPricedPaths: false,
246
+ * confirmHuman: (req) => boolean,
245
247
  * trustProxy: false,
246
248
  * replayProtection: true,
247
249
  * maxTracked: 10000,
@@ -262,6 +264,15 @@ export class Wayleave {
262
264
  // is something the payer could equally have made up.
263
265
  this.verifyPayment = opts.verifyPayment || null;
264
266
 
267
+ // The human lane is a fall-through: it means no automation tell was found,
268
+ // not that a person is present. Since humans are never priced, that
269
+ // asymmetry pays the spoofer — a browser User-Agent plus an
270
+ // accept-language header is the whole bypass. Strict mode inverts the
271
+ // burden on priced routes only: nothing crosses free without positive
272
+ // evidence, which is a verified signature or confirmHuman saying yes.
273
+ this.strictPricedPaths = opts.strictPricedPaths === true;
274
+ this.confirmHuman = opts.confirmHuman || null;
275
+
265
276
  // x-forwarded-for is written by the client unless a proxy you control
266
277
  // overwrites it. Trusting it by default hands every attacker an unlimited
267
278
  // supply of fresh rate-limit buckets. Opt in only behind a real proxy.
@@ -302,6 +313,22 @@ export class Wayleave {
302
313
  return req.ip || 'unknown';
303
314
  }
304
315
 
316
+ /**
317
+ * Positive evidence that a person is present, which only your application
318
+ * holds — a signed-in session, a cookie you set, a challenge you passed.
319
+ * Consulted only under strictPricedPaths, and fails closed: no callback, a
320
+ * throw, or anything other than true means no free crossing of a priced
321
+ * route. Deliberately not inferred from headers; every header a browser
322
+ * sends, a bot can send too.
323
+ */
324
+ _confirmedHuman(req) {
325
+ if (!this.confirmHuman) return false;
326
+ // Hand back the framework's own request when an adapter supplied one, so
327
+ // req.session and req.cookies are there to be checked.
328
+ try { return this.confirmHuman(req.raw || req) === true; }
329
+ catch { return false; }
330
+ }
331
+
305
332
  /**
306
333
  * Fixed-window counting. On rollover the whole table is dropped — last
307
334
  * window's counts are meaningless anyway, and it keeps memory bounded to
@@ -347,12 +374,23 @@ export class Wayleave {
347
374
  const n = this._bump(ident, now);
348
375
  if (n > limit) return { status: 429, why: `${n} req in window, limit ${limit}` };
349
376
  }
350
- if (lane !== LANES.HUMAN) {
377
+ // Who crosses a priced route without paying. Normally the human lane —
378
+ // which anything browser-shaped reaches. Under strictPricedPaths only an
379
+ // application-confirmed human does, so an unsigned request that merely
380
+ // looks like a browser is asked to pay like the automation it may be.
381
+ const freeOnPriced = this.strictPricedPaths
382
+ ? this._confirmedHuman(req)
383
+ : lane === LANES.HUMAN;
384
+ if (!freeOnPriced) {
351
385
  for (const [prefix, price] of Object.entries(this.pricedPaths)) {
352
386
  if (req.path.startsWith(prefix)) {
353
387
  const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
354
- if (!paymentProof)
355
- return { status: 402, why: 'payment required for agent access', challenge };
388
+ if (!paymentProof) {
389
+ const why = this.strictPricedPaths && lane === LANES.HUMAN
390
+ ? 'payment required: no signature and no confirmed human'
391
+ : 'payment required for agent access';
392
+ return { status: 402, why, challenge };
393
+ }
356
394
  const v = this._checkPayment(paymentProof, {
357
395
  price, resource: prefix, identity: ident, path: req.path, now });
358
396
  if (v.ok)
@@ -393,6 +431,10 @@ export class Wayleave {
393
431
  // The socket address, not a header. Express only populates req.ip
394
432
  // from x-forwarded-for when ITS trust proxy setting says to.
395
433
  ip: req.socket?.remoteAddress || req.connection?.remoteAddress,
434
+ // confirmHuman needs the session and cookies your framework parsed,
435
+ // none of which survive the projection above. Classification still
436
+ // reads only the fields it declares; this is for your callback.
437
+ raw: req,
396
438
  }, undefined, req.headers['x-payment-proof'] || '');
397
439
  req.wayleave = r;
398
440
  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.1.6",
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",