wayleave 0.5.1 → 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 +4 -2
- package/index.js +27 -2
- package/package.json +2 -2
- package/policy.js +30 -1
package/README.md
CHANGED
|
@@ -62,8 +62,10 @@ npm install wayleave
|
|
|
62
62
|
|
|
63
63
|
[Create a Meter account](https://meter.wayleave.dev/account.html), issue an API key,
|
|
64
64
|
and set `WAYLEAVE_METER_KEY` in your server environment. Signing in does not
|
|
65
|
-
start billing. The optional hosted
|
|
66
|
-
|
|
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.
|
|
67
69
|
|
|
68
70
|
In your existing Express app:
|
|
69
71
|
|
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
|
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
|
|