wayleave 0.5.0 → 0.5.2
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 +8 -10
- package/index.js +58 -4
- package/package.json +2 -2
- package/policy.js +30 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ signature headers and get back who signed it and which lane it belongs in.
|
|
|
32
32
|
Nothing is stored, and your traffic never routes through us.
|
|
33
33
|
|
|
34
34
|
```sh
|
|
35
|
-
curl -X POST https://
|
|
35
|
+
curl -X POST https://api.wayleave.dev/v1/identify \
|
|
36
36
|
-H "Authorization: Bearer $WAYLEAVE_API_KEY" \
|
|
37
37
|
-H "Content-Type: application/json" \
|
|
38
38
|
-d '{"method":"GET","url":"https://api.yourapp.com/v1/data",
|
|
@@ -48,8 +48,7 @@ curl -X POST https://wayleave-api-production.up.railway.app/v1/identify \
|
|
|
48
48
|
|
|
49
49
|
The same evaluator answers here and in the middleware, and a test asserts they
|
|
50
50
|
agree — if the two front doors disagreed about one request you would have no
|
|
51
|
-
way to know which to believe.
|
|
52
|
-
has no DNS record yet; the URL above is the one that answers today.)
|
|
51
|
+
way to know which to believe.
|
|
53
52
|
|
|
54
53
|
**2. The middleware — in your own process.** Nothing leaves your server that
|
|
55
54
|
you do not send. This is the path if you want to price routes, enforce access
|
|
@@ -63,8 +62,10 @@ npm install wayleave
|
|
|
63
62
|
|
|
64
63
|
[Create a Meter account](https://meter.wayleave.dev/account.html), issue an API key,
|
|
65
64
|
and set `WAYLEAVE_METER_KEY` in your server environment. Signing in does not
|
|
66
|
-
start billing. The optional hosted
|
|
67
|
-
|
|
65
|
+
start billing. The optional hosted plans require a card and start after a
|
|
66
|
+
3-day trial unless canceled — current plans and prices are at
|
|
67
|
+
[wayleave.dev/pricing](https://wayleave.dev/pricing/), which is the one place
|
|
68
|
+
they are kept up to date.
|
|
68
69
|
|
|
69
70
|
In your existing Express app:
|
|
70
71
|
|
|
@@ -122,10 +123,7 @@ rather than quietly denying every payment.
|
|
|
122
123
|
|
|
123
124
|
## Enforce rules you set elsewhere
|
|
124
125
|
|
|
125
|
-
> Requires 0.5.0
|
|
126
|
-
> installing from npm and pasting this will silently do nothing. Until 0.5.0 is
|
|
127
|
-
> published, install from source:
|
|
128
|
-
> `npm install github:gibrancorbin11-hub/wayleave`
|
|
126
|
+
> Requires 0.5.0 or later, which is what `npm install wayleave` now gives you.
|
|
129
127
|
|
|
130
128
|
A policy the gate fetches, rather than configuration compiled into your app:
|
|
131
129
|
|
|
@@ -192,7 +190,7 @@ that tooling in the wild does fetch.
|
|
|
192
190
|
|
|
193
191
|
## Status
|
|
194
192
|
|
|
195
|
-
v0.5.0 — 155 tests, zero dependencies. Published on npm
|
|
193
|
+
v0.5.0 — 155 tests, zero dependencies. Published on npm.
|
|
196
194
|
|
|
197
195
|
**0.5.0** does three things. Rules you set are now *enforced*: `policy: { url, publicKey }` fetches a
|
|
198
196
|
signed policy, verifies it, caches it, and evaluates per request — denials carry the rule id in
|
package/index.js
CHANGED
|
@@ -95,6 +95,31 @@ function sigBase(components, ctx, paramsRaw) {
|
|
|
95
95
|
return Buffer.from(lines.join('\n'));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Does `path` fall under `prefix`?
|
|
100
|
+
*
|
|
101
|
+
* Not `startsWith`. A raw prefix test makes "/api" match "/apiv2" and
|
|
102
|
+
* "/api-internal", which is wrong in both directions and dangerous in one:
|
|
103
|
+
*
|
|
104
|
+
* pricedPaths { "/api": 0.05 } charges for /apiv2, which the operator
|
|
105
|
+
* never priced
|
|
106
|
+
* rules { bot: [["/public", true]] } ALLOWS /public-admin, which the
|
|
107
|
+
* operator never opened
|
|
108
|
+
*
|
|
109
|
+
* The second is the one that matters. An over-matching deny is merely
|
|
110
|
+
* annoying; an over-matching allow is a hole.
|
|
111
|
+
*
|
|
112
|
+
* A prefix matches the path itself or anything beneath its boundary, and a
|
|
113
|
+
* trailing slash on the prefix changes nothing — "/api" and "/api/" mean the
|
|
114
|
+
* same thing, because operators write both and neither should surprise them.
|
|
115
|
+
*/
|
|
116
|
+
export function pathUnder(path, prefix) {
|
|
117
|
+
if (typeof path !== 'string' || typeof prefix !== 'string') return false;
|
|
118
|
+
if (prefix === '' || prefix === '/') return true;
|
|
119
|
+
const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
120
|
+
return path === base || path.startsWith(base + '/');
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
export function buildParams(keyid, created, expires, nonce = null) {
|
|
99
124
|
return `("@authority" "signature-agent");created=${created};` +
|
|
100
125
|
`keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"` +
|
|
@@ -631,7 +656,7 @@ export class Wayleave {
|
|
|
631
656
|
*/
|
|
632
657
|
_preDecide(req, lane, ident, now, paymentProof, c) {
|
|
633
658
|
for (const [prefix, allow] of this.rules[lane] || []) {
|
|
634
|
-
if (req.path
|
|
659
|
+
if (pathUnder(req.path, prefix)) {
|
|
635
660
|
if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
|
|
636
661
|
break;
|
|
637
662
|
}
|
|
@@ -659,7 +684,7 @@ export class Wayleave {
|
|
|
659
684
|
: lane === LANES.HUMAN;
|
|
660
685
|
if (!freeOnPriced) {
|
|
661
686
|
for (const [prefix, price] of Object.entries(this.pricedPaths)) {
|
|
662
|
-
if (req.path
|
|
687
|
+
if (pathUnder(req.path, prefix)) {
|
|
663
688
|
const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
|
|
664
689
|
if (!paymentProof) {
|
|
665
690
|
const why = this.strictPricedPaths && lane === LANES.HUMAN
|
|
@@ -802,6 +827,20 @@ export class Wayleave {
|
|
|
802
827
|
|
|
803
828
|
express() {
|
|
804
829
|
return async (req, res, next) => {
|
|
830
|
+
/* The discovery document is answered first: before classification,
|
|
831
|
+
before rate limiting, before pricing. A manifest an agent must pay
|
|
832
|
+
to read cannot be discovered, and one that is classified as bot
|
|
833
|
+
traffic gets a 402 instead of the price list.
|
|
834
|
+
|
|
835
|
+
This lived only on manifestFor() until 0.5.1, so `app.use(gate.express())`
|
|
836
|
+
-- the integration the Quickstart shows -- served no manifest at all
|
|
837
|
+
while the README said every install with a priced route does. */
|
|
838
|
+
const manifest = this.manifestFor({
|
|
839
|
+
path: req.path || (req.url || '').split('?')[0],
|
|
840
|
+
headers: req.headers,
|
|
841
|
+
});
|
|
842
|
+
if (manifest) return res.status(manifest.status).set(manifest.headers).json(manifest.body);
|
|
843
|
+
|
|
805
844
|
const r = await this.handleAsync({
|
|
806
845
|
method: req.method, path: req.path || req.url,
|
|
807
846
|
authority: req.headers.host || '',
|
|
@@ -816,10 +855,25 @@ export class Wayleave {
|
|
|
816
855
|
}, undefined, req.headers['x-payment-proof'] || '');
|
|
817
856
|
req.wayleave = r;
|
|
818
857
|
if (r.status === 200) return next();
|
|
819
|
-
if (r.status === 402)
|
|
858
|
+
if (r.status === 402) {
|
|
859
|
+
/* The 402 body and the manifest must come from one source, or an
|
|
860
|
+
index quotes a price the origin does not honour. `accepts` is
|
|
861
|
+
built by the same call the manifest is, so they cannot drift.
|
|
862
|
+
|
|
863
|
+
Without a payment config there is nothing spec-shaped to say --
|
|
864
|
+
no payee, no network, no asset -- so the internal challenge is
|
|
865
|
+
all that is sent, and `accepts` is absent rather than empty.
|
|
866
|
+
An empty accepts array would read as "priced at nothing". */
|
|
867
|
+
const body = { error: r.why, challenge: r.challenge };
|
|
868
|
+
const requirements = this.paymentRequirements({
|
|
869
|
+
path: req.path || (req.url || '').split('?')[0],
|
|
870
|
+
headers: req.headers,
|
|
871
|
+
});
|
|
872
|
+
if (requirements) { body.x402Version = 1; body.accepts = [requirements]; }
|
|
820
873
|
return res.status(402)
|
|
821
874
|
.set('x-payment-challenge', JSON.stringify(r.challenge))
|
|
822
|
-
.json(
|
|
875
|
+
.json(body);
|
|
876
|
+
}
|
|
823
877
|
return res.status(r.status).json({ error: r.why });
|
|
824
878
|
};
|
|
825
879
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wayleave",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Know which AI agents cross your API
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
7
7
|
"types": "./index.d.ts",
|
package/policy.js
CHANGED
|
@@ -114,9 +114,33 @@ export class RemotePolicy {
|
|
|
114
114
|
* @param {(e:object)=>void} [o.onEvent] observability; never throws into us
|
|
115
115
|
*/
|
|
116
116
|
constructor({ apiKey = null, url = null, publicKey = null, endpoint = DEFAULT_ENDPOINT,
|
|
117
|
-
refreshMs = DEFAULT_REFRESH_MS, graceMs = 0,
|
|
117
|
+
refreshMs = DEFAULT_REFRESH_MS, graceMs = 0, allowUnsigned = false,
|
|
118
118
|
cachePath = null, onEvent = null, onChange = null, fetchImpl = null } = {}) {
|
|
119
119
|
if (!apiKey && !url) throw new Error('RemotePolicy requires a policy url (or the meter apiKey)');
|
|
120
|
+
/* A policy document decides who is allowed through and what they are
|
|
121
|
+
charged. Applying one nobody signed means whoever can answer that URL
|
|
122
|
+
— a hijacked CDN, a DNS answer, anyone on the path — writes your access
|
|
123
|
+
rules. Until 0.5.2 a missing publicKey silently skipped the check.
|
|
124
|
+
|
|
125
|
+
The requirement applies to a bare `url` only. With `apiKey` the document
|
|
126
|
+
comes from the customer's own meter over TLS with their key, which is an
|
|
127
|
+
authenticated channel; the signature is defence in depth there, not the
|
|
128
|
+
only defence. A public policy URL has no such channel — being publicly
|
|
129
|
+
cacheable is the point of it — so the signature IS the authentication.
|
|
130
|
+
|
|
131
|
+
Refusing at construction rather than at fetch time is deliberate: the
|
|
132
|
+
failure belongs at startup, where someone is watching, not on a poll at
|
|
133
|
+
three in the morning. `allowUnsigned: true` remains for a URL you trust
|
|
134
|
+
end to end, and it has to be written down. */
|
|
135
|
+
if (url && !apiKey && !publicKey && !allowUnsigned)
|
|
136
|
+
throw new Error(
|
|
137
|
+
'RemotePolicy requires publicKey: an unsigned policy document is an ' +
|
|
138
|
+
'unauthenticated one, and it decides access and pricing. Pass the ' +
|
|
139
|
+
'signing key, or allowUnsigned: true if you genuinely trust this URL.');
|
|
140
|
+
this.allowUnsigned = allowUnsigned;
|
|
141
|
+
// True only for a bare public URL, where the signature is the only
|
|
142
|
+
// thing authenticating the document.
|
|
143
|
+
this._needsSignature = Boolean(url) && !apiKey;
|
|
120
144
|
this.apiKey = apiKey;
|
|
121
145
|
this.publicKey = publicKey;
|
|
122
146
|
this.graceMs = graceMs;
|
|
@@ -186,6 +210,11 @@ export class RemotePolicy {
|
|
|
186
210
|
const v = verifyEnvelope(raw, this.publicKey, { graceMs: this.graceMs });
|
|
187
211
|
if (!v.ok) { this._emit('policy.rejected', { why: v.reason }); return this.document; }
|
|
188
212
|
doc = v.policy;
|
|
213
|
+
} else if (this._needsSignature && !this.allowUnsigned) {
|
|
214
|
+
// Unreachable via the constructor, which refuses this combination.
|
|
215
|
+
// Kept because the field is public and a caller may clear it.
|
|
216
|
+
this._emit('policy.rejected', { why: 'no signing key configured' });
|
|
217
|
+
return this.document;
|
|
189
218
|
}
|
|
190
219
|
if (!isUsable(doc)) { this._emit('policy.rejected', { why: 'document not usable' }); return this.document; }
|
|
191
220
|
|