wayleave 0.5.4 → 0.5.7
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 +20 -1
- package/index.d.ts +10 -1
- package/index.js +49 -8
- package/manifest.js +35 -5
- package/package.json +1 -1
- package/x402.js +28 -2
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Then policy runs per lane: allow, deny, rate-limit — and on routes you price,
|
|
|
25
25
|
|
|
26
26
|
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.
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Three ways to use it
|
|
29
29
|
|
|
30
30
|
**1. The hosted Identity API — no install, any language.** Post a request's
|
|
31
31
|
signature headers and get back who signed it and which lane it belongs in.
|
|
@@ -54,6 +54,25 @@ way to know which to believe.
|
|
|
54
54
|
you do not send. This is the path if you want to price routes, enforce access
|
|
55
55
|
rules, or stay in-process. Start below.
|
|
56
56
|
|
|
57
|
+
**3. A platform adapter — if your code does not run on Node.** This package is
|
|
58
|
+
Express middleware and Node's crypto; it cannot run on a Worker or a Deno
|
|
59
|
+
edge function. These can, share one policy engine with it, and are configured
|
|
60
|
+
the same way:
|
|
61
|
+
|
|
62
|
+
| Where you run | Install |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Cloudflare Workers | [`@wayleave/cloudflare`](https://www.npmjs.com/package/@wayleave/cloudflare) |
|
|
65
|
+
| Vercel middleware | [`@wayleave/vercel`](https://www.npmjs.com/package/@wayleave/vercel) |
|
|
66
|
+
| Supabase Edge Functions | [`@wayleave/supabase`](https://www.npmjs.com/package/@wayleave/supabase) |
|
|
67
|
+
| Netlify Edge Functions | [`@wayleave/netlify`](https://www.npmjs.com/package/@wayleave/netlify) |
|
|
68
|
+
| An MCP server | [`@wayleave/mcp`](https://www.npmjs.com/package/@wayleave/mcp) |
|
|
69
|
+
|
|
70
|
+
All four HTTP adapters default to observe mode and fail open, exactly as this
|
|
71
|
+
one does. The MCP guard is the odd one: an MCP server has no HTTP request to
|
|
72
|
+
inspect and MCP clients carry no signatures, so it does not classify lanes at
|
|
73
|
+
all — it bounds tool calls by rate, budget and repetition instead, and says
|
|
74
|
+
`classification: "unknown"` rather than guessing.
|
|
75
|
+
|
|
57
76
|
## Quickstart: observe first
|
|
58
77
|
|
|
59
78
|
```sh
|
package/index.d.ts
CHANGED
|
@@ -264,7 +264,16 @@ export interface WayleaveOptions {
|
|
|
264
264
|
/** Window length in seconds. Default 60. */
|
|
265
265
|
rateWindow?: number;
|
|
266
266
|
/** Path prefix -> price in USD. Non-human lanes get 402 here. */
|
|
267
|
-
|
|
267
|
+
/** A price, or a price with the method and description to publish.
|
|
268
|
+
* A bare number keeps the old behaviour: GET, generic description. */
|
|
269
|
+
pricedPaths?: Record<string, number | {
|
|
270
|
+
price: number | string;
|
|
271
|
+
/** The HTTP method this route actually serves. Default GET.
|
|
272
|
+
* Publishing GET for a POST API advertises a call that cannot work. */
|
|
273
|
+
method?: string;
|
|
274
|
+
description?: string;
|
|
275
|
+
mimeType?: string;
|
|
276
|
+
}>;
|
|
268
277
|
/**
|
|
269
278
|
* Confirms a payment actually settled. **Without this, every priced route
|
|
270
279
|
* stays 402 forever** — deny is the only safe default, since the proof is a
|
package/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { MeterSink } from './meter.js';
|
|
|
25
25
|
import { RemotePolicy } from './policy.js';
|
|
26
26
|
import { evaluatePolicy, evaluatePolicySync } from './policy-engine.js';
|
|
27
27
|
import { Registry } from './registry.js';
|
|
28
|
-
import { ManifestServer, MANIFEST_PATHS, originFor, paymentRequirementsFor } from './manifest.js';
|
|
28
|
+
import { ManifestServer, MANIFEST_PATHS, originFor, paymentRequirementsFor, routeSpec } from './manifest.js';
|
|
29
29
|
|
|
30
30
|
export const LANES = Object.freeze({
|
|
31
31
|
VERIFIED: 'verified_agent',
|
|
@@ -81,6 +81,30 @@ function headerValue(headers, name) {
|
|
|
81
81
|
return Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v));
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The payment header, read under both names.
|
|
86
|
+
*
|
|
87
|
+
* x402 calls this `X-PAYMENT`. This gate shipped reading only
|
|
88
|
+
* `x-payment-proof`, a name of its own invention, and nothing read the
|
|
89
|
+
* standard one. So an agent that did everything right — fetched
|
|
90
|
+
* /.well-known/x402, read the `accepts` block this gate itself publishes,
|
|
91
|
+
* and paid exactly as the protocol specifies — had its payment ignored and
|
|
92
|
+
* received a second 402. The gate advertised x402 and could not be paid by
|
|
93
|
+
* x402, which is the one thing it exists to do.
|
|
94
|
+
*
|
|
95
|
+
* `X-PAYMENT` wins because it is what the protocol mandates and what a
|
|
96
|
+
* stranger's agent sends without being told. `x-payment-proof` is still read
|
|
97
|
+
* so anything already deployed against it keeps working; this is strictly a
|
|
98
|
+
* superset.
|
|
99
|
+
*
|
|
100
|
+
* The value is passed through untouched. This gate does not parse payments —
|
|
101
|
+
* `verifyPayment` does, and it is yours — so this is a transport fix, not a
|
|
102
|
+
* protocol implementation.
|
|
103
|
+
*/
|
|
104
|
+
function paymentHeader(headers) {
|
|
105
|
+
return headerValue(headers, 'x-payment') || headerValue(headers, 'x-payment-proof');
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
/**
|
|
85
109
|
* Build the signature base from the components the signer actually declared,
|
|
86
110
|
* not from a fixed list. An agent covering only ("@authority") is valid.
|
|
@@ -344,7 +368,18 @@ export function classify(req, directories, now, opts = {}) {
|
|
|
344
368
|
if (h['signature'] || h['signature-input'])
|
|
345
369
|
return { lane: LANES.SUSPECT, agentId: null,
|
|
346
370
|
evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
|
|
347
|
-
|
|
371
|
+
/* WHICH pattern matched, not merely that one did.
|
|
372
|
+
|
|
373
|
+
This used to be `.some(...)`, and the evidence then quoted the first 40
|
|
374
|
+
characters of the user-agent. Almost every bot UA opens with a Mozilla
|
|
375
|
+
compatibility preamble and names itself at the END, so the quote cut off
|
|
376
|
+
exactly where the identifying part began: 486 crossings on one site all
|
|
377
|
+
recorded as `mozilla/5.0 applewebkit/537.36 (khtml, l`, indistinguishable
|
|
378
|
+
from each other and from a browser.
|
|
379
|
+
|
|
380
|
+
The matched pattern is the name. It was in hand and discarded. */
|
|
381
|
+
const agentMatch = AGENT_UA.map(p => (p.exec(ua) || [])[0]).find(Boolean);
|
|
382
|
+
if (agentMatch) {
|
|
348
383
|
// A UA naming a known operator is a claim, and claims are checkable when
|
|
349
384
|
// the operator publishes its addresses. Claiming to be GPTBot from an
|
|
350
385
|
// address OpenAI does not own is a stronger fraud signal than saying
|
|
@@ -356,10 +391,10 @@ export function classify(req, directories, now, opts = {}) {
|
|
|
356
391
|
: null;
|
|
357
392
|
if (ipOk === false)
|
|
358
393
|
return { lane: LANES.SUSPECT, agentId: null,
|
|
359
|
-
evidence: [`UA claims "${
|
|
394
|
+
evidence: [`UA claims "${agentMatch}"`,
|
|
360
395
|
`but ${opts.ip || 'the client'} is not a published address for it`] };
|
|
361
396
|
return { lane: LANES.DECLARED, agentId: null,
|
|
362
|
-
evidence: [`self-identified
|
|
397
|
+
evidence: [`self-identified as "${agentMatch}"`,
|
|
363
398
|
ipOk === true ? 'source address matches published range'
|
|
364
399
|
: 'no signature presented'] };
|
|
365
400
|
}
|
|
@@ -471,6 +506,7 @@ export class Wayleave {
|
|
|
471
506
|
* rules: { [lane]: [ [pathPrefix, allow], ... ] },
|
|
472
507
|
* rateLimits: { [lane]: perWindow }, rateWindow: 60,
|
|
473
508
|
* pricedPaths: { pathPrefix: priceUsd },
|
|
509
|
+
* pricedPaths: { pathPrefix: { price, method, description } },
|
|
474
510
|
* verifyPayment: (proof, ctx) => boolean | { ok, ref },
|
|
475
511
|
* strictPricedPaths: false,
|
|
476
512
|
* confirmHuman: (req) => boolean,
|
|
@@ -617,7 +653,7 @@ export class Wayleave {
|
|
|
617
653
|
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
618
654
|
const { lane, agentId, evidence } = c;
|
|
619
655
|
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
620
|
-
const proof = paymentProof ||
|
|
656
|
+
const proof = paymentProof || paymentHeader(req.headers);
|
|
621
657
|
/* The same policy the async path enforces, evaluated here for every rule
|
|
622
658
|
that needs no I/O. Before this, `handle()` skipped policy entirely and
|
|
623
659
|
one document behaved as two: enforced through express(), ignored here. */
|
|
@@ -676,7 +712,7 @@ export class Wayleave {
|
|
|
676
712
|
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
677
713
|
const { lane, agentId, evidence } = c;
|
|
678
714
|
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
679
|
-
const proof = paymentProof ||
|
|
715
|
+
const proof = paymentProof || paymentHeader(req.headers);
|
|
680
716
|
const fromPolicy = await this._policyDecision(r, lane, ident, c);
|
|
681
717
|
const d = fromPolicy || await this._decideAsync(r, lane, ident, now, proof, c);
|
|
682
718
|
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
@@ -765,8 +801,13 @@ export class Wayleave {
|
|
|
765
801
|
? this._confirmedHuman(req)
|
|
766
802
|
: lane === LANES.HUMAN;
|
|
767
803
|
if (!freeOnPriced) {
|
|
768
|
-
for (const [prefix,
|
|
804
|
+
for (const [prefix, configured] of Object.entries(this.pricedPaths)) {
|
|
769
805
|
if (pathUnder(req.path, prefix)) {
|
|
806
|
+
/* Through routeSpec, so a route configured with a method and a
|
|
807
|
+
description still charges its price and not the object holding
|
|
808
|
+
it. The manifest reads the same function, so the document and
|
|
809
|
+
this challenge cannot disagree about what a route costs. */
|
|
810
|
+
const { price } = routeSpec(configured);
|
|
770
811
|
const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
|
|
771
812
|
if (!paymentProof) {
|
|
772
813
|
const why = this.strictPricedPaths && lane === LANES.HUMAN
|
|
@@ -999,7 +1040,7 @@ export class Wayleave {
|
|
|
999
1040
|
// none of which survive the projection above. Classification still
|
|
1000
1041
|
// reads only the fields it declares; this is for your callback.
|
|
1001
1042
|
raw: req,
|
|
1002
|
-
}, undefined, req.headers
|
|
1043
|
+
}, undefined, paymentHeader(req.headers));
|
|
1003
1044
|
req.wayleave = r;
|
|
1004
1045
|
if (r.status === 200) return next();
|
|
1005
1046
|
if (r.status === 402) {
|
package/manifest.js
CHANGED
|
@@ -41,6 +41,35 @@ const KNOWN_ASSETS = {
|
|
|
41
41
|
|
|
42
42
|
const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* One priced route, however it was configured.
|
|
46
|
+
*
|
|
47
|
+
* `pricedPaths` was `{ path: priceUsd }`, and the manifest hardcoded
|
|
48
|
+
* `method: 'GET'` and the description "x402-priced route" for every entry.
|
|
49
|
+
* For a POST API — which is most paid APIs — that published a call that
|
|
50
|
+
* cannot work, and by this module's own standard an entry an index cannot
|
|
51
|
+
* act on is "indistinguishable from not publishing at all".
|
|
52
|
+
*
|
|
53
|
+
* A value may now be the price as before, or an object carrying the method
|
|
54
|
+
* and a real description. Both consumers of a priced path go through here —
|
|
55
|
+
* the manifest and the gate's own 402 — so the document and the challenge
|
|
56
|
+
* cannot describe the route differently.
|
|
57
|
+
*
|
|
58
|
+
* pricedPaths: { '/api/x': 0.05 }
|
|
59
|
+
* pricedPaths: { '/api/x': { price: 0.05, method: 'POST',
|
|
60
|
+
* description: 'Score one transaction' } }
|
|
61
|
+
*/
|
|
62
|
+
export function routeSpec(value) {
|
|
63
|
+
const rich = value !== null && typeof value === 'object';
|
|
64
|
+
const price = rich ? value.price : value;
|
|
65
|
+
return {
|
|
66
|
+
price,
|
|
67
|
+
method: String((rich && value.method) || 'GET').toUpperCase(),
|
|
68
|
+
description: (rich && value.description) || 'x402-priced route',
|
|
69
|
+
mimeType: (rich && value.mimeType) || 'application/json',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
44
73
|
/**
|
|
45
74
|
* Build the manifest from configuration. A pure function of `pricedPaths` and
|
|
46
75
|
* `payment`: there is no separate document to author, and therefore none to
|
|
@@ -68,19 +97,20 @@ export function buildManifest({ pricedPaths = {}, payment = {}, origin,
|
|
|
68
97
|
// a wrong address is worse than a missing one.
|
|
69
98
|
const priceable = Boolean(asset && payTo && Number.isInteger(decimals));
|
|
70
99
|
|
|
71
|
-
const resources = routes.map(([path,
|
|
100
|
+
const resources = routes.map(([path, value]) => {
|
|
101
|
+
const spec = routeSpec(value);
|
|
72
102
|
const url = origin.replace(/\/+$/, '') + path;
|
|
73
|
-
const entry = { url, method:
|
|
103
|
+
const entry = { url, method: spec.method, description: spec.description };
|
|
74
104
|
if (priceable) {
|
|
75
105
|
entry.accepts = [{
|
|
76
106
|
scheme: 'exact',
|
|
77
107
|
network,
|
|
78
|
-
maxAmountRequired: toAtomicUnits(String(
|
|
108
|
+
maxAmountRequired: toAtomicUnits(String(spec.price), decimals),
|
|
79
109
|
asset,
|
|
80
110
|
payTo,
|
|
81
111
|
resource: url,
|
|
82
|
-
description:
|
|
83
|
-
mimeType:
|
|
112
|
+
description: spec.description,
|
|
113
|
+
mimeType: spec.mimeType,
|
|
84
114
|
maxTimeoutSeconds: payment.maxTimeoutSeconds || DEFAULT_TIMEOUT_SECONDS,
|
|
85
115
|
}];
|
|
86
116
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wayleave",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Know which AI agents cross your API \u2014 verified by signature, not guessed from an IP. Sorts every request into four lanes, prices the non-human ones with HTTP 402, and never charges a human. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/x402.js
CHANGED
|
@@ -87,6 +87,19 @@ const KNOWN_ASSETS = {
|
|
|
87
87
|
},
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/** JSON, or base64 of JSON. Returns null when it is neither. */
|
|
91
|
+
function parsePayload(raw) {
|
|
92
|
+
const text = String(raw).trim();
|
|
93
|
+
if (!text) return null;
|
|
94
|
+
try { const v = JSON.parse(text); if (v && typeof v === 'object') return v; } catch {}
|
|
95
|
+
try {
|
|
96
|
+
const decoded = Buffer.from(text, 'base64').toString('utf8');
|
|
97
|
+
const v = JSON.parse(decoded);
|
|
98
|
+
if (v && typeof v === 'object') return v;
|
|
99
|
+
} catch {}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
/**
|
|
91
104
|
* Build a `verifyPayment(proof, ctx)` for the Coinbase x402 facilitator.
|
|
92
105
|
*
|
|
@@ -138,8 +151,21 @@ export function coinbaseFacilitator({
|
|
|
138
151
|
// payload as JSON. Accept either so a customer does not have to care.
|
|
139
152
|
let paymentPayload = proof;
|
|
140
153
|
if (typeof proof === 'string') {
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
/* Raw JSON or base64 JSON.
|
|
155
|
+
*
|
|
156
|
+
* x402 defines the X-PAYMENT header as the base64 encoding of the
|
|
157
|
+
* payload, so that is what a spec-compliant agent sends. This accepted
|
|
158
|
+
* only raw JSON, which meant the agent cleared the header fix and then
|
|
159
|
+
* failed here with "payment proof is not a valid x402 payload" — an
|
|
160
|
+
* error that reads like the agent sent something malformed when what it
|
|
161
|
+
* sent was exactly what the protocol prescribes.
|
|
162
|
+
*
|
|
163
|
+
* Both are taken, which is the stated intent of this block anyway.
|
|
164
|
+
* JSON first: a payload is unambiguous JSON and never also valid
|
|
165
|
+
* base64 of different JSON, so trying it first cannot mis-read one as
|
|
166
|
+
* the other. */
|
|
167
|
+
paymentPayload = parsePayload(proof);
|
|
168
|
+
if (!paymentPayload) return deny('payment proof is not a valid x402 payload');
|
|
143
169
|
}
|
|
144
170
|
if (!paymentPayload || typeof paymentPayload !== 'object')
|
|
145
171
|
return deny('no payment payload supplied');
|