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 +27 -2
- package/index.d.ts +29 -0
- package/index.js +45 -3
- 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
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|