wayleave 0.4.1 → 0.5.1

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
@@ -4,9 +4,11 @@
4
4
  [![npm](https://img.shields.io/npm/v/wayleave.svg)](https://www.npmjs.com/package/wayleave)
5
5
  [![install size](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/wayleave)
6
6
 
7
- **Observe agent traffic. Control access. Charge payment-ready agents.**
7
+ **Know which AI agents cross your API — verified by signature, not guessed from an IP.**
8
8
 
9
- Wayleave is open-source middleware for request classification, signed-agent verification, route-level policies and pricing. The [hosted Meter](https://meter.wayleave.dev) records traffic and settlement activity.
9
+ Wayleave is open-source middleware that classifies every request, verifies signed agents against a key
10
+ directory, applies the access rules you set, and prices the non-human traffic with HTTP 402. Humans are
11
+ never charged. The [hosted Meter](https://meter.wayleave.dev) records what crossed and what settled.
10
12
 
11
13
  Zero dependencies. Node's native crypto only. TypeScript declarations included.
12
14
 
@@ -23,6 +25,35 @@ Then policy runs per lane: allow, deny, rate-limit — and on routes you price,
23
25
 
24
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.
25
27
 
28
+ ## Two ways to use it
29
+
30
+ **1. The hosted Identity API — no install, any language.** Post a request's
31
+ signature headers and get back who signed it and which lane it belongs in.
32
+ Nothing is stored, and your traffic never routes through us.
33
+
34
+ ```sh
35
+ curl -X POST https://api.wayleave.dev/v1/identify \
36
+ -H "Authorization: Bearer $WAYLEAVE_API_KEY" \
37
+ -H "Content-Type: application/json" \
38
+ -d '{"method":"GET","url":"https://api.yourapp.com/v1/data",
39
+ "headers":{"user-agent":"...","signature":"...","signature-input":"..."},
40
+ "client_ip":"203.0.113.7"}'
41
+ ```
42
+
43
+ ```json
44
+ { "lane": "verified_agent", "verified": true,
45
+ "identity": { "keyid": "k1", "operator": "Example Labs" },
46
+ "signature": { "present": true, "valid": true, "reason": null } }
47
+ ```
48
+
49
+ The same evaluator answers here and in the middleware, and a test asserts they
50
+ agree — if the two front doors disagreed about one request you would have no
51
+ way to know which to believe.
52
+
53
+ **2. The middleware — in your own process.** Nothing leaves your server that
54
+ you do not send. This is the path if you want to price routes, enforce access
55
+ rules, or stay in-process. Start below.
56
+
26
57
  ## Quickstart: observe first
27
58
 
28
59
  ```sh
@@ -88,6 +119,61 @@ Confirming settlement is a network call, so a real `verifyPayment` returns a
88
119
  Promise. The synchronous `handle()` cannot await one and will tell you so
89
120
  rather than quietly denying every payment.
90
121
 
122
+ ## Enforce rules you set elsewhere
123
+
124
+ > Requires 0.5.0 or later, which is what `npm install wayleave` now gives you.
125
+
126
+ A policy the gate fetches, rather than configuration compiled into your app:
127
+
128
+ ```js
129
+ const gate = new Wayleave({
130
+ policy: { url: 'https://meter.wayleave.dev/v1/policy/public/<id>.json',
131
+ publicKey: WAYLEAVE_ROOT_KEY },
132
+ });
133
+ await gate.ready();
134
+ app.use(gate.express());
135
+ ```
136
+
137
+ Fetched on a timer and evaluated from memory — never a network call inside a request. A denial
138
+ returns `403` with `x-wayleave-rule: <ruleId>`; a quota denial returns `429` with `Retry-After`.
139
+
140
+ The document is signed, which is the only reason the URL can be public. A CDN, or anyone on the
141
+ path, can serve whatever it likes and the gate refuses the bytes.
142
+
143
+ **If we are unreachable, your traffic is unaffected.** Last cached policy; with no cache, the gate
144
+ behaves exactly as if no policy were configured. That is tested five ways, and the cold-start case is
145
+ asserted by comparing against a gate constructed with no policy at all.
146
+
147
+ ## Be found by the agents that can pay
148
+
149
+ A priced route nothing can discover earns nothing. Every install with `pricedPaths` serves a
150
+ manifest derived from your own configuration:
151
+
152
+ ```js
153
+ new Wayleave({
154
+ pricedPaths: { '/api/premium': 0.05 },
155
+ payment: { payTo: '0xYourAddress', network: 'base' },
156
+ publicOrigin: 'https://api.example.com',
157
+ });
158
+ ```
159
+
160
+ ```sh
161
+ curl https://api.example.com/.well-known/x402
162
+ ```
163
+
164
+ No priced routes, or `manifest: false`, returns 404 — an empty manifest tells an index there is
165
+ nothing to buy here, which is a claim.
166
+
167
+ Use `gate.paymentRequirements(req)` to build your 402 body. It returns the same spec-shaped entry the
168
+ manifest advertises, so the two cannot disagree about the price.
169
+
170
+ Three details that decide whether an index reads you at all, all verified against the spec rather
171
+ than assumed: the path is `/.well-known/x402` (not `.json`), `asset` is the **token contract
172
+ address** rather than a symbol, and `maxTimeoutSeconds` is required. Note that the merged x402
173
+ specification has no well-known discovery document — this is a
174
+ [proposed extension](https://datatracker.ietf.org/doc/html/draft-hawkins-x402-dns-discovery-01)
175
+ that tooling in the wild does fetch.
176
+
91
177
  ## Guarantees, honestly stated
92
178
 
93
179
  - Signature verification is real Ed25519 over an RFC 9421 signature base — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially, and against other implementations' wire formats rather than only its own.
@@ -95,20 +181,46 @@ rather than quietly denying every payment.
95
181
  - Signature parameters are parsed as an RFC 9421 dictionary: order-independent, `alg` enforced as Ed25519, any signature label, and the signature base is built from whatever components the signer declared. Requests signed in Cloudflare's documented format verify.
96
182
  - Local verification is benchmarked by the tests; latency depends on your hardware and configuration. Payment-provider network calls add latency.
97
183
  - The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
98
- - Key rotation has a seam but no implementation. `directories` accepts a resolver function you can point at a cache, but there is no JWKS fetcher in the box and the resolver must not block. Wiring that cache is still your job.
184
+ - Key rotation has an implementation now: `directories: 'wayleave:default'` fetches a signed directory on a timer and resolves from memory, and you can still pass your own resolver function instead. The resolver must not block; that constraint has not moved. The bundled registry currently lists no operators — an empty directory resolves nothing rather than everything, so it changes no decision until it has entries.
99
185
  - **A bot that sends a browser `user-agent` and an `accept-language` header is classified `human` and crosses priced routes free.** Those two headers are the entire bypass. The `human` lane is a fall-through — it is reached by tripping none of the automation tells, which is absence of evidence, not evidence of a person. There is no TLS fingerprinting and no challenge here; that work belongs at your edge or CDN, and pretending otherwise would be the dishonest version of this list. (`verifyAgentIP` checks a *declared* operator against its published ranges — it does nothing about a request claiming to be a browser, which is this bypass.) `strictPricedPaths: true` inverts the burden on priced routes so that only a verified signature or your own `confirmHuman(req)` crosses. Recommended wherever there is a price.
100
186
  - Wayleave prices **disclosure**; it does not detect **concealment**. It is a tollbooth, not a wall — it works on operators who want to be identifiable, which today is most of the ones worth billing.
101
187
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
102
188
 
103
189
  ## Status
104
190
 
105
- v0.3.0 — verification, lanes, policy, pricing, and the metering hook, all under test (101 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, spoofed operator identity, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
106
-
107
- 0.1.5 closes a hole worth naming: before it, a priced route accepted a payment proof that any agent could derive from the 402 challenge it had just been sent — free passage, recorded as revenue. Settlement confirmation is now yours to supply and denies by default. If you shipped 0.1.4 or earlier on a priced route, upgrade.
108
-
109
- 0.1.6 addresses the other half of the same question. Payment could be faked; so could being human. `strictPricedPaths` lets a priced route demand positive evidence instead of accepting the absence of a bot signal as one.
110
-
111
- 0.2.1 ships `wayleave/meter`, a buffering and retrying sink for the hosted meter. 0.2.0 turns three hardcoded strategies into seams — state, metering, and key lookup — so shared storage, durable billing, and key rotation become configuration rather than a rewrite. Defaults are unchanged; see [Extending it](#extending-it).
191
+ v0.5.0 — 155 tests, zero dependencies. Published on npm.
192
+
193
+ **0.5.0** does three things. Rules you set are now *enforced*: `policy: { url, publicKey }` fetches a
194
+ signed policy, verifies it, caches it, and evaluates per request — denials carry the rule id in
195
+ `x-wayleave-rule`, and a quota denial is 429 with `Retry-After`. `directories: 'wayleave:default'`
196
+ resolves agent keys from a signed directory. And every install with a priced route now serves
197
+ `/.well-known/x402`, so discovery indexes can find it.
198
+
199
+ The property worth testing for yourself: **a Wayleave outage cannot break your API.** Unreachable,
200
+ 500, unparseable, wrong signature — each keeps the last good policy, and with no cache at all the
201
+ gate behaves exactly as if no policy were configured. Money is the opposite law and fails closed: an
202
+ unreachable rail is a 402, never free passage on a priced route.
203
+
204
+ That claim is not only unit-tested. `acceptance-policy.test.js` drives the real meter over a socket
205
+ and this gate fetching over HTTP with a real Ed25519 signature: a rule is set and enforced, changed
206
+ and re-enforced, a forged document is refused, and the service is then killed while traffic keeps
207
+ flowing. The same run against production on 2026-09-22 found a bug no local test had — the public
208
+ policy URL advertised an ETag and ignored `if-none-match`, so every poll re-sent and re-signed the
209
+ whole document. Fixed and covered.
210
+
211
+ **0.4.1** fixed a rail that could never settle. Two required fields were missing from every request
212
+ the x402 module made — `asset`, and the EIP-712 domain in `extra` — so the documented configuration
213
+ would have failed for anyone who tried it. Found by settling a real payment, not by reading the code.
214
+ If you were running the Coinbase rail before 0.4.1 and saw nothing settle, that is why.
215
+
216
+ **0.1.5** closed a hole worth naming: a priced route accepted a payment proof any agent could derive
217
+ from the 402 challenge it had just been sent — free passage, recorded as revenue. Settlement
218
+ confirmation is yours to supply and denies by default. If you shipped 0.1.4 or earlier on a priced
219
+ route, upgrade.
220
+
221
+ **0.1.6** addressed the other half: payment could be faked, and so could being human.
222
+ `strictPricedPaths` lets a priced route demand positive evidence rather than accepting the absence of
223
+ a bot signal as one.
112
224
 
113
225
  ## How payment actually works
114
226
 
package/index.js CHANGED
@@ -22,6 +22,10 @@ import { createPublicKey, verify as edVerify, sign as edSign,
22
22
  // Same package, zero dependencies, ~9KB. Importing it unconditionally costs
23
23
  // nothing measurable and makes `meter: { apiKey }` work without ceremony.
24
24
  import { MeterSink } from './meter.js';
25
+ import { RemotePolicy } from './policy.js';
26
+ import { evaluatePolicy } from './policy-engine.js';
27
+ import { Registry } from './registry.js';
28
+ import { ManifestServer, MANIFEST_PATHS, originFor, paymentRequirementsFor } from './manifest.js';
25
29
 
26
30
  export const LANES = Object.freeze({
27
31
  VERIFIED: 'verified_agent',
@@ -411,11 +415,34 @@ export class Wayleave {
411
415
  * }
412
416
  */
413
417
  constructor(opts = {}) {
414
- this.directories = opts.directories || {};
418
+ // `directories: 'wayleave:default'` resolves to the signed public registry.
419
+ // The verification path below does not change: asResolver() already accepts
420
+ // a function, and this supplies one backed by a document fetched on a timer
421
+ // and resolved from memory.
422
+ if (opts.directories === 'wayleave:default' || opts.registry) {
423
+ this.registry = new Registry({
424
+ onEvent: opts.onEvent,
425
+ ...(typeof opts.registry === 'object' ? opts.registry : {}),
426
+ });
427
+ this.directories = this.registry.resolver();
428
+ } else {
429
+ this.registry = null;
430
+ this.directories = opts.directories || {};
431
+ }
415
432
  this.rules = opts.rules || {};
416
433
  this.rateLimits = opts.rateLimits || {};
417
434
  this.rateWindow = opts.rateWindow || 60;
418
435
  this.pricedPaths = opts.pricedPaths || {};
436
+
437
+ // Discovery. Default on when there is something to discover, because a
438
+ // priced route nothing can find earns nothing.
439
+ this.publicOrigin = opts.publicOrigin || null;
440
+ this.manifestEnabled = opts.manifest !== false && Object.keys(this.pricedPaths).length > 0;
441
+ this.manifests = this.manifestEnabled
442
+ ? new ManifestServer({ pricedPaths: this.pricedPaths, payment: opts.payment || {},
443
+ name: opts.name, description: opts.description,
444
+ docs: opts.docs, contact: opts.contact })
445
+ : null;
419
446
  this.onEvent = opts.onEvent || (() => {});
420
447
 
421
448
  // No payment verifier configured means no agent can ever buy passage.
@@ -466,6 +493,23 @@ export class Wayleave {
466
493
  // Identifies events from this process so a retrying sink can dedup. A
467
494
  // counter alone would collide across instances; a timestamp alone would
468
495
  // collide within a millisecond.
496
+ // What the developer configured here is the floor and never moves. A
497
+ // policy fetched from the meter is layered ON TOP, so if our service is
498
+ // unreachable -- or returns nothing at all -- the gate still enforces
499
+ // exactly what this constructor was given.
500
+ this._localRules = this.rules;
501
+ this._localRateLimits = this.rateLimits;
502
+ this._localPricedPaths = this.pricedPaths;
503
+
504
+ this.quotaStore = opts.quotaStore || null;
505
+ this.policy = opts.policy
506
+ ? new RemotePolicy({
507
+ ...opts.policy,
508
+ onEvent: this.onEvent,
509
+ onChange: compiled => this._applyPolicy(compiled),
510
+ })
511
+ : null;
512
+
469
513
  this._instance = randomUUID();
470
514
  this._seq = 0;
471
515
  }
@@ -486,6 +530,7 @@ export class Wayleave {
486
530
  status: d.status, why: d.why, evidence,
487
531
  billedUsd: d.billed || 0 };
488
532
  if (d.paymentRef) entry.paymentRef = d.paymentRef;
533
+ if (d.ruleId) entry.ruleId = d.ruleId;
489
534
  this.sink.emit(entry);
490
535
  return { lane, identity: ident, ...d, evidence };
491
536
  }
@@ -496,13 +541,41 @@ export class Wayleave {
496
541
  * Identical in every respect except that it can await your verifyPayment.
497
542
  * Use this whenever you actually sell something; the Express adapter does.
498
543
  */
544
+ /**
545
+ * The discovery document, if this request is asking for it.
546
+ *
547
+ * Checked before classification so it is never metered, never rate-limited
548
+ * and never priced. A paywalled discovery document cannot be discovered.
549
+ */
550
+ /**
551
+ * Spec-shaped payment requirements for a priced path, or null.
552
+ *
553
+ * Use this to build a 402 body. The alternative is every application
554
+ * inventing its own translation of the internal challenge, and getting it
555
+ * wrong the same way once each.
556
+ */
557
+ paymentRequirements(req) {
558
+ if (!this.manifests) return null;
559
+ const origin = originFor(req, this.publicOrigin);
560
+ return origin ? paymentRequirementsFor(this.manifests.forOrigin(origin), req.path) : null;
561
+ }
562
+
563
+ manifestFor(req) {
564
+ if (!this.manifests || !MANIFEST_PATHS.includes(req.path)) return null;
565
+ const origin = originFor(req, this.publicOrigin);
566
+ if (!origin) return null;
567
+ const body = this.manifests.forOrigin(origin);
568
+ return body ? { status: 200, body, headers: { 'content-type': 'application/json' } } : null;
569
+ }
570
+
499
571
  async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
500
572
  const c = classify(req, this.directories, now,
501
573
  { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
502
574
  const { lane, agentId, evidence } = c;
503
575
  const ident = agentId || `${lane}:${this._client(req)}`;
504
576
  const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
505
- const d = await this._decideAsync(req, lane, ident, now, proof, c);
577
+ const fromPolicy = await this._policyDecision(req, lane, ident, c);
578
+ const d = fromPolicy || await this._decideAsync(req, lane, ident, now, proof, c);
506
579
  const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
507
580
  t: now, path: req.path, lane, identity: ident,
508
581
  status: d.status, why: d.why, evidence,
@@ -662,8 +735,87 @@ export class Wayleave {
662
735
  }
663
736
 
664
737
  /** Express/Connect adapter: app.use(wayleave.express()) */
738
+ /**
739
+ * Start polling for a remote policy. Awaiting this is optional: skip it and
740
+ * the gate enforces its local configuration until the first refresh lands.
741
+ * It never rejects, because a gate that will not boot when our service is
742
+ * down is worse than one that enforces nothing.
743
+ */
744
+ async ready() {
745
+ // Neither rejects. A gate that will not boot because our service is down
746
+ // is worse than one that enforces nothing.
747
+ if (this.registry) await this.registry.start();
748
+ if (this.policy) await this.policy.start();
749
+ return this;
750
+ }
751
+
752
+ /** Stop polling. Safe to call when no remote policy is configured. */
753
+ close() { this.policy?.stop(); this.registry?.stop(); }
754
+
755
+ /**
756
+ * Evaluate the fetched policy for one request.
757
+ *
758
+ * The context is built from the gate's OWN verification result. Nothing here
759
+ * comes from a header or body the caller controls: the engine only honours
760
+ * subject and operator selectors when identity.verified is true, and passing
761
+ * a caller's claims through would defeat exactly that check.
762
+ *
763
+ * Returns null when there is no policy, or when it says allow -- in both
764
+ * cases the gate's local configuration decides, as it always did.
765
+ */
766
+ async _policyDecision(req, lane, ident, c) {
767
+ const policy = this.policy?.document;
768
+ if (!policy) return null;
769
+ const identity = c?.agentId && c.lane === 'verified_agent'
770
+ ? { verified: true, subject: c.agentId, operator: c.operator || undefined }
771
+ : { verified: false };
772
+ let d;
773
+ try {
774
+ d = await evaluatePolicy(policy, {
775
+ tenant: this.policy.tenant || 'self',
776
+ path: req.path, method: req.method, lane,
777
+ identity, anonymousBucket: ident,
778
+ }, { quotaStore: this.quotaStore });
779
+ } catch (err) {
780
+ // A policy we cannot evaluate must not break the request. Traffic fails open.
781
+ this.onEvent({ type: 'policy.evaluate.failed', error: err?.message });
782
+ return null;
783
+ }
784
+ if (d.action === 'allow') return null;
785
+ if (d.action === 'deny')
786
+ return { status: 403, why: d.reason || `denied by rule ${d.ruleId || 'default'}`,
787
+ ruleId: d.ruleId, headers: { 'x-wayleave-rule': String(d.ruleId || 'default') },
788
+ ...(d.reason === 'Quota exceeded'
789
+ ? { status: 429, headers: { 'x-wayleave-rule': String(d.ruleId),
790
+ 'retry-after': String(this.rateWindow) } }
791
+ : {}) };
792
+ if (d.action === 'pay')
793
+ return { status: 402, why: `payment required by rule ${d.ruleId}`, ruleId: d.ruleId,
794
+ headers: { 'x-wayleave-rule': String(d.ruleId) },
795
+ challenge: { scheme: d.rail || 'x402', price_usd: d.priceMicros / 1e6,
796
+ resource: req.path } };
797
+ return null;
798
+ }
799
+
800
+ /** Local configuration, untouched by any fetched policy. */
801
+ _applyPolicy() { /* policy is evaluated per request; nothing to compile */ }
802
+
665
803
  express() {
666
804
  return async (req, res, next) => {
805
+ /* The discovery document is answered first: before classification,
806
+ before rate limiting, before pricing. A manifest an agent must pay
807
+ to read cannot be discovered, and one that is classified as bot
808
+ traffic gets a 402 instead of the price list.
809
+
810
+ This lived only on manifestFor() until 0.5.1, so `app.use(gate.express())`
811
+ -- the integration the Quickstart shows -- served no manifest at all
812
+ while the README said every install with a priced route does. */
813
+ const manifest = this.manifestFor({
814
+ path: req.path || (req.url || '').split('?')[0],
815
+ headers: req.headers,
816
+ });
817
+ if (manifest) return res.status(manifest.status).set(manifest.headers).json(manifest.body);
818
+
667
819
  const r = await this.handleAsync({
668
820
  method: req.method, path: req.path || req.url,
669
821
  authority: req.headers.host || '',
@@ -678,10 +830,25 @@ export class Wayleave {
678
830
  }, undefined, req.headers['x-payment-proof'] || '');
679
831
  req.wayleave = r;
680
832
  if (r.status === 200) return next();
681
- if (r.status === 402)
833
+ if (r.status === 402) {
834
+ /* The 402 body and the manifest must come from one source, or an
835
+ index quotes a price the origin does not honour. `accepts` is
836
+ built by the same call the manifest is, so they cannot drift.
837
+
838
+ Without a payment config there is nothing spec-shaped to say --
839
+ no payee, no network, no asset -- so the internal challenge is
840
+ all that is sent, and `accepts` is absent rather than empty.
841
+ An empty accepts array would read as "priced at nothing". */
842
+ const body = { error: r.why, challenge: r.challenge };
843
+ const requirements = this.paymentRequirements({
844
+ path: req.path || (req.url || '').split('?')[0],
845
+ headers: req.headers,
846
+ });
847
+ if (requirements) { body.x402Version = 1; body.accepts = [requirements]; }
682
848
  return res.status(402)
683
849
  .set('x-payment-challenge', JSON.stringify(r.challenge))
684
- .json({ error: r.why, challenge: r.challenge });
850
+ .json(body);
851
+ }
685
852
  return res.status(r.status).json({ error: r.why });
686
853
  };
687
854
  }
package/manifest.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Discovery manifest — E-1.
3
+ *
4
+ * The problem it solves: a developer installs the gate, prices a route, and
5
+ * nothing finds it. Agent discovery indexes register resources they can read a
6
+ * price for, so an unadvertised priced route is invisible to the exact agents
7
+ * that could pay it.
8
+ *
9
+ * ── WHAT THE SPEC ACTUALLY SAYS, as of 2026-09-18 ──────────────────────────
10
+ *
11
+ * The merged x402 specification has NO well-known discovery document. Its
12
+ * mechanism is the `bazaar` extension carried inside 402 responses, which a
13
+ * facilitator catalogs as it sees them (specs/extensions/bazaar.md).
14
+ *
15
+ * The well-known manifest is a PROPOSED extension: draft-hawkins-x402-dns-
16
+ * discovery-01, plus an open PR against the foundation repo. It is widely used
17
+ * in the wild — validators and index tooling fetch it — so it is worth serving,
18
+ * but it is a convention, not a ratified standard, and this comment is here so
19
+ * nobody later mistakes it for one.
20
+ *
21
+ * The path is `/.well-known/x402`. NOT `.json`: tooling that looks for the
22
+ * manifest requests the extensionless path, and a document served only at
23
+ * `.json` is a document nobody fetches. We answer both, because answering the
24
+ * alias costs nothing and a 404 costs a listing.
25
+ *
26
+ * Field names below were read from the draft and from
27
+ * specs/x402-specification-v1.md, not from memory. Two traps in particular:
28
+ * - `asset` is the TOKEN CONTRACT ADDRESS, not a symbol like "usdc".
29
+ * - `maxTimeoutSeconds` is REQUIRED in PaymentRequirements.
30
+ * An entry that gets either wrong is an entry an index silently skips, which
31
+ * is indistinguishable from not publishing at all.
32
+ */
33
+
34
+ import { toAtomicUnits } from './x402.js';
35
+
36
+ /** Networks whose USDC contract we have actually verified. See x402.js. */
37
+ const KNOWN_ASSETS = {
38
+ 'base': { asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6 },
39
+ 'base-sepolia': { asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', decimals: 6 },
40
+ };
41
+
42
+ const DEFAULT_TIMEOUT_SECONDS = 60;
43
+
44
+ /**
45
+ * Build the manifest from configuration. A pure function of `pricedPaths` and
46
+ * `payment`: there is no separate document to author, and therefore none to
47
+ * drift out of step with what the gate actually charges.
48
+ *
49
+ * Returns null when there is nothing honest to publish. An empty manifest is
50
+ * worse than no manifest — it tells an index there is nothing to buy here,
51
+ * which is a claim, and a false one.
52
+ */
53
+ export function buildManifest({ pricedPaths = {}, payment = {}, origin,
54
+ name, description, docs, contact,
55
+ now = () => new Date() } = {}) {
56
+ const routes = Object.entries(pricedPaths);
57
+ if (!routes.length || !origin) return null;
58
+
59
+ const network = payment.network || 'base';
60
+ const known = KNOWN_ASSETS[network];
61
+ // An address we were given wins over our table; a symbol never does.
62
+ const asset = isAddress(payment.asset) ? payment.asset : known?.asset;
63
+ const decimals = Number.isInteger(payment.decimals) ? payment.decimals : known?.decimals;
64
+ const payTo = payment.payTo;
65
+
66
+ // Priceable only when we can name a real contract and a real payee. Without
67
+ // both, publish the resource without `accepts` rather than inventing fields:
68
+ // a wrong address is worse than a missing one.
69
+ const priceable = Boolean(asset && payTo && Number.isInteger(decimals));
70
+
71
+ const resources = routes.map(([path, priceUsd]) => {
72
+ const url = origin.replace(/\/+$/, '') + path;
73
+ const entry = { url, method: 'GET', description: 'x402-priced route' };
74
+ if (priceable) {
75
+ entry.accepts = [{
76
+ scheme: 'exact',
77
+ network,
78
+ maxAmountRequired: toAtomicUnits(String(priceUsd), decimals),
79
+ asset,
80
+ payTo,
81
+ resource: url,
82
+ description: 'x402-priced route',
83
+ mimeType: 'application/json',
84
+ maxTimeoutSeconds: payment.maxTimeoutSeconds || DEFAULT_TIMEOUT_SECONDS,
85
+ }];
86
+ }
87
+ return entry;
88
+ });
89
+
90
+ const manifest = {
91
+ // What we actually speak. The draft's example shows 2; advertising a
92
+ // version we do not implement would be a lie an index acts on.
93
+ x402Version: 1,
94
+ kind: 'resource-server',
95
+ name: name || hostOf(origin),
96
+ description: description || 'Paid routes served behind Wayleave.',
97
+ resources,
98
+ attestation: { type: 'none' },
99
+ updated: now().toISOString(),
100
+ };
101
+ if (docs) manifest.docs = docs;
102
+ if (contact) manifest.contact = contact;
103
+ return manifest;
104
+ }
105
+
106
+ const isAddress = v => typeof v === 'string' && /^0x[0-9a-fA-F]{40}$/.test(v);
107
+ const hostOf = origin => { try { return new URL(origin).host; } catch { return 'wayleave resource server'; } };
108
+
109
+ /**
110
+ * The x402 PaymentRequirements for one priced path.
111
+ *
112
+ * The gate's own `challenge` is an internal shape — `{scheme:'x402',
113
+ * price_usd, resource}` — which is fine for an application that speaks it and
114
+ * wrong for anything that validates against the x402 schema. An index reading
115
+ * that body skips the endpoint, silently, which is the same as never having
116
+ * published. Applications returning a 402 body should use this, so the 402 and
117
+ * the manifest cannot describe different prices.
118
+ */
119
+ export function paymentRequirementsFor(manifest, path) {
120
+ if (!manifest) return null;
121
+ const entry = manifest.resources?.find(r => { try { return new URL(r.url).pathname === path; } catch { return false; } })
122
+ || manifest.resources?.find(r => r.url.endsWith(path));
123
+ return entry?.accepts?.[0] ?? null;
124
+ }
125
+
126
+ /** The canonical path and the alias we also answer. */
127
+ export const MANIFEST_PATHS = ['/.well-known/x402', '/.well-known/x402.json'];
128
+
129
+ /**
130
+ * Serve the manifest, computed once per origin and held.
131
+ *
132
+ * No I/O in the handler, and never priced: a discovery document behind a
133
+ * paywall cannot be discovered, which defeats its only purpose.
134
+ */
135
+ export class ManifestServer {
136
+ #cache = new Map();
137
+ constructor(config) { this.config = config; }
138
+
139
+ /** Cached per origin, since the same install may answer on several hosts. */
140
+ forOrigin(origin) {
141
+ if (!this.#cache.has(origin))
142
+ this.#cache.set(origin, buildManifest({ ...this.config, origin }));
143
+ return this.#cache.get(origin);
144
+ }
145
+
146
+ /** Invalidate when configuration changes underneath us. */
147
+ reset(config) { if (config) this.config = config; this.#cache.clear(); }
148
+ }
149
+
150
+ /** Origin from the request, unless the app configured one explicitly. */
151
+ export function originFor(req, publicOrigin) {
152
+ if (publicOrigin) return publicOrigin.replace(/\/+$/, '');
153
+ const host = headerish(req, 'x-forwarded-host') || headerish(req, 'host');
154
+ if (!host) return null;
155
+ const proto = headerish(req, 'x-forwarded-proto') || 'https';
156
+ return `${proto}://${host}`;
157
+ }
158
+
159
+ function headerish(req, name) {
160
+ const h = req?.headers || {};
161
+ const v = h[name] ?? h[name.toLowerCase()];
162
+ return Array.isArray(v) ? v[0] : (typeof v === 'string' ? v.split(',')[0].trim() : null);
163
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.4.1",
4
- "description": "Charge AI agents for passage across your app. Verify agent signatures (Web Bot Auth / Ed25519), classify every request, and return 402 with a price on routes you choose. Humans browse free.",
3
+ "version": "0.5.1",
4
+ "description": "Know which AI agents cross your API — 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",
@@ -17,18 +17,33 @@
17
17
  "./x402": {
18
18
  "types": "./x402.d.ts",
19
19
  "default": "./x402.js"
20
+ },
21
+ "./policy": {
22
+ "types": "./policy.d.ts",
23
+ "default": "./policy.js"
24
+ },
25
+ "./registry": {
26
+ "default": "./registry.js"
27
+ },
28
+ "./manifest": {
29
+ "default": "./manifest.js"
20
30
  }
21
31
  },
22
32
  "files": [
23
33
  "LICENSE",
24
34
  "README.md",
35
+ "cdp-auth.js",
25
36
  "index.d.ts",
26
37
  "index.js",
38
+ "manifest.js",
27
39
  "meter.d.ts",
28
40
  "meter.js",
41
+ "policy-engine.js",
42
+ "policy.d.ts",
43
+ "policy.js",
44
+ "registry.js",
29
45
  "x402.d.ts",
30
- "x402.js",
31
- "cdp-auth.js"
46
+ "x402.js"
32
47
  ],
33
48
  "scripts": {
34
49
  "test": "node --test *.test.js"
@@ -38,16 +53,26 @@
38
53
  },
39
54
  "keywords": [
40
55
  "ai-agents",
56
+ "agent-identity",
57
+ "agent-traffic",
58
+ "bot-detection",
41
59
  "web-bot-auth",
42
60
  "rfc9421",
61
+ "http-message-signatures",
62
+ "ed25519",
43
63
  "402",
64
+ "http-402",
44
65
  "x402",
45
66
  "agent-payments",
46
- "bot-detection",
67
+ "micropayments",
47
68
  "middleware",
48
69
  "express",
70
+ "connect",
71
+ "api-monetization",
49
72
  "monetization",
50
- "ed25519"
73
+ "rate-limiting",
74
+ "access-control",
75
+ "zero-dependencies"
51
76
  ],
52
77
  "license": "MIT",
53
78
  "author": "Gibran Corbin (https://github.com/gibrancorbin11-hub)",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The policy evaluator, evaluated IN THE GATE.
3
+ *
4
+ * ── THIS FILE IS A COPY ────────────────────────────────────────────────
5
+ * It must stay byte-identical in behaviour to src/policy-engine.js in the
6
+ * meter. The meter validates what it stores; the gate evaluates what it
7
+ * fetched. If the two ever disagree, a customer sees a rule accepted by the
8
+ * dashboard and ignored at their origin, which is the worst kind of wrong:
9
+ * it looks like it is working.
10
+ *
11
+ * The copy exists because the gate has zero dependencies and cannot import
12
+ * from the meter. policy-engine.parity.test.js asserts the two files match.
13
+ * ───────────────────────────────────────────────────────────────────────
14
+ */
15
+ export function validatePolicy(policy) {
16
+ 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
+ if (!['allow','deny'].includes(policy.default)) throw new Error('Explicit default required');
18
+ const ids = new Set();
19
+ for (const r of policy.rules) {
20
+ if (!r || typeof r.id !== 'string' || !r.id || ids.has(r.id)) throw new Error('Unique rule IDs required');
21
+ ids.add(r.id);
22
+ const supported = ['id','route','method','tool','lane','subject','operator','verified','action','priceMicros','rail','quota'];
23
+ if (Object.keys(r).some(k => !supported.includes(k))) throw new Error('Unknown rule property');
24
+ if (!['allow','deny','pay','quota','allowance'].includes(r.action)) throw new Error('Unsupported action');
25
+ if (r.route !== undefined && (typeof r.route !== 'string' || !r.route.startsWith('/') || /[?#\\]/.test(r.route))) throw new Error('Invalid route prefix');
26
+ for (const k of ['method','tool','subject','operator']) if (r[k] !== undefined && (typeof r[k] !== 'string' || !r[k])) throw new Error('Invalid selector');
27
+ // The lane is the gate's own classification of the request. It is not an
28
+ // identity: suspected_bot means nothing was proven, so a rule written
29
+ // against it is a rule about absence of evidence, which is what a site
30
+ // owner actually wants to act on.
31
+ if (r.lane !== undefined && !['verified_agent','declared_agent','suspected_bot','human'].includes(r.lane)) throw new Error('Unknown lane');
32
+ if (r.verified !== undefined && typeof r.verified !== 'boolean') throw new Error('Invalid verified selector');
33
+ 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
+ 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
+ }
36
+ return policy;
37
+ }
38
+ export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
39
+ validatePolicy(policy);
40
+ if (typeof context.tenant !== 'string' || !context.tenant || typeof context.path !== 'string') throw new Error('Trusted tenant and path required');
41
+ const identity = context.identity?.verified === true ? context.identity : null;
42
+ const matches = r =>
43
+ (r.route === undefined || context.path === r.route || context.path.startsWith(r.route.endsWith('/') ? r.route : r.route+'/')) &&
44
+ (r.method === undefined || r.method === context.method) &&
45
+ (r.lane === undefined || r.lane === context.lane) &&
46
+ (r.tool === undefined || r.tool === context.tool) &&
47
+ (r.verified === undefined || r.verified === !!identity) &&
48
+ (r.subject === undefined || r.subject === identity?.subject) &&
49
+ (r.operator === undefined || r.operator === identity?.operator);
50
+ // An exhausted allowance does not answer the request; it steps aside for the
51
+ // rule behind it. Carried so the eventual answer can say why it was reached.
52
+ let spent = null;
53
+ for (const r of policy.rules) {
54
+ if (!matches(r)) continue;
55
+ const base = { policyVersion: policy.version, ruleId: r.id, ...(spent ? { reason: spent } : {}) };
56
+ if (r.action === 'quota' || r.action === 'allowance') {
57
+ const subject = identity?.subject || context.anonymousBucket;
58
+ if (!subject || !quotaStore) {
59
+ if (r.action === 'quota') return { ...base, action: 'deny', reason: 'Quota store or identity bucket unavailable' };
60
+ spent = 'Free allowance could not be verified'; continue;
61
+ }
62
+ const key = JSON.stringify([context.tenant, policy.version, r.id, subject]);
63
+ let allowed;
64
+ try { allowed = await quotaStore.consume({ key, limit: r.quota.limit, windowSeconds: r.quota.windowSeconds }); }
65
+ catch {
66
+ if (r.action === 'quota') return { ...base, action: 'deny', reason: 'Quota store unavailable' };
67
+ // Unknown consumption must not spend the allowance on the operator's
68
+ // behalf. Step aside so the priced rule behind this one applies.
69
+ spent = 'Free allowance could not be verified'; continue;
70
+ }
71
+ if (r.action === 'quota')
72
+ return { ...base, action: allowed === true ? 'allow' : 'deny', reason: allowed === true ? 'Within quota' : 'Quota exceeded' };
73
+ if (allowed === true) return { ...base, action: 'allow', reason: 'Within free allowance' };
74
+ spent = 'Free allowance exhausted'; continue;
75
+ }
76
+ return r.action === 'pay' ? { ...base, action: 'pay', priceMicros: r.priceMicros, currency: 'USD', rail: r.rail } : { ...base, action: r.action };
77
+ }
78
+ // A route the operator metered never falls back to a permissive default.
79
+ if (spent) return { policyVersion: policy.version, ruleId: null, action: 'deny', reason: spent };
80
+ return { policyVersion: policy.version, ruleId: null, action: policy.default };
81
+ }
package/policy.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** Remote policy: fetched out of band, cached on disk, fail-open by construction. */
2
+ export interface PolicyRule {
3
+ id: string;
4
+ route?: string;
5
+ method?: string;
6
+ tool?: string;
7
+ lane?: 'verified_agent' | 'declared_agent' | 'suspected_bot' | 'human';
8
+ subject?: string;
9
+ operator?: string;
10
+ verified?: boolean;
11
+ action: 'allow' | 'deny' | 'pay' | 'quota';
12
+ priceMicros?: number;
13
+ rail?: string;
14
+ quota?: { limit: number; windowSeconds: number };
15
+ }
16
+
17
+ export interface PolicyDocument {
18
+ version: string;
19
+ default: 'allow' | 'deny';
20
+ rules: PolicyRule[];
21
+ fetched?: string;
22
+ onUnreachable?: string;
23
+ }
24
+
25
+ export interface CompiledPolicy {
26
+ rules: Record<string, [string, boolean][]>;
27
+ rateLimits: Record<string, number>;
28
+ pricedPaths: Record<string, number>;
29
+ /** Rule ids the gate can enforce at this layer. */
30
+ applied: string[];
31
+ /** Rules deliberately not applied, with the reason. Never silently widened. */
32
+ skipped: { id: string; why: string }[];
33
+ }
34
+
35
+ export function compilePolicy(doc: PolicyDocument | null): CompiledPolicy;
36
+
37
+ export interface RemotePolicyOptions {
38
+ apiKey: string;
39
+ endpoint?: string;
40
+ refreshMs?: number;
41
+ cachePath?: string;
42
+ onEvent?: (event: { type: string; [k: string]: unknown }) => void;
43
+ onChange?: (compiled: CompiledPolicy) => void;
44
+ fetchImpl?: typeof fetch;
45
+ }
46
+
47
+ export class RemotePolicy {
48
+ constructor(options: RemotePolicyOptions);
49
+ document: PolicyDocument | null;
50
+ etag: string | null;
51
+ fetchedAt: number | null;
52
+ compiled(): CompiledPolicy;
53
+ /** Never rejects. A gate must boot even when the meter is unreachable. */
54
+ start(): Promise<this>;
55
+ refresh(): Promise<PolicyDocument | null>;
56
+ stop(): void;
57
+ }
package/policy.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Remote policy for an installed gate.
3
+ *
4
+ * THE LAW THIS FILE EXISTS TO OBEY: traffic fails open.
5
+ *
6
+ * Wayleave sits in front of somebody else's site. If our service is slow,
7
+ * unreachable, misconfigured or returning nonsense, their users must not
8
+ * notice. Nothing here is awaited in a request path, nothing here can throw
9
+ * into one, and every failure mode ends in "enforce what we last knew, and if
10
+ * we never knew anything, enforce nothing."
11
+ *
12
+ * boot -> disk cache, then network, then nothing
13
+ * every 5m -> conditional GET, swap in memory on success
14
+ * decide -> pure memory read, no I/O, no await
15
+ *
16
+ * Money is the opposite law and fails closed; that lives in x402.js.
17
+ */
18
+
19
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
+ import { tmpdir } from 'node:os';
21
+ import { join, dirname } from 'node:path';
22
+ import { verify as edVerify, createPublicKey } from 'node:crypto';
23
+
24
+ const DEFAULT_ENDPOINT = 'https://meter.wayleave.dev';
25
+ const DEFAULT_REFRESH_MS = 5 * 60_000;
26
+ const FETCH_TIMEOUT_MS = 5_000;
27
+ const LANES = ['verified_agent', 'declared_agent', 'suspected_bot', 'human'];
28
+
29
+ /** Deterministic JSON. Must match the meter's canonical() exactly, or every
30
+ * signature verifies where it was made and nowhere else. */
31
+ export function canonical(value) {
32
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
33
+ if (value && typeof value === 'object') {
34
+ return '{' + Object.keys(value).sort()
35
+ .filter(k => value[k] !== undefined)
36
+ .map(k => JSON.stringify(k) + ':' + canonical(value[k]))
37
+ .join(',') + '}';
38
+ }
39
+ return JSON.stringify(value === undefined ? null : value);
40
+ }
41
+
42
+ /**
43
+ * Verify the signed envelope. Returns rather than throws: a gate's only
44
+ * correct response to a bad document is to keep serving from its last good
45
+ * copy, never to fail a request.
46
+ */
47
+ export function verifyEnvelope(envelope, publicKey, { now = Date.now(), graceMs = 0 } = {}) {
48
+ if (!envelope || typeof envelope !== 'object') return { ok: false, reason: 'not an object' };
49
+ if (envelope.v !== 1) return { ok: false, reason: 'unsupported envelope version' };
50
+ if (typeof envelope.sig !== 'string' || !envelope.sig) return { ok: false, reason: 'missing signature' };
51
+ const { sig, ...body } = envelope;
52
+ try {
53
+ const key = typeof publicKey === 'string' ? createPublicKey(publicKey) : publicKey;
54
+ if (!edVerify(null, Buffer.from(canonical(body), 'utf8'), key, Buffer.from(sig, 'base64')))
55
+ return { ok: false, reason: 'signature does not match' };
56
+ } catch (err) { return { ok: false, reason: 'signature check failed: ' + err.message }; }
57
+ // After the signature, so a forged expiry cannot decide whether we check it.
58
+ const expires = Date.parse(envelope.expires);
59
+ if (!Number.isFinite(expires)) return { ok: false, reason: 'unreadable expiry' };
60
+ if (now > expires + graceMs) return { ok: false, reason: 'expired past grace' };
61
+ return { ok: true, policy: envelope.policy };
62
+ }
63
+
64
+ /**
65
+ * The meter's policy document, translated into the shapes the gate already
66
+ * enforces. Anything unrecognised is dropped rather than guessed at: a rule we
67
+ * cannot express is a rule we must not pretend to apply.
68
+ */
69
+ export function compilePolicy(doc) {
70
+ const rules = {}, rateLimits = {}, pricedPaths = {};
71
+ const applied = [], skipped = [];
72
+ if (!doc || !Array.isArray(doc.rules)) return { rules, rateLimits, pricedPaths, applied, skipped };
73
+
74
+ for (const r of doc.rules) {
75
+ const lanes = r.lane ? [r.lane] : LANES;
76
+ const prefix = typeof r.route === 'string' && r.route.startsWith('/') ? r.route : '/';
77
+
78
+ if (r.action === 'allow' || r.action === 'deny') {
79
+ // Selectors the gate cannot evaluate at this layer. Applying such a rule
80
+ // as if it matched everything would deny traffic the customer meant to
81
+ // allow, which is exactly the outage this file exists to prevent.
82
+ if (r.subject || r.operator || r.tool || r.method || r.verified !== undefined) {
83
+ skipped.push({ id: r.id, why: 'selector not enforceable by the gate' });
84
+ continue;
85
+ }
86
+ for (const lane of lanes) (rules[lane] ||= []).push([prefix, r.action === 'allow']);
87
+ applied.push(r.id);
88
+ } else if (r.action === 'quota') {
89
+ // The gate's limiter is per-lane and per-window, not per-route, so a
90
+ // route-scoped quota is not something it can honour faithfully.
91
+ if (r.route) { skipped.push({ id: r.id, why: 'route-scoped quota needs the shared store' }); continue; }
92
+ for (const lane of lanes) rateLimits[lane] = r.quota.limit;
93
+ applied.push(r.id);
94
+ } else if (r.action === 'pay') {
95
+ if (!r.route) { skipped.push({ id: r.id, why: 'priced rule needs a route' }); continue; }
96
+ // Integer micro-USD is the wire format; pricedPaths is decimal USD. The
97
+ // division is exact for any price a person would write.
98
+ pricedPaths[r.route] = r.priceMicros / 1_000_000;
99
+ applied.push(r.id);
100
+ } else {
101
+ skipped.push({ id: r.id, why: `unsupported action ${r.action}` });
102
+ }
103
+ }
104
+ return { rules, rateLimits, pricedPaths, applied, skipped };
105
+ }
106
+
107
+ export class RemotePolicy {
108
+ /**
109
+ * @param {object} o
110
+ * @param {string} o.apiKey the meter API key for this tenant
111
+ * @param {string} [o.endpoint] meter base URL
112
+ * @param {number} [o.refreshMs] poll interval
113
+ * @param {string} [o.cachePath] where the last good document is kept
114
+ * @param {(e:object)=>void} [o.onEvent] observability; never throws into us
115
+ */
116
+ constructor({ apiKey = null, url = null, publicKey = null, endpoint = DEFAULT_ENDPOINT,
117
+ refreshMs = DEFAULT_REFRESH_MS, graceMs = 0,
118
+ cachePath = null, onEvent = null, onChange = null, fetchImpl = null } = {}) {
119
+ if (!apiKey && !url) throw new Error('RemotePolicy requires a policy url (or the meter apiKey)');
120
+ this.apiKey = apiKey;
121
+ this.publicKey = publicKey;
122
+ this.graceMs = graceMs;
123
+ this.url = url || endpoint.replace(/\/+$/, '') + '/v1/policy';
124
+ this.refreshMs = refreshMs;
125
+ this.cachePath = cachePath || defaultCachePath();
126
+ this.onEvent = onEvent || (() => {});
127
+ this._onChange = onChange || (() => {});
128
+ this._fetch = fetchImpl || globalThis.fetch;
129
+ this.document = null; // last good policy, or null meaning enforce nothing
130
+ this.etag = null;
131
+ this.fetchedAt = null;
132
+ this._timer = null;
133
+ }
134
+
135
+ /** Compiled form of whatever we currently know. Pure memory. */
136
+ compiled() { return compilePolicy(this.document); }
137
+
138
+ /**
139
+ * Load whatever is on disk, then try the network once. Never rejects: a gate
140
+ * that cannot start because our service is down is the failure this whole
141
+ * design refuses.
142
+ */
143
+ async start() {
144
+ await this._loadCache();
145
+ await this.refresh();
146
+ if (this.refreshMs > 0) {
147
+ this._timer = setInterval(() => { this.refresh(); }, this.refreshMs);
148
+ this._timer.unref?.();
149
+ }
150
+ return this;
151
+ }
152
+
153
+ stop() { if (this._timer) clearInterval(this._timer); this._timer = null; }
154
+
155
+ /** One conditional GET. Swallows everything; reports through onEvent. */
156
+ async refresh() {
157
+ const ac = new AbortController();
158
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
159
+ try {
160
+ const res = await this._fetch(this.url, {
161
+ signal: ac.signal,
162
+ headers: {
163
+ authorization: `Bearer ${this.apiKey}`,
164
+ ...(this.etag ? { 'if-none-match': this.etag } : {}),
165
+ },
166
+ });
167
+
168
+ if (res.status === 304) { this._emit('policy.unchanged'); return this.document; }
169
+
170
+ // 404 is "this tenant has set no policy", which is a real answer and
171
+ // means enforce nothing -- not an error, and not a reason to keep
172
+ // enforcing something the customer has since deleted.
173
+ if (res.status === 404) {
174
+ if (this.document) { this.document = null; this.etag = null; await this._writeCache(null); }
175
+ this._emit('policy.none');
176
+ this._changed();
177
+ return null;
178
+ }
179
+
180
+ if (!res.ok) { this._emit('policy.error', { status: res.status }); return this.document; }
181
+
182
+ const raw = await res.json();
183
+ // A signed URL that is served unverified is an unauthenticated URL.
184
+ let doc = raw;
185
+ if (this.publicKey) {
186
+ const v = verifyEnvelope(raw, this.publicKey, { graceMs: this.graceMs });
187
+ if (!v.ok) { this._emit('policy.rejected', { why: v.reason }); return this.document; }
188
+ doc = v.policy;
189
+ }
190
+ if (!isUsable(doc)) { this._emit('policy.rejected', { why: 'document not usable' }); return this.document; }
191
+
192
+ this.document = doc;
193
+ this.etag = res.headers.get?.('etag') ?? null;
194
+ this.fetchedAt = Date.now();
195
+ await this._writeCache(doc);
196
+ const { applied, skipped } = compilePolicy(doc);
197
+ this._emit('policy.applied', { version: doc.version, applied, skipped });
198
+ this._changed();
199
+ return doc;
200
+ } catch (err) {
201
+ // Unreachable, timed out, DNS gone, TLS wrong, body not JSON: all the
202
+ // same answer. Keep the last good copy and say nothing to the request path.
203
+ this._emit('policy.unreachable', { error: err?.message });
204
+ return this.document;
205
+ } finally { clearTimeout(timer); }
206
+ }
207
+
208
+ async _loadCache() {
209
+ try {
210
+ const raw = JSON.parse(await readFile(this.cachePath, 'utf8'));
211
+ if (raw?.url === this.url && isUsable(raw.document)) {
212
+ this.document = raw.document;
213
+ this.etag = null; // revalidate against the server, not against disk
214
+ this._emit('policy.cache.loaded', { version: raw.document.version });
215
+ this._changed();
216
+ }
217
+ } catch { /* no cache is the normal first-run state */ }
218
+ }
219
+
220
+ async _writeCache(document) {
221
+ try {
222
+ await mkdir(dirname(this.cachePath), { recursive: true });
223
+ await writeFile(this.cachePath, JSON.stringify({ url: this.url, document, at: Date.now() }));
224
+ } catch (err) { this._emit('policy.cache.unwritable', { error: err?.message }); }
225
+ }
226
+
227
+ _changed() { try { this._onChange(this.compiled()); } catch { /* never breaks a refresh */ } }
228
+
229
+ _emit(type, data = {}) { try { this.onEvent({ type, ...data }); } catch { /* never our problem */ } }
230
+ }
231
+
232
+ /** A document we can act on: right shape, explicit default, sane rule count. */
233
+ function isUsable(doc) {
234
+ return !!doc && Array.isArray(doc.rules) && doc.rules.length <= 100
235
+ && (doc.default === 'allow' || doc.default === 'deny');
236
+ }
237
+
238
+ function defaultCachePath() {
239
+ // Beside the install when that is writable, /tmp when it is not. A read-only
240
+ // node_modules is normal in a container and must not cost us the cache.
241
+ try { return join(process.cwd(), 'node_modules', '.cache', 'wayleave', 'policy.json'); }
242
+ catch { return join(tmpdir(), 'wayleave-policy.json'); }
243
+ }
package/registry.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The Wayleave registry: a signed, cached, fail-open directory of agent keys.
3
+ *
4
+ * Why this exists rather than more verification code: verification is
5
+ * commoditizable — an edge provider could clone the gate in a quarter. A
6
+ * directory that every install consults, whose value grows on both sides, can
7
+ * only be accumulated. It is also the one position an edge provider
8
+ * structurally cannot take, because a directory owned by one network is not a
9
+ * neutral directory.
10
+ *
11
+ * WHAT v1 IS: a signed list of operators' public keys, each traced to the
12
+ * endpoint that operator published it at.
13
+ *
14
+ * WHAT v1 IS NOT: reputation, scores, ranking, paid placement. Those are
15
+ * separate decisions with their own specs, and each is only safe after
16
+ * NEUTRALITY.md has held under pressure.
17
+ *
18
+ * THE FAIL-OPEN LAW, restated because it is the whole design:
19
+ * unreachable, expired past grace, or bad signature -> last cached copy
20
+ * no cache -> behave exactly as if no directory were configured
21
+ * Unknown agents stay unknown. They do not become suspected, and nothing
22
+ * about a customer's traffic changes because our CDN had a bad day.
23
+ */
24
+
25
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
26
+ import { tmpdir } from 'node:os';
27
+ import { join, dirname } from 'node:path';
28
+ import { verify as edVerify, createPublicKey } from 'node:crypto';
29
+ import { canonical } from './policy.js';
30
+
31
+ export const DEFAULT_REGISTRY_URL = 'https://registry.wayleave.dev/v1/agents.json';
32
+ const DEFAULT_REFRESH_MS = 6 * 60 * 60 * 1000;
33
+ const FETCH_TIMEOUT_MS = 5_000;
34
+
35
+ /**
36
+ * Root keys this package accepts, newest first.
37
+ *
38
+ * Shipping the public half INSIDE the package is what makes a CDN compromise
39
+ * useless: a tampered document fails here, not at a server we also have to
40
+ * trust. A key fetched from the same origin as the document proves nothing.
41
+ *
42
+ * Rotation is never a flag day. Publish a release that accepts both the new
43
+ * and old key, then drop the old one a version later.
44
+ *
45
+ * EMPTY UNTIL A ROOT KEY EXISTS. With no key pinned, `wayleave:default`
46
+ * verifies nothing and therefore resolves nothing — which is the correct
47
+ * inert state, not a bypass. It must never be made to "accept anything when
48
+ * unconfigured"; that would turn the registry into an open door.
49
+ */
50
+ export const WAYLEAVE_ROOT_KEYS = [];
51
+
52
+ /** Verify the signed envelope. Returns rather than throws; see the header. */
53
+ export function verifyRegistry(envelope, rootKeys, { now = Date.now(), graceMs = 0 } = {}) {
54
+ if (!envelope || typeof envelope !== 'object') return { ok: false, reason: 'not an object' };
55
+ if (envelope.v !== 1) return { ok: false, reason: 'unsupported registry version' };
56
+ if (!Array.isArray(envelope.agents)) return { ok: false, reason: 'no agents array' };
57
+ if (typeof envelope.sig !== 'string' || !envelope.sig) return { ok: false, reason: 'missing signature' };
58
+ if (!rootKeys?.length) return { ok: false, reason: 'no root key pinned in this build' };
59
+
60
+ const { sig, ...body } = envelope;
61
+ const bytes = Buffer.from(canonical(body), 'utf8');
62
+ const signature = Buffer.from(sig, 'base64');
63
+ const matched = rootKeys.some(k => {
64
+ try { return edVerify(null, bytes, typeof k === 'string' ? createPublicKey(k) : k, signature); }
65
+ catch { return false; }
66
+ });
67
+ if (!matched) return { ok: false, reason: 'signature does not match any pinned root key' };
68
+
69
+ // After the signature, so a forged expiry cannot decide whether we check it.
70
+ const expires = Date.parse(envelope.expires);
71
+ if (!Number.isFinite(expires)) return { ok: false, reason: 'unreadable expiry' };
72
+ if (now > expires + graceMs) return { ok: false, reason: 'expired past grace' };
73
+
74
+ return { ok: true, agents: envelope.agents };
75
+ }
76
+
77
+ /**
78
+ * Build the synchronous resolver verifySignature() expects.
79
+ *
80
+ * Keys are indexed by the operator's OWN published endpoint, because that is
81
+ * what an agent's signature names. An entry with no `source` is dropped: a key
82
+ * we cannot trace back to where the operator published it is a key we guessed,
83
+ * and guessing is the one thing this list must never do.
84
+ */
85
+ export function buildIndex(agents) {
86
+ const byDirectory = new Map();
87
+ for (const a of agents || []) {
88
+ if (!a || typeof a.source !== 'string' || !a.source) continue;
89
+ if (a.status && a.status !== 'listed') continue;
90
+ const keys = byDirectory.get(a.source) || {};
91
+ for (const k of a.keys || []) {
92
+ if (!k || k.scheme !== 'ed25519' || k.status === 'revoked') continue;
93
+ if (typeof k.keyid !== 'string' || typeof k.pubkey !== 'string') continue;
94
+ keys[k.keyid] = k.pubkey;
95
+ }
96
+ if (Object.keys(keys).length) byDirectory.set(a.source, keys);
97
+ }
98
+ return byDirectory;
99
+ }
100
+
101
+ export class Registry {
102
+ constructor({ url = DEFAULT_REGISTRY_URL, rootKeys = WAYLEAVE_ROOT_KEYS,
103
+ refreshMs = DEFAULT_REFRESH_MS, graceMs = 0, cachePath = null,
104
+ onEvent = null, fetchImpl = null } = {}) {
105
+ this.url = url;
106
+ this.rootKeys = rootKeys;
107
+ this.refreshMs = refreshMs;
108
+ this.graceMs = graceMs;
109
+ this.cachePath = cachePath || defaultCachePath();
110
+ this.onEvent = onEvent || (() => {});
111
+ this._fetch = fetchImpl || globalThis.fetch;
112
+ this.agents = null;
113
+ this.index = new Map();
114
+ this._timer = null;
115
+ }
116
+
117
+ /** Synchronous, memory-only. This runs on every signed request. */
118
+ resolver() {
119
+ return directoryUrl => this.index.get(directoryUrl);
120
+ }
121
+
122
+ /** How many operators and keys are live. For a dashboard, not a decision. */
123
+ stats() {
124
+ let keys = 0;
125
+ for (const m of this.index.values()) keys += Object.keys(m).length;
126
+ return { directories: this.index.size, keys, listed: this.agents?.length ?? 0 };
127
+ }
128
+
129
+ async start() {
130
+ await this._loadCache();
131
+ await this.refresh();
132
+ if (this.refreshMs > 0) {
133
+ this._timer = setInterval(() => { this.refresh(); }, this.refreshMs);
134
+ this._timer.unref?.();
135
+ }
136
+ return this;
137
+ }
138
+
139
+ stop() { if (this._timer) clearInterval(this._timer); this._timer = null; }
140
+
141
+ async refresh() {
142
+ const ac = new AbortController();
143
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
144
+ try {
145
+ const res = await this._fetch(this.url, { signal: ac.signal });
146
+ if (!res.ok) { this._emit('registry.error', { status: res.status }); return this.agents; }
147
+ const envelope = await res.json();
148
+ const v = verifyRegistry(envelope, this.rootKeys, { graceMs: this.graceMs });
149
+ if (!v.ok) { this._emit('registry.rejected', { why: v.reason }); return this.agents; }
150
+ this._adopt(v.agents);
151
+ await this._writeCache(envelope);
152
+ this._emit('registry.applied', this.stats());
153
+ return this.agents;
154
+ } catch (err) {
155
+ this._emit('registry.unreachable', { error: err?.message });
156
+ return this.agents;
157
+ } finally { clearTimeout(timer); }
158
+ }
159
+
160
+ _adopt(agents) { this.agents = agents; this.index = buildIndex(agents); }
161
+
162
+ async _loadCache() {
163
+ try {
164
+ const raw = JSON.parse(await readFile(this.cachePath, 'utf8'));
165
+ if (raw?.url !== this.url) return;
166
+ // The cache is re-verified, not trusted. A file on disk is not evidence.
167
+ const v = verifyRegistry(raw.envelope, this.rootKeys, { graceMs: this.graceMs });
168
+ if (!v.ok) { this._emit('registry.cache.rejected', { why: v.reason }); return; }
169
+ this._adopt(v.agents);
170
+ this._emit('registry.cache.loaded', this.stats());
171
+ } catch { /* no cache is the normal first run */ }
172
+ }
173
+
174
+ async _writeCache(envelope) {
175
+ try {
176
+ await mkdir(dirname(this.cachePath), { recursive: true });
177
+ await writeFile(this.cachePath, JSON.stringify({ url: this.url, envelope, at: Date.now() }));
178
+ } catch (err) { this._emit('registry.cache.unwritable', { error: err?.message }); }
179
+ }
180
+
181
+ _emit(type, data = {}) { try { this.onEvent({ type, ...data }); } catch { /* never our problem */ } }
182
+ }
183
+
184
+ function defaultCachePath() {
185
+ try { return join(process.cwd(), 'node_modules', '.cache', 'wayleave', 'registry.json'); }
186
+ catch { return join(tmpdir(), 'wayleave-registry.json'); }
187
+ }