wayleave 0.1.4 → 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.
Files changed (4) hide show
  1. package/README.md +81 -2
  2. package/index.d.ts +100 -1
  3. package/index.js +166 -19
  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
@@ -36,6 +38,8 @@ const gate = new Wayleave({
36
38
  },
37
39
  rateLimits: { declared_agent: 10 },
38
40
  pricedPaths: { '/api/premium': 0.05 }, // agents pay 5¢/call, humans free
41
+ verifyPayment: (proof, { price, resource }) =>
42
+ facilitator.confirmSettled(proof, price, resource), // REQUIRED to sell
39
43
  onEvent: e => queueForBilling(e), // metering hook; never blocks serving
40
44
  });
41
45
 
@@ -49,11 +53,86 @@ app.use(gate.express());
49
53
  - Sub-millisecond per request. Your latency budget won't notice.
50
54
  - The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
51
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.
52
58
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
53
59
 
54
60
  ## Status
55
61
 
56
- v0.1 — verification, lanes, policy, pricing, and the metering hook, all under test (29 scenarios, including forgery, tampering, replay, and cross-implementation wire formats). Payment settlement network integration is in progress; today the 402 challenge and payment-proof check are pluggable.
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.
63
+
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.
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
+ ## How payment actually works
69
+
70
+ Pay-at-the-door, not IOUs. On a route you price:
71
+
72
+ 1. An agent requests the route → Wayleave answers `402 Payment Required`
73
+ with an x402-shaped challenge: the price and *your* receiving address.
74
+ 2. A wallet-carrying agent signs payment and retries with proof attached.
75
+ 3. The proof is verified and settlement executes on a licensed rail
76
+ (x402 facilitator) — money moves to your account.
77
+ 4. Only then: `200`. No settled payment, no passage. Data never moves
78
+ on a promise.
79
+
80
+ Agents without wallets (most crawlers today) are simply turned away on
81
+ priced routes — you aren't paid by them, but you also never serve them
82
+ free. The ledger shows exactly how much turned-away demand is standing
83
+ at your gate.
84
+
85
+ Wayleave never holds funds. Agent money flows agent → facilitator → you.
86
+ The hosted meter (not built yet — see the waitlist) is what wires this
87
+ end-to-end; today the 402 challenge is built in and step 3 is a function
88
+ you supply.
89
+
90
+ **Step 3 is yours, and it defaults to no.** `verifyPayment(proof, ctx)` is
91
+ the only thing that can turn a 402 into a 200. Configure nothing and every
92
+ priced route stays 402 forever — deliberately. A payment proof is a string
93
+ written by the party who owes you money, so there is nothing this library
94
+ can check about it on its own that the payer could not have fabricated.
95
+ Point it at a facilitator that confirms settlement, and never at a
96
+ comparison against the challenge you just issued. A verifier that returns
97
+ anything but `true`/`{ok:true}` — or that throws — denies passage, and
98
+ nothing but a confirmed settlement is ever written to the ledger as billed.
99
+
100
+ ## Security posture
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.
121
+ - **Rate limits key on the connection address, not `x-forwarded-for`.**
122
+ The header is client-written unless a proxy you own overwrites it; believing
123
+ it by default hands an attacker unlimited fresh buckets. Behind a real proxy,
124
+ set `trustProxy: true`. Pass `req.ip` (the Express adapter does).
125
+ - **In-memory state is bounded** by `maxTracked` (default 10,000) and cleared
126
+ each rate window, so a flood of distinct clients cannot exhaust memory.
127
+ - **Replay: single-use is enforced only for signers that send RFC 9421's
128
+ `nonce`.** Signature bytes are not a usable substitute — Ed25519 is
129
+ deterministic and the Web Bot Auth profile covers only `@authority`, so one
130
+ agent hitting two paths in the same second produces byte-identical
131
+ signatures. Rejecting those would break real traffic. Without a signer
132
+ nonce, replay is bounded by the expiry window and nothing tighter.
133
+ - **State is per-process.** Rate limits and replay memory do not survive a
134
+ restart and do not span instances — two instances mean two independent
135
+ limiters. Shared state is the next real gap, alongside key rotation.
57
136
 
58
137
  ## License
59
138
 
package/index.d.ts CHANGED
@@ -53,6 +53,19 @@ export interface WayleaveRequest {
53
53
  /** Host authority the signature was computed over, e.g. "example.com". */
54
54
  authority: string;
55
55
  headers: Record<string, string | string[] | undefined>;
56
+ /**
57
+ * The connection's source address, e.g. `req.socket.remoteAddress`. Used to
58
+ * key rate limits for unsigned traffic. Supply it: without it every
59
+ * anonymous client shares one bucket. Never taken from a header unless
60
+ * `trustProxy` is on.
61
+ */
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;
56
69
  }
57
70
 
58
71
  export interface VerifyResult {
@@ -61,6 +74,15 @@ export interface VerifyResult {
61
74
  agentId: string | null;
62
75
  /** Human-readable reason, safe to log. */
63
76
  reason: string;
77
+ /**
78
+ * Single-use token, set only when the signer supplied RFC 9421's `nonce`
79
+ * parameter. Null otherwise — a signature hash is NOT a safe substitute,
80
+ * because deterministic Ed25519 over `@authority` alone makes two
81
+ * legitimate same-second requests byte-identical.
82
+ */
83
+ nonce?: string | null;
84
+ /** Unix seconds the signature stops being valid. */
85
+ expires?: number;
64
86
  }
65
87
 
66
88
  export interface Classification {
@@ -68,8 +90,34 @@ export interface Classification {
68
90
  agentId: string | null;
69
91
  /** Why this lane was chosen. Loggable. */
70
92
  evidence: string[];
93
+ /** Present only for verified agents whose signer sent a nonce. */
94
+ nonce?: string | null;
95
+ sigExpires?: number;
96
+ }
97
+
98
+ /** What `verifyPayment` is told about the crossing it is confirming. */
99
+ export interface PaymentContext {
100
+ /** Price in USD for the matched resource. */
101
+ price: number;
102
+ /** The priced path prefix that matched. */
103
+ resource: string;
104
+ /** Cryptographic agent identity when verified, else lane:address. */
105
+ identity: string;
106
+ path: string;
107
+ /** Unix seconds. */
108
+ now: number;
71
109
  }
72
110
 
111
+ /**
112
+ * Your answer to "did this actually settle?". Return `true`, or an object
113
+ * carrying the settlement reference so it reaches the ledger. Anything else
114
+ * — including a throw — denies passage.
115
+ */
116
+ export type PaymentVerifier = (
117
+ proof: string,
118
+ ctx: PaymentContext
119
+ ) => boolean | { ok: boolean; ref?: string; reason?: string };
120
+
73
121
  /** x402-shaped payment challenge returned with a 402. */
74
122
  export interface PaymentChallenge {
75
123
  scheme: 'x402';
@@ -88,8 +136,10 @@ export interface Decision {
88
136
  evidence: string[];
89
137
  /** Present only on 402. */
90
138
  challenge?: PaymentChallenge;
91
- /** Present only when a priced request was paid. */
139
+ /** Present only when `verifyPayment` confirmed settlement. */
92
140
  billed?: number;
141
+ /** Settlement reference returned by your verifier, if any. */
142
+ paymentRef?: string | null;
93
143
  }
94
144
 
95
145
  /** What the metering hook receives. It may throw; serving continues. */
@@ -102,7 +152,10 @@ export interface MeterEvent {
102
152
  status: number;
103
153
  why: string;
104
154
  evidence: string[];
155
+ /** Zero unless settlement was confirmed. Never records an unpaid crossing. */
105
156
  billedUsd: number;
157
+ /** Settlement reference from your verifier, when it supplied one. */
158
+ paymentRef?: string;
106
159
  }
107
160
 
108
161
  /** `[pathPrefix, allow]` — first matching prefix wins. */
@@ -118,6 +171,52 @@ export interface WayleaveOptions {
118
171
  rateWindow?: number;
119
172
  /** Path prefix -> price in USD. Non-human lanes get 402 here. */
120
173
  pricedPaths?: Record<string, number>;
174
+ /**
175
+ * Confirms a payment actually settled. **Without this, every priced route
176
+ * stays 402 forever** — deny is the only safe default, since the proof is a
177
+ * string the payer wrote. Wire it to your facilitator, not to a comparison
178
+ * against the challenge you just issued.
179
+ */
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;
204
+ /**
205
+ * Believe `x-forwarded-for` for the client address. Default false. Turn on
206
+ * ONLY behind a proxy you control that overwrites the header — otherwise
207
+ * clients mint themselves unlimited rate-limit buckets.
208
+ */
209
+ trustProxy?: boolean;
210
+ /**
211
+ * Reject a second use of a signature whose signer supplied a nonce.
212
+ * Default true. In-process only: it does not span instances or restarts.
213
+ */
214
+ replayProtection?: boolean;
215
+ /**
216
+ * Ceiling on in-memory identities and nonces. Default 10000. Bounds memory
217
+ * under a flood of distinct clients; oldest entries are evicted.
218
+ */
219
+ maxTracked?: number;
121
220
  /**
122
221
  * Metering hook, called once per request. Exceptions are swallowed —
123
222
  * billing must never break serving.
package/index.js CHANGED
@@ -17,7 +17,8 @@
17
17
  * Zero dependencies. Node's native crypto only.
18
18
  */
19
19
 
20
- import { createPublicKey, verify as edVerify, sign as edSign } from 'node:crypto';
20
+ import { createPublicKey, verify as edVerify, sign as edSign,
21
+ createHash } from 'node:crypto';
21
22
 
22
23
  export const LANES = Object.freeze({
23
24
  VERIFIED: 'verified_agent',
@@ -109,7 +110,15 @@ export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
109
110
 
110
111
  /**
111
112
  * directories: { [directoryUrl]: { [keyid]: publicKey (KeyObject|PEM|raw b64) } }
112
- * Returns { ok, agentId, reason }
113
+ * Returns { ok, agentId, reason, nonce, expires }
114
+ *
115
+ * `nonce` is set only when the signer supplied RFC 9421's `nonce` parameter,
116
+ * and it is what makes a signature single-use. Do NOT substitute a hash of
117
+ * the signature itself: Ed25519 is deterministic and the Web Bot Auth profile
118
+ * covers only `@authority`, so one agent hitting two different paths in the
119
+ * same second legitimately produces the SAME signature bytes. Treating that
120
+ * as a replay would reject real traffic. Without a signer-supplied nonce,
121
+ * replay is bounded by the expiry window and nothing tighter — see README.
113
122
  */
114
123
  export function verifySignature(h, authority, directories,
115
124
  now = Math.floor(Date.now() / 1000)) {
@@ -173,7 +182,12 @@ export function verifySignature(h, authority, directories,
173
182
  if (!valid)
174
183
  return { ok: false, agentId: null,
175
184
  reason: 'signature verification FAILED (forged or tampered)' };
176
- return { ok: true, agentId: `${directory}#${keyid}`, reason: 'valid signature' };
185
+ return { ok: true, agentId: `${directory}#${keyid}`, reason: 'valid signature',
186
+ nonce: params.nonce
187
+ ? createHash('sha256')
188
+ .update(`${directory}#${keyid}#${params.nonce}`).digest('base64')
189
+ : null,
190
+ expires };
177
191
  }
178
192
 
179
193
  // ── classification ──────────────────────────────────────────────────────
@@ -199,7 +213,8 @@ export function classify(req, directories, now) {
199
213
  const h = req.headers, ua = (h['user-agent'] || '').toLowerCase();
200
214
  const v = verifySignature(h, req.authority, directories, now);
201
215
  if (v.ok) return { lane: LANES.VERIFIED, agentId: v.agentId,
202
- evidence: [`crypto: ${v.reason}`] };
216
+ evidence: [`crypto: ${v.reason}`],
217
+ nonce: v.nonce, sigExpires: v.expires };
203
218
  if (h['signature'] || h['signature-input'])
204
219
  return { lane: LANES.SUSPECT, agentId: null,
205
220
  evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
@@ -226,6 +241,12 @@ export class Wayleave {
226
241
  * rules: { [lane]: [ [pathPrefix, allow], ... ] },
227
242
  * rateLimits: { [lane]: perWindow }, rateWindow: 60,
228
243
  * pricedPaths: { pathPrefix: priceUsd },
244
+ * verifyPayment: (proof, ctx) => boolean | { ok, ref },
245
+ * strictPricedPaths: false,
246
+ * confirmHuman: (req) => boolean,
247
+ * trustProxy: false,
248
+ * replayProtection: true,
249
+ * maxTracked: 10000,
229
250
  * onEvent: (logEntry) => {} // metering/billing hook, async-safe
230
251
  * }
231
252
  */
@@ -236,43 +257,146 @@ export class Wayleave {
236
257
  this.rateWindow = opts.rateWindow || 60;
237
258
  this.pricedPaths = opts.pricedPaths || {};
238
259
  this.onEvent = opts.onEvent || (() => {});
239
- this._hits = new Map();
260
+
261
+ // No payment verifier configured means no agent can ever buy passage.
262
+ // Deny is the only safe default: a proof is a string the PAYER wrote, and
263
+ // anything this library could check about it without a settlement network
264
+ // is something the payer could equally have made up.
265
+ this.verifyPayment = opts.verifyPayment || null;
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
+
276
+ // x-forwarded-for is written by the client unless a proxy you control
277
+ // overwrites it. Trusting it by default hands every attacker an unlimited
278
+ // supply of fresh rate-limit buckets. Opt in only behind a real proxy.
279
+ this.trustProxy = opts.trustProxy === true;
280
+
281
+ this.replayProtection = opts.replayProtection !== false;
282
+ this.maxTracked = opts.maxTracked || 10000;
283
+
284
+ this._hits = new Map(); // identity -> count, cleared each window
285
+ this._window = -1;
286
+ this._seen = new Map(); // signature nonce -> expiry, pruned on rollover
240
287
  }
241
288
 
242
- /** req: { method, path, authority, headers } → decision */
289
+ /** req: { method, path, authority, headers, ip } → decision */
243
290
  handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
244
- const { lane, agentId, evidence } = classify(req, this.directories, now);
245
- const ident = agentId ||
246
- `${lane}:${req.headers['x-forwarded-for'] || 'unknown'}`;
247
- const d = this._decide(req, lane, ident, now, paymentProof);
291
+ const c = classify(req, this.directories, now);
292
+ const { lane, agentId, evidence } = c;
293
+ const ident = agentId || `${lane}:${this._client(req)}`;
294
+ const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
295
+ const d = this._decide(req, lane, ident, now, proof, c);
248
296
  const entry = { t: now, path: req.path, lane, identity: ident,
249
297
  status: d.status, why: d.why, evidence,
250
298
  billedUsd: d.billed || 0 };
299
+ if (d.paymentRef) entry.paymentRef = d.paymentRef;
251
300
  try { this.onEvent(entry); } catch { /* metering must never break serving */ }
252
301
  return { lane, identity: ident, ...d, evidence };
253
302
  }
254
303
 
255
- _decide(req, lane, ident, now, paymentProof) {
304
+ /**
305
+ * Who to rate-limit. A cryptographic identity is authoritative; a network
306
+ * address is at least expensive to rotate. A header is neither.
307
+ */
308
+ _client(req) {
309
+ if (this.trustProxy) {
310
+ const xff = headerValue(req.headers, 'x-forwarded-for');
311
+ if (xff) return xff.split(',')[0].trim();
312
+ }
313
+ return req.ip || 'unknown';
314
+ }
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
+
332
+ /**
333
+ * Fixed-window counting. On rollover the whole table is dropped — last
334
+ * window's counts are meaningless anyway, and it keeps memory bounded to
335
+ * one window's worth of identities instead of growing forever.
336
+ */
337
+ _bump(ident, now) {
338
+ const w = Math.floor(now / this.rateWindow);
339
+ if (w !== this._window) {
340
+ this._window = w;
341
+ this._hits.clear();
342
+ for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
343
+ }
344
+ // Hard ceiling so a flood of distinct identities inside a single window
345
+ // cannot exhaust memory. Evicting the oldest keeps the newest arrivals
346
+ // counted; the durable fix is shared state, not a bigger Map.
347
+ if (this._hits.size >= this.maxTracked && !this._hits.has(ident))
348
+ this._hits.delete(this._hits.keys().next().value);
349
+ const n = (this._hits.get(ident) || 0) + 1;
350
+ this._hits.set(ident, n);
351
+ return n;
352
+ }
353
+
354
+ _decide(req, lane, ident, now, paymentProof, c) {
256
355
  for (const [prefix, allow] of this.rules[lane] || []) {
257
356
  if (req.path.startsWith(prefix)) {
258
357
  if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
259
358
  break;
260
359
  }
261
360
  }
361
+ // A valid signature replayed is still a replay. Checked before rate
362
+ // limiting so a captured request cannot be farmed into N billed crossings.
363
+ // Only signers that send a nonce can be held to single use; see
364
+ // verifySignature for why a signature hash is not a safe substitute.
365
+ if (this.replayProtection && c && c.nonce) {
366
+ if (this._seen.has(c.nonce))
367
+ return { status: 403, why: 'signature already used (replay)' };
368
+ if (this._seen.size >= this.maxTracked)
369
+ this._seen.delete(this._seen.keys().next().value);
370
+ this._seen.set(c.nonce, c.sigExpires || now + this.rateWindow);
371
+ }
262
372
  const limit = this.rateLimits[lane];
263
373
  if (limit) {
264
- const k = `${ident}|${Math.floor(now / this.rateWindow)}`;
265
- const n = (this._hits.get(k) || 0) + 1;
266
- this._hits.set(k, n);
374
+ const n = this._bump(ident, now);
267
375
  if (n > limit) return { status: 429, why: `${n} req in window, limit ${limit}` };
268
376
  }
269
- 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) {
270
385
  for (const [prefix, price] of Object.entries(this.pricedPaths)) {
271
386
  if (req.path.startsWith(prefix)) {
272
- if (paymentProof === `paid:${prefix}:${price}`)
273
- return { status: 200, billed: price, why: `paid access $${price}` };
274
- return { status: 402, why: 'payment required for agent access',
275
- challenge: { scheme: 'x402', price_usd: price, resource: prefix } };
387
+ const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
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
+ }
394
+ const v = this._checkPayment(paymentProof, {
395
+ price, resource: prefix, identity: ident, path: req.path, now });
396
+ if (v.ok)
397
+ return { status: 200, billed: price, paymentRef: v.ref,
398
+ why: `settled $${price}` };
399
+ return { status: 402, why: `payment proof rejected: ${v.reason}`, challenge };
276
400
  }
277
401
  }
278
402
  }
@@ -281,6 +405,22 @@ export class Wayleave {
281
405
  return { status: 200, why: `${lane} allowed` };
282
406
  }
283
407
 
408
+ /**
409
+ * Settlement is somebody else's job — a facilitator that actually moved the
410
+ * money. This asks yours and believes nothing else. A verifier that throws
411
+ * is a verifier that did not say yes, so the answer is no.
412
+ */
413
+ _checkPayment(proof, ctx) {
414
+ if (!this.verifyPayment)
415
+ return { ok: false, reason: 'no verifyPayment configured' };
416
+ let r;
417
+ try { r = this.verifyPayment(proof, ctx); }
418
+ catch (e) { return { ok: false, reason: `verifier threw: ${e.message}` }; }
419
+ if (r === true) return { ok: true, ref: null };
420
+ if (r && r.ok === true) return { ok: true, ref: r.ref || null };
421
+ return { ok: false, reason: (r && r.reason) || 'not settled' };
422
+ }
423
+
284
424
  /** Express/Connect adapter: app.use(wayleave.express()) */
285
425
  express() {
286
426
  return (req, res, next) => {
@@ -288,6 +428,13 @@ export class Wayleave {
288
428
  method: req.method, path: req.path || req.url,
289
429
  authority: req.headers.host || '',
290
430
  headers: req.headers,
431
+ // The socket address, not a header. Express only populates req.ip
432
+ // from x-forwarded-for when ITS trust proxy setting says to.
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,
291
438
  }, undefined, req.headers['x-payment-proof'] || '');
292
439
  req.wayleave = r;
293
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.4",
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",