wayleave 0.5.5 → 0.6.0
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 +43 -0
- package/index.d.ts +10 -1
- package/index.js +64 -6
- package/manifest.js +94 -5
- package/package.json +1 -1
- package/x402.js +28 -2
package/README.md
CHANGED
|
@@ -240,6 +240,49 @@ confirmation is yours to supply and denies by default. If you shipped 0.1.4 or e
|
|
|
240
240
|
route, upgrade.
|
|
241
241
|
|
|
242
242
|
**0.1.6** addressed the other half: payment could be faked, and so could being human.
|
|
243
|
+
### Charge by lane
|
|
244
|
+
|
|
245
|
+
A route can cost different amounts depending on who is asking.
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
pricedPaths: {
|
|
249
|
+
'/api/v1/properties/search': 0.001, // flat: every lane pays this
|
|
250
|
+
|
|
251
|
+
'/api/v1/properties/valuation-history': { // or per lane
|
|
252
|
+
verified_agent: 0.01, // proved who it is
|
|
253
|
+
declared_agent: 0.05, // said who it is
|
|
254
|
+
suspected_bot: 0.15, // neither
|
|
255
|
+
method: 'GET',
|
|
256
|
+
description: 'Historical comps, tax and owner records',
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The reason to bother is not price discrimination. Signing a request under Web
|
|
262
|
+
Bot Auth currently earns an operator nothing — it is pure cost, which is the
|
|
263
|
+
honest explanation for how few agents do it. A lower price for a proved
|
|
264
|
+
identity turns adoption into a bill that goes down, and it is the only lever
|
|
265
|
+
on that an origin actually controls.
|
|
266
|
+
|
|
267
|
+
Three rules, each of which exists because the alternative is worse:
|
|
268
|
+
|
|
269
|
+
- **The manifest publishes the dearest lane.** A discovery document is read
|
|
270
|
+
before anyone knows who is asking, so the only safe direction for the real
|
|
271
|
+
quote to move is downward. Advertise $0.01 and bill a suspected bot $0.15
|
|
272
|
+
and you have surprised them upward; advertise $0.15 and every other lane is
|
|
273
|
+
a discount.
|
|
274
|
+
- **An unlisted lane pays the list price, never free.** Set `default` to
|
|
275
|
+
choose otherwise. A lane nobody configured is a case nobody considered, and
|
|
276
|
+
giving the route away is the one outcome they certainly did not intend.
|
|
277
|
+
- **The `human` lane cannot be priced.** It is a fall-through, so that is
|
|
278
|
+
where an ordinary visitor lands. A `human:` key warns at startup and is
|
|
279
|
+
unreachable in the pricing path — including under `strictPricedPaths`,
|
|
280
|
+
where an unconfirmed human *does* reach it. To charge everything that is
|
|
281
|
+
not a proven person, use `strictPricedPaths: true`, not a human price.
|
|
282
|
+
|
|
283
|
+
The 402 names the lane it priced, so a caller asking why it was charged more
|
|
284
|
+
than someone else has an answer.
|
|
285
|
+
|
|
243
286
|
`strictPricedPaths` lets a priced route demand positive evidence rather than accepting the absence of
|
|
244
287
|
a bot signal as one.
|
|
245
288
|
|
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.
|
|
@@ -482,6 +506,9 @@ export class Wayleave {
|
|
|
482
506
|
* rules: { [lane]: [ [pathPrefix, allow], ... ] },
|
|
483
507
|
* rateLimits: { [lane]: perWindow }, rateWindow: 60,
|
|
484
508
|
* pricedPaths: { pathPrefix: priceUsd },
|
|
509
|
+
* pricedPaths: { pathPrefix: { price, method, description } },
|
|
510
|
+
* pricedPaths: { pathPrefix: { verified_agent, declared_agent,
|
|
511
|
+
* suspected_bot, default } },
|
|
485
512
|
* verifyPayment: (proof, ctx) => boolean | { ok, ref },
|
|
486
513
|
* strictPricedPaths: false,
|
|
487
514
|
* confirmHuman: (req) => boolean,
|
|
@@ -548,6 +575,27 @@ export class Wayleave {
|
|
|
548
575
|
: opts.strictPricedPaths === true;
|
|
549
576
|
const priced = Object.keys(this.pricedPaths).length > 0;
|
|
550
577
|
const warn = this._onWarn = opts.onWarn || console.warn;
|
|
578
|
+
/* A price on the human lane would charge people.
|
|
579
|
+
*
|
|
580
|
+
* The lane is a fall-through: a request reaches it by tripping none of
|
|
581
|
+
* the automation tells, which is how an ordinary visitor arrives. Every
|
|
582
|
+
* other guarantee here is hedged somewhere in the README; "humans are
|
|
583
|
+
* never charged" is not, and a per-lane table is the first place someone
|
|
584
|
+
* could break it by typing one extra key.
|
|
585
|
+
*
|
|
586
|
+
* Ignored rather than thrown -- an app that boots is easier to fix than
|
|
587
|
+
* one that does not -- but said once, loudly, naming the route. */
|
|
588
|
+
for (const [prefix, cfg] of Object.entries(this.pricedPaths)) {
|
|
589
|
+
if (cfg === null || typeof cfg !== 'object') continue;
|
|
590
|
+
for (const k of ['human', 'unclassified', 'unknown']) {
|
|
591
|
+
if (typeof cfg[k] === 'number')
|
|
592
|
+
warn(`[wayleave] pricedPaths["${prefix}"] sets a price for the ` +
|
|
593
|
+
`"${k}" lane. That lane is where people land, and it is not ` +
|
|
594
|
+
`charged. The price is ignored. To charge anything that is ` +
|
|
595
|
+
`not a proven person, set strictPricedPaths: true instead.`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
551
599
|
if (opts.strictPricedPaths === false && priced)
|
|
552
600
|
warn('[wayleave] strictPricedPaths is off: priced routes are reachable free ' +
|
|
553
601
|
'by any client that looks like a browser. Pass confirmHuman() to charge ' +
|
|
@@ -628,7 +676,7 @@ export class Wayleave {
|
|
|
628
676
|
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
629
677
|
const { lane, agentId, evidence } = c;
|
|
630
678
|
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
631
|
-
const proof = paymentProof ||
|
|
679
|
+
const proof = paymentProof || paymentHeader(req.headers);
|
|
632
680
|
/* The same policy the async path enforces, evaluated here for every rule
|
|
633
681
|
that needs no I/O. Before this, `handle()` skipped policy entirely and
|
|
634
682
|
one document behaved as two: enforced through express(), ignored here. */
|
|
@@ -687,7 +735,7 @@ export class Wayleave {
|
|
|
687
735
|
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
688
736
|
const { lane, agentId, evidence } = c;
|
|
689
737
|
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
690
|
-
const proof = paymentProof ||
|
|
738
|
+
const proof = paymentProof || paymentHeader(req.headers);
|
|
691
739
|
const fromPolicy = await this._policyDecision(r, lane, ident, c);
|
|
692
740
|
const d = fromPolicy || await this._decideAsync(r, lane, ident, now, proof, c);
|
|
693
741
|
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
@@ -776,9 +824,19 @@ export class Wayleave {
|
|
|
776
824
|
? this._confirmedHuman(req)
|
|
777
825
|
: lane === LANES.HUMAN;
|
|
778
826
|
if (!freeOnPriced) {
|
|
779
|
-
for (const [prefix,
|
|
827
|
+
for (const [prefix, configured] of Object.entries(this.pricedPaths)) {
|
|
780
828
|
if (pathUnder(req.path, prefix)) {
|
|
781
|
-
|
|
829
|
+
/* Through routeSpec, so a route configured with a method and a
|
|
830
|
+
description still charges its price and not the object holding
|
|
831
|
+
it. The manifest reads the same function, so the document and
|
|
832
|
+
this challenge cannot disagree about what a route costs.
|
|
833
|
+
|
|
834
|
+
The lane is passed in because a route may be priced per lane.
|
|
835
|
+
Here we know who is asking, so the quote is exact; the manifest
|
|
836
|
+
has no lane and publishes the dearest, which is why the two can
|
|
837
|
+
differ without either being wrong. */
|
|
838
|
+
const { price } = routeSpec(configured, lane);
|
|
839
|
+
const challenge = { scheme: 'x402', price_usd: price, resource: prefix, lane };
|
|
782
840
|
if (!paymentProof) {
|
|
783
841
|
const why = this.strictPricedPaths && lane === LANES.HUMAN
|
|
784
842
|
? 'payment required: no signature and no confirmed human'
|
|
@@ -1010,7 +1068,7 @@ export class Wayleave {
|
|
|
1010
1068
|
// none of which survive the projection above. Classification still
|
|
1011
1069
|
// reads only the fields it declares; this is for your callback.
|
|
1012
1070
|
raw: req,
|
|
1013
|
-
}, undefined, req.headers
|
|
1071
|
+
}, undefined, paymentHeader(req.headers));
|
|
1014
1072
|
req.wayleave = r;
|
|
1015
1073
|
if (r.status === 200) return next();
|
|
1016
1074
|
if (r.status === 402) {
|
package/manifest.js
CHANGED
|
@@ -41,6 +41,94 @@ 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
|
+
/**
|
|
63
|
+
* The agent lanes a route may be priced for, dearest first.
|
|
64
|
+
*
|
|
65
|
+
* `human` is deliberately absent and cannot be priced. It is a fall-through
|
|
66
|
+
* lane -- reached by tripping none of the automation tells -- so a real
|
|
67
|
+
* person lands there, and charging it would charge people. That is the one
|
|
68
|
+
* promise this library makes without qualification.
|
|
69
|
+
*/
|
|
70
|
+
export const PRICED_LANES = Object.freeze(
|
|
71
|
+
['suspected_bot', 'declared_agent', 'verified_agent']);
|
|
72
|
+
|
|
73
|
+
/** Is this object a per-lane price table rather than a rich single price? */
|
|
74
|
+
function isLaneTable(v) {
|
|
75
|
+
return v !== null && typeof v === 'object' &&
|
|
76
|
+
PRICED_LANES.some((l) => typeof v[l] === 'number');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One priced route, normalised.
|
|
81
|
+
*
|
|
82
|
+
* Called with a lane it answers what that lane pays. Called without one -- as
|
|
83
|
+
* the manifest does, because a document has no lane -- it answers the LIST
|
|
84
|
+
* price, which is the dearest of the configured lanes.
|
|
85
|
+
*
|
|
86
|
+
* List price is the maximum on purpose. The manifest is an advertisement read
|
|
87
|
+
* before anyone knows who is asking, so the only safe direction for the real
|
|
88
|
+
* quote to differ is downward. Publish the cheapest and a suspected bot that
|
|
89
|
+
* discovered the route at $0.01 gets billed $0.15, which is a surprise in the
|
|
90
|
+
* worst direction. Publish the dearest and every other lane is a discount.
|
|
91
|
+
*
|
|
92
|
+
* A lane with no entry falls back to `default`, and failing that to the list
|
|
93
|
+
* price. Not to free: an unlisted lane is an operator who did not think about
|
|
94
|
+
* that case, and money fails closed.
|
|
95
|
+
*/
|
|
96
|
+
export function routeSpec(value, lane) {
|
|
97
|
+
const rich = value !== null && typeof value === 'object';
|
|
98
|
+
const lanes = isLaneTable(value) ? value : null;
|
|
99
|
+
|
|
100
|
+
let price;
|
|
101
|
+
if (!lanes) {
|
|
102
|
+
price = rich ? value.price : value;
|
|
103
|
+
} else if (lane && PRICED_LANES.includes(lane) && typeof lanes[lane] === 'number') {
|
|
104
|
+
/* PRICED_LANES.includes() is load-bearing, not a tidy-up.
|
|
105
|
+
*
|
|
106
|
+
* Without it a `human: 0.99` key is reachable: under strictPricedPaths an
|
|
107
|
+
* unconfirmed human DOES reach the pricing loop -- that is the whole
|
|
108
|
+
* point of strict mode -- and would be quoted its own entry. The
|
|
109
|
+
* constructor warns about such a key, but a warning is advice and this is
|
|
110
|
+
* the code path that takes money. Reading the lane through the allowed
|
|
111
|
+
* list means the price cannot be quoted even if the warning is ignored. */
|
|
112
|
+
price = lanes[lane];
|
|
113
|
+
} else if (typeof lanes.default === 'number') {
|
|
114
|
+
price = lanes.default;
|
|
115
|
+
} else {
|
|
116
|
+
price = Math.max(...PRICED_LANES
|
|
117
|
+
.map((l) => lanes[l]).filter((n) => typeof n === 'number'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
price,
|
|
122
|
+
lanes: lanes
|
|
123
|
+
? Object.fromEntries(PRICED_LANES
|
|
124
|
+
.filter((l) => typeof lanes[l] === 'number').map((l) => [l, lanes[l]]))
|
|
125
|
+
: null,
|
|
126
|
+
method: String((rich && value.method) || 'GET').toUpperCase(),
|
|
127
|
+
description: (rich && value.description) || 'x402-priced route',
|
|
128
|
+
mimeType: (rich && value.mimeType) || 'application/json',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
44
132
|
/**
|
|
45
133
|
* Build the manifest from configuration. A pure function of `pricedPaths` and
|
|
46
134
|
* `payment`: there is no separate document to author, and therefore none to
|
|
@@ -68,19 +156,20 @@ export function buildManifest({ pricedPaths = {}, payment = {}, origin,
|
|
|
68
156
|
// a wrong address is worse than a missing one.
|
|
69
157
|
const priceable = Boolean(asset && payTo && Number.isInteger(decimals));
|
|
70
158
|
|
|
71
|
-
const resources = routes.map(([path,
|
|
159
|
+
const resources = routes.map(([path, value]) => {
|
|
160
|
+
const spec = routeSpec(value);
|
|
72
161
|
const url = origin.replace(/\/+$/, '') + path;
|
|
73
|
-
const entry = { url, method:
|
|
162
|
+
const entry = { url, method: spec.method, description: spec.description };
|
|
74
163
|
if (priceable) {
|
|
75
164
|
entry.accepts = [{
|
|
76
165
|
scheme: 'exact',
|
|
77
166
|
network,
|
|
78
|
-
maxAmountRequired: toAtomicUnits(String(
|
|
167
|
+
maxAmountRequired: toAtomicUnits(String(spec.price), decimals),
|
|
79
168
|
asset,
|
|
80
169
|
payTo,
|
|
81
170
|
resource: url,
|
|
82
|
-
description:
|
|
83
|
-
mimeType:
|
|
171
|
+
description: spec.description,
|
|
172
|
+
mimeType: spec.mimeType,
|
|
84
173
|
maxTimeoutSeconds: payment.maxTimeoutSeconds || DEFAULT_TIMEOUT_SECONDS,
|
|
85
174
|
}];
|
|
86
175
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wayleave",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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');
|