wayleave 0.1.3 → 0.1.5

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 +58 -2
  2. package/index.d.ts +71 -1
  3. package/index.js +211 -38
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -36,6 +36,8 @@ const gate = new Wayleave({
36
36
  },
37
37
  rateLimits: { declared_agent: 10 },
38
38
  pricedPaths: { '/api/premium': 0.05 }, // agents pay 5¢/call, humans free
39
+ verifyPayment: (proof, { price, resource }) =>
40
+ facilitator.confirmSettled(proof, price, resource), // REQUIRED to sell
39
41
  onEvent: e => queueForBilling(e), // metering hook; never blocks serving
40
42
  });
41
43
 
@@ -44,14 +46,68 @@ app.use(gate.express());
44
46
 
45
47
  ## Guarantees, honestly stated
46
48
 
47
- - Signature verification is real Ed25519 over an RFC 9421 signature base (`@authority`, `signature-agent`) — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially.
49
+ - Signature verification is real Ed25519 over an RFC 9421 signature base — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially, and against other implementations' wire formats rather than only its own.
50
+ - 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.
48
51
  - Sub-millisecond per request. Your latency budget won't notice.
49
52
  - 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.
50
54
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
51
55
 
52
56
  ## Status
53
57
 
54
- v0.1 — verification, lanes, policy, pricing, and the metering hook, all under test (22 scenarios, including forgery, tampering, and replay). Payment settlement network integration is in progress; today the 402 challenge and payment-proof check are pluggable.
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.
59
+
60
+ 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
+
62
+ ## How payment actually works
63
+
64
+ Pay-at-the-door, not IOUs. On a route you price:
65
+
66
+ 1. An agent requests the route → Wayleave answers `402 Payment Required`
67
+ with an x402-shaped challenge: the price and *your* receiving address.
68
+ 2. A wallet-carrying agent signs payment and retries with proof attached.
69
+ 3. The proof is verified and settlement executes on a licensed rail
70
+ (x402 facilitator) — money moves to your account.
71
+ 4. Only then: `200`. No settled payment, no passage. Data never moves
72
+ on a promise.
73
+
74
+ Agents without wallets (most crawlers today) are simply turned away on
75
+ priced routes — you aren't paid by them, but you also never serve them
76
+ free. The ledger shows exactly how much turned-away demand is standing
77
+ at your gate.
78
+
79
+ Wayleave never holds funds. Agent money flows agent → facilitator → you.
80
+ The hosted meter (not built yet — see the waitlist) is what wires this
81
+ end-to-end; today the 402 challenge is built in and step 3 is a function
82
+ you supply.
83
+
84
+ **Step 3 is yours, and it defaults to no.** `verifyPayment(proof, ctx)` is
85
+ the only thing that can turn a 402 into a 200. Configure nothing and every
86
+ priced route stays 402 forever — deliberately. A payment proof is a string
87
+ written by the party who owes you money, so there is nothing this library
88
+ can check about it on its own that the payer could not have fabricated.
89
+ Point it at a facilitator that confirms settlement, and never at a
90
+ comparison against the challenge you just issued. A verifier that returns
91
+ anything but `true`/`{ok:true}` — or that throws — denies passage, and
92
+ nothing but a confirmed settlement is ever written to the ledger as billed.
93
+
94
+ ## Security posture
95
+
96
+ - **Rate limits key on the connection address, not `x-forwarded-for`.**
97
+ The header is client-written unless a proxy you own overwrites it; believing
98
+ it by default hands an attacker unlimited fresh buckets. Behind a real proxy,
99
+ set `trustProxy: true`. Pass `req.ip` (the Express adapter does).
100
+ - **In-memory state is bounded** by `maxTracked` (default 10,000) and cleared
101
+ each rate window, so a flood of distinct clients cannot exhaust memory.
102
+ - **Replay: single-use is enforced only for signers that send RFC 9421's
103
+ `nonce`.** Signature bytes are not a usable substitute — Ed25519 is
104
+ deterministic and the Web Bot Auth profile covers only `@authority`, so one
105
+ agent hitting two paths in the same second produces byte-identical
106
+ signatures. Rejecting those would break real traffic. Without a signer
107
+ 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.
55
111
 
56
112
  ## License
57
113
 
package/index.d.ts CHANGED
@@ -53,6 +53,13 @@ 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;
56
63
  }
57
64
 
58
65
  export interface VerifyResult {
@@ -61,6 +68,15 @@ export interface VerifyResult {
61
68
  agentId: string | null;
62
69
  /** Human-readable reason, safe to log. */
63
70
  reason: string;
71
+ /**
72
+ * Single-use token, set only when the signer supplied RFC 9421's `nonce`
73
+ * parameter. Null otherwise — a signature hash is NOT a safe substitute,
74
+ * because deterministic Ed25519 over `@authority` alone makes two
75
+ * legitimate same-second requests byte-identical.
76
+ */
77
+ nonce?: string | null;
78
+ /** Unix seconds the signature stops being valid. */
79
+ expires?: number;
64
80
  }
65
81
 
66
82
  export interface Classification {
@@ -68,8 +84,34 @@ export interface Classification {
68
84
  agentId: string | null;
69
85
  /** Why this lane was chosen. Loggable. */
70
86
  evidence: string[];
87
+ /** Present only for verified agents whose signer sent a nonce. */
88
+ nonce?: string | null;
89
+ sigExpires?: number;
90
+ }
91
+
92
+ /** What `verifyPayment` is told about the crossing it is confirming. */
93
+ export interface PaymentContext {
94
+ /** Price in USD for the matched resource. */
95
+ price: number;
96
+ /** The priced path prefix that matched. */
97
+ resource: string;
98
+ /** Cryptographic agent identity when verified, else lane:address. */
99
+ identity: string;
100
+ path: string;
101
+ /** Unix seconds. */
102
+ now: number;
71
103
  }
72
104
 
105
+ /**
106
+ * Your answer to "did this actually settle?". Return `true`, or an object
107
+ * carrying the settlement reference so it reaches the ledger. Anything else
108
+ * — including a throw — denies passage.
109
+ */
110
+ export type PaymentVerifier = (
111
+ proof: string,
112
+ ctx: PaymentContext
113
+ ) => boolean | { ok: boolean; ref?: string; reason?: string };
114
+
73
115
  /** x402-shaped payment challenge returned with a 402. */
74
116
  export interface PaymentChallenge {
75
117
  scheme: 'x402';
@@ -88,8 +130,10 @@ export interface Decision {
88
130
  evidence: string[];
89
131
  /** Present only on 402. */
90
132
  challenge?: PaymentChallenge;
91
- /** Present only when a priced request was paid. */
133
+ /** Present only when `verifyPayment` confirmed settlement. */
92
134
  billed?: number;
135
+ /** Settlement reference returned by your verifier, if any. */
136
+ paymentRef?: string | null;
93
137
  }
94
138
 
95
139
  /** What the metering hook receives. It may throw; serving continues. */
@@ -102,7 +146,10 @@ export interface MeterEvent {
102
146
  status: number;
103
147
  why: string;
104
148
  evidence: string[];
149
+ /** Zero unless settlement was confirmed. Never records an unpaid crossing. */
105
150
  billedUsd: number;
151
+ /** Settlement reference from your verifier, when it supplied one. */
152
+ paymentRef?: string;
106
153
  }
107
154
 
108
155
  /** `[pathPrefix, allow]` — first matching prefix wins. */
@@ -118,6 +165,29 @@ export interface WayleaveOptions {
118
165
  rateWindow?: number;
119
166
  /** Path prefix -> price in USD. Non-human lanes get 402 here. */
120
167
  pricedPaths?: Record<string, number>;
168
+ /**
169
+ * Confirms a payment actually settled. **Without this, every priced route
170
+ * stays 402 forever** — deny is the only safe default, since the proof is a
171
+ * string the payer wrote. Wire it to your facilitator, not to a comparison
172
+ * against the challenge you just issued.
173
+ */
174
+ verifyPayment?: PaymentVerifier;
175
+ /**
176
+ * Believe `x-forwarded-for` for the client address. Default false. Turn on
177
+ * ONLY behind a proxy you control that overwrites the header — otherwise
178
+ * clients mint themselves unlimited rate-limit buckets.
179
+ */
180
+ trustProxy?: boolean;
181
+ /**
182
+ * Reject a second use of a signature whose signer supplied a nonce.
183
+ * Default true. In-process only: it does not span instances or restarts.
184
+ */
185
+ replayProtection?: boolean;
186
+ /**
187
+ * Ceiling on in-memory identities and nonces. Default 10000. Bounds memory
188
+ * under a flood of distinct clients; oldest entries are evicted.
189
+ */
190
+ maxTracked?: number;
121
191
  /**
122
192
  * Metering hook, called once per request. Exceptions are swallowed —
123
193
  * 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',
@@ -28,28 +29,80 @@ export const LANES = Object.freeze({
28
29
 
29
30
  // ── RFC 9421 profile (Web Bot Auth usage) ───────────────────────────────
30
31
 
31
- const PARAMS_RE = /created=(\d+);expires=(\d+);keyid="([^"]+)";tag="([^"]+)"/;
32
+ /**
33
+ * Parse an RFC 9421 `signature-input` value: `label=("a" "b");k=v;k2="v2"`.
34
+ *
35
+ * Parameters form a dictionary — order is NOT significant, and no
36
+ * implementation is obliged to emit them in any particular sequence. The
37
+ * label is arbitrary too (Cloudflare uses `sig2`, we emit `sig1`). Anything
38
+ * that hardcodes either will reject legitimately signed traffic.
39
+ *
40
+ * Returns { label, components, params, paramsRaw } or null.
41
+ * `paramsRaw` is preserved verbatim — the signature base must reproduce it
42
+ * byte for byte or verification fails.
43
+ */
44
+ function parseSignatureInput(raw) {
45
+ const eq = raw.indexOf('=');
46
+ if (eq < 1) return null;
47
+ const label = raw.slice(0, eq).trim();
48
+ if (!/^[A-Za-z0-9_-]+$/.test(label)) return null;
49
+
50
+ const paramsRaw = raw.slice(eq + 1);
51
+ const close = paramsRaw.indexOf(')');
52
+ if (!paramsRaw.startsWith('(') || close < 0) return null;
53
+
54
+ const components = (paramsRaw.slice(1, close).match(/"[^"]*"/g) || [])
55
+ .map(s => s.slice(1, -1).toLowerCase());
56
+
57
+ const params = {};
58
+ const re = /;\s*([A-Za-z0-9_.-]+)=("[^"]*"|[^;]+)/g;
59
+ let m;
60
+ while ((m = re.exec(paramsRaw.slice(close + 1))) !== null) {
61
+ const v = m[2].trim();
62
+ params[m[1]] = v.startsWith('"') ? v.slice(1, -1) : v;
63
+ }
64
+ return { label, components, params, paramsRaw };
65
+ }
32
66
 
33
- function sigBase(authority, signatureAgent, params) {
34
- return Buffer.from(
35
- `"@authority": ${authority}\n` +
36
- `"signature-agent": ${signatureAgent}\n` +
37
- `"@signature-params": ${params}`
67
+ /** Structured-field strings arrive quoted; the directory URL itself is not. */
68
+ function unquote(s) {
69
+ return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
70
+ }
71
+
72
+ function headerValue(headers, name) {
73
+ const v = headers[name];
74
+ return Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v));
75
+ }
76
+
77
+ /**
78
+ * Build the signature base from the components the signer actually declared,
79
+ * not from a fixed list. An agent covering only ("@authority") is valid.
80
+ */
81
+ function sigBase(components, ctx, paramsRaw) {
82
+ const lines = components.map(c =>
83
+ c === '@authority'
84
+ ? `"@authority": ${ctx.authority}`
85
+ : `"${c}": ${headerValue(ctx.headers, c)}`
38
86
  );
87
+ lines.push(`"@signature-params": ${paramsRaw}`);
88
+ return Buffer.from(lines.join('\n'));
39
89
  }
40
90
 
41
91
  export function buildParams(keyid, created, expires) {
42
92
  return `("@authority" "signature-agent");created=${created};` +
43
- `expires=${expires};keyid="${keyid}";tag="web-bot-auth"`;
93
+ `keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"`;
44
94
  }
45
95
 
46
96
  /** What a legitimate agent operator's SDK does before sending. */
47
97
  export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
48
98
  created = Math.floor(Date.now() / 1000),
49
99
  expires = created + 300) {
50
- headers['signature-agent'] = directoryUrl;
100
+ // Structured-field string: the value is sent quoted.
101
+ headers['signature-agent'] = `"${directoryUrl}"`;
51
102
  const params = buildParams(keyid, created, expires);
52
- const sig = edSign(null, sigBase(authority, directoryUrl, params), privateKey);
103
+ const base = sigBase(['@authority', 'signature-agent'],
104
+ { authority, headers }, params);
105
+ const sig = edSign(null, base, privateKey);
53
106
  headers['signature-input'] = `sig1=${params}`;
54
107
  headers['signature'] = `sig1=:${sig.toString('base64')}:`;
55
108
  return headers;
@@ -57,22 +110,44 @@ export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
57
110
 
58
111
  /**
59
112
  * directories: { [directoryUrl]: { [keyid]: publicKey (KeyObject|PEM|raw b64) } }
60
- * 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.
61
122
  */
62
123
  export function verifySignature(h, authority, directories,
63
124
  now = Math.floor(Date.now() / 1000)) {
64
- const sigInput = h['signature-input'] || '';
65
- const sigHeader = h['signature'] || '';
66
- const directory = h['signature-agent'] || '';
125
+ const sigInput = headerValue(h, 'signature-input');
126
+ const sigHeader = headerValue(h, 'signature');
127
+ const rawAgent = headerValue(h, 'signature-agent');
67
128
 
68
- if (!sigInput || !sigHeader || !directory)
129
+ if (!sigInput || !sigHeader || !rawAgent)
69
130
  return { ok: false, agentId: null, reason: 'no signature presented' };
70
131
 
71
- const m = PARAMS_RE.exec(sigInput);
72
- if (!m) return { ok: false, agentId: null, reason: 'malformed signature-input' };
73
- const [, createdS, expiresS, keyid, tag] = m;
74
- const created = +createdS, expires = +expiresS;
132
+ const parsed = parseSignatureInput(sigInput);
133
+ if (!parsed) return { ok: false, agentId: null, reason: 'malformed signature-input' };
134
+ const { label, components, params, paramsRaw } = parsed;
75
135
 
136
+ const directory = unquote(rawAgent);
137
+ const keyid = params.keyid;
138
+ const tag = params.tag;
139
+ const created = Number(params.created);
140
+ const expires = Number(params.expires);
141
+
142
+ if (!keyid) return { ok: false, agentId: null, reason: 'signature-input missing keyid' };
143
+ if (!Number.isFinite(created) || !Number.isFinite(expires))
144
+ return { ok: false, agentId: null, reason: 'signature-input missing created/expires' };
145
+ if (!components.includes('@authority'))
146
+ return { ok: false, agentId: null, reason: '@authority not covered by signature' };
147
+ // alg is mandatory in the Web Bot Auth profile; tolerate its absence for
148
+ // signers that omit it, but reject anything that is not Ed25519.
149
+ if (params.alg && params.alg.toLowerCase() !== 'ed25519')
150
+ return { ok: false, agentId: null, reason: `unsupported alg "${params.alg}"` };
76
151
  if (tag !== 'web-bot-auth')
77
152
  return { ok: false, agentId: null, reason: `unexpected tag "${tag}"` };
78
153
  if (now < created - 30)
@@ -96,16 +171,23 @@ export function verifySignature(h, authority, directories,
96
171
  dir[keyid] = key; // cache the KeyObject
97
172
  }
98
173
 
99
- const sm = /sig1=:([A-Za-z0-9+/=]+):/.exec(sigHeader);
174
+ // Match the signature by the label the signer used, not a hardcoded "sig1".
175
+ const sm = new RegExp(
176
+ `(?:^|,)\\s*${label.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}=:([A-Za-z0-9+/=]+):`
177
+ ).exec(sigHeader);
100
178
  if (!sm) return { ok: false, agentId: null, reason: 'malformed signature header' };
101
179
 
102
- const params = sigInput.slice(sigInput.indexOf('sig1=') + 5);
103
- const valid = edVerify(null, sigBase(authority, directory, params),
180
+ const valid = edVerify(null, sigBase(components, { authority, headers: h }, paramsRaw),
104
181
  key, Buffer.from(sm[1], 'base64'));
105
182
  if (!valid)
106
183
  return { ok: false, agentId: null,
107
184
  reason: 'signature verification FAILED (forged or tampered)' };
108
- 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 };
109
191
  }
110
192
 
111
193
  // ── classification ──────────────────────────────────────────────────────
@@ -131,7 +213,8 @@ export function classify(req, directories, now) {
131
213
  const h = req.headers, ua = (h['user-agent'] || '').toLowerCase();
132
214
  const v = verifySignature(h, req.authority, directories, now);
133
215
  if (v.ok) return { lane: LANES.VERIFIED, agentId: v.agentId,
134
- evidence: [`crypto: ${v.reason}`] };
216
+ evidence: [`crypto: ${v.reason}`],
217
+ nonce: v.nonce, sigExpires: v.expires };
135
218
  if (h['signature'] || h['signature-input'])
136
219
  return { lane: LANES.SUSPECT, agentId: null,
137
220
  evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
@@ -158,6 +241,10 @@ export class Wayleave {
158
241
  * rules: { [lane]: [ [pathPrefix, allow], ... ] },
159
242
  * rateLimits: { [lane]: perWindow }, rateWindow: 60,
160
243
  * pricedPaths: { pathPrefix: priceUsd },
244
+ * verifyPayment: (proof, ctx) => boolean | { ok, ref },
245
+ * trustProxy: false,
246
+ * replayProtection: true,
247
+ * maxTracked: 10000,
161
248
  * onEvent: (logEntry) => {} // metering/billing hook, async-safe
162
249
  * }
163
250
  */
@@ -168,43 +255,110 @@ export class Wayleave {
168
255
  this.rateWindow = opts.rateWindow || 60;
169
256
  this.pricedPaths = opts.pricedPaths || {};
170
257
  this.onEvent = opts.onEvent || (() => {});
171
- this._hits = new Map();
258
+
259
+ // No payment verifier configured means no agent can ever buy passage.
260
+ // Deny is the only safe default: a proof is a string the PAYER wrote, and
261
+ // anything this library could check about it without a settlement network
262
+ // is something the payer could equally have made up.
263
+ this.verifyPayment = opts.verifyPayment || null;
264
+
265
+ // x-forwarded-for is written by the client unless a proxy you control
266
+ // overwrites it. Trusting it by default hands every attacker an unlimited
267
+ // supply of fresh rate-limit buckets. Opt in only behind a real proxy.
268
+ this.trustProxy = opts.trustProxy === true;
269
+
270
+ this.replayProtection = opts.replayProtection !== false;
271
+ this.maxTracked = opts.maxTracked || 10000;
272
+
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
172
276
  }
173
277
 
174
- /** req: { method, path, authority, headers } → decision */
278
+ /** req: { method, path, authority, headers, ip } → decision */
175
279
  handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
176
- const { lane, agentId, evidence } = classify(req, this.directories, now);
177
- const ident = agentId ||
178
- `${lane}:${req.headers['x-forwarded-for'] || 'unknown'}`;
179
- const d = this._decide(req, lane, ident, now, paymentProof);
280
+ const c = classify(req, this.directories, now);
281
+ const { lane, agentId, evidence } = c;
282
+ const ident = agentId || `${lane}:${this._client(req)}`;
283
+ const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
284
+ const d = this._decide(req, lane, ident, now, proof, c);
180
285
  const entry = { t: now, path: req.path, lane, identity: ident,
181
286
  status: d.status, why: d.why, evidence,
182
287
  billedUsd: d.billed || 0 };
288
+ if (d.paymentRef) entry.paymentRef = d.paymentRef;
183
289
  try { this.onEvent(entry); } catch { /* metering must never break serving */ }
184
290
  return { lane, identity: ident, ...d, evidence };
185
291
  }
186
292
 
187
- _decide(req, lane, ident, now, paymentProof) {
293
+ /**
294
+ * Who to rate-limit. A cryptographic identity is authoritative; a network
295
+ * address is at least expensive to rotate. A header is neither.
296
+ */
297
+ _client(req) {
298
+ if (this.trustProxy) {
299
+ const xff = headerValue(req.headers, 'x-forwarded-for');
300
+ if (xff) return xff.split(',')[0].trim();
301
+ }
302
+ return req.ip || 'unknown';
303
+ }
304
+
305
+ /**
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.
309
+ */
310
+ _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;
325
+ }
326
+
327
+ _decide(req, lane, ident, now, paymentProof, c) {
188
328
  for (const [prefix, allow] of this.rules[lane] || []) {
189
329
  if (req.path.startsWith(prefix)) {
190
330
  if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
191
331
  break;
192
332
  }
193
333
  }
334
+ // A valid signature replayed is still a replay. Checked before rate
335
+ // limiting so a captured request cannot be farmed into N billed crossings.
336
+ // Only signers that send a nonce can be held to single use; see
337
+ // verifySignature for why a signature hash is not a safe substitute.
338
+ if (this.replayProtection && c && c.nonce) {
339
+ if (this._seen.has(c.nonce))
340
+ 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);
344
+ }
194
345
  const limit = this.rateLimits[lane];
195
346
  if (limit) {
196
- const k = `${ident}|${Math.floor(now / this.rateWindow)}`;
197
- const n = (this._hits.get(k) || 0) + 1;
198
- this._hits.set(k, n);
347
+ const n = this._bump(ident, now);
199
348
  if (n > limit) return { status: 429, why: `${n} req in window, limit ${limit}` };
200
349
  }
201
350
  if (lane !== LANES.HUMAN) {
202
351
  for (const [prefix, price] of Object.entries(this.pricedPaths)) {
203
352
  if (req.path.startsWith(prefix)) {
204
- if (paymentProof === `paid:${prefix}:${price}`)
205
- return { status: 200, billed: price, why: `paid access $${price}` };
206
- return { status: 402, why: 'payment required for agent access',
207
- challenge: { scheme: 'x402', price_usd: price, resource: prefix } };
353
+ const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
354
+ if (!paymentProof)
355
+ return { status: 402, why: 'payment required for agent access', challenge };
356
+ const v = this._checkPayment(paymentProof, {
357
+ price, resource: prefix, identity: ident, path: req.path, now });
358
+ if (v.ok)
359
+ return { status: 200, billed: price, paymentRef: v.ref,
360
+ why: `settled $${price}` };
361
+ return { status: 402, why: `payment proof rejected: ${v.reason}`, challenge };
208
362
  }
209
363
  }
210
364
  }
@@ -213,6 +367,22 @@ export class Wayleave {
213
367
  return { status: 200, why: `${lane} allowed` };
214
368
  }
215
369
 
370
+ /**
371
+ * Settlement is somebody else's job — a facilitator that actually moved the
372
+ * money. This asks yours and believes nothing else. A verifier that throws
373
+ * is a verifier that did not say yes, so the answer is no.
374
+ */
375
+ _checkPayment(proof, ctx) {
376
+ if (!this.verifyPayment)
377
+ return { ok: false, reason: 'no verifyPayment configured' };
378
+ let r;
379
+ try { r = this.verifyPayment(proof, ctx); }
380
+ catch (e) { return { ok: false, reason: `verifier threw: ${e.message}` }; }
381
+ if (r === true) return { ok: true, ref: null };
382
+ if (r && r.ok === true) return { ok: true, ref: r.ref || null };
383
+ return { ok: false, reason: (r && r.reason) || 'not settled' };
384
+ }
385
+
216
386
  /** Express/Connect adapter: app.use(wayleave.express()) */
217
387
  express() {
218
388
  return (req, res, next) => {
@@ -220,6 +390,9 @@ export class Wayleave {
220
390
  method: req.method, path: req.path || req.url,
221
391
  authority: req.headers.host || '',
222
392
  headers: req.headers,
393
+ // The socket address, not a header. Express only populates req.ip
394
+ // from x-forwarded-for when ITS trust proxy setting says to.
395
+ ip: req.socket?.remoteAddress || req.connection?.remoteAddress,
223
396
  }, undefined, req.headers['x-payment-proof'] || '');
224
397
  req.wayleave = r;
225
398
  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",
3
+ "version": "0.1.5",
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",