wayleave 0.5.3 → 0.5.5

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 CHANGED
@@ -25,7 +25,7 @@ Then policy runs per lane: allow, deny, rate-limit — and on routes you price,
25
25
 
26
26
  Only the first lane is cryptographic. The other three are read off what the client says about itself, so a bot willing to lie reaches the human lane — see [Guarantees](#guarantees-honestly-stated), and set `strictPricedPaths` on anything you charge for.
27
27
 
28
- ## Two ways to use it
28
+ ## Three ways to use it
29
29
 
30
30
  **1. The hosted Identity API — no install, any language.** Post a request's
31
31
  signature headers and get back who signed it and which lane it belongs in.
@@ -54,6 +54,25 @@ way to know which to believe.
54
54
  you do not send. This is the path if you want to price routes, enforce access
55
55
  rules, or stay in-process. Start below.
56
56
 
57
+ **3. A platform adapter — if your code does not run on Node.** This package is
58
+ Express middleware and Node's crypto; it cannot run on a Worker or a Deno
59
+ edge function. These can, share one policy engine with it, and are configured
60
+ the same way:
61
+
62
+ | Where you run | Install |
63
+ |---|---|
64
+ | Cloudflare Workers | [`@wayleave/cloudflare`](https://www.npmjs.com/package/@wayleave/cloudflare) |
65
+ | Vercel middleware | [`@wayleave/vercel`](https://www.npmjs.com/package/@wayleave/vercel) |
66
+ | Supabase Edge Functions | [`@wayleave/supabase`](https://www.npmjs.com/package/@wayleave/supabase) |
67
+ | Netlify Edge Functions | [`@wayleave/netlify`](https://www.npmjs.com/package/@wayleave/netlify) |
68
+ | An MCP server | [`@wayleave/mcp`](https://www.npmjs.com/package/@wayleave/mcp) |
69
+
70
+ All four HTTP adapters default to observe mode and fail open, exactly as this
71
+ one does. The MCP guard is the odd one: an MCP server has no HTTP request to
72
+ inspect and MCP clients carry no signatures, so it does not classify lanes at
73
+ all — it bounds tool calls by rate, budget and repetition instead, and says
74
+ `classification: "unknown"` rather than guessing.
75
+
57
76
  ## Quickstart: observe first
58
77
 
59
78
  ```sh
package/index.js CHANGED
@@ -23,7 +23,7 @@ import { createPublicKey, verify as edVerify, sign as edSign,
23
23
  // nothing measurable and makes `meter: { apiKey }` work without ceremony.
24
24
  import { MeterSink } from './meter.js';
25
25
  import { RemotePolicy } from './policy.js';
26
- import { evaluatePolicy } from './policy-engine.js';
26
+ import { evaluatePolicy, evaluatePolicySync } from './policy-engine.js';
27
27
  import { Registry } from './registry.js';
28
28
  import { ManifestServer, MANIFEST_PATHS, originFor, paymentRequirementsFor } from './manifest.js';
29
29
 
@@ -344,7 +344,18 @@ export function classify(req, directories, now, opts = {}) {
344
344
  if (h['signature'] || h['signature-input'])
345
345
  return { lane: LANES.SUSPECT, agentId: null,
346
346
  evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
347
- if (AGENT_UA.some(p => p.test(ua))) {
347
+ /* WHICH pattern matched, not merely that one did.
348
+
349
+ This used to be `.some(...)`, and the evidence then quoted the first 40
350
+ characters of the user-agent. Almost every bot UA opens with a Mozilla
351
+ compatibility preamble and names itself at the END, so the quote cut off
352
+ exactly where the identifying part began: 486 crossings on one site all
353
+ recorded as `mozilla/5.0 applewebkit/537.36 (khtml, l`, indistinguishable
354
+ from each other and from a browser.
355
+
356
+ The matched pattern is the name. It was in hand and discarded. */
357
+ const agentMatch = AGENT_UA.map(p => (p.exec(ua) || [])[0]).find(Boolean);
358
+ if (agentMatch) {
348
359
  // A UA naming a known operator is a claim, and claims are checkable when
349
360
  // the operator publishes its addresses. Claiming to be GPTBot from an
350
361
  // address OpenAI does not own is a stronger fraud signal than saying
@@ -356,10 +367,10 @@ export function classify(req, directories, now, opts = {}) {
356
367
  : null;
357
368
  if (ipOk === false)
358
369
  return { lane: LANES.SUSPECT, agentId: null,
359
- evidence: [`UA claims "${ua.slice(0, 40)}"`,
370
+ evidence: [`UA claims "${agentMatch}"`,
360
371
  `but ${opts.ip || 'the client'} is not a published address for it`] };
361
372
  return { lane: LANES.DECLARED, agentId: null,
362
- evidence: [`self-identified in UA: "${ua.slice(0, 40)}"`,
373
+ evidence: [`self-identified as "${agentMatch}"`,
363
374
  ipOk === true ? 'source address matches published range'
364
375
  : 'no signature presented'] };
365
376
  }
@@ -611,16 +622,6 @@ export class Wayleave {
611
622
 
612
623
  /** req: { method, path, authority, headers, ip } → decision */
613
624
  handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
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
625
  const path = requestPath(req);
625
626
  const r = this._withPath(req, path);
626
627
  const c = classify(r, this.directories, now,
@@ -628,7 +629,11 @@ export class Wayleave {
628
629
  const { lane, agentId, evidence } = c;
629
630
  const ident = agentId || `${lane}:${this._client(r)}`;
630
631
  const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
631
- const d = this._decide(r, lane, ident, now, proof, c);
632
+ /* The same policy the async path enforces, evaluated here for every rule
633
+ that needs no I/O. Before this, `handle()` skipped policy entirely and
634
+ one document behaved as two: enforced through express(), ignored here. */
635
+ const fromPolicy = this._policyDecisionSync(r, lane, ident, c);
636
+ const d = fromPolicy || this._decide(r, lane, ident, now, proof, c);
632
637
  // v is the event schema version. A meter that outlives one release has to
633
638
  // know which shape it is reading; adding this after receipts exist in the
634
639
  // wild would mean reconciling two formats forever.
@@ -690,6 +695,10 @@ export class Wayleave {
690
695
  status: d.status, why: d.why, evidence,
691
696
  billedUsd: d.billed || 0 };
692
697
  if (d.paymentRef) entry.paymentRef = d.paymentRef;
698
+ // The sync path recorded this and the async path did not, so a policy
699
+ // denial arrived at the meter with no rule on it — unanswerable when the
700
+ // operator asks which rule refused a request.
701
+ if (d.ruleId) entry.ruleId = d.ruleId;
693
702
  this.sink.emit(entry);
694
703
  return { lane, identity: ident, ...d, evidence };
695
704
  }
@@ -887,24 +896,74 @@ export class Wayleave {
887
896
  * Returns null when there is no policy, or when it says allow -- in both
888
897
  * cases the gate's local configuration decides, as it always did.
889
898
  */
890
- async _policyDecision(req, lane, ident, c) {
899
+ /**
900
+ * The policy decision that needs no network.
901
+ *
902
+ * Everything the async path does, minus quota — consuming from a quota
903
+ * store is I/O and cannot happen inside a synchronous call. A quota rule
904
+ * reached here is NOT passed over: passing it would let through exactly the
905
+ * traffic the operator metered, which is worse than refusing. It answers
906
+ * the way the async path answers when the store cannot be consulted, and
907
+ * says which call to use instead.
908
+ */
909
+ _policyDecisionSync(req, lane, ident, c) {
891
910
  const policy = this.policy?.document;
892
911
  if (!policy) return null;
893
- const identity = c?.agentId && c.lane === 'verified_agent'
912
+ let d;
913
+ try {
914
+ d = evaluatePolicySync(policy, {
915
+ tenant: this.policy.tenant || 'self',
916
+ path: req.path, method: req.method, lane,
917
+ identity: this._policyIdentity(c), anonymousBucket: ident,
918
+ });
919
+ } catch (err) {
920
+ this.onEvent({ type: 'policy.evaluate.failed', error: err?.message });
921
+ return null; // traffic fails open
922
+ }
923
+ if (d.action === 'needs-async') {
924
+ if (!this._warnedSyncQuota) {
925
+ this._warnedSyncQuota = true;
926
+ this._onWarn('[wayleave] a quota rule was reached through handle(), which cannot ' +
927
+ 'consult a quota store. The request was refused rather than passed. ' +
928
+ 'Use handleAsync(); gate.express() already does.');
929
+ }
930
+ return { status: 403, why: 'quota rule requires handleAsync', ruleId: d.ruleId,
931
+ headers: { 'x-wayleave-rule': String(d.ruleId) } };
932
+ }
933
+ return this._fromPolicyAction(d, req);
934
+ }
935
+
936
+ /** The identity shape the engine expects, from a classification. */
937
+ _policyIdentity(c) {
938
+ return c?.agentId && c.lane === 'verified_agent'
894
939
  ? { verified: true, subject: c.agentId, operator: c.operator || undefined }
895
940
  : { verified: false };
941
+ }
942
+
943
+ async _policyDecision(req, lane, ident, c) {
944
+ const policy = this.policy?.document;
945
+ if (!policy) return null;
896
946
  let d;
897
947
  try {
898
948
  d = await evaluatePolicy(policy, {
899
949
  tenant: this.policy.tenant || 'self',
900
950
  path: req.path, method: req.method, lane,
901
- identity, anonymousBucket: ident,
951
+ identity: this._policyIdentity(c), anonymousBucket: ident,
902
952
  }, { quotaStore: this.quotaStore });
903
953
  } catch (err) {
904
954
  // A policy we cannot evaluate must not break the request. Traffic fails open.
905
955
  this.onEvent({ type: 'policy.evaluate.failed', error: err?.message });
906
956
  return null;
907
957
  }
958
+ return this._fromPolicyAction(d, req);
959
+ }
960
+
961
+ /**
962
+ * One translation from an engine action to a gate decision, used by both
963
+ * paths. Two copies of this is how a deny becomes a 403 on one path and a
964
+ * 429 on the other for the same rule.
965
+ */
966
+ _fromPolicyAction(d, req) {
908
967
  if (d.action === 'allow') return null;
909
968
  if (d.action === 'deny')
910
969
  return { status: 403, why: d.reason || `denied by rule ${d.ruleId || 'default'}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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",
@@ -27,6 +27,9 @@
27
27
  },
28
28
  "./manifest": {
29
29
  "default": "./manifest.js"
30
+ },
31
+ "./policy-engine": {
32
+ "default": "./policy-engine.js"
30
33
  }
31
34
  },
32
35
  "files": [
package/policy-engine.js CHANGED
@@ -12,6 +12,20 @@
12
12
  * from the meter. policy-engine.parity.test.js asserts the two files match.
13
13
  * ───────────────────────────────────────────────────────────────────────
14
14
  */
15
+ /* Documents that have already been through validatePolicy.
16
+
17
+ A WeakSet, so remembering a document cannot keep it alive: entries vanish
18
+ when the policy is replaced by the next fetch. The point is that a policy
19
+ is validated once, when it arrives, and not again on every request that
20
+ consults it -- a hot path doing full schema validation per crossing is
21
+ work nobody asked for, repeated forever. */
22
+ const VALIDATED = new WeakSet();
23
+
24
+ /** True when this exact object has already been validated. */
25
+ export function isValidated(policy) {
26
+ return typeof policy === 'object' && policy !== null && VALIDATED.has(policy);
27
+ }
28
+
15
29
  export function validatePolicy(policy) {
16
30
  if (!policy || typeof policy.version !== 'string' || !policy.version || !Array.isArray(policy.rules) || policy.rules.length > 100) throw new Error('Version and at most 100 rules required');
17
31
  if (!['allow','deny'].includes(policy.default)) throw new Error('Explicit default required');
@@ -33,10 +47,25 @@ export function validatePolicy(policy) {
33
47
  if (r.action === 'pay' && (!Number.isSafeInteger(r.priceMicros) || r.priceMicros <= 0 || typeof r.rail !== 'string' || !r.rail)) throw new Error('Payment requires positive integer USD micros and rail');
34
48
  if ((r.action === 'quota' || r.action === 'allowance') && (!r.quota || !Number.isSafeInteger(r.quota.limit) || r.quota.limit < 1 || !Number.isSafeInteger(r.quota.windowSeconds) || r.quota.windowSeconds < 1)) throw new Error('Invalid quota');
35
49
  }
50
+ VALIDATED.add(policy);
36
51
  return policy;
37
52
  }
38
- export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
39
- validatePolicy(policy);
53
+ /**
54
+ * Everything both evaluators need before either can decide anything.
55
+ *
56
+ * Extracted so the matching rules exist once. Two copies of "does this rule
57
+ * apply" is how a sync path and an async path quietly begin enforcing
58
+ * different policies from the same document — which is the bug this file
59
+ * already had between the gate and the meter, and is not worth reproducing
60
+ * inside a single file.
61
+ */
62
+ function prepare(policy, context) {
63
+ /* Validated once, when the document arrived — not again here. A caller
64
+ handing over a document nobody has checked still gets it checked, because
65
+ evaluating an unvalidated policy is how a malformed rule becomes a wrong
66
+ decision. But the normal path, where the fetcher validated it before it
67
+ became the live document, does no schema work per request at all. */
68
+ if (!isValidated(policy)) validatePolicy(policy);
40
69
  if (typeof context.tenant !== 'string' || !context.tenant || typeof context.path !== 'string') throw new Error('Trusted tenant and path required');
41
70
  const identity = context.identity?.verified === true ? context.identity : null;
42
71
  const matches = r =>
@@ -47,6 +76,44 @@ export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
47
76
  (r.verified === undefined || r.verified === !!identity) &&
48
77
  (r.subject === undefined || r.subject === identity?.subject) &&
49
78
  (r.operator === undefined || r.operator === identity?.operator);
79
+ return { identity, matches };
80
+ }
81
+
82
+ /** A rule that needs no I/O to answer. Shared by both evaluators. */
83
+ function plainDecision(r, base) {
84
+ return r.action === 'pay'
85
+ ? { ...base, action: 'pay', priceMicros: r.priceMicros, currency: 'USD', rail: r.rail }
86
+ : { ...base, action: r.action };
87
+ }
88
+
89
+ /**
90
+ * Evaluate without touching the quota store.
91
+ *
92
+ * `handle()` is synchronous, so it could not evaluate a policy at all — and
93
+ * simply did not. The same document enforced through `express()` and was
94
+ * ignored through `handle()`, which is one policy behaving as two.
95
+ *
96
+ * Everything needing no I/O is decided here: allow, deny, pay, and the
97
+ * default. A quota or allowance rule cannot be, because consuming from the
98
+ * store is a network or database call. Rather than skip such a rule — which
99
+ * would silently pass exactly the traffic the operator metered — it stops and
100
+ * says so, and the caller can use handleAsync() or treat the answer as the
101
+ * refusal it is.
102
+ */
103
+ export function evaluatePolicySync(policy, context) {
104
+ const { matches } = prepare(policy, context);
105
+ for (const r of policy.rules) {
106
+ if (!matches(r)) continue;
107
+ const base = { policyVersion: policy.version, ruleId: r.id };
108
+ if (r.action === 'quota' || r.action === 'allowance')
109
+ return { ...base, action: 'needs-async', reason: 'quota rule requires handleAsync' };
110
+ return plainDecision(r, base);
111
+ }
112
+ return { policyVersion: policy.version, ruleId: null, action: policy.default };
113
+ }
114
+
115
+ export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
116
+ const { identity, matches } = prepare(policy, context);
50
117
  // An exhausted allowance does not answer the request; it steps aside for the
51
118
  // rule behind it. Carried so the eventual answer can say why it was reached.
52
119
  let spent = null;
@@ -73,7 +140,7 @@ export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
73
140
  if (allowed === true) return { ...base, action: 'allow', reason: 'Within free allowance' };
74
141
  spent = 'Free allowance exhausted'; continue;
75
142
  }
76
- return r.action === 'pay' ? { ...base, action: 'pay', priceMicros: r.priceMicros, currency: 'USD', rail: r.rail } : { ...base, action: r.action };
143
+ return plainDecision(r, base);
77
144
  }
78
145
  // A route the operator metered never falls back to a permissive default.
79
146
  if (spent) return { policyVersion: policy.version, ruleId: null, action: 'deny', reason: spent };
package/policy.js CHANGED
@@ -20,6 +20,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { verify as edVerify, createPublicKey } from 'node:crypto';
23
+ import { validatePolicy } from './policy-engine.js';
23
24
 
24
25
  const DEFAULT_ENDPOINT = 'https://meter.wayleave.dev';
25
26
  const DEFAULT_REFRESH_MS = 5 * 60_000;
@@ -216,7 +217,13 @@ export class RemotePolicy {
216
217
  this._emit('policy.rejected', { why: 'no signing key configured' });
217
218
  return this.document;
218
219
  }
219
- if (!isUsable(doc)) { this._emit('policy.rejected', { why: 'document not usable' }); return this.document; }
220
+ const check = usable(doc);
221
+ if (!check.ok) {
222
+ // The last good policy stays in force. A bad publish must not
223
+ // silently disarm a gate that was working a second ago.
224
+ this._emit('policy.rejected', { why: check.why, version: doc?.version ?? null });
225
+ return this.document;
226
+ }
220
227
 
221
228
  this.document = doc;
222
229
  this.etag = res.headers.get?.('etag') ?? null;
@@ -237,7 +244,12 @@ export class RemotePolicy {
237
244
  async _loadCache() {
238
245
  try {
239
246
  const raw = JSON.parse(await readFile(this.cachePath, 'utf8'));
240
- if (raw?.url === this.url && isUsable(raw.document)) {
247
+ /* The cache is a file on disk, which means it is a file somebody or
248
+ something else can edit. It gets the same full validation a freshly
249
+ fetched document gets -- and it has to, because this is the copy that
250
+ enforces on the very first request after a restart, before any fetch
251
+ has completed. */
252
+ if (raw?.url === this.url && usable(raw.document).ok) {
241
253
  this.document = raw.document;
242
254
  this.etag = null; // revalidate against the server, not against disk
243
255
  this._emit('policy.cache.loaded', { version: raw.document.version });
@@ -259,9 +271,26 @@ export class RemotePolicy {
259
271
  }
260
272
 
261
273
  /** A document we can act on: right shape, explicit default, sane rule count. */
262
- function isUsable(doc) {
263
- return !!doc && Array.isArray(doc.rules) && doc.rules.length <= 100
264
- && (doc.default === 'allow' || doc.default === 'deny');
274
+ /**
275
+ * Whether a fetched document may become the live policy.
276
+ *
277
+ * Full schema validation, here, once — not the shallow shape check this used
278
+ * to be. Two things follow from doing it at the door rather than per request:
279
+ * a document with a malformed rule never becomes live at all (the last good
280
+ * copy stays, which is the whole point of caching one), and the evaluator
281
+ * does no validation work on the hot path because the document it is handed
282
+ * has already passed.
283
+ *
284
+ * Returns a reason rather than a boolean, because "document not usable" told
285
+ * an operator nothing about which rule was wrong.
286
+ */
287
+ function usable(doc) {
288
+ try {
289
+ validatePolicy(doc);
290
+ return { ok: true };
291
+ } catch (err) {
292
+ return { ok: false, why: err.message };
293
+ }
265
294
  }
266
295
 
267
296
  function defaultCachePath() {