wayleave 0.5.2 → 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.
Files changed (3) hide show
  1. package/index.d.ts +12 -0
  2. package/index.js +114 -15
  3. package/package.json +1 -1
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
@@ -113,8 +113,34 @@ function sigBase(components, ctx, paramsRaw) {
113
113
  * trailing slash on the prefix changes nothing — "/api" and "/api/" mean the
114
114
  * same thing, because operators write both and neither should surprise them.
115
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
+
116
135
  export function pathUnder(path, prefix) {
117
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];
118
144
  if (prefix === '' || prefix === '/') return true;
119
145
  const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
120
146
  return path === base || path.startsWith(base + '/');
@@ -264,10 +290,25 @@ export function verifySignature(h, authority, directories,
264
290
 
265
291
  // ── classification ──────────────────────────────────────────────────────
266
292
 
267
- const AGENT_UA = [
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 = [
268
303
  /gptbot/, /oai-searchbot/, /chatgpt-user/, /claude(?:bot|-user|-searchbot)?/,
269
- /perplexitybot/, /google-extended/, /bingbot/, /anthropic/, /openai/,
270
- /agent/, /autonomous/, /\bbot\b/,
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/,
271
312
  ];
272
313
  const AUTOMATION_TELLS = [
273
314
  ['headlesschrome', 'headless browser UA'],
@@ -482,7 +523,36 @@ export class Wayleave {
482
523
  // accept-language header is the whole bypass. Strict mode inverts the
483
524
  // burden on priced routes only: nothing crosses free without positive
484
525
  // evidence, which is a verified signature or confirmHuman saying yes.
485
- this.strictPricedPaths = opts.strictPricedPaths === true;
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.');
486
556
  this.confirmHuman = opts.confirmHuman || null;
487
557
 
488
558
  // x-forwarded-for is written by the client unless a proxy you control
@@ -541,17 +611,29 @@ export class Wayleave {
541
611
 
542
612
  /** req: { method, path, authority, headers, ip } → decision */
543
613
  handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
544
- const c = classify(req, this.directories, now,
545
- { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
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) });
546
628
  const { lane, agentId, evidence } = c;
547
- const ident = agentId || `${lane}:${this._client(req)}`;
629
+ const ident = agentId || `${lane}:${this._client(r)}`;
548
630
  const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
549
- const d = this._decide(req, lane, ident, now, proof, c);
631
+ const d = this._decide(r, lane, ident, now, proof, c);
550
632
  // v is the event schema version. A meter that outlives one release has to
551
633
  // know which shape it is reading; adding this after receipts exist in the
552
634
  // wild would mean reconciling two formats forever.
553
635
  const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
554
- t: now, path: req.path, lane, identity: ident,
636
+ t: now, path, lane, identity: ident,
555
637
  status: d.status, why: d.why, evidence,
556
638
  billedUsd: d.billed || 0 };
557
639
  if (d.paymentRef) entry.paymentRef = d.paymentRef;
@@ -594,15 +676,17 @@ export class Wayleave {
594
676
  }
595
677
 
596
678
  async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
597
- const c = classify(req, this.directories, now,
598
- { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
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) });
599
683
  const { lane, agentId, evidence } = c;
600
- const ident = agentId || `${lane}:${this._client(req)}`;
684
+ const ident = agentId || `${lane}:${this._client(r)}`;
601
685
  const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
602
- const fromPolicy = await this._policyDecision(req, lane, ident, c);
603
- const d = fromPolicy || await this._decideAsync(req, lane, ident, now, proof, c);
686
+ const fromPolicy = await this._policyDecision(r, lane, ident, c);
687
+ const d = fromPolicy || await this._decideAsync(r, lane, ident, now, proof, c);
604
688
  const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
605
- t: now, path: req.path, lane, identity: ident,
689
+ t: now, path, lane, identity: ident,
606
690
  status: d.status, why: d.why, evidence,
607
691
  billedUsd: d.billed || 0 };
608
692
  if (d.paymentRef) entry.paymentRef = d.paymentRef;
@@ -777,6 +861,21 @@ export class Wayleave {
777
861
  /** Stop polling. Safe to call when no remote policy is configured. */
778
862
  close() { this.policy?.stop(); this.registry?.stop(); }
779
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
+
780
879
  /**
781
880
  * Evaluate the fetched policy for one request.
782
881
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",