wayleave 0.5.3 → 0.5.4
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/index.js +63 -15
- package/package.json +4 -1
- package/policy-engine.js +70 -3
- package/policy.js +34 -5
package/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { createPublicKey, verify as edVerify, sign as edSign,
|
|
|
23
23
|
// nothing measurable and makes `meter: { apiKey }` work without ceremony.
|
|
24
24
|
import { MeterSink } from './meter.js';
|
|
25
25
|
import { RemotePolicy } from './policy.js';
|
|
26
|
-
import { evaluatePolicy } from './policy-engine.js';
|
|
26
|
+
import { evaluatePolicy, evaluatePolicySync } from './policy-engine.js';
|
|
27
27
|
import { Registry } from './registry.js';
|
|
28
28
|
import { ManifestServer, MANIFEST_PATHS, originFor, paymentRequirementsFor } from './manifest.js';
|
|
29
29
|
|
|
@@ -611,16 +611,6 @@ export class Wayleave {
|
|
|
611
611
|
|
|
612
612
|
/** req: { method, path, authority, headers, ip } → decision */
|
|
613
613
|
handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
614
|
-
/* A remote policy is a signed document fetched over the network, so a
|
|
615
|
-
synchronous call cannot evaluate one. Silence here would mean an
|
|
616
|
-
operator's rules quietly not applying; say it once, loudly, rather than
|
|
617
|
-
let them believe a policy is in force that is not. */
|
|
618
|
-
if (this.policy && !this._warnedSyncPolicy) {
|
|
619
|
-
this._warnedSyncPolicy = true;
|
|
620
|
-
this._onWarn('[wayleave] handle() cannot evaluate a remote policy — verifying a ' +
|
|
621
|
-
'signed document is asynchronous, so your policy rules are NOT being ' +
|
|
622
|
-
'applied here. Use handleAsync(); gate.express() already does.');
|
|
623
|
-
}
|
|
624
614
|
const path = requestPath(req);
|
|
625
615
|
const r = this._withPath(req, path);
|
|
626
616
|
const c = classify(r, this.directories, now,
|
|
@@ -628,7 +618,11 @@ export class Wayleave {
|
|
|
628
618
|
const { lane, agentId, evidence } = c;
|
|
629
619
|
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
630
620
|
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
631
|
-
|
|
621
|
+
/* The same policy the async path enforces, evaluated here for every rule
|
|
622
|
+
that needs no I/O. Before this, `handle()` skipped policy entirely and
|
|
623
|
+
one document behaved as two: enforced through express(), ignored here. */
|
|
624
|
+
const fromPolicy = this._policyDecisionSync(r, lane, ident, c);
|
|
625
|
+
const d = fromPolicy || this._decide(r, lane, ident, now, proof, c);
|
|
632
626
|
// v is the event schema version. A meter that outlives one release has to
|
|
633
627
|
// know which shape it is reading; adding this after receipts exist in the
|
|
634
628
|
// wild would mean reconciling two formats forever.
|
|
@@ -690,6 +684,10 @@ export class Wayleave {
|
|
|
690
684
|
status: d.status, why: d.why, evidence,
|
|
691
685
|
billedUsd: d.billed || 0 };
|
|
692
686
|
if (d.paymentRef) entry.paymentRef = d.paymentRef;
|
|
687
|
+
// The sync path recorded this and the async path did not, so a policy
|
|
688
|
+
// denial arrived at the meter with no rule on it — unanswerable when the
|
|
689
|
+
// operator asks which rule refused a request.
|
|
690
|
+
if (d.ruleId) entry.ruleId = d.ruleId;
|
|
693
691
|
this.sink.emit(entry);
|
|
694
692
|
return { lane, identity: ident, ...d, evidence };
|
|
695
693
|
}
|
|
@@ -887,24 +885,74 @@ export class Wayleave {
|
|
|
887
885
|
* Returns null when there is no policy, or when it says allow -- in both
|
|
888
886
|
* cases the gate's local configuration decides, as it always did.
|
|
889
887
|
*/
|
|
890
|
-
|
|
888
|
+
/**
|
|
889
|
+
* The policy decision that needs no network.
|
|
890
|
+
*
|
|
891
|
+
* Everything the async path does, minus quota — consuming from a quota
|
|
892
|
+
* store is I/O and cannot happen inside a synchronous call. A quota rule
|
|
893
|
+
* reached here is NOT passed over: passing it would let through exactly the
|
|
894
|
+
* traffic the operator metered, which is worse than refusing. It answers
|
|
895
|
+
* the way the async path answers when the store cannot be consulted, and
|
|
896
|
+
* says which call to use instead.
|
|
897
|
+
*/
|
|
898
|
+
_policyDecisionSync(req, lane, ident, c) {
|
|
891
899
|
const policy = this.policy?.document;
|
|
892
900
|
if (!policy) return null;
|
|
893
|
-
|
|
901
|
+
let d;
|
|
902
|
+
try {
|
|
903
|
+
d = evaluatePolicySync(policy, {
|
|
904
|
+
tenant: this.policy.tenant || 'self',
|
|
905
|
+
path: req.path, method: req.method, lane,
|
|
906
|
+
identity: this._policyIdentity(c), anonymousBucket: ident,
|
|
907
|
+
});
|
|
908
|
+
} catch (err) {
|
|
909
|
+
this.onEvent({ type: 'policy.evaluate.failed', error: err?.message });
|
|
910
|
+
return null; // traffic fails open
|
|
911
|
+
}
|
|
912
|
+
if (d.action === 'needs-async') {
|
|
913
|
+
if (!this._warnedSyncQuota) {
|
|
914
|
+
this._warnedSyncQuota = true;
|
|
915
|
+
this._onWarn('[wayleave] a quota rule was reached through handle(), which cannot ' +
|
|
916
|
+
'consult a quota store. The request was refused rather than passed. ' +
|
|
917
|
+
'Use handleAsync(); gate.express() already does.');
|
|
918
|
+
}
|
|
919
|
+
return { status: 403, why: 'quota rule requires handleAsync', ruleId: d.ruleId,
|
|
920
|
+
headers: { 'x-wayleave-rule': String(d.ruleId) } };
|
|
921
|
+
}
|
|
922
|
+
return this._fromPolicyAction(d, req);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** The identity shape the engine expects, from a classification. */
|
|
926
|
+
_policyIdentity(c) {
|
|
927
|
+
return c?.agentId && c.lane === 'verified_agent'
|
|
894
928
|
? { verified: true, subject: c.agentId, operator: c.operator || undefined }
|
|
895
929
|
: { verified: false };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async _policyDecision(req, lane, ident, c) {
|
|
933
|
+
const policy = this.policy?.document;
|
|
934
|
+
if (!policy) return null;
|
|
896
935
|
let d;
|
|
897
936
|
try {
|
|
898
937
|
d = await evaluatePolicy(policy, {
|
|
899
938
|
tenant: this.policy.tenant || 'self',
|
|
900
939
|
path: req.path, method: req.method, lane,
|
|
901
|
-
identity, anonymousBucket: ident,
|
|
940
|
+
identity: this._policyIdentity(c), anonymousBucket: ident,
|
|
902
941
|
}, { quotaStore: this.quotaStore });
|
|
903
942
|
} catch (err) {
|
|
904
943
|
// A policy we cannot evaluate must not break the request. Traffic fails open.
|
|
905
944
|
this.onEvent({ type: 'policy.evaluate.failed', error: err?.message });
|
|
906
945
|
return null;
|
|
907
946
|
}
|
|
947
|
+
return this._fromPolicyAction(d, req);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* One translation from an engine action to a gate decision, used by both
|
|
952
|
+
* paths. Two copies of this is how a deny becomes a 403 on one path and a
|
|
953
|
+
* 429 on the other for the same rule.
|
|
954
|
+
*/
|
|
955
|
+
_fromPolicyAction(d, req) {
|
|
908
956
|
if (d.action === 'allow') return null;
|
|
909
957
|
if (d.action === 'deny')
|
|
910
958
|
return { status: 403, why: d.reason || `denied by rule ${d.ruleId || 'default'}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wayleave",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Know which AI agents cross your API \u2014 verified by signature, not guessed from an IP. Sorts every request into four lanes, prices the non-human ones with HTTP 402, and never charges a human. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"./manifest": {
|
|
29
29
|
"default": "./manifest.js"
|
|
30
|
+
},
|
|
31
|
+
"./policy-engine": {
|
|
32
|
+
"default": "./policy-engine.js"
|
|
30
33
|
}
|
|
31
34
|
},
|
|
32
35
|
"files": [
|
package/policy-engine.js
CHANGED
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
* from the meter. policy-engine.parity.test.js asserts the two files match.
|
|
13
13
|
* ───────────────────────────────────────────────────────────────────────
|
|
14
14
|
*/
|
|
15
|
+
/* Documents that have already been through validatePolicy.
|
|
16
|
+
|
|
17
|
+
A WeakSet, so remembering a document cannot keep it alive: entries vanish
|
|
18
|
+
when the policy is replaced by the next fetch. The point is that a policy
|
|
19
|
+
is validated once, when it arrives, and not again on every request that
|
|
20
|
+
consults it -- a hot path doing full schema validation per crossing is
|
|
21
|
+
work nobody asked for, repeated forever. */
|
|
22
|
+
const VALIDATED = new WeakSet();
|
|
23
|
+
|
|
24
|
+
/** True when this exact object has already been validated. */
|
|
25
|
+
export function isValidated(policy) {
|
|
26
|
+
return typeof policy === 'object' && policy !== null && VALIDATED.has(policy);
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
export function validatePolicy(policy) {
|
|
16
30
|
if (!policy || typeof policy.version !== 'string' || !policy.version || !Array.isArray(policy.rules) || policy.rules.length > 100) throw new Error('Version and at most 100 rules required');
|
|
17
31
|
if (!['allow','deny'].includes(policy.default)) throw new Error('Explicit default required');
|
|
@@ -33,10 +47,25 @@ export function validatePolicy(policy) {
|
|
|
33
47
|
if (r.action === 'pay' && (!Number.isSafeInteger(r.priceMicros) || r.priceMicros <= 0 || typeof r.rail !== 'string' || !r.rail)) throw new Error('Payment requires positive integer USD micros and rail');
|
|
34
48
|
if ((r.action === 'quota' || r.action === 'allowance') && (!r.quota || !Number.isSafeInteger(r.quota.limit) || r.quota.limit < 1 || !Number.isSafeInteger(r.quota.windowSeconds) || r.quota.windowSeconds < 1)) throw new Error('Invalid quota');
|
|
35
49
|
}
|
|
50
|
+
VALIDATED.add(policy);
|
|
36
51
|
return policy;
|
|
37
52
|
}
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Everything both evaluators need before either can decide anything.
|
|
55
|
+
*
|
|
56
|
+
* Extracted so the matching rules exist once. Two copies of "does this rule
|
|
57
|
+
* apply" is how a sync path and an async path quietly begin enforcing
|
|
58
|
+
* different policies from the same document — which is the bug this file
|
|
59
|
+
* already had between the gate and the meter, and is not worth reproducing
|
|
60
|
+
* inside a single file.
|
|
61
|
+
*/
|
|
62
|
+
function prepare(policy, context) {
|
|
63
|
+
/* Validated once, when the document arrived — not again here. A caller
|
|
64
|
+
handing over a document nobody has checked still gets it checked, because
|
|
65
|
+
evaluating an unvalidated policy is how a malformed rule becomes a wrong
|
|
66
|
+
decision. But the normal path, where the fetcher validated it before it
|
|
67
|
+
became the live document, does no schema work per request at all. */
|
|
68
|
+
if (!isValidated(policy)) validatePolicy(policy);
|
|
40
69
|
if (typeof context.tenant !== 'string' || !context.tenant || typeof context.path !== 'string') throw new Error('Trusted tenant and path required');
|
|
41
70
|
const identity = context.identity?.verified === true ? context.identity : null;
|
|
42
71
|
const matches = r =>
|
|
@@ -47,6 +76,44 @@ export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
|
|
|
47
76
|
(r.verified === undefined || r.verified === !!identity) &&
|
|
48
77
|
(r.subject === undefined || r.subject === identity?.subject) &&
|
|
49
78
|
(r.operator === undefined || r.operator === identity?.operator);
|
|
79
|
+
return { identity, matches };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A rule that needs no I/O to answer. Shared by both evaluators. */
|
|
83
|
+
function plainDecision(r, base) {
|
|
84
|
+
return r.action === 'pay'
|
|
85
|
+
? { ...base, action: 'pay', priceMicros: r.priceMicros, currency: 'USD', rail: r.rail }
|
|
86
|
+
: { ...base, action: r.action };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Evaluate without touching the quota store.
|
|
91
|
+
*
|
|
92
|
+
* `handle()` is synchronous, so it could not evaluate a policy at all — and
|
|
93
|
+
* simply did not. The same document enforced through `express()` and was
|
|
94
|
+
* ignored through `handle()`, which is one policy behaving as two.
|
|
95
|
+
*
|
|
96
|
+
* Everything needing no I/O is decided here: allow, deny, pay, and the
|
|
97
|
+
* default. A quota or allowance rule cannot be, because consuming from the
|
|
98
|
+
* store is a network or database call. Rather than skip such a rule — which
|
|
99
|
+
* would silently pass exactly the traffic the operator metered — it stops and
|
|
100
|
+
* says so, and the caller can use handleAsync() or treat the answer as the
|
|
101
|
+
* refusal it is.
|
|
102
|
+
*/
|
|
103
|
+
export function evaluatePolicySync(policy, context) {
|
|
104
|
+
const { matches } = prepare(policy, context);
|
|
105
|
+
for (const r of policy.rules) {
|
|
106
|
+
if (!matches(r)) continue;
|
|
107
|
+
const base = { policyVersion: policy.version, ruleId: r.id };
|
|
108
|
+
if (r.action === 'quota' || r.action === 'allowance')
|
|
109
|
+
return { ...base, action: 'needs-async', reason: 'quota rule requires handleAsync' };
|
|
110
|
+
return plainDecision(r, base);
|
|
111
|
+
}
|
|
112
|
+
return { policyVersion: policy.version, ruleId: null, action: policy.default };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
|
|
116
|
+
const { identity, matches } = prepare(policy, context);
|
|
50
117
|
// An exhausted allowance does not answer the request; it steps aside for the
|
|
51
118
|
// rule behind it. Carried so the eventual answer can say why it was reached.
|
|
52
119
|
let spent = null;
|
|
@@ -73,7 +140,7 @@ export async function evaluatePolicy(policy, context, { quotaStore } = {}) {
|
|
|
73
140
|
if (allowed === true) return { ...base, action: 'allow', reason: 'Within free allowance' };
|
|
74
141
|
spent = 'Free allowance exhausted'; continue;
|
|
75
142
|
}
|
|
76
|
-
return r
|
|
143
|
+
return plainDecision(r, base);
|
|
77
144
|
}
|
|
78
145
|
// A route the operator metered never falls back to a permissive default.
|
|
79
146
|
if (spent) return { policyVersion: policy.version, ruleId: null, action: 'deny', reason: spent };
|
package/policy.js
CHANGED
|
@@ -20,6 +20,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
21
|
import { join, dirname } from 'node:path';
|
|
22
22
|
import { verify as edVerify, createPublicKey } from 'node:crypto';
|
|
23
|
+
import { validatePolicy } from './policy-engine.js';
|
|
23
24
|
|
|
24
25
|
const DEFAULT_ENDPOINT = 'https://meter.wayleave.dev';
|
|
25
26
|
const DEFAULT_REFRESH_MS = 5 * 60_000;
|
|
@@ -216,7 +217,13 @@ export class RemotePolicy {
|
|
|
216
217
|
this._emit('policy.rejected', { why: 'no signing key configured' });
|
|
217
218
|
return this.document;
|
|
218
219
|
}
|
|
219
|
-
|
|
220
|
+
const check = usable(doc);
|
|
221
|
+
if (!check.ok) {
|
|
222
|
+
// The last good policy stays in force. A bad publish must not
|
|
223
|
+
// silently disarm a gate that was working a second ago.
|
|
224
|
+
this._emit('policy.rejected', { why: check.why, version: doc?.version ?? null });
|
|
225
|
+
return this.document;
|
|
226
|
+
}
|
|
220
227
|
|
|
221
228
|
this.document = doc;
|
|
222
229
|
this.etag = res.headers.get?.('etag') ?? null;
|
|
@@ -237,7 +244,12 @@ export class RemotePolicy {
|
|
|
237
244
|
async _loadCache() {
|
|
238
245
|
try {
|
|
239
246
|
const raw = JSON.parse(await readFile(this.cachePath, 'utf8'));
|
|
240
|
-
|
|
247
|
+
/* The cache is a file on disk, which means it is a file somebody or
|
|
248
|
+
something else can edit. It gets the same full validation a freshly
|
|
249
|
+
fetched document gets -- and it has to, because this is the copy that
|
|
250
|
+
enforces on the very first request after a restart, before any fetch
|
|
251
|
+
has completed. */
|
|
252
|
+
if (raw?.url === this.url && usable(raw.document).ok) {
|
|
241
253
|
this.document = raw.document;
|
|
242
254
|
this.etag = null; // revalidate against the server, not against disk
|
|
243
255
|
this._emit('policy.cache.loaded', { version: raw.document.version });
|
|
@@ -259,9 +271,26 @@ export class RemotePolicy {
|
|
|
259
271
|
}
|
|
260
272
|
|
|
261
273
|
/** A document we can act on: right shape, explicit default, sane rule count. */
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Whether a fetched document may become the live policy.
|
|
276
|
+
*
|
|
277
|
+
* Full schema validation, here, once — not the shallow shape check this used
|
|
278
|
+
* to be. Two things follow from doing it at the door rather than per request:
|
|
279
|
+
* a document with a malformed rule never becomes live at all (the last good
|
|
280
|
+
* copy stays, which is the whole point of caching one), and the evaluator
|
|
281
|
+
* does no validation work on the hot path because the document it is handed
|
|
282
|
+
* has already passed.
|
|
283
|
+
*
|
|
284
|
+
* Returns a reason rather than a boolean, because "document not usable" told
|
|
285
|
+
* an operator nothing about which rule was wrong.
|
|
286
|
+
*/
|
|
287
|
+
function usable(doc) {
|
|
288
|
+
try {
|
|
289
|
+
validatePolicy(doc);
|
|
290
|
+
return { ok: true };
|
|
291
|
+
} catch (err) {
|
|
292
|
+
return { ok: false, why: err.message };
|
|
293
|
+
}
|
|
265
294
|
}
|
|
266
295
|
|
|
267
296
|
function defaultCachePath() {
|