wayleave 0.5.1 → 0.5.3
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.d.ts +12 -0
- package/index.js +141 -17
- 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.d.ts
CHANGED
|
@@ -377,6 +377,18 @@ export function classify(
|
|
|
377
377
|
}
|
|
378
378
|
): Classification;
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* The path a request is for, whichever property the framework used.
|
|
382
|
+
*
|
|
383
|
+
* Express sets `req.path`; Node's own server and most other frameworks set
|
|
384
|
+
* `req.url` with the query still attached. Query and fragment are removed,
|
|
385
|
+
* so "/api?x=1" and "/api" are the same route.
|
|
386
|
+
*/
|
|
387
|
+
export function requestPath(req: { path?: string; url?: string } | null | undefined): string;
|
|
388
|
+
|
|
389
|
+
/** True when `path` is `prefix` or lies beneath its boundary. Query-insensitive. */
|
|
390
|
+
export function pathUnder(path: string, prefix: string): boolean;
|
|
391
|
+
|
|
380
392
|
export class Wayleave {
|
|
381
393
|
constructor(opts?: WayleaveOptions);
|
|
382
394
|
/**
|
package/index.js
CHANGED
|
@@ -95,6 +95,57 @@ 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
|
+
/**
|
|
117
|
+
* The path to decide about, whatever shape the caller's request object has.
|
|
118
|
+
*
|
|
119
|
+
* Express puts a query-free path on `req.path`. Node's own http server -- and
|
|
120
|
+
* every framework that hands you its raw request -- puts "/api/x?y=1" on
|
|
121
|
+
* `req.url` and nothing on `req.path`. `handle()` read `req.path` alone, so a
|
|
122
|
+
* caller outside Express passed `undefined` into the matcher, matched no
|
|
123
|
+
* priced prefix and no rule, and was allowed through. Silently: a 200 on a
|
|
124
|
+
* route the operator had priced, with a receipt recording no path at all.
|
|
125
|
+
*
|
|
126
|
+
* The query and fragment come off here for the same reason they come off in
|
|
127
|
+
* pathUnder: "/api?x=1" must not be a different route from "/api", and a
|
|
128
|
+
* query string recorded in a receipt is user data we never asked for.
|
|
129
|
+
*/
|
|
130
|
+
export function requestPath(req) {
|
|
131
|
+
const raw = (req && (req.path ?? req.url)) ?? '';
|
|
132
|
+
return String(raw).split('#')[0].split('?')[0] || '/';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function pathUnder(path, prefix) {
|
|
136
|
+
if (typeof path !== 'string' || typeof prefix !== 'string') return false;
|
|
137
|
+
/* Strip the query before comparing. `handle()` takes whatever the caller
|
|
138
|
+
passes, and callers pass `req.url` as often as `req.path` — so "/api?x=1"
|
|
139
|
+
arrived here as a literal and matched no prefix at all. In 0.5.2 that
|
|
140
|
+
meant appending "?x=1" walked straight past a priced route: the boundary
|
|
141
|
+
fix was bypassable by anyone who noticed. Also cut the fragment, which a
|
|
142
|
+
server should never see but a hand-built request can carry. */
|
|
143
|
+
path = path.split('#')[0].split('?')[0];
|
|
144
|
+
if (prefix === '' || prefix === '/') return true;
|
|
145
|
+
const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
146
|
+
return path === base || path.startsWith(base + '/');
|
|
147
|
+
}
|
|
148
|
+
|
|
98
149
|
export function buildParams(keyid, created, expires, nonce = null) {
|
|
99
150
|
return `("@authority" "signature-agent");created=${created};` +
|
|
100
151
|
`keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"` +
|
|
@@ -239,10 +290,25 @@ export function verifySignature(h, authority, directories,
|
|
|
239
290
|
|
|
240
291
|
// ── classification ──────────────────────────────────────────────────────
|
|
241
292
|
|
|
242
|
-
|
|
293
|
+
/* Named agents, not the word "agent".
|
|
294
|
+
*
|
|
295
|
+
* `/agent/` matched "MyAgencyCRM" and "UserAgent/1.0" — ordinary software with
|
|
296
|
+
* an unlucky name, sorted into the lane that gets charged. Guessing from a
|
|
297
|
+
* substring is the opposite of what this library is for: identity comes from
|
|
298
|
+
* signature math, and the UA list is only a declared-intent hint for the
|
|
299
|
+
* crawlers that announce themselves honestly.
|
|
300
|
+
*
|
|
301
|
+
* Exported so an operator can add the one their traffic actually shows. */
|
|
302
|
+
export const AGENT_UA = [
|
|
243
303
|
/gptbot/, /oai-searchbot/, /chatgpt-user/, /claude(?:bot|-user|-searchbot)?/,
|
|
244
|
-
/perplexitybot/, /google-extended/, /bingbot/, /
|
|
245
|
-
/
|
|
304
|
+
/perplexitybot/, /google-extended/, /bingbot/, /duckassistbot/, /applebot-extended/,
|
|
305
|
+
/meta-externalagent/, /amazonbot/, /youbot/, /ccbot/, /cohere-ai/, /diffbot/,
|
|
306
|
+
/anthropic/, /openai/, /autonomous[- ]agent/,
|
|
307
|
+
/* `bot` ending a word, not `\bbot\b`. Agents name themselves by suffix —
|
|
308
|
+
GPTBot, ClaudeBot, PerplexityBot, ShopBot — and a leading word boundary
|
|
309
|
+
misses every one of them. A trailing boundary still refuses `bottle`,
|
|
310
|
+
because the `t` that follows is a word character. */
|
|
311
|
+
/bot\b/, /crawler\b/, /spider\b/,
|
|
246
312
|
];
|
|
247
313
|
const AUTOMATION_TELLS = [
|
|
248
314
|
['headlesschrome', 'headless browser UA'],
|
|
@@ -457,7 +523,36 @@ export class Wayleave {
|
|
|
457
523
|
// accept-language header is the whole bypass. Strict mode inverts the
|
|
458
524
|
// burden on priced routes only: nothing crosses free without positive
|
|
459
525
|
// evidence, which is a verified signature or confirmHuman saying yes.
|
|
460
|
-
|
|
526
|
+
/* Item 3. The human lane means "nothing tripped", not "verified person":
|
|
527
|
+
a User-Agent and an Accept-Language are enough to reach it, and both are
|
|
528
|
+
one line in any scraper. With strict mode off, every priced route was
|
|
529
|
+
free to anything that looked like a browser — which is the whole product
|
|
530
|
+
leaking.
|
|
531
|
+
|
|
532
|
+
So it now defaults ON wherever there is something to protect. Turning it
|
|
533
|
+
off stays possible and says so once at startup, because an operator who
|
|
534
|
+
chooses it should know what they chose. */
|
|
535
|
+
this.strictPricedPaths = opts.strictPricedPaths === undefined
|
|
536
|
+
? Object.keys(this.pricedPaths).length > 0
|
|
537
|
+
: opts.strictPricedPaths === true;
|
|
538
|
+
const priced = Object.keys(this.pricedPaths).length > 0;
|
|
539
|
+
const warn = this._onWarn = opts.onWarn || console.warn;
|
|
540
|
+
if (opts.strictPricedPaths === false && priced)
|
|
541
|
+
warn('[wayleave] strictPricedPaths is off: priced routes are reachable free ' +
|
|
542
|
+
'by any client that looks like a browser. Pass confirmHuman() to charge ' +
|
|
543
|
+
'agents while letting your signed-in people through.');
|
|
544
|
+
/* The other half of item 3, and the one that can hurt a real person.
|
|
545
|
+
|
|
546
|
+
Strict mode says the human lane is not enough on a priced route. If the
|
|
547
|
+
operator has not also said how to recognise their own people, then
|
|
548
|
+
nobody qualifies — and a customer reading an article gets a 402. The law
|
|
549
|
+
this library is built on is that people never pay and never get blocked,
|
|
550
|
+
so the combination that breaks it does not pass silently. */
|
|
551
|
+
if (this.strictPricedPaths && priced && typeof opts.confirmHuman !== 'function')
|
|
552
|
+
warn('[wayleave] priced routes are strict but no confirmHuman() was given, ' +
|
|
553
|
+
'so signed-in people will be asked to pay like agents. Pass ' +
|
|
554
|
+
'confirmHuman: (req) => Boolean(yourSession(req)), or set ' +
|
|
555
|
+
'strictPricedPaths: false if these routes have no human visitors.');
|
|
461
556
|
this.confirmHuman = opts.confirmHuman || null;
|
|
462
557
|
|
|
463
558
|
// x-forwarded-for is written by the client unless a proxy you control
|
|
@@ -516,17 +611,29 @@ export class Wayleave {
|
|
|
516
611
|
|
|
517
612
|
/** req: { method, path, authority, headers, ip } → decision */
|
|
518
613
|
handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
519
|
-
|
|
520
|
-
|
|
614
|
+
/* A remote policy is a signed document fetched over the network, so a
|
|
615
|
+
synchronous call cannot evaluate one. Silence here would mean an
|
|
616
|
+
operator's rules quietly not applying; say it once, loudly, rather than
|
|
617
|
+
let them believe a policy is in force that is not. */
|
|
618
|
+
if (this.policy && !this._warnedSyncPolicy) {
|
|
619
|
+
this._warnedSyncPolicy = true;
|
|
620
|
+
this._onWarn('[wayleave] handle() cannot evaluate a remote policy — verifying a ' +
|
|
621
|
+
'signed document is asynchronous, so your policy rules are NOT being ' +
|
|
622
|
+
'applied here. Use handleAsync(); gate.express() already does.');
|
|
623
|
+
}
|
|
624
|
+
const path = requestPath(req);
|
|
625
|
+
const r = this._withPath(req, path);
|
|
626
|
+
const c = classify(r, this.directories, now,
|
|
627
|
+
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
521
628
|
const { lane, agentId, evidence } = c;
|
|
522
|
-
const ident = agentId || `${lane}:${this._client(
|
|
629
|
+
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
523
630
|
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
524
|
-
const d = this._decide(
|
|
631
|
+
const d = this._decide(r, lane, ident, now, proof, c);
|
|
525
632
|
// v is the event schema version. A meter that outlives one release has to
|
|
526
633
|
// know which shape it is reading; adding this after receipts exist in the
|
|
527
634
|
// wild would mean reconciling two formats forever.
|
|
528
635
|
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
529
|
-
t: now, path
|
|
636
|
+
t: now, path, lane, identity: ident,
|
|
530
637
|
status: d.status, why: d.why, evidence,
|
|
531
638
|
billedUsd: d.billed || 0 };
|
|
532
639
|
if (d.paymentRef) entry.paymentRef = d.paymentRef;
|
|
@@ -569,15 +676,17 @@ export class Wayleave {
|
|
|
569
676
|
}
|
|
570
677
|
|
|
571
678
|
async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
572
|
-
const
|
|
573
|
-
|
|
679
|
+
const path = requestPath(req);
|
|
680
|
+
const r = this._withPath(req, path);
|
|
681
|
+
const c = classify(r, this.directories, now,
|
|
682
|
+
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
574
683
|
const { lane, agentId, evidence } = c;
|
|
575
|
-
const ident = agentId || `${lane}:${this._client(
|
|
684
|
+
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
576
685
|
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
577
|
-
const fromPolicy = await this._policyDecision(
|
|
578
|
-
const d = fromPolicy || await this._decideAsync(
|
|
686
|
+
const fromPolicy = await this._policyDecision(r, lane, ident, c);
|
|
687
|
+
const d = fromPolicy || await this._decideAsync(r, lane, ident, now, proof, c);
|
|
579
688
|
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
580
|
-
t: now, path
|
|
689
|
+
t: now, path, lane, identity: ident,
|
|
581
690
|
status: d.status, why: d.why, evidence,
|
|
582
691
|
billedUsd: d.billed || 0 };
|
|
583
692
|
if (d.paymentRef) entry.paymentRef = d.paymentRef;
|
|
@@ -631,7 +740,7 @@ export class Wayleave {
|
|
|
631
740
|
*/
|
|
632
741
|
_preDecide(req, lane, ident, now, paymentProof, c) {
|
|
633
742
|
for (const [prefix, allow] of this.rules[lane] || []) {
|
|
634
|
-
if (req.path
|
|
743
|
+
if (pathUnder(req.path, prefix)) {
|
|
635
744
|
if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
|
|
636
745
|
break;
|
|
637
746
|
}
|
|
@@ -659,7 +768,7 @@ export class Wayleave {
|
|
|
659
768
|
: lane === LANES.HUMAN;
|
|
660
769
|
if (!freeOnPriced) {
|
|
661
770
|
for (const [prefix, price] of Object.entries(this.pricedPaths)) {
|
|
662
|
-
if (req.path
|
|
771
|
+
if (pathUnder(req.path, prefix)) {
|
|
663
772
|
const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
|
|
664
773
|
if (!paymentProof) {
|
|
665
774
|
const why = this.strictPricedPaths && lane === LANES.HUMAN
|
|
@@ -752,6 +861,21 @@ export class Wayleave {
|
|
|
752
861
|
/** Stop polling. Safe to call when no remote policy is configured. */
|
|
753
862
|
close() { this.policy?.stop(); this.registry?.stop(); }
|
|
754
863
|
|
|
864
|
+
/**
|
|
865
|
+
* The caller's request, seen with a `path` on it.
|
|
866
|
+
*
|
|
867
|
+
* Built with Object.create so the original is never mutated and never
|
|
868
|
+
* copied: a framework's request object owns sockets, streams and getters,
|
|
869
|
+
* and cloning one is a good way to break something far from here. Reads
|
|
870
|
+
* fall through to the real request; only `path` is ours.
|
|
871
|
+
*/
|
|
872
|
+
_withPath(req, path) {
|
|
873
|
+
if (!req || typeof req !== 'object' || req.path === path) return req;
|
|
874
|
+
try {
|
|
875
|
+
return Object.create(req, { path: { value: path, enumerable: true, configurable: true } });
|
|
876
|
+
} catch { return req; }
|
|
877
|
+
}
|
|
878
|
+
|
|
755
879
|
/**
|
|
756
880
|
* Evaluate the fetched policy for one request.
|
|
757
881
|
*
|
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.3",
|
|
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
|
|