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 +122 -10
- package/index.js +171 -4
- package/manifest.js +163 -0
- package/package.json +31 -6
- package/policy-engine.js +81 -0
- package/policy.d.ts +57 -0
- package/policy.js +243 -0
- package/registry.js +187 -0
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/wayleave)
|
|
5
5
|
[](https://www.npmjs.com/package/wayleave)
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**Know which AI agents cross your API — verified by signature, not guessed from an IP.**
|
|
8
8
|
|
|
9
|
-
Wayleave is open-source middleware
|
|
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
|
|
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.
|
|
106
|
-
|
|
107
|
-
0.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
"description": "
|
|
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
|
-
"
|
|
67
|
+
"micropayments",
|
|
47
68
|
"middleware",
|
|
48
69
|
"express",
|
|
70
|
+
"connect",
|
|
71
|
+
"api-monetization",
|
|
49
72
|
"monetization",
|
|
50
|
-
"
|
|
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)",
|
package/policy-engine.js
ADDED
|
@@ -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
|
+
}
|