wayleave 0.5.2 → 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.d.ts +12 -0
- package/index.js +166 -19
- package/package.json +4 -1
- package/policy-engine.js +70 -3
- package/policy.js +34 -5
package/index.d.ts
CHANGED
|
@@ -377,6 +377,18 @@ export function classify(
|
|
|
377
377
|
}
|
|
378
378
|
): Classification;
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* The path a request is for, whichever property the framework used.
|
|
382
|
+
*
|
|
383
|
+
* Express sets `req.path`; Node's own server and most other frameworks set
|
|
384
|
+
* `req.url` with the query still attached. Query and fragment are removed,
|
|
385
|
+
* so "/api?x=1" and "/api" are the same route.
|
|
386
|
+
*/
|
|
387
|
+
export function requestPath(req: { path?: string; url?: string } | null | undefined): string;
|
|
388
|
+
|
|
389
|
+
/** True when `path` is `prefix` or lies beneath its boundary. Query-insensitive. */
|
|
390
|
+
export function pathUnder(path: string, prefix: string): boolean;
|
|
391
|
+
|
|
380
392
|
export class Wayleave {
|
|
381
393
|
constructor(opts?: WayleaveOptions);
|
|
382
394
|
/**
|
package/index.js
CHANGED
|
@@ -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
|
|
|
@@ -113,8 +113,34 @@ function sigBase(components, ctx, paramsRaw) {
|
|
|
113
113
|
* trailing slash on the prefix changes nothing — "/api" and "/api/" mean the
|
|
114
114
|
* same thing, because operators write both and neither should surprise them.
|
|
115
115
|
*/
|
|
116
|
+
/**
|
|
117
|
+
* The path to decide about, whatever shape the caller's request object has.
|
|
118
|
+
*
|
|
119
|
+
* Express puts a query-free path on `req.path`. Node's own http server -- and
|
|
120
|
+
* every framework that hands you its raw request -- puts "/api/x?y=1" on
|
|
121
|
+
* `req.url` and nothing on `req.path`. `handle()` read `req.path` alone, so a
|
|
122
|
+
* caller outside Express passed `undefined` into the matcher, matched no
|
|
123
|
+
* priced prefix and no rule, and was allowed through. Silently: a 200 on a
|
|
124
|
+
* route the operator had priced, with a receipt recording no path at all.
|
|
125
|
+
*
|
|
126
|
+
* The query and fragment come off here for the same reason they come off in
|
|
127
|
+
* pathUnder: "/api?x=1" must not be a different route from "/api", and a
|
|
128
|
+
* query string recorded in a receipt is user data we never asked for.
|
|
129
|
+
*/
|
|
130
|
+
export function requestPath(req) {
|
|
131
|
+
const raw = (req && (req.path ?? req.url)) ?? '';
|
|
132
|
+
return String(raw).split('#')[0].split('?')[0] || '/';
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
export function pathUnder(path, prefix) {
|
|
117
136
|
if (typeof path !== 'string' || typeof prefix !== 'string') return false;
|
|
137
|
+
/* Strip the query before comparing. `handle()` takes whatever the caller
|
|
138
|
+
passes, and callers pass `req.url` as often as `req.path` — so "/api?x=1"
|
|
139
|
+
arrived here as a literal and matched no prefix at all. In 0.5.2 that
|
|
140
|
+
meant appending "?x=1" walked straight past a priced route: the boundary
|
|
141
|
+
fix was bypassable by anyone who noticed. Also cut the fragment, which a
|
|
142
|
+
server should never see but a hand-built request can carry. */
|
|
143
|
+
path = path.split('#')[0].split('?')[0];
|
|
118
144
|
if (prefix === '' || prefix === '/') return true;
|
|
119
145
|
const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
120
146
|
return path === base || path.startsWith(base + '/');
|
|
@@ -264,10 +290,25 @@ export function verifySignature(h, authority, directories,
|
|
|
264
290
|
|
|
265
291
|
// ── classification ──────────────────────────────────────────────────────
|
|
266
292
|
|
|
267
|
-
|
|
293
|
+
/* Named agents, not the word "agent".
|
|
294
|
+
*
|
|
295
|
+
* `/agent/` matched "MyAgencyCRM" and "UserAgent/1.0" — ordinary software with
|
|
296
|
+
* an unlucky name, sorted into the lane that gets charged. Guessing from a
|
|
297
|
+
* substring is the opposite of what this library is for: identity comes from
|
|
298
|
+
* signature math, and the UA list is only a declared-intent hint for the
|
|
299
|
+
* crawlers that announce themselves honestly.
|
|
300
|
+
*
|
|
301
|
+
* Exported so an operator can add the one their traffic actually shows. */
|
|
302
|
+
export const AGENT_UA = [
|
|
268
303
|
/gptbot/, /oai-searchbot/, /chatgpt-user/, /claude(?:bot|-user|-searchbot)?/,
|
|
269
|
-
/perplexitybot/, /google-extended/, /bingbot/, /
|
|
270
|
-
/
|
|
304
|
+
/perplexitybot/, /google-extended/, /bingbot/, /duckassistbot/, /applebot-extended/,
|
|
305
|
+
/meta-externalagent/, /amazonbot/, /youbot/, /ccbot/, /cohere-ai/, /diffbot/,
|
|
306
|
+
/anthropic/, /openai/, /autonomous[- ]agent/,
|
|
307
|
+
/* `bot` ending a word, not `\bbot\b`. Agents name themselves by suffix —
|
|
308
|
+
GPTBot, ClaudeBot, PerplexityBot, ShopBot — and a leading word boundary
|
|
309
|
+
misses every one of them. A trailing boundary still refuses `bottle`,
|
|
310
|
+
because the `t` that follows is a word character. */
|
|
311
|
+
/bot\b/, /crawler\b/, /spider\b/,
|
|
271
312
|
];
|
|
272
313
|
const AUTOMATION_TELLS = [
|
|
273
314
|
['headlesschrome', 'headless browser UA'],
|
|
@@ -482,7 +523,36 @@ export class Wayleave {
|
|
|
482
523
|
// accept-language header is the whole bypass. Strict mode inverts the
|
|
483
524
|
// burden on priced routes only: nothing crosses free without positive
|
|
484
525
|
// evidence, which is a verified signature or confirmHuman saying yes.
|
|
485
|
-
|
|
526
|
+
/* Item 3. The human lane means "nothing tripped", not "verified person":
|
|
527
|
+
a User-Agent and an Accept-Language are enough to reach it, and both are
|
|
528
|
+
one line in any scraper. With strict mode off, every priced route was
|
|
529
|
+
free to anything that looked like a browser — which is the whole product
|
|
530
|
+
leaking.
|
|
531
|
+
|
|
532
|
+
So it now defaults ON wherever there is something to protect. Turning it
|
|
533
|
+
off stays possible and says so once at startup, because an operator who
|
|
534
|
+
chooses it should know what they chose. */
|
|
535
|
+
this.strictPricedPaths = opts.strictPricedPaths === undefined
|
|
536
|
+
? Object.keys(this.pricedPaths).length > 0
|
|
537
|
+
: opts.strictPricedPaths === true;
|
|
538
|
+
const priced = Object.keys(this.pricedPaths).length > 0;
|
|
539
|
+
const warn = this._onWarn = opts.onWarn || console.warn;
|
|
540
|
+
if (opts.strictPricedPaths === false && priced)
|
|
541
|
+
warn('[wayleave] strictPricedPaths is off: priced routes are reachable free ' +
|
|
542
|
+
'by any client that looks like a browser. Pass confirmHuman() to charge ' +
|
|
543
|
+
'agents while letting your signed-in people through.');
|
|
544
|
+
/* The other half of item 3, and the one that can hurt a real person.
|
|
545
|
+
|
|
546
|
+
Strict mode says the human lane is not enough on a priced route. If the
|
|
547
|
+
operator has not also said how to recognise their own people, then
|
|
548
|
+
nobody qualifies — and a customer reading an article gets a 402. The law
|
|
549
|
+
this library is built on is that people never pay and never get blocked,
|
|
550
|
+
so the combination that breaks it does not pass silently. */
|
|
551
|
+
if (this.strictPricedPaths && priced && typeof opts.confirmHuman !== 'function')
|
|
552
|
+
warn('[wayleave] priced routes are strict but no confirmHuman() was given, ' +
|
|
553
|
+
'so signed-in people will be asked to pay like agents. Pass ' +
|
|
554
|
+
'confirmHuman: (req) => Boolean(yourSession(req)), or set ' +
|
|
555
|
+
'strictPricedPaths: false if these routes have no human visitors.');
|
|
486
556
|
this.confirmHuman = opts.confirmHuman || null;
|
|
487
557
|
|
|
488
558
|
// x-forwarded-for is written by the client unless a proxy you control
|
|
@@ -541,17 +611,23 @@ export class Wayleave {
|
|
|
541
611
|
|
|
542
612
|
/** req: { method, path, authority, headers, ip } → decision */
|
|
543
613
|
handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
544
|
-
const
|
|
545
|
-
|
|
614
|
+
const path = requestPath(req);
|
|
615
|
+
const r = this._withPath(req, path);
|
|
616
|
+
const c = classify(r, this.directories, now,
|
|
617
|
+
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
546
618
|
const { lane, agentId, evidence } = c;
|
|
547
|
-
const ident = agentId || `${lane}:${this._client(
|
|
619
|
+
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
548
620
|
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
549
|
-
|
|
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);
|
|
550
626
|
// v is the event schema version. A meter that outlives one release has to
|
|
551
627
|
// know which shape it is reading; adding this after receipts exist in the
|
|
552
628
|
// wild would mean reconciling two formats forever.
|
|
553
629
|
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
554
|
-
t: now, path
|
|
630
|
+
t: now, path, lane, identity: ident,
|
|
555
631
|
status: d.status, why: d.why, evidence,
|
|
556
632
|
billedUsd: d.billed || 0 };
|
|
557
633
|
if (d.paymentRef) entry.paymentRef = d.paymentRef;
|
|
@@ -594,18 +670,24 @@ export class Wayleave {
|
|
|
594
670
|
}
|
|
595
671
|
|
|
596
672
|
async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
597
|
-
const
|
|
598
|
-
|
|
673
|
+
const path = requestPath(req);
|
|
674
|
+
const r = this._withPath(req, path);
|
|
675
|
+
const c = classify(r, this.directories, now,
|
|
676
|
+
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(r) });
|
|
599
677
|
const { lane, agentId, evidence } = c;
|
|
600
|
-
const ident = agentId || `${lane}:${this._client(
|
|
678
|
+
const ident = agentId || `${lane}:${this._client(r)}`;
|
|
601
679
|
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
602
|
-
const fromPolicy = await this._policyDecision(
|
|
603
|
-
const d = fromPolicy || await this._decideAsync(
|
|
680
|
+
const fromPolicy = await this._policyDecision(r, lane, ident, c);
|
|
681
|
+
const d = fromPolicy || await this._decideAsync(r, lane, ident, now, proof, c);
|
|
604
682
|
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
605
|
-
t: now, path
|
|
683
|
+
t: now, path, lane, identity: ident,
|
|
606
684
|
status: d.status, why: d.why, evidence,
|
|
607
685
|
billedUsd: d.billed || 0 };
|
|
608
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;
|
|
609
691
|
this.sink.emit(entry);
|
|
610
692
|
return { lane, identity: ident, ...d, evidence };
|
|
611
693
|
}
|
|
@@ -777,6 +859,21 @@ export class Wayleave {
|
|
|
777
859
|
/** Stop polling. Safe to call when no remote policy is configured. */
|
|
778
860
|
close() { this.policy?.stop(); this.registry?.stop(); }
|
|
779
861
|
|
|
862
|
+
/**
|
|
863
|
+
* The caller's request, seen with a `path` on it.
|
|
864
|
+
*
|
|
865
|
+
* Built with Object.create so the original is never mutated and never
|
|
866
|
+
* copied: a framework's request object owns sockets, streams and getters,
|
|
867
|
+
* and cloning one is a good way to break something far from here. Reads
|
|
868
|
+
* fall through to the real request; only `path` is ours.
|
|
869
|
+
*/
|
|
870
|
+
_withPath(req, path) {
|
|
871
|
+
if (!req || typeof req !== 'object' || req.path === path) return req;
|
|
872
|
+
try {
|
|
873
|
+
return Object.create(req, { path: { value: path, enumerable: true, configurable: true } });
|
|
874
|
+
} catch { return req; }
|
|
875
|
+
}
|
|
876
|
+
|
|
780
877
|
/**
|
|
781
878
|
* Evaluate the fetched policy for one request.
|
|
782
879
|
*
|
|
@@ -788,24 +885,74 @@ export class Wayleave {
|
|
|
788
885
|
* Returns null when there is no policy, or when it says allow -- in both
|
|
789
886
|
* cases the gate's local configuration decides, as it always did.
|
|
790
887
|
*/
|
|
791
|
-
|
|
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) {
|
|
792
899
|
const policy = this.policy?.document;
|
|
793
900
|
if (!policy) return null;
|
|
794
|
-
|
|
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'
|
|
795
928
|
? { verified: true, subject: c.agentId, operator: c.operator || undefined }
|
|
796
929
|
: { verified: false };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async _policyDecision(req, lane, ident, c) {
|
|
933
|
+
const policy = this.policy?.document;
|
|
934
|
+
if (!policy) return null;
|
|
797
935
|
let d;
|
|
798
936
|
try {
|
|
799
937
|
d = await evaluatePolicy(policy, {
|
|
800
938
|
tenant: this.policy.tenant || 'self',
|
|
801
939
|
path: req.path, method: req.method, lane,
|
|
802
|
-
identity, anonymousBucket: ident,
|
|
940
|
+
identity: this._policyIdentity(c), anonymousBucket: ident,
|
|
803
941
|
}, { quotaStore: this.quotaStore });
|
|
804
942
|
} catch (err) {
|
|
805
943
|
// A policy we cannot evaluate must not break the request. Traffic fails open.
|
|
806
944
|
this.onEvent({ type: 'policy.evaluate.failed', error: err?.message });
|
|
807
945
|
return null;
|
|
808
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) {
|
|
809
956
|
if (d.action === 'allow') return null;
|
|
810
957
|
if (d.action === 'deny')
|
|
811
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() {
|